# -*- coding: utf-8 -*- """ FinMind 技術指標資料模組 用於獲取台灣股市技術指標和籌碼面數據 API 文檔: - 技術面:https://finmind.github.io/tutor/TaiwanMarket/Technical/ - 籌碼面:https://finmind.github.io/tutor/TaiwanMarket/Chip/ 可用的資料集: - TaiwanStockPER: 個股 PER、PBR 資料 - TaiwanStockMarginPurchaseShortSale: 個股融資融劵表 - TaiwanStockInstitutionalInvestorsBuySell: 法人買賣表 - TaiwanStockShareholding: 外資持股表 技術指標計算: - 本模組會從 FinMind 獲取股價數據,自行計算技術指標(SMA、RSI、MACD 等) - 不需要依賴 Alpha Vantage 或其他外部服務 注意:本模組不使用需要 backer/sponsor 會員資格的功能 """ import json from datetime import datetime from dateutil.relativedelta import relativedelta from typing import Optional import pandas as pd import numpy as np from .finmind_common import ( _make_api_request, format_date, get_default_start_date, normalize_stock_id, FinMindError, FinMindDataNotFoundError, format_output, ) # 指標描述 INDICATOR_DESCRIPTIONS = { "per": "本益比(PER):股價與每股盈餘之比,用於評估股票價值。較低的 PER 可能表示股票被低估。", "pbr": "股價淨值比(PBR):股價與每股淨值之比,用於評估公司淨資產價值。PBR 低於 1 可能表示股票被低估。", "dividend_yield": "殖利率:股利與股價之比,衡量股票收益率。較高的殖利率可能吸引存股族。", "margin_purchase": "融資餘額:投資人向券商借錢買股票的未還金額。融資增加可能表示看多情緒。", "short_sale": "融券餘額:投資人借股票賣出的未補回數量。融券增加可能表示看空情緒。", "institutional": "三大法人買賣超:外資、投信、自營商的買賣超情況,是重要的市場動向指標。", "foreign_holding": "外資持股比例:外資持有該股票的比例。外資持續買超可能推升股價。", # 技術指標描述 "sma": "簡單移動平均線(SMA):過去 N 日收盤價的平均值,用於判斷趨勢方向。", "ema": "指數移動平均線(EMA):加權移動平均,對近期價格給予較高權重。", "rsi": "相對強弱指標(RSI):衡量價格動能,RSI>70 為超買,RSI<30 為超賣。", "macd": "MACD:趨勢動能指標,由快線、慢線和柱狀圖組成,用於判斷買賣時機。", "bbands": "布林通道(Bollinger Bands):由中軌(SMA)和上下軌組成,用於判斷價格波動範圍。", } # 支援的技術指標列表 CALCULATED_INDICATORS = [ "sma", "ema", "rsi", "macd", "bbands", "close_5_sma", "close_10_sma", "close_20_sma", "close_50_sma", "close_100_sma", "close_200_sma", "close_5_ema", "close_10_ema", "close_20_ema", "close_50_ema", ] def _get_stock_price_data(symbol: str, start_date: str, end_date: str) -> pd.DataFrame: """獲取股價數據並轉換為 DataFrame""" response = _make_api_request( dataset="TaiwanStockPrice", data_id=symbol, start_date=start_date, end_date=end_date ) if "data" not in response or not response["data"]: raise FinMindDataNotFoundError(f"找不到 {symbol} 的股價數據") df = pd.DataFrame(response["data"]) df["date"] = pd.to_datetime(df["date"]) df = df.sort_values("date").reset_index(drop=True) # 重命名欄位 df = df.rename(columns={ "max": "high", "min": "low", "Trading_Volume": "volume" }) return df def _calculate_sma(df: pd.DataFrame, period: int) -> pd.Series: """計算簡單移動平均線""" return df["close"].rolling(window=period).mean() def _calculate_ema(df: pd.DataFrame, period: int) -> pd.Series: """計算指數移動平均線""" return df["close"].ewm(span=period, adjust=False).mean() def _calculate_rsi(df: pd.DataFrame, period: int = 14) -> pd.Series: """計算 RSI 指標""" delta = df["close"].diff() gain = delta.where(delta > 0, 0) loss = (-delta).where(delta < 0, 0) avg_gain = gain.rolling(window=period).mean() avg_loss = loss.rolling(window=period).mean() rs = avg_gain / avg_loss rsi = 100 - (100 / (1 + rs)) return rsi def _calculate_macd(df: pd.DataFrame, fast: int = 12, slow: int = 26, signal: int = 9) -> dict: """計算 MACD 指標""" ema_fast = df["close"].ewm(span=fast, adjust=False).mean() ema_slow = df["close"].ewm(span=slow, adjust=False).mean() macd_line = ema_fast - ema_slow signal_line = macd_line.ewm(span=signal, adjust=False).mean() histogram = macd_line - signal_line return { "macd": macd_line, "signal": signal_line, "histogram": histogram } def _calculate_bbands(df: pd.DataFrame, period: int = 20, std_dev: float = 2.0) -> dict: """計算布林通道""" sma = df["close"].rolling(window=period).mean() std = df["close"].rolling(window=period).std() return { "upper": sma + (std * std_dev), "middle": sma, "lower": sma - (std * std_dev) } def get_indicator( symbol: str, indicator: str, curr_date: str, look_back_days: int, interval: str = "daily", time_period: int = 14, series_type: str = "close" ) -> str: """ 返回 FinMind 在一個時間窗口內的技術指標/籌碼面數據。 支援的籌碼面指標: - per: 本益比 - pbr: 股價淨值比 - dividend_yield: 殖利率 - margin_purchase: 融資餘額 - short_sale: 融券餘額 - institutional: 三大法人買賣超 - foreign_holding: 外資持股比例 支援的技術指標(從股價計算): - sma, close_N_sma: 簡單移動平均線 - ema, close_N_ema: 指數移動平均線 - rsi: 相對強弱指標 - macd: MACD 指標 - bbands: 布林通道 Args: symbol: 股票代碼(例如 "2330") indicator: 要獲取的指標類型 curr_date: 當前交易日期,格式為 YYYY-mm-dd look_back_days: 回溯天數 interval: 時間間隔(保留參數,FinMind 只支援日線) time_period: 用於計算的天數 series_type: 價格類型(保留參數) Returns: str: 包含指標值和描述的字串 """ symbol = normalize_stock_id(symbol) indicator = indicator.lower() # 籌碼面指標 chip_indicators = [ "per", "pbr", "dividend_yield", "margin_purchase", "short_sale", "institutional", "foreign_holding" ] curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") before = curr_date_dt - relativedelta(days=look_back_days) start_date = format_date(before) end_date = format_date(curr_date_dt) try: # 籌碼面指標 if indicator in chip_indicators: if indicator in ["per", "pbr", "dividend_yield"]: return _get_per_pbr_indicator(symbol, indicator, start_date, end_date) elif indicator in ["margin_purchase", "short_sale"]: return _get_margin_indicator(symbol, indicator, start_date, end_date) elif indicator == "institutional": return _get_institutional_indicator(symbol, start_date, end_date) elif indicator == "foreign_holding": return _get_foreign_holding_indicator(symbol, start_date, end_date) # 技術指標(從股價計算) else: return _calculate_technical_indicator( symbol=symbol, indicator=indicator, start_date=start_date, end_date=end_date, time_period=time_period, look_back_days=look_back_days ) except Exception as e: print(f"獲取 {indicator} 的 FinMind 指標數據時出錯:{e}") return f"檢索 {indicator} 數據時出錯:{str(e)}" def _calculate_technical_indicator( symbol: str, indicator: str, start_date: str, end_date: str, time_period: int, look_back_days: int ) -> str: """計算技術指標""" indicator = indicator.lower() # 解析指標名稱(例如 close_50_sma -> sma, period=50) period = time_period indicator_type = indicator # 解析 close_N_sma 或 close_N_ema 格式 if "_sma" in indicator: parts = indicator.split("_") for i, p in enumerate(parts): if p.isdigit(): period = int(p) indicator_type = "sma" elif "_ema" in indicator: parts = indicator.split("_") for i, p in enumerate(parts): if p.isdigit(): period = int(p) indicator_type = "ema" # 需要更多歷史數據來計算指標 start_date_dt = datetime.strptime(start_date, "%Y-%m-%d") extended_start = start_date_dt - relativedelta(days=max(period + 50, 300)) extended_start_str = format_date(extended_start) try: # 獲取股價數據 df = _get_stock_price_data(symbol, extended_start_str, end_date) if df.empty: return f"找不到 {symbol} 的股價數據來計算 {indicator}。" # 計算指標 if indicator_type == "sma": df["indicator"] = _calculate_sma(df, period) desc = f"{period} 日簡單移動平均線" elif indicator_type == "ema": df["indicator"] = _calculate_ema(df, period) desc = f"{period} 日指數移動平均線" elif indicator_type == "rsi": df["indicator"] = _calculate_rsi(df, period) desc = f"{period} 日 RSI 相對強弱指標" elif indicator_type == "macd": macd_data = _calculate_macd(df) df["macd"] = macd_data["macd"] df["signal"] = macd_data["signal"] df["histogram"] = macd_data["histogram"] desc = "MACD 指標(12, 26, 9)" elif indicator_type == "bbands": bbands_data = _calculate_bbands(df, period) df["bb_upper"] = bbands_data["upper"] df["bb_middle"] = bbands_data["middle"] df["bb_lower"] = bbands_data["lower"] desc = f"{period} 日布林通道" else: return f"不支援的技術指標 {indicator}。支援的技術指標:sma, ema, rsi, macd, bbands, close_N_sma, close_N_ema" # 過濾日期範圍 start_date_dt = datetime.strptime(start_date, "%Y-%m-%d") df = df[df["date"] >= pd.Timestamp(start_date_dt)] # 只顯示最近的數據 df = df.tail(min(look_back_days, 30)) # 格式化輸出 ind_string = "" if indicator_type == "macd": for _, row in df.iterrows(): date = row["date"].strftime("%Y-%m-%d") macd_val = row["macd"] signal_val = row["signal"] hist_val = row["histogram"] if pd.notna(macd_val): ind_string += f"{date}: MACD={macd_val:.4f}, Signal={signal_val:.4f}, Histogram={hist_val:.4f}\n" elif indicator_type == "bbands": for _, row in df.iterrows(): date = row["date"].strftime("%Y-%m-%d") upper = row["bb_upper"] middle = row["bb_middle"] lower = row["bb_lower"] close = row["close"] if pd.notna(upper): ind_string += f"{date}: Upper={upper:.2f}, Middle={middle:.2f}, Lower={lower:.2f}, Close={close:.2f}\n" else: for _, row in df.iterrows(): date = row["date"].strftime("%Y-%m-%d") value = row["indicator"] close = row["close"] if pd.notna(value): ind_string += f"{date}: {indicator.upper()}={value:.4f}, Close={close:.2f}\n" if not ind_string: ind_string = "指定日期範圍內無足夠數據計算指標。\n" result_str = ( f"## 從 {start_date} 到 {end_date} 的 {desc} ({symbol}):\n\n" + ind_string + "\n\n" + INDICATOR_DESCRIPTIONS.get(indicator_type, "技術指標計算自 FinMind 股價數據。") ) return result_str except FinMindError as e: return f"獲取 {indicator} 數據時出錯:{str(e)}" except Exception as e: return f"計算 {indicator} 時出錯:{str(e)}" def _get_per_pbr_indicator( symbol: str, indicator: str, start_date: str, end_date: str ) -> str: """獲取 PER、PBR、殖利率指標""" try: response = _make_api_request( dataset="TaiwanStockPER", data_id=symbol, start_date=start_date, end_date=end_date ) if "data" in response and response["data"]: data = response["data"] # 欄位映射 field_map = { "per": "PER", "pbr": "PBR", "dividend_yield": "dividend_yield" } field_name = field_map.get(indicator, indicator) # 格式化輸出 ind_string = "" for row in sorted(data, key=lambda x: x.get("date", "")): date = row.get("date", "") value = row.get(field_name, "N/A") ind_string += f"{date}: {value}\n" if not ind_string: ind_string = "指定日期範圍內無可用數據。\n" result_str = ( f"## 從 {start_date} 到 {end_date} 的 {indicator.upper()} 值:\n\n" + ind_string + "\n\n" + INDICATOR_DESCRIPTIONS.get(indicator, "無可用描述。") ) return result_str else: return f"找不到 {symbol} 的 {indicator} 數據。" except FinMindError as e: return f"獲取 {indicator} 數據時出錯:{str(e)}" def _get_margin_indicator( symbol: str, indicator: str, start_date: str, end_date: str ) -> str: """獲取融資融券指標""" try: response = _make_api_request( dataset="TaiwanStockMarginPurchaseShortSale", data_id=symbol, start_date=start_date, end_date=end_date ) if "data" in response and response["data"]: data = response["data"] # 欄位映射 if indicator == "margin_purchase": field_name = "MarginPurchaseTodayBalance" display_name = "融資餘額" else: # short_sale field_name = "ShortSaleTodayBalance" display_name = "融券餘額" # 格式化輸出 ind_string = "" for row in sorted(data, key=lambda x: x.get("date", "")): date = row.get("date", "") value = row.get(field_name, "N/A") ind_string += f"{date}: {value:,}\n" if isinstance(value, (int, float)) else f"{date}: {value}\n" if not ind_string: ind_string = "指定日期範圍內無可用數據。\n" result_str = ( f"## 從 {start_date} 到 {end_date} 的 {display_name} ({symbol}):\n\n" + ind_string + "\n\n" + INDICATOR_DESCRIPTIONS.get(indicator, "無可用描述。") ) return result_str else: return f"找不到 {symbol} 的融資融券數據。" except FinMindError as e: return f"獲取融資融券數據時出錯:{str(e)}" def _get_institutional_indicator( symbol: str, start_date: str, end_date: str ) -> str: """獲取三大法人買賣超指標""" try: response = _make_api_request( dataset="TaiwanStockInstitutionalInvestorsBuySell", data_id=symbol, start_date=start_date, end_date=end_date ) if "data" in response and response["data"]: data = response["data"] df = pd.DataFrame(data) # 按日期分組計算各法人買賣超 ind_string = "" # 獲取唯一日期 dates = sorted(df["date"].unique()) for date in dates: day_data = df[df["date"] == date] # 彙總各法人買賣超 foreign = day_data[day_data["name"].str.contains("外資", na=False)]["buy"].sum() - \ day_data[day_data["name"].str.contains("外資", na=False)]["sell"].sum() investment_trust = day_data[day_data["name"].str.contains("投信", na=False)]["buy"].sum() - \ day_data[day_data["name"].str.contains("投信", na=False)]["sell"].sum() dealer = day_data[day_data["name"].str.contains("自營商", na=False)]["buy"].sum() - \ day_data[day_data["name"].str.contains("自營商", na=False)]["sell"].sum() total = foreign + investment_trust + dealer ind_string += f"{date}: 外資 {foreign:+,} / 投信 {investment_trust:+,} / 自營 {dealer:+,} / 合計 {total:+,}\n" if not ind_string: ind_string = "指定日期範圍內無可用數據。\n" result_str = ( f"## 從 {start_date} 到 {end_date} 的三大法人買賣超 ({symbol}):\n\n" + ind_string + "\n\n" + INDICATOR_DESCRIPTIONS.get("institutional", "無可用描述。") ) return result_str else: return f"找不到 {symbol} 的三大法人買賣超數據。" except FinMindError as e: return f"獲取三大法人數據時出錯:{str(e)}" def _get_foreign_holding_indicator( symbol: str, start_date: str, end_date: str ) -> str: """獲取外資持股比例指標""" try: response = _make_api_request( dataset="TaiwanStockShareholding", data_id=symbol, start_date=start_date, end_date=end_date ) if "data" in response and response["data"]: data = response["data"] # 格式化輸出 ind_string = "" for row in sorted(data, key=lambda x: x.get("date", "")): date = row.get("date", "") holding_percent = row.get("ForeignInvestmentSharesRatio", "N/A") holding_shares = row.get("ForeignInvestmentShares", "N/A") if isinstance(holding_percent, (int, float)): ind_string += f"{date}: {holding_percent:.2f}% ({holding_shares:,} 股)\n" else: ind_string += f"{date}: {holding_percent}\n" if not ind_string: ind_string = "指定日期範圍內無可用數據。\n" result_str = ( f"## 從 {start_date} 到 {end_date} 的外資持股比例 ({symbol}):\n\n" + ind_string + "\n\n" + INDICATOR_DESCRIPTIONS.get("foreign_holding", "無可用描述。") ) return result_str else: return f"找不到 {symbol} 的外資持股數據。" except FinMindError as e: return f"獲取外資持股數據時出錯:{str(e)}" def get_margin_data( symbol: str, start_date: str, end_date: str ) -> str: """ 獲取個股融資融劵表完整數據。 資料區間:2001-01-01 ~ now 返回欄位: - date: 日期 - stock_id: 股票代碼 - MarginPurchaseBuy: 融資買進 - MarginPurchaseSell: 融資賣出 - MarginPurchaseTodayBalance: 融資今日餘額 - ShortSaleBuy: 融券買進 - ShortSaleSell: 融券賣出 - ShortSaleTodayBalance: 融券今日餘額 Returns: str: JSON 格式的融資融券數據 """ symbol = normalize_stock_id(symbol) try: response = _make_api_request( dataset="TaiwanStockMarginPurchaseShortSale", data_id=symbol, start_date=start_date, end_date=end_date ) if "data" in response and response["data"]: result = { "stock_id": symbol, "data_type": "margin_trading", "data": response["data"] } return format_output(result) else: return format_output({ "stock_id": symbol, "data": [], "message": "查無資料" }) except FinMindError as e: return format_output({ "error": str(e), "stock_id": symbol }) def get_institutional_data( symbol: str, start_date: str, end_date: str ) -> str: """ 獲取法人買賣表完整數據。 資料區間:2005-01-01 ~ now 返回欄位: - date: 日期 - stock_id: 股票代碼 - name: 法人名稱(外資、投信、自營商等) - buy: 買進金額 - sell: 賣出金額 Returns: str: JSON 格式的法人買賣數據 """ symbol = normalize_stock_id(symbol) try: response = _make_api_request( dataset="TaiwanStockInstitutionalInvestorsBuySell", data_id=symbol, start_date=start_date, end_date=end_date ) if "data" in response and response["data"]: result = { "stock_id": symbol, "data_type": "institutional_investors", "data": response["data"] } return format_output(result) else: return format_output({ "stock_id": symbol, "data": [], "message": "查無資料" }) except FinMindError as e: return format_output({ "error": str(e), "stock_id": symbol })