Files
claude_dev 164690373f feat(trader): C期分期项收尾—资金占用成本+分红送股+_restore_ledger修复
- 资金占用成本(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预拉日历)
2026-07-10 08:44:35 +08:00

102 lines
3.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""分红送股事件源(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:
"""安全转 floatNaN/缺失 → default。"""
try:
f = float(val)
except (ValueError, TypeError):
return default
return default if pd.isna(f) else f