""" akshare data adapter for TradingAgents. Replaces yfinance as the data vendor for Chinese (A-share), US, and HK stocks. Uses akshare (https://github.com/akfamily/akshare) which sources data from East Money (东方财富), providing reliable access without Yahoo Finance rate limits. """ import pandas as pd from datetime import datetime, timedelta from typing import Optional from dateutil.relativedelta import relativedelta import os # Suppress akshare's internal tqdm progress bars os.environ.setdefault('TQDM_DISABLE', '1') import akshare as ak # ═══════════════════════════════════════════════════════════════ # Symbol detection helpers # ═══════════════════════════════════════════════════════════════ def _detect_market(symbol: str) -> str: """Detect market: 'cn' (A-share), 'us' (US), or 'hk' (HK). A-share: 6-digit numeric (000001, 600519) US: alphabetic tickers (AAPL, TSLA) HK: 5-digit numeric (00700, 09988) """ sym = symbol.strip().upper().replace(".", "").replace("-", "") if sym.isdigit(): if len(sym) == 6: return "cn" elif len(sym) == 5: return "hk" return "us" def _get_exchange_prefix(symbol: str) -> str: """Get exchange prefix for A-share financial statement APIs. Shanghai: 60xxxx, 68xxxx → 'SH' Shenzhen: 00xxxx, 30xxxx → 'SZ' Beijing: 8xxxxx, 4xxxxx → 'BJ' """ sym = symbol.strip() first = sym[0] if first == '6': return "SH" elif first in ('0', '3'): return "SZ" elif first in ('8', '4'): return "BJ" return "SZ" # ═══════════════════════════════════════════════════════════════ # Data fetching helpers # ═══════════════════════════════════════════════════════════════ COLUMN_MAP = { "日期": "date", "开盘": "open", "收盘": "close", "最高": "high", "最低": "low", "成交量": "volume", "成交额": "amount", "振幅": "amplitude", "涨跌幅": "pct_change", "涨跌额": "change", "换手率": "turnover", } def _get_cn_stock_data(symbol: str, start_date: str, end_date: str) -> pd.DataFrame: """Fetch A-share OHLCV via akshare stock_zh_a_hist.""" df = ak.stock_zh_a_hist( symbol=symbol.strip(), period="daily", start_date=start_date.replace("-", ""), end_date=end_date.replace("-", ""), adjust="qfq", ) df = df.rename(columns=COLUMN_MAP) needed = ["date", "open", "close", "high", "low", "volume"] return df[[c for c in needed if c in df.columns]] def _get_us_stock_data(symbol: str, start_date: str, end_date: str) -> pd.DataFrame: """Fetch US stock OHLCV via akshare stock_us_daily (no market code needed). stock_us_daily returns all historical data; we filter by date range here. """ ticker = symbol.strip().upper() # akshare US tickers use dot notation (BRK.B), not dash (BRK-B) if "-" in symbol: ticker = ticker.replace("-", ".") df = ak.stock_us_daily(symbol=ticker, adjust="qfq") df["date"] = pd.to_datetime(df["date"]) mask = (df["date"] >= pd.Timestamp(start_date)) & (df["date"] <= pd.Timestamp(end_date)) df = df[mask] needed = ["date", "open", "close", "high", "low", "volume"] return df[[c for c in needed if c in df.columns]] def _get_hk_stock_data(symbol: str, start_date: str, end_date: str) -> pd.DataFrame: """Fetch HK stock OHLCV via akshare stock_hk_hist.""" df = ak.stock_hk_hist( symbol=symbol.strip(), period="daily", start_date=start_date.replace("-", ""), end_date=end_date.replace("-", ""), adjust="qfq", ) df = df.rename(columns=COLUMN_MAP) needed = ["date", "open", "close", "high", "low", "volume"] return df[[c for c in needed if c in df.columns]] # ═══════════════════════════════════════════════════════════════ # Formatting helpers # ═══════════════════════════════════════════════════════════════ def _fmt_stock_data(df: pd.DataFrame, symbol: str) -> str: """Format OHLCV DataFrame as Markdown table string.""" if df.empty: return f"No stock data found for {symbol} in the given date range." df = df.tail(60).copy() df["date"] = df["date"].astype(str) return df.to_markdown(index=False) def _fmt_financial(df: pd.DataFrame, statement: str, symbol: str, freq: str) -> str: """Format financial statement DataFrame. Handles both wide format (A-share: rows=periods, cols=items) and long format (US: rows=item×period, cols=ITEM_NAME+AMOUNT). """ if df.empty: return f"No {freq} {statement} data found for {symbol}." # Long format (US stocks) — pivot to wide if "ITEM_NAME" in df.columns and "AMOUNT" in df.columns: pivot = df.pivot_table( index="REPORT_DATE", columns="ITEM_NAME", values="AMOUNT", aggfunc="first", ) pivot = pivot.sort_index(ascending=False) return pivot.head(8).to_markdown() # Wide format (A-share) — select date + key items key_items = { "balance sheet": [ "REPORT_DATE", "TOTAL_ASSETS", "TOTAL_LIABILITIES", "TOTAL_EQUITY", "MONETARYFUNDS", "INVENTORY", "TOTAL_CURRENT_ASSETS", "TOTAL_CURRENT_LIAB", "TOTAL_NONCURRENT_ASSETS", "TOTAL_NONCURRENT_LIAB", "SHORT_LOAN", "LONG_LOAN", "GOODWILL", ], "cash flow": [ "REPORT_DATE", "NETCASH_OPERATE", "NETCASH_INVEST", "NETCASH_FINANCE", "CASH_EQUIVALENT_BEGIN", "CASH_EQUIVALENT_END", "OPERATE_INFLOW", "OPERATE_OUTFLOW", "INVEST_INFLOW", "INVEST_OUTFLOW", "FINANCE_INFLOW", "FINANCE_OUTFLOW", ], "income statement": [ "REPORT_DATE", "TOTAL_OPERATE_INCOME", "OPERATE_PROFIT", "TOTAL_PROFIT", "NET_PROFIT", "BASIC_EPS", "TOTAL_OPERATE_COST", "OPERATE_COST", "RESEARCH_EXPENSE", "SELL_EXPENSE", "MANAGE_EXPENSE", "FINANCE_EXPENSE", ], } stat_key = statement.lower().replace("_", " ") items = key_items.get(stat_key, []) available = [c for c in items if c in df.columns] if not available: # Fallback: show all columns but limit columns to first 15 result = df.head(8).iloc[:, :15] else: result = df[available].head(8) return result.to_markdown(index=False) # ═══════════════════════════════════════════════════════════════ # Public API — matches y_finance.py interface # ═══════════════════════════════════════════════════════════════ def get_stock_data(symbol: str, start_date: str, end_date: str) -> str: """Get stock OHLCV price data. Returns Markdown table string.""" try: market = _detect_market(symbol) if market == "cn": df = _get_cn_stock_data(symbol, start_date, end_date) elif market == "us": df = _get_us_stock_data(symbol, start_date, end_date) else: df = _get_hk_stock_data(symbol, start_date, end_date) return _fmt_stock_data(df, symbol) except Exception as e: return f"[akshare] Error fetching stock data for {symbol}: {e}" def get_indicators( symbol: str, indicator: str, curr_date: str, look_back_days: int = 365, ) -> str: """Get technical indicator values. Returns formatted string with date: value lines. Uses stockstats for calculation (same as yfinance adapter). """ from stockstats import wrap # Indicator descriptions (same as yfinance) best_ind_params = { "close_5_sma": ( "5-day SMA: A short-term simple moving average. " "Usage: Identify short-term trend direction and potential entry/exit signals. " "Tips: Often combined with longer SMAs (e.g., 20-day) for crossover strategies." ), "close_10_sma": ( "10-day SMA: A short to medium-term simple moving average. " "Usage: Useful for gauging immediate trend strength and potential support/resistance levels." ), "close_20_sma": ( "20-day SMA: A medium-term simple moving average. " "Usage: Commonly used to identify the intermediate trend and key swing levels." ), "close_50_sma": ( "50-day SMA: A medium to long-term simple moving average. " "Usage: Watched by many traders to gauge the intermediate trend strength." ), "close_200_sma": ( "200-day SMA: A long-term simple moving average. " "Usage: Often used to determine the overall bull/bear market trend." ), "close_5_ema": ( "5-day EMA: A short-term exponential moving average giving more weight to recent prices. " "Usage: Provides early signals for short-term trend changes." ), "close_10_ema": ( "10-day EMA: A short to medium-term exponential moving average. " "Usage: Reacts faster to price changes than SMA; good for active trading strategies." ), "close_20_ema": ( "20-day EMA: A medium-term exponential moving average. " "Usage: Commonly watched by swing and short-term traders for trend confirmation." ), "close_50_ema": ( "50-day EMA: A medium to long-term exponential moving average. " "Usage: Offers a balance between responsiveness and smoothing for trend analysis." ), "close_200_ema": ( "200-day EMA: A long-term exponential moving average. " "Usage: Often used by longer-term traders to gauge major trend turns." ), "rsi": ( "RSI (14): The Relative Strength Index measures momentum on a scale of 0 to 100. " "Usage: Identify overbought (>70) or oversold (<30) conditions. " "Tips: Combine with trend indicators; divergences between RSI and price can signal reversals." ), "rsi_6": ( "RSI (6): A faster-setting RSI. " "Usage: More sensitive and generates earlier signals, ideal for quick momentum shifts." ), "rsi_14": ( "RSI (14): The standard Relative Strength Index. " "Usage: Same as RSI; widely used for identifying overbought/oversold and reversals." ), "macd": ( "MACD (Moving Average Convergence Divergence): Trend-following momentum indicator. " "Usage: Shows the relationship between two EMAs. " "Tips: Crossovers of the MACD and signal line, as well as divergence from price, can indicate trades." ), "macds": ( "MACD Signal: The 9-day EMA of the MACD line. " "Usage: Acts as a trigger for buy/sell signals when the MACD line crosses above or below the signal line." ), "macdh": ( "MACD Histogram: The difference between MACD and signal line. " "Usage: Illustrates changes in momentum; a rising histogram indicates increasing momentum." ), "boll": ( "Bollinger Bands Middle: 20-day SMA central line. " "Usage: The baseline for volatility bands. " "Tips: Prices reverting to the middle may suggest fading momentum." ), "boll_ub": ( "Bollinger Bands Upper: Upper volatility band (2 std dev above middle). " "Usage: May indicate overbought conditions. " "Tips: Used with lower band to identify squeeze and breakout patterns." ), "boll_lb": ( "Bollinger Bands Lower: Lower volatility band (2 std dev below middle). " "Usage: May indicate oversold conditions. " "Tips: Price touching/extending beyond the band can signal strong trend continuation." ), "atr": ( "ATR: Average True Range measures market volatility. " "Usage: High ATR indicates increased volatility; useful for setting stop-losses and position sizing." ), "vwma": ( "VWMA: A moving average weighted by volume. " "Usage: Confirm trends by integrating price action with volume data. " "Tips: Watch for skewed results from volume spikes; use in combination with other volume analyses." ), "mfi": ( "MFI: The Money Flow Index is a momentum indicator that uses both price and volume to measure buying and selling pressure. " "Usage: Identify overbought (>80) or oversold (<20) conditions and confirm the strength of trends or reversals. " "Tips: Use alongside RSI or MACD to confirm signals; divergence between price and MFI can indicate potential reversals." ), } if indicator not in best_ind_params: raise ValueError( f"Indicator {indicator} is not supported. Please choose from: {list(best_ind_params.keys())}" ) end_date = datetime.strptime(curr_date, "%Y-%m-%d") start_date = end_date - relativedelta(days=look_back_days + 200) # buffer for SMA calc try: # Fetch OHLCV via akshare market = _detect_market(symbol) if market == "cn": df = _get_cn_stock_data(symbol, start_date.strftime("%Y-%m-%d"), curr_date) elif market == "us": df = _get_us_stock_data(symbol, start_date.strftime("%Y-%m-%d"), curr_date) else: df = _get_hk_stock_data(symbol, start_date.strftime("%Y-%m-%d"), curr_date) # Prepare for stockstats (expects capitalized column names) df = df.rename(columns={ "date": "Date", "open": "Open", "close": "Close", "high": "High", "low": "Low", "volume": "Volume", }) sdf = wrap(df) sdf["Date"] = pd.to_datetime(sdf["Date"]).dt.strftime("%Y-%m-%d") # Trigger calculation sdf[indicator] # Build dict: date → value result_map = {} for _, row in sdf.iterrows(): val = row[indicator] result_map[row["Date"]] = f"{val:.4f}" if not pd.isna(val) and isinstance(val, float) else str(val) if not pd.isna(val) else "N/A" except Exception as e: # Fallback: date-by-date calculation (less efficient but more resilient) print(f"[akshare] Bulk indicator calc failed: {e}, falling back to per-date") ind_string = "" curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") fallback_dt = curr_date_dt while fallback_dt >= end_date - relativedelta(days=look_back_days): indicator_value = _get_indicator_single(symbol, indicator, fallback_dt.strftime("%Y-%m-%d")) ind_string += f"{fallback_dt.strftime('%Y-%m-%d')}: {indicator_value}\n" fallback_dt -= relativedelta(days=1) result_str = ( f"## {indicator} values from " f"{end_date - relativedelta(days=look_back_days):%Y-%m-%d} to {curr_date}:\n\n" + ind_string + "\n\n" + best_ind_params.get(indicator, "") ) return result_str # Build output string ind_string = "" current_dt = end_date before = end_date - relativedelta(days=look_back_days) while current_dt >= before: date_str = current_dt.strftime("%Y-%m-%d") value = result_map.get(date_str, "N/A: Not a trading day (weekend or holiday)") ind_string += f"{date_str}: {value}\n" current_dt -= relativedelta(days=1) result_str = ( f"## {indicator} values from {before.strftime('%Y-%m-%d')} to {curr_date}:\n\n" + ind_string + "\n\n" + best_ind_params.get(indicator, "") ) return result_str def _get_indicator_single(symbol: str, indicator: str, date_str: str) -> str: """Fallback: calculate a single indicator value for one date.""" from stockstats import wrap try: end_date = datetime.strptime(date_str, "%Y-%m-%d") start_date = end_date - relativedelta(days=400) market = _detect_market(symbol) if market == "cn": df = _get_cn_stock_data(symbol, start_date.strftime("%Y-%m-%d"), date_str) elif market == "us": df = _get_us_stock_data(symbol, start_date.strftime("%Y-%m-%d"), date_str) else: df = _get_hk_stock_data(symbol, start_date.strftime("%Y-%m-%d"), date_str) df = df.rename(columns={ "date": "Date", "open": "Open", "close": "Close", "high": "High", "low": "Low", "volume": "Volume", }) sdf = wrap(df) sdf["Date"] = pd.to_datetime(sdf["Date"]).dt.strftime("%Y-%m-%d") sdf[indicator] matching = sdf[sdf["Date"].str.startswith(date_str)] if not matching.empty: val = matching[indicator].values[0] return f"{val:.4f}" if isinstance(val, float) else str(val) return "N/A: Not a trading day (weekend or holiday)" except Exception as e: return f"N/A (error: {e})" def get_fundamentals(ticker: str, curr_date: str = None) -> str: """Get company fundamentals. Returns Markdown string.""" try: market = _detect_market(ticker) if market == "cn": df = ak.stock_individual_info_em(symbol=ticker.strip()) lines = [f"# Fundamentals for {ticker}\n"] for _, row in df.head(30).iterrows(): lines.append(f"- **{row['item']}**: {row['value']}") return "\n".join(lines) elif market == "us": t = ticker.strip().upper().replace("-", ".") df = ak.stock_individual_basic_info_us_xq(symbol=t) lines = [f"# Fundamentals for {ticker}\n"] for _, row in df.head(25).iterrows(): lines.append(f"- **{row['item']}**: {row['value']}") return "\n".join(lines) else: # HK try: df = ak.stock_hk_company_profile_em(symbol=ticker.strip()) return df.to_markdown(index=False) except Exception: return f"Fundamentals not available for HK stock {ticker} via akshare." except Exception as e: return f"[akshare] Error fetching fundamentals for {ticker}: {e}" def get_balance_sheet(ticker: str, freq: str = "annual", curr_date: str = None) -> str: """Get balance sheet. freq: 'annual' or 'quarterly'. Returns Markdown string.""" try: market = _detect_market(ticker) if market == "cn": prefix = _get_exchange_prefix(ticker) df = ak.stock_balance_sheet_by_report_em(symbol=f"{prefix}{ticker.strip()}") if freq in ("annual", "yearly"): df = df[df["REPORT_TYPE"] == "年报"] else: df = df[df["REPORT_TYPE"].isin(["一季报", "中报", "三季报"])] if curr_date and not df.empty: df = df[pd.to_datetime(df["REPORT_DATE"]) <= pd.Timestamp(curr_date)] return _fmt_financial(df, "balance sheet", ticker, freq) elif market == "us": indicator = "年报" if freq in ("annual", "yearly") else "单季报" t = ticker.strip().upper().replace("-", ".") df = ak.stock_financial_us_report_em( stock=t, symbol="资产负债表", indicator=indicator, ) if curr_date and not df.empty: df = df[pd.to_datetime(df["REPORT_DATE"]) <= pd.Timestamp(curr_date)] return _fmt_financial(df, "balance sheet", ticker, freq) else: return f"Balance sheet not supported for HK stock {ticker} via akshare." except Exception as e: return f"[akshare] Error fetching balance sheet for {ticker}: {e}" def get_cashflow(ticker: str, freq: str = "annual", curr_date: str = None) -> str: """Get cash flow statement. freq: 'annual' or 'quarterly'. Returns Markdown string.""" try: market = _detect_market(ticker) if market == "cn": prefix = _get_exchange_prefix(ticker) df = ak.stock_cash_flow_sheet_by_report_em(symbol=f"{prefix}{ticker.strip()}") if freq in ("annual", "yearly"): df = df[df["REPORT_TYPE"] == "年报"] else: df = df[df["REPORT_TYPE"].isin(["一季报", "中报", "三季报"])] if curr_date and not df.empty: df = df[pd.to_datetime(df["REPORT_DATE"]) <= pd.Timestamp(curr_date)] return _fmt_financial(df, "cash flow", ticker, freq) elif market == "us": indicator = "年报" if freq in ("annual", "yearly") else "单季报" t = ticker.strip().upper().replace("-", ".") df = ak.stock_financial_us_report_em( stock=t, symbol="现金流量表", indicator=indicator, ) if curr_date and not df.empty: df = df[pd.to_datetime(df["REPORT_DATE"]) <= pd.Timestamp(curr_date)] return _fmt_financial(df, "cash flow", ticker, freq) else: return f"Cash flow statement not supported for HK stock {ticker} via akshare." except Exception as e: return f"[akshare] Error fetching cash flow for {ticker}: {e}" def get_income_statement(ticker: str, freq: str = "annual", curr_date: str = None) -> str: """Get income statement. freq: 'annual' or 'quarterly'. Returns Markdown string.""" try: market = _detect_market(ticker) if market == "cn": prefix = _get_exchange_prefix(ticker) df = ak.stock_profit_sheet_by_report_em(symbol=f"{prefix}{ticker.strip()}") if freq in ("annual", "yearly"): df = df[df["REPORT_TYPE"] == "年报"] else: df = df[df["REPORT_TYPE"].isin(["一季报", "中报", "三季报"])] if curr_date and not df.empty: df = df[pd.to_datetime(df["REPORT_DATE"]) <= pd.Timestamp(curr_date)] return _fmt_financial(df, "income statement", ticker, freq) elif market == "us": indicator = "年报" if freq in ("annual", "yearly") else "单季报" t = ticker.strip().upper().replace("-", ".") df = ak.stock_financial_us_report_em( stock=t, symbol="综合损益表", indicator=indicator, ) if curr_date and not df.empty: df = df[pd.to_datetime(df["REPORT_DATE"]) <= pd.Timestamp(curr_date)] return _fmt_financial(df, "income statement", ticker, freq) else: return f"Income statement not supported for HK stock {ticker} via akshare." except Exception as e: return f"[akshare] Error fetching income statement for {ticker}: {e}" def get_insider_transactions(ticker: str) -> str: """Insider transactions not supported by akshare. Raise AlphaVantageRateLimitError to trigger fallback to yfinance.""" from .alpha_vantage_common import AlphaVantageRateLimitError raise AlphaVantageRateLimitError( f"akshare does not support insider transactions for {ticker}. " "Falling back to next vendor." )