586 lines
19 KiB
Python
586 lines
19 KiB
Python
# -*- 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
|