164690373f
- 资金占用成本(spec§195): StrategyRunner.daily_borrow_cost(used×risk_free/365) 归因per_strategy_pnl(不碰account总账, account.equity真实净值不变); config risk_free_rate=0.02; engine.step mark_to_market后计扣; =0向后兼容跳过 - 分红送股(spec§295): dividend_source.py(akshare stock_history_dividend_detail, 实测600000/000001纯现金分红); PositionLedger.apply_split(volume×factor/avg÷factor); Account.apply_cash_dividend; engine._apply_dividends(除权日调整,现金先split后); mark_to_market停牌prev_close兜底(今收→前收→均价); _run_replay注入dividends日历 - 修_restore_ledger预存bug: PositionLedger.__init__加volume/frozen/avg_price参数 (原只symbol, live_orchestrator跨日恢复4参数调用会TypeError, 首次step空仓未暴露) - 139 passed(119基准+20分红+3占用成本), 无回归 - live_step dividends注入待分期项(每日拉全市场分红慢, 需run_daily_update预拉日历)
102 lines
3.7 KiB
Python
102 lines
3.7 KiB
Python
"""分红送股事件源(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
|