TradingAgents/tradingagents/dataflows/finmind_common.py

586 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
FinMind API 共用工具模組
用於與 FinMind 台灣股市資料 API 進行互動
API 文檔https://finmind.github.io/
注意:本模組僅使用公開可用的 API 端點,
不使用需要 backer/sponsor 會員資格的功能。
"""
import os
import requests
import json
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, Union
from io import StringIO
# ============================================================================
# 常數定義
# ============================================================================
API_BASE_URL = "https://api.finmindtrade.com/api/v4/data"
# 可用的公開資料集(不需要 backer/sponsor 會員)
# 基本面
FUNDAMENTAL_DATASETS = {
"financial_statements": "TaiwanStockFinancialStatements", # 綜合損益表
"balance_sheet": "TaiwanStockBalanceSheet", # 資產負債表
"cashflow": "TaiwanStockCashFlowsStatement", # 現金流量表
"dividend": "TaiwanStockDividend", # 股利政策表
"dividend_result": "TaiwanStockDividendResult", # 除權除息結果表
"month_revenue": "TaiwanStockMonthRevenue", # 月營收表
"capital_reduction": "TaiwanStockCapitalReductionReferencePrice", # 減資恢復買賣參考價格
"delisting": "TaiwanStockDelisting", # 台灣股票下市櫃表
"split_price": "TaiwanStockSplitPrice", # 台股分割後參考價
"par_value_change": "TaiwanStockParValueChange", # 變更面額恢復買賣參考價格
}
# 技術面
TECHNICAL_DATASETS = {
"stock_info": "TaiwanStockInfo", # 台股總覽
"stock_info_warrant": "TaiwanStockInfoWithWarrant", # 台股總覽(含權證)
"trading_date": "TaiwanStockTradingDate", # 台股交易日
"stock_price": "TaiwanStockPrice", # 股價日成交資訊
"stock_per": "TaiwanStockPER", # PER、PBR 資料
"order_book_trade": "TaiwanStockStatisticsOfOrderBookAndTrade", # 每5秒委託成交統計
"indicators_5sec": "TaiwanVariousIndicators5Seconds", # 台股加權指數
"day_trading": "TaiwanStockDayTrading", # 當日沖銷交易
"total_return_index": "TaiwanStockTotalReturnIndex", # 加權、櫃買報酬指數
}
# 籌碼面
CHIP_DATASETS = {
"margin_purchase": "TaiwanStockMarginPurchaseShortSale", # 個股融資融劵表
"margin_total": "TaiwanStockTotalMarginPurchaseShortSale", # 整體市場融資融劵表
"institutional": "TaiwanStockInstitutionalInvestorsBuySell", # 法人買賣表
"institutional_total": "TaiwanStockTotalInstitutionalInvestors", # 市場三大法人買賣表
"shareholding": "TaiwanStockShareholding", # 外資持股表
"securities_lending": "TaiwanStockSecuritiesLending", # 借券成交明細
"short_sale_suspension": "TaiwanStockMarginShortSaleSuspension", # 暫停融券賣出表
"short_sale_balances": "TaiwanDailyShortSaleBalances", # 信用額度總量管制餘額表
"securities_trader_info": "TaiwanSecuritiesTraderInfo", # 證券商資訊表
}
# 需要 backer/sponsor 會員的資料集(不使用)
RESTRICTED_DATASETS = [
"TaiwanStockMarketValue", # 台灣股價市值表
"TaiwanStockMarketValueWeight", # 台股市值比重表
"TaiwanStockWeekPrice", # 台股週 K 資料表
"TaiwanStockMonthPrice", # 台股月 K 資料表
"TaiwanStockPriceAdj", # 台灣還原股價資料表
"TaiwanStockPriceTick", # 台灣股價歷史逐筆資料表
"TaiwanStock10Year", # 台灣個股十年線資料表
"TaiwanStockKBar", # 台股分 K 資料表
"TaiwanStockEvery5SecondsIndex", # 每 5 秒指數統計
"TaiwanStockHoldingSharesPer", # 股權持股分級表
"TaiwanStockTradingDailyReport", # 台股分點資料表
"TaiwanStockWarrantTradingDailyReport", # 台股權證分點資料表
"TaiwanstockGovernmentBankBuySell", # 台股八大行庫賣賣表
"TaiwanTotalExchangeMarginMaintenance", # 台灣大盤融資維持率
"TaiwanStockTradingDailyReportSecIdAgg",# 當日卷商分點統計表
"TaiwanStockDispositionSecuritiesPeriod",# 公布處置有價證券表
"TaiwanStockInfoWithWarrantSummary", # 台股權證標的對照表
]
# ============================================================================
# 自定義例外
# ============================================================================
class FinMindError(Exception):
"""FinMind API 通用錯誤"""
pass
class FinMindRateLimitError(FinMindError):
"""當超過 FinMind API 速率限制時引發的例外"""
pass
class FinMindAuthenticationError(FinMindError):
"""當 API Token 無效或缺失時引發的例外"""
pass
class FinMindDataNotFoundError(FinMindError):
"""當查詢的資料不存在時引發的例外"""
pass
# ============================================================================
# API Token 管理
# ============================================================================
def get_api_token() -> str:
"""
從環境變數中檢索 FinMind 的 API Token。
FinMind 使用 Bearer Token 進行身份驗證。
您可以在 https://finmindtrade.com/ 註冊後獲取 Token。
Returns:
str: API Token
Raises:
FinMindAuthenticationError: 當環境變數未設定時
"""
token = os.getenv("FINMIND_API_TOKEN")
if not token:
# 也支援舊的環境變數名稱
token = os.getenv("FINMIND_API_KEY")
if not token:
raise FinMindAuthenticationError(
"未設定 FINMIND_API_TOKEN 環境變數。"
"請在 https://finmindtrade.com/ 註冊並獲取 Token"
"然後設定環境變數export FINMIND_API_TOKEN='your_token'"
)
return token
# ============================================================================
# 日期格式處理
# ============================================================================
def format_date(date_input: Union[str, datetime]) -> str:
"""
將各種日期格式轉換為 FinMind API 所需的 YYYY-MM-DD 格式。
Args:
date_input: 日期字串或 datetime 物件
Returns:
str: 格式化後的日期字串 (YYYY-MM-DD)
Raises:
ValueError: 當日期格式不支援時
"""
if isinstance(date_input, datetime):
return date_input.strftime("%Y-%m-%d")
if isinstance(date_input, str):
# 如果已經是正確格式,直接返回
if len(date_input) == 10 and date_input[4] == '-' and date_input[7] == '-':
return date_input
# 嘗試解析常見的日期格式
formats_to_try = [
"%Y-%m-%d",
"%Y/%m/%d",
"%Y%m%d",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M",
]
for fmt in formats_to_try:
try:
dt = datetime.strptime(date_input, fmt)
return dt.strftime("%Y-%m-%d")
except ValueError:
continue
raise ValueError(f"不支援的日期格式:{date_input}")
raise ValueError(f"日期必須是字串或 datetime 物件,但得到的是 {type(date_input)}")
def get_default_start_date(years_back: int = 3) -> str:
"""
獲取預設的開始日期(預設為往前推算指定年數)。
Args:
years_back: 往前推算的年數
Returns:
str: 格式化的開始日期
"""
start_date = datetime.now() - timedelta(days=years_back * 365)
return format_date(start_date)
# ============================================================================
# 輸出格式化toon / JSON
# ============================================================================
def _convert_to_serializable(obj):
"""
將 numpy/pandas 資料類型轉換為 Python 原生類型,
以便 JSON 序列化。
"""
import numpy as np
import pandas as pd
if isinstance(obj, dict):
return {k: _convert_to_serializable(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [_convert_to_serializable(item) for item in obj]
elif hasattr(obj, 'item'): # numpy scalar types
return obj.item()
elif hasattr(obj, 'tolist'): # numpy array
return obj.tolist()
elif pd.isna(obj):
return None
else:
return obj
def format_output(data: dict, use_toon: bool = True) -> str:
"""
格式化輸出資料,強制使用 toon 格式以減少 token 消耗。
toon 格式可以大幅減少 token 消耗(通常節省 40-60%)。
Args:
data: 要輸出的資料字典
use_toon: 是否使用 toon 格式。預設為 True強制使用 toon
Returns:
str: 格式化後的字串JSON 或 toon 格式)
"""
# 先確保資料可序列化
serializable_data = _convert_to_serializable(data)
if use_toon:
try:
from tradingagents.utils.toon_converter import convert_json_to_toon
return convert_json_to_toon(serializable_data)
except Exception as e:
print(f"警告toon 轉換失敗:{e},使用 JSON 格式")
return json.dumps(serializable_data, ensure_ascii=False, indent=2)
else:
return json.dumps(serializable_data, ensure_ascii=False, indent=2)
# ============================================================================
# API 請求處理
# ============================================================================
def _make_api_request(
dataset: str,
data_id: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
**extra_params
) -> Dict[str, Any]:
"""
發送 API 請求並處理回應的輔助函式。
根據 FinMind 文檔API 請求格式為:
GET https://api.finmindtrade.com/api/v4/data
必要參數:
- dataset: 資料集名稱
選填參數:
- data_id: 股票代碼(例如 "2330"
- start_date: 開始日期
- end_date: 結束日期
Args:
dataset: FinMind 資料集名稱
data_id: 股票代碼(例如 "2330"
start_date: 開始日期 (YYYY-MM-DD)
end_date: 結束日期 (YYYY-MM-DD)
**extra_params: 額外的查詢參數
Returns:
dict: API 回應的 JSON 資料
Raises:
FinMindRateLimitError: 當超過 API 速率限制時
FinMindAuthenticationError: 當 Token 無效時
FinMindDataNotFoundError: 當資料不存在時
FinMindError: 其他 API 錯誤
"""
# 獲取 Token
token = get_api_token()
# 建立請求標頭
headers = {
"Authorization": f"Bearer {token}"
}
# 建立請求參數
params = {
"dataset": dataset,
}
# 添加股票代碼(如果提供)
if data_id:
params["data_id"] = normalize_stock_id(data_id)
# 添加開始日期(如果提供)
if start_date:
params["start_date"] = format_date(start_date)
# 添加結束日期(如果提供)
if end_date:
params["end_date"] = format_date(end_date)
# 添加額外參數
params.update(extra_params)
try:
response = requests.get(
API_BASE_URL,
headers=headers,
params=params,
timeout=30
)
response.raise_for_status()
# 解析 JSON 回應
data = response.json()
# 檢查 API 層級的錯誤
if "msg" in data:
msg = data["msg"].lower()
# 速率限制錯誤
if "rate limit" in msg or "too many requests" in msg:
raise FinMindRateLimitError(f"超過 FinMind API 速率限制:{data['msg']}")
# 認證錯誤
if "token" in msg or "authentication" in msg or "unauthorized" in msg:
raise FinMindAuthenticationError(f"FinMind API 認證失敗:{data['msg']}")
# 資料不存在
if "no data" in msg or "not found" in msg:
raise FinMindDataNotFoundError(f"查無資料:{data['msg']}")
# 其他錯誤
if data.get("status") != 200:
raise FinMindError(f"FinMind API 錯誤:{data['msg']}")
return data
except requests.exceptions.Timeout:
raise FinMindError("FinMind API 請求超時")
except requests.exceptions.RequestException as e:
raise FinMindError(f"FinMind API 請求失敗:{str(e)}")
except json.JSONDecodeError:
raise FinMindError("無法解析 FinMind API 回應")
# ============================================================================
# 資料驗證和處理工具
# ============================================================================
def validate_taiwan_stock_id(stock_id: str) -> bool:
"""
驗證台灣股票代碼格式。
台灣股票代碼通常為 4-6 位數字,
ETF 或特殊商品可能包含字母。
Args:
stock_id: 股票代碼
Returns:
bool: 是否為有效的股票代碼格式
"""
if not stock_id:
return False
# 移除空白
stock_id = stock_id.strip()
# 基本長度檢查
if len(stock_id) < 4 or len(stock_id) > 6:
return False
# 大多數台股代碼為純數字
if stock_id.isdigit():
return True
# 某些 ETF 或特殊商品可能包含字母(如 00878
if stock_id[0].isdigit():
return True
return False
def normalize_stock_id(stock_id: str) -> str:
"""
標準化股票代碼格式。
Args:
stock_id: 股票代碼
Returns:
str: 標準化後的股票代碼
"""
# 移除空白和特殊字元
normalized = stock_id.strip().upper()
# 移除可能的 .TW 或 .TWO 後綴(台股在某些系統的格式)
for suffix in [".TW", ".TWO", ".TT"]:
if normalized.endswith(suffix):
normalized = normalized[:-len(suffix)]
break
return normalized
def _filter_by_date_range(
data: list,
date_field: str,
start_date: str,
end_date: str
) -> list:
"""
按日期範圍過濾資料列表。
Args:
data: 要過濾的資料列表
date_field: 日期欄位名稱
start_date: 開始日期
end_date: 結束日期
Returns:
list: 過濾後的資料列表
"""
if not data:
return data
try:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
filtered = []
for item in data:
if date_field in item:
try:
item_date = datetime.strptime(item[date_field], "%Y-%m-%d")
if start_dt <= item_date <= end_dt:
filtered.append(item)
except (ValueError, TypeError):
continue
return filtered
except Exception:
return data
# ============================================================================
# 台股市場類型判斷
# ============================================================================
# 股票市場類型緩存(避免重複查詢)
_stock_market_type_cache: Dict[str, str] = {}
def get_stock_market_type(stock_id: str) -> str:
"""
獲取台股的市場類型(上市/上櫃/興櫃)。
透過 FinMind 的 TaiwanStockInfo API 查詢股票資訊,
根據 type 欄位判斷市場類型:
- twse: 上市Yahoo Finance 後綴 .TW
- tpex: 上櫃Yahoo Finance 後綴 .TWO
- rotc: 興櫃Yahoo Finance 後綴 .TWO
Args:
stock_id: 台灣股票代碼(例如 "2330"
Returns:
str: 市場類型代碼 ("twse", "tpex", "rotc") 或 "unknown"
"""
global _stock_market_type_cache
stock_id = normalize_stock_id(stock_id)
# 檢查緩存
if stock_id in _stock_market_type_cache:
return _stock_market_type_cache[stock_id]
try:
response = _make_api_request(
dataset="TaiwanStockInfo",
data_id=stock_id
)
if "data" in response and response["data"]:
for item in response["data"]:
if item.get("stock_id") == stock_id:
market_type = item.get("type", "unknown")
_stock_market_type_cache[stock_id] = market_type
return market_type
# 如果找不到,預設為上市
_stock_market_type_cache[stock_id] = "twse"
return "twse"
except Exception as e:
print(f"警告:無法獲取 {stock_id} 的市場類型:{e}")
# 預設為上市
return "twse"
def get_yfinance_ticker(stock_id: str) -> str:
"""
將台股代碼轉換為 Yahoo Finance 格式。
根據市場類型添加適當的後綴:
- 上市(twse): 加 .TW
- 上櫃(tpex): 加 .TWO
- 興櫃(rotc): 加 .TWO
Args:
stock_id: 台灣股票代碼(例如 "2330"
Returns:
str: Yahoo Finance 格式的代碼(例如 "2330.TW"
"""
stock_id = normalize_stock_id(stock_id)
# 檢查是否為台股代碼數字開頭4-6位
if not stock_id or not stock_id[0].isdigit():
return stock_id # 非台股,直接返回
if len(stock_id) < 4 or len(stock_id) > 6:
return stock_id # 不符合台股格式
# 獲取市場類型
market_type = get_stock_market_type(stock_id)
if market_type == "twse":
return f"{stock_id}.TW"
elif market_type in ["tpex", "rotc"]:
return f"{stock_id}.TWO"
else:
# 預設使用上市後綴
return f"{stock_id}.TW"
def is_taiwan_stock(stock_id: str) -> bool:
"""
判斷股票代碼是否為台灣股票。
判斷邏輯:
- 4-6 位數字開頭
- 或已有 .TW/.TWO 後綴
Args:
stock_id: 股票代碼
Returns:
bool: 是否為台灣股票
"""
if not stock_id:
return False
stock_id = stock_id.strip().upper()
# 檢查是否有台股後綴
if stock_id.endswith(".TW") or stock_id.endswith(".TWO"):
return True
# 檢查是否為純數字或數字開頭的 4-6 位代碼
if len(stock_id) >= 4 and len(stock_id) <= 6 and stock_id[0].isdigit():
return True
return False