"""分红送股事件源(spec §295 C-S3)。 akshare `stock_history_dividend_detail(symbol, indicator="分红")` 拉取 A 股 分红送股明细。akshare 字段均为「每 10 股」口径,本模块统一转 per-share: - 送股 + 转增(每 10 股 X 股)→ split_factor = 1 + (送股+转增)/10 - 派息(每 10 股 X 元) → cash_per_share = 派息/10 - 除权除息日:持仓调整日(当日开盘前持仓享权) akshare 不可用/拉取失败 → 返回 [](事件源抽象,不抛异常避免阻断回测)。 """ import logging from dataclasses import dataclass import pandas as pd logger = logging.getLogger(__name__) @dataclass(frozen=True) class DividendEvent: """单次分红送股事件。""" ex_date: str # 除权除息日 YYYY-MM-DD(持仓调整日) symbol: str split_factor: float # 1.0 = 无送转;1.5 = 10送5 cash_per_share: float # 每股现金分红(元);0.0 = 无现金分红 def fetch_dividends(symbol: str, start: str, end: str) -> list[DividendEvent]: """拉取 symbol 在 [start, end] 除权除息日内的已实施分红送股事件。 akshare 未装/出错 → 返回 [](不抛异常)。 """ try: import akshare as ak except ImportError: logger.warning("akshare 未安装,%s 分红事件返回空", symbol) return [] try: df = ak.stock_history_dividend_detail(symbol=symbol, indicator="分红") except Exception as e: # noqa: BLE001 —— 数据源不可控,兜底 logger.warning("拉取 %s 分红失败,返回空: %s", symbol, e) return [] return _parse_dividend_df(df, symbol, start, end) def _parse_dividend_df(df: pd.DataFrame, symbol: str, start: str, end: str) -> list[DividendEvent]: """解析 akshare 分红明细 DataFrame → DividendEvent 列表。""" if df is None or len(df) == 0: return [] events: list[DividendEvent] = [] for _, row in df.iterrows(): if str(row.get("进度", "")) != "实施": continue ex_date = _norm_date(row.get("除权除息日")) if ex_date is None or not (start <= ex_date <= end): continue send = _to_float(row.get("送股", 0)) # 每 10 股送股 transfer = _to_float(row.get("转增", 0)) # 每 10 股转增 cash = _to_float(row.get("派息", 0)) # 每 10 股派息(元) split_factor = 1.0 + (send + transfer) / 10.0 cash_per_share = cash / 10.0 if split_factor == 1.0 and cash_per_share == 0.0: continue events.append(DividendEvent(ex_date, symbol, split_factor, cash_per_share)) return events def build_dividend_calendar( symbols: list[str], start: str, end: str ) -> dict[str, dict[str, DividendEvent]]: """批量构建 {ex_date: {symbol: DividendEvent}} 日历(回测 preload 用)。""" calendar: dict[str, dict[str, DividendEvent]] = {} for sym in symbols: for ev in fetch_dividends(sym, start, end): calendar.setdefault(ev.ex_date, {})[sym] = ev return calendar def _norm_date(val) -> str | None: """除权除息日归一化为 YYYY-MM-DD 字符串;NaT/缺失 → None。""" if val is None or (isinstance(val, float) and pd.isna(val)): return None try: ts = pd.Timestamp(val) except (ValueError, TypeError): return None if pd.isna(ts): return None return ts.strftime("%Y-%m-%d") def _to_float(val, default: float = 0.0) -> float: """安全转 float;NaN/缺失 → default。""" try: f = float(val) except (ValueError, TypeError): return default return default if pd.isna(f) else f