"""akshare 龙虎榜数据源(2026-07-01 引入)。 提供: - 龙虎榜聚合层(`ak.stock_lhb_detail_em`):每日上榜股票汇总 - 龙虎榜席位层(`ak.stock_lhb_stock_detail_em`):单只上榜票的买卖前五营业部 设计原则: - 严格遵循 [[no-akshare]] 当前规则:仅"无替代源"场景可用。 龙虎榜无合理替代(baostock / mairui / 雪球均无龙虎榜接口)→ 用 akshare。 - 速率限制:~0.5 RPS 主动 sleep(akshare 经验值;东财未明确限速) - 失败处理:席位层对"未上榜"的股票会触发 akshare 内部 `'NoneType' object is not subscriptable` → 在外层 try/except 吞掉该异常,按"空数据"处理。 - 每次调用前显式 sleep 0.4s,避免被东财风控。 """ from __future__ import annotations import threading import time from typing import Any, Optional import pandas as pd from app.core.datasource.base import DataSource from app.core.utils.logging import get_logger logger = get_logger("akshare_lhb") class AkshareLhbSource(DataSource): """akshare 龙虎榜数据源(基于东方财富私有 API 的 akshare 封装)。""" key = "datasource_akshare_lhb" name = "akshare 龙虎榜(东方财富封装)" provides = ["longhubang_daily", "longhubang_seat"] requires_credential = False # 全局 RPS 限流(akshare → 东财私有 API) # 设 2.5 RPS(0.4s 间隔)—— 经验值比 mairui 钻石档保守,避免被东财风控 _RPS_LIMIT = 2.5 _rate_lock = threading.Lock() _last_request_ts = 0.0 @classmethod def _wait_rps(cls) -> None: """全局 RPS 限流:每次 akshare 调用前 sleep。""" with cls._rate_lock: now = time.time() elapsed = now - cls._last_request_ts min_interval = 1.0 / cls._RPS_LIMIT if elapsed < min_interval: time.sleep(min_interval - elapsed) cls._last_request_ts = time.time() def is_available(self) -> tuple[bool, str]: try: import akshare # noqa: F401 return True, "akshare 已安装" except ImportError as e: return False, f"akshare 未安装: {e}" def health_check(self) -> dict[str, Any]: try: import akshare as ak # 用一次历史数据作烟测(取一个肯定有数据的工作日;2024-06-14 是周五) self._wait_rps() try: df = ak.stock_lhb_detail_em(start_date="20240614", end_date="20240614") except Exception as inner_e: # akshare 在节假日可能返 None 然后内部崩,catch 后报失败 return {"success": False, "message": f"akshare 龙虎榜失败: {inner_e}"} if df is None or df.empty: return {"success": False, "message": "akshare 龙虎榜返回空(可能是节假日)"} return {"success": True, "message": f"akshare 龙虎榜 OK,样例 {len(df)} 行"} except Exception as e: return {"success": False, "message": f"akshare 龙虎榜失败: {e}"} # ── 聚合层 ─────────────────────────────────────────── def fetch_longhubang_daily( self, start_date: str, end_date: str, ) -> pd.DataFrame: """龙虎榜每日上榜股票汇总。 Args: start_date / end_date: 'YYYY-MM-DD' 或 'YYYYMMDD'(akshare 接受两种) Returns: DataFrame 字段: 序号 / 代码 / 名称 / 上榜日 / 解读 / 收盘价 / 涨跌幅 / 龙虎榜净买额 / 龙虎榜买入额 / 龙虎榜卖出额 / 龙虎榜成交额 / 市场总成交额 / 净买额占总成交比 / 成交额占总成交比 / 换手率 / 流通市值 / 上榜原因 / 上榜后1日 / 上榜后2日 / 上榜后5日 / 上榜后10日 """ try: import akshare as ak except ImportError: return pd.DataFrame() # 兼容两种日期格式:YYYYMMDD 直接传,YYYY-MM-DD 剥横线 sd = start_date.replace("-", "") ed = end_date.replace("-", "") self._wait_rps() try: df = ak.stock_lhb_detail_em(start_date=sd, end_date=ed) except Exception as e: logger.warning(f"[akshare] stock_lhb_detail_em({sd},{ed}) failed: {e}") return pd.DataFrame() if df is None or df.empty: return pd.DataFrame() # akshare 6 位代码不带交易所前缀;project 标准是 SH600000 / SZ000001 → 加前缀 df = df.copy() df["stock_code"] = df["代码"].astype(str).str.zfill(6).apply(self._prefix_code) df = df.rename(columns={ "名称": "stock_name", "上榜日": "trade_date", "解读": "comment", "收盘价": "close", "涨跌幅": "pct_chg", "龙虎榜净买额": "lhb_net_buy", "龙虎榜买入额": "lhb_buy_amt", "龙虎榜卖出额": "lhb_sell_amt", "龙虎榜成交额": "lhb_total_amt", "市场总成交额": "market_total_amt", "净买额占总成交比": "net_buy_ratio", "成交额占总成交比": "total_amt_ratio", "换手率": "turnover_rate", "流通市值": "float_mv", "上榜原因": "reason", "上榜后1日": "post_1d_pct", "上榜后2日": "post_2d_pct", "上榜后5日": "post_5d_pct", "上榜后10日": "post_10d_pct", "序号": "rank_idx", }) # 去重:akshare 同一天同一只票可能因多上榜原因(涨幅+换手率)返回多行, # 数值相同只 reason 不同 → 按 (stock_code, trade_date) group by, # reason 用 " | " 拼接保留所有原因,数值列 first() agg_cols = { "stock_name": "first", "rank_idx": "min", "comment": "first", "close": "first", "pct_chg": "first", "lhb_net_buy": "first", "lhb_buy_amt": "first", "lhb_sell_amt": "first", "lhb_total_amt": "first", "market_total_amt": "first", "net_buy_ratio": "first", "total_amt_ratio": "first", "turnover_rate": "first", "float_mv": "first", "reason": lambda s: " | ".join(sorted(set(v for v in s if v))), "post_1d_pct": "first", "post_2d_pct": "first", "post_5d_pct": "first", "post_10d_pct": "first", } df = ( df.groupby(["stock_code", "trade_date"], as_index=False) .agg(agg_cols) ) return df @staticmethod def _prefix_code(code6: str) -> str: """6 位代码 → SH/SZ 前缀。""" if code6.startswith(("5", "6", "9")): return f"SH{code6}" return f"SZ{code6}" # ── 席位层 ─────────────────────────────────────────── def fetch_longhubang_seat( self, code6: str, trade_date: str, direction: str = "buy", ) -> pd.DataFrame: """单只上榜股票单日的买/卖前 5 营业部。 Args: code6: 6 位股票代码(无交易所前缀) trade_date: 'YYYY-MM-DD'(akshare 要求此格式) direction: 'buy' / 'sell'(中文:'买入' / '卖出') Returns: DataFrame 字段: 序号 / 交易营业部名称 / 买入金额 / 买入金额-占总成交比例 / 卖出金额 / 卖出金额-占总成交比例 / 净额 / 类型 注意:若该股当日未上榜,akshare 内部抛 `'NoneType' object is not subscriptable`, 我们 try/except 吞掉返回空 DataFrame(正常情况,不是错)。 """ try: import akshare as ak except ImportError: return pd.DataFrame() flag = "买入" if direction == "buy" else "卖出" # akshare 的 stock_lhb_stock_detail_em 要 YYYYMMDD 格式(不带横线), # YYYY-MM-DD 会返回 None 然后内部崩在 'NoneType' object is not subscriptable date_yyyymmdd = str(trade_date).replace("-", "") self._wait_rps() try: df = ak.stock_lhb_stock_detail_em( symbol=str(code6).zfill(6), date=date_yyyymmdd, flag=flag, ) except Exception as e: # 'NoneType' object is not subscriptable = 股票未上榜(akshare bug) # 其他真错也兜住,返回空 logger.debug(f"[akshare] stock_lhb_stock_detail_em({code6},{date_yyyymmdd},{flag}) → {type(e).__name__}: {e}") return pd.DataFrame() if df is None or df.empty: return pd.DataFrame() return df