"""Unified data downloader: akshare primary, baostock fallback.""" import logging from datetime import date, datetime from typing import Optional import pandas as pd import akshare as ak import baostock as bs logger = logging.getLogger(__name__) BAOSTOCK_FREQ_MAP = {"d": "d", "w": "w", "m": "m"} # baostock only supports daily def _download_akshare(symbol: str, start: str, end: str, adjust: str = "qfq") -> Optional[pd.DataFrame]: """Download daily bars from akshare. Returns DataFrame with OHLCV columns.""" try: # symbol format: 'sh600000' in akshare stock_zh_a_hist expects raw code like '600000' # strip exchange prefix for akshare raw = symbol.replace("sh", "").replace("sz", "") df = ak.stock_zh_a_hist( symbol=raw, period="daily", start_date=start, end_date=end, adjust=adjust, ) if df is None or df.empty: return None # Standardize columns col_map = { "日期": "date", "开盘": "open", "最高": "high", "最低": "low", "收盘": "close", "成交量": "volume", "成交额": "amount", } df = df.rename(columns=col_map) df["symbol"] = symbol return df[["symbol", "date", "open", "high", "low", "close", "volume", "amount"]] except Exception as e: logger.warning(f"akshare download failed for {symbol}: {e}") return None def _download_baostock(symbol: str, start: str, end: str, frequency: str = "d") -> Optional[pd.DataFrame]: """Download daily bars from baostock as fallback.""" try: bs.login() # baostock format: sh.600000 code = f"{symbol[:2]}.{symbol[2:]}" rs = bs.query_history_k_data_plus( code=code, fields="date,open,high,low,close,volume,amount", start_date=start, end_date=end, frequency=frequency, adjustflag="2", # qfq ) if rs.error_code != "0": logger.warning(f"baostock error for {symbol}: {rs.error_msg}") return None data_list = [] while rs.next(): data_list.append(rs.get_row_data()) bs.logout() if not data_list: return None df = pd.DataFrame(data_list, columns=["date", "open", "high", "low", "close", "volume", "amount"]) df[["open", "high", "low", "close", "volume", "amount"]] = df[ ["open", "high", "low", "close", "volume", "amount"] ].astype(float) df["symbol"] = symbol return df[["symbol", "date", "open", "high", "low", "close", "volume", "amount"]] except Exception as e: logger.warning(f"baostock download failed for {symbol}: {e}") try: bs.logout() except Exception: pass return None def download_daily( symbol: str, start: str, end: str, adjust: str = "qfq", source: str = "auto", ) -> pd.DataFrame: """ Download daily OHLCV data. Tries akshare first, falls back to baostock. Args: symbol: Stock symbol like 'sh600000' or 'sz000001' start: Start date 'YYYY-MM-DD' end: End date 'YYYY-MM-DD' adjust: 'qfq' (forward-adjusted), 'hfq' (backward), '' (none) source: 'auto' (akshare then baostock fallback), 'akshare' only, or 'baostock' only Returns: DataFrame with columns: symbol, date, open, high, low, close, volume, amount """ df = None if source in ("akshare", "auto"): df = _download_akshare(symbol, start, end, adjust) if df is None and source in ("baostock", "auto"): df = _download_baostock(symbol, start, end) if df is None or df.empty: raise RuntimeError(f"Failed to download data for {symbol} from {start} to {end}") df["date"] = pd.to_datetime(df["date"]) df = df.sort_values("date").reset_index(drop=True) return df def download_batch( symbols: list[str], start: str, end: str, adjust: str = "qfq", ) -> dict[str, pd.DataFrame]: """Download daily data for multiple symbols. Returns {symbol: DataFrame}.""" results = {} for sym in symbols: try: results[sym] = download_daily(sym, start, end, adjust) logger.info(f"Downloaded {sym}: {len(results[sym])} bars") except Exception as e: logger.error(f"Failed {sym}: {e}") return results