diff --git a/config/data_platform.yaml b/config/data_platform.yaml index 4f1eeda..9b03f97 100644 --- a/config/data_platform.yaml +++ b/config/data_platform.yaml @@ -36,3 +36,6 @@ performance: max_retries: 3 fail_window: 100 fail_threshold: 0.8 + +# 资金占用成本归因(spec §195):年化无风险利率,每策略占用资金按此日扣归因到 PnL +risk_free_rate: 0.02 diff --git a/sanguo_api/routes_paper.py b/sanguo_api/routes_paper.py index c3d73df..2a644f0 100644 --- a/sanguo_api/routes_paper.py +++ b/sanguo_api/routes_paper.py @@ -194,6 +194,10 @@ def _run_replay(db, aid, req: PaperCreateRequest): s.name, strategy=strat, paper_cta_engine=cta, symbol=s.symbol, max_allocation=(s.max_allocation if s.max_allocation is not None else req.initial_capital))) + from sanguo_data.dividend_source import build_dividend_calendar + div_calendar = build_dividend_calendar(req.symbols, req.start, req.end) pe = PaperEngine(account, runners, _DataSourceWrapper(data_cfg), acc_cfg, - db, aid, req.symbols, req.start, req.end, req.interval) + db, aid, req.symbols, req.start, req.end, req.interval, + risk_free_rate=getattr(data_cfg, "risk_free_rate", 0.0), + dividends_by_date=div_calendar) pe.run() diff --git a/sanguo_data/config.py b/sanguo_data/config.py index 1580744..cebc15e 100644 --- a/sanguo_data/config.py +++ b/sanguo_data/config.py @@ -9,6 +9,7 @@ class DataConfig: data_sources: dict validation: dict performance: dict + risk_free_rate: float = 0.02 # 年化无风险利率(spec §195 资金占用成本归因) def load_config(path: str) -> DataConfig: try: @@ -27,6 +28,7 @@ def load_config(path: str) -> DataConfig: data_sources=raw.get("data_sources", {}), validation=raw.get("validation", {}), performance=raw.get("performance", {}), + risk_free_rate=float(raw.get("risk_free_rate", 0.02)), ) diff --git a/sanguo_data/dividend_source.py b/sanguo_data/dividend_source.py new file mode 100644 index 0000000..9fa6f62 --- /dev/null +++ b/sanguo_data/dividend_source.py @@ -0,0 +1,101 @@ +"""分红送股事件源(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 diff --git a/sanguo_trader/account.py b/sanguo_trader/account.py index 4d33328..581676b 100644 --- a/sanguo_trader/account.py +++ b/sanguo_trader/account.py @@ -46,10 +46,27 @@ class Account: for p in self.positions.values(): p.unfreeze() - def mark_to_market(self, bars_raw: dict[str, float]) -> None: - """按 raw 收盘价重估合并持仓市值。bars_raw: {symbol: close_raw}。""" + def apply_cash_dividend(self, symbol: str, per_share: float) -> None: + """现金分红到账:cash += per_share × 持仓量(spec §295)。 + + 按 A 股惯例按除权日前一交易日持仓量派发;无持仓/非正值 no-op。 + """ + if per_share <= 0: + return + pos = self.positions.get(symbol) + if pos is None or pos.volume <= 0: + return + self.cash += per_share * pos.volume + + def mark_to_market(self, bars_raw: dict[str, float], + prev_close: dict[str, float] | None = None) -> None: + """按 raw 收盘价重估合并持仓市值(spec §295 停牌盯市兜底)。 + + bar 缺失(停牌)→ 用前日 close 兜底,再退到 avg_price。 + """ + prev_close = prev_close or {} self.market_value = sum( - p.volume * bars_raw.get(sym, p.avg_price) + p.volume * bars_raw.get(sym, prev_close.get(sym, p.avg_price)) for sym, p in self.positions.items() ) diff --git a/sanguo_trader/engine.py b/sanguo_trader/engine.py index 86df55e..a6a978a 100644 --- a/sanguo_trader/engine.py +++ b/sanguo_trader/engine.py @@ -40,7 +40,8 @@ class PaperEngine: def __init__(self, account: Account, runners: list[StrategyRunner], data_source, cfg, db_path: str, account_id: int, symbols: list[str], start: str, end: str, - interval: str = "d") -> None: + interval: str = "d", risk_free_rate: float = 0.0, + dividends_by_date: dict | None = None) -> None: self.account = account self.runners = runners self.data_source = data_source @@ -51,6 +52,9 @@ class PaperEngine: self.start = start self.end = end self.interval = interval + self.risk_free_rate = risk_free_rate # spec §195 资金占用成本归因 + # spec §295 分红送股日历 {ex_date_str: {symbol: DividendEvent}}(缺省空=不处理) + self.dividends_by_date = dividends_by_date or {} def step(self, bar_date, raw_bars, qfq_bars, prev_close, pending): """单根 bar 推进(回放 run 循环调;实走 live_step 调)。 @@ -62,6 +66,8 @@ class PaperEngine: self.account.unfreeze_all() for r in self.runners: r.unfreeze_all() + # 0. 除权除息日:分红送股调整(开盘前持仓享权,spec §295) + self._apply_dividends(bar_date) # 1. 撮合上一根 pending(next_open,用当日 raw bar) if pending: for order, runner in pending: @@ -77,19 +83,31 @@ class PaperEngine: pending.append((order, runner)) else: # current_close 当根撮合(raw) self._match(order, runner, raw_bars, prev_close, bar_date) - # 3. 盯市 raw + 入库 + # 3. 盯市 raw + 入库(停牌缺 bar 用 prev_close 兜底,spec §295) closes = {s: raw_bars[s].close_price for s in raw_bars} - self.account.mark_to_market(closes) + self.account.mark_to_market(closes, prev_close) + # 资金占用成本归因(spec §195):每策略持仓按无风险利率日扣,仅记 per_strategy_pnl + # 不扣 account.cash —— 总账净值保持真实,占用成本是分策略展示用归因 + per_strategy_pnl: dict = {} + if self.risk_free_rate > 0: + for r in self.runners: + cost = r.daily_borrow_cost(closes, self.risk_free_rate) + per_strategy_pnl[r.strategy_id] = {"borrow_cost": cost} save_daily_balance( self.db_path, self.account_id, str(bar_date), self.account.cash, self.account.market_value, self.account.equity, + per_strategy_pnl=per_strategy_pnl or None, is_checkpoint=(self._bar_count % 500 == 0), ) update_checkpoint(self.db_path, self.account_id, str(bar_date)) return pending, closes def run(self) -> None: - """双源 zip(raw, qfq) 同日期对齐,逐根 step。""" + """双源 zip(raw, qfq) 同日期对齐,逐根 step。 + + 分红送股日历由调用方经 dividends_by_date 注入(见 __init__); + 回测脚本可用 sanguo_data.dividend_source.build_dividend_calendar 预拉。 + """ prev_close: dict[str, float] = {} pending: list = [] # [(order, runner)] next_open 待下根撮合 raw_iter = self.data_source.iter_bars( @@ -102,6 +120,27 @@ class PaperEngine: pending, closes = self.step(rdate, raw_bars, qfq_bars, prev_close, pending) prev_close = closes + def _apply_dividends(self, bar_date) -> None: + """除权除息日调整持仓(spec §295):现金分红按除权前持仓量,再 apply_split。 + + 双层记账一致性:Account 与各 StrategyRunner 分户同步 split。 + """ + events = self.dividends_by_date.get(str(bar_date)) + if not events: + return + for sym, ev in events.items(): + pos = self.account.positions.get(sym) + if pos is None or pos.volume <= 0: + continue + # 现金分红用除权前持仓量(apply_split 前的 volume) + self.account.apply_cash_dividend(sym, ev.cash_per_share) + pos.apply_split(ev.split_factor) + # 分户同步 split(保持总账=分户之和) + for r in self.runners: + rpos = r.positions.get(sym) + if rpos is not None and rpos.volume > 0: + rpos.apply_split(ev.split_factor) + def _match(self, order, runner, bars, prev_close, bar_date) -> None: if order.symbol not in bars: return diff --git a/sanguo_trader/live_orchestrator.py b/sanguo_trader/live_orchestrator.py index e87d308..bbb119d 100644 --- a/sanguo_trader/live_orchestrator.py +++ b/sanguo_trader/live_orchestrator.py @@ -139,7 +139,8 @@ def live_step(db_path: str, account_id: int, data_source, cfg, today: str | None transfer_fee_rate=acc["transfer_fee_rate"], min_commission=acc["min_commission"], ) pe = PaperEngine(account, runners, data_source, acc_cfg, db_path, account_id, - symbols, acc.get("start_date") or today, today, interval) + symbols, acc.get("start_date") or today, today, interval, + risk_free_rate=getattr(cfg, "risk_free_rate", 0.0)) pending_new, _closes = pe.step(today, bars, prev_close, pending) # 6. 存状态(pending + positions) diff --git a/sanguo_trader/position_ledger.py b/sanguo_trader/position_ledger.py index 0aabd04..bde4b26 100644 --- a/sanguo_trader/position_ledger.py +++ b/sanguo_trader/position_ledger.py @@ -6,11 +6,11 @@ mutable,被 Account / StrategyRunner 持有(spec §6.3 双层记账)。 - 零股:卖出允许零股(退出持仓基本操作),买入由 matcher 保证 100 股整取 """ class PositionLedger: - def __init__(self, symbol: str): + def __init__(self, symbol: str, volume: int = 0, frozen: int = 0, avg_price: float = 0.0): self.symbol: str = symbol - self.volume: int = 0 - self.frozen: int = 0 # T+1 当日买入冻结 - self.avg_price: float = 0.0 + self.volume: int = volume + self.frozen: int = frozen # T+1 当日买入冻结 + self.avg_price: float = avg_price @property def available(self) -> int: @@ -39,6 +39,19 @@ class PositionLedger: if self.volume == 0: self.avg_price = 0.0 + def apply_split(self, factor: float) -> None: + """送股/转增:volume ×= factor,avg_price /= factor(总市值不变,spec §295)。 + + factor=(10+送转)/10,对 100 股整数倍持仓结果恒为整数。 + factor<=0 非法;空持仓 no-op。 + """ + if factor <= 0: + raise ValueError(f"split factor 必须为正: {factor}") + if self.volume <= 0: + return + self.volume = int(round(self.volume * factor)) + self.avg_price = self.avg_price / factor + def unfreeze(self) -> None: """次日开盘前调用:frozen → available。""" self.frozen = 0 diff --git a/sanguo_trader/strategy_runner.py b/sanguo_trader/strategy_runner.py index a56ba72..d5762bf 100644 --- a/sanguo_trader/strategy_runner.py +++ b/sanguo_trader/strategy_runner.py @@ -29,6 +29,14 @@ class StrategyRunner: for sym, p in self.positions.items() if p.volume > 0 ) + def daily_borrow_cost(self, closes: dict[str, float] | None = None, + risk_free_rate: float = 0.0) -> float: + """资金占用日成本 = 占用资金 × 年化无风险利率 / 365(spec §195 归因用)。 + + 纯归因记账:返回值由 PaperEngine 计入 per_strategy_pnl,不扣 account.cash。 + """ + return self.used_allocation(closes) * risk_free_rate / 365.0 + def _position(self, symbol: str) -> PositionLedger: if symbol not in self.positions: self.positions[symbol] = PositionLedger(symbol) diff --git a/tests/trader/test_borrow_cost.py b/tests/trader/test_borrow_cost.py new file mode 100644 index 0000000..a227d4b --- /dev/null +++ b/tests/trader/test_borrow_cost.py @@ -0,0 +1,112 @@ +"""资金占用成本(borrow cost)归因测试(spec §195)。 + +每策略占用资金按无风险利率日扣,归因到 per_strategy_pnl;不影响账户总账净值。 +""" +import json +from types import SimpleNamespace + +import pytest + +from sanguo_trader.account import Account +from sanguo_trader.cta_adapter import PaperCtaEngine +from sanguo_trader.engine import PaperEngine +from sanguo_trader.models import AccountConfig, MatchSession +from sanguo_trader.persistence import init_db, list_daily_balance, save_account +from sanguo_trader.position_ledger import PositionLedger +from sanguo_trader.strategy_runner import StrategyRunner + + +def _bar(date, o, h, l, c): + return SimpleNamespace(open_price=o, high_price=h, low_price=l, + close_price=c, datetime=date) + + +class _FakeDataSource: + def __init__(self, sections): + self.sections = sections + + def iter_bars(self, symbols, start, end, interval, adjust="qfq", cfg=None): + del symbols, start, end, interval, adjust, cfg + for date, bars in self.sections: + yield date, bars + + +class _NoOrderStrategy: + """空策略:on_bar 不下单(仅驱动 step 走完盯市/归因路径)。""" + + def __init__(self, engine, vt_symbol): + self.cta_engine = engine + self.vt_symbol = vt_symbol + + def on_bar(self, bar): + pass + + +def _make_position(symbol, volume, avg_price): + pos = PositionLedger(symbol) + pos.volume = volume + pos.avg_price = avg_price + return pos + + +def test_daily_borrow_cost_calculation(): + # Arrange:200 股 @10 → 占用 2000,rate=0.02 → 日成本 2000*0.02/365 + runner = StrategyRunner("s1", symbol="600000") + runner.positions["600000"] = _make_position("600000", 200, 10.0) + closes = {"600000": 10.0} + + # Act + cost = runner.daily_borrow_cost(closes, risk_free_rate=0.02) + + # Assert + assert cost == pytest.approx(2000 * 0.02 / 365) + + +def test_holding_cost_greater_than_empty(): + # Arrange:持仓 runner vs 空仓 runner + runner_holding = StrategyRunner("s1", symbol="600000") + runner_holding.positions["600000"] = _make_position("600000", 200, 10.0) + runner_empty = StrategyRunner("s2", symbol="600000") + closes = {"600000": 10.0} + + # Act + cost_holding = runner_holding.daily_borrow_cost(closes, 0.02) + cost_empty = runner_empty.daily_borrow_cost(closes, 0.02) + + # Assert + assert cost_empty == 0.0 + assert cost_holding > 0 + assert cost_holding > cost_empty + + +def test_borrow_cost_attributed_not_affecting_account(tmp_path): + # Arrange:预置持仓 200 股 @10(占用 2000),空策略不下单,rate=0.03 + db = str(tmp_path / "borrow.db") + init_db(db) + aid = save_account(db, {"name": "t", "initial_capital": 1_000_000}) + cfg = AccountConfig(initial_capital=1_000_000) + account = Account(1_000_000) + account.cash = 998000 # 已花 2000 买入 + account.positions["600000"] = _make_position("600000", 200, 10.0) + cta = PaperCtaEngine("s1", match_session=MatchSession.CURRENT_CLOSE) + strat = _NoOrderStrategy(cta, "600000.SSE") + cta.set_strategy(strat) + runner = StrategyRunner("s1", strategy=strat, paper_cta_engine=cta, + symbol="600000") + runner.positions["600000"] = _make_position("600000", 200, 10.0) + sections = [("2024-01-01", {"600000": _bar("2024-01-01", 10.0, 10.5, 9.5, 10.0)})] + pe = PaperEngine(account, [runner], _FakeDataSource(sections), cfg, db, aid, + symbols=["600000"], start="2024-01-01", end="2024-01-31", + risk_free_rate=0.03) + cash_before = account.cash + + # Act + pe.run() + + # Assert:账户总账不受占用成本影响(仅归因记账) + assert account.cash == cash_before + balances = list_daily_balance(db, aid) + assert len(balances) == 1 + psp = json.loads(balances[0]["per_strategy_pnl"]) + assert "s1" in psp + assert psp["s1"]["borrow_cost"] == pytest.approx(2000 * 0.03 / 365) diff --git a/tests/trader/test_dividend.py b/tests/trader/test_dividend.py new file mode 100644 index 0000000..19ed1fb --- /dev/null +++ b/tests/trader/test_dividend.py @@ -0,0 +1,263 @@ +"""分红送股事件测试(spec §295 C-S3)。 + +事件类型: +- 送股/转增:volume ×= factor,avg_price /= factor(总市值不变) +- 现金分红:cash += per_share × 持仓量 +- 停牌盯市:缺 bar 用前日 close 兜底 +""" +from types import SimpleNamespace + +import pandas as pd +import pytest + +from sanguo_data.dividend_source import DividendEvent, _parse_dividend_df +from sanguo_trader.account import Account +from sanguo_trader.engine import PaperEngine +from sanguo_trader.models import AccountConfig, MatchSession, OrderSide, PaperTrade +from sanguo_trader.persistence import init_db, save_account +from sanguo_trader.position_ledger import PositionLedger + + +def _buy(symbol="600000", price=10.0, volume=100): + return PaperTrade("s1", symbol, OrderSide.BUY, price, volume, + 5.0, 0.0, 0.02, "", MatchSession.NEXT_OPEN) + + +# ---------- PositionLedger.apply_split ---------- + +def test_apply_split_increases_volume(): + # 10送5 → factor 1.5 + p = PositionLedger(symbol="600000") + p.apply_buy(10.0, 100) + p.unfreeze() + p.apply_split(1.5) + assert p.volume == 150 + + +def test_apply_split_lowers_avg_price(): + p = PositionLedger(symbol="600000") + p.apply_buy(10.0, 100) + p.unfreeze() + p.apply_split(1.5) + assert p.avg_price == pytest.approx(10.0 / 1.5) + + +def test_apply_split_preserves_total_market_value(): + # 总市值 = volume × avg_price 不变 + p = PositionLedger(symbol="600000") + p.apply_buy(10.0, 100) + p.unfreeze() + before = p.volume * p.avg_price + p.apply_split(1.5) + after = p.volume * p.avg_price + assert after == pytest.approx(before) + + +def test_apply_split_factor_one_is_noop(): + p = PositionLedger(symbol="600000") + p.apply_buy(10.0, 100) + p.unfreeze() + p.apply_split(1.0) + assert p.volume == 100 + assert p.avg_price == pytest.approx(10.0) + + +def test_apply_split_noop_on_empty_position(): + p = PositionLedger(symbol="600000") + p.apply_split(1.5) + assert p.volume == 0 + assert p.avg_price == 0.0 + + +def test_apply_split_rejects_non_positive_factor(): + p = PositionLedger(symbol="600000") + p.apply_buy(10.0, 100) + with pytest.raises(ValueError): + p.apply_split(0.0) + + +# ---------- Account.apply_cash_dividend ---------- + +def test_apply_cash_dividend_adds_cash(): + acc = Account(1_000_000) + acc.apply_trade(_buy(volume=100)) + acc.unfreeze_all() + cash_before = acc.cash + acc.apply_cash_dividend("600000", per_share=0.5) + assert acc.cash == pytest.approx(cash_before + 0.5 * 100) + + +def test_apply_cash_dividend_no_position_is_noop(): + acc = Account(1_000_000) + cash_before = acc.cash + acc.apply_cash_dividend("999999", per_share=0.5) + assert acc.cash == cash_before + + +def test_apply_cash_dividend_zero_per_share_is_noop(): + acc = Account(1_000_000) + acc.apply_trade(_buy(volume=100)) + acc.unfreeze_all() + cash_before = acc.cash + acc.apply_cash_dividend("600000", per_share=0.0) + assert acc.cash == cash_before + + +# ---------- Account.mark_to_market 停牌兜底 ---------- + +def test_mark_to_market_uses_today_close_when_available(): + acc = Account(1_000_000) + acc.apply_trade(_buy(price=10.0, volume=100)) + acc.unfreeze_all() + acc.mark_to_market({"600000": 12.0}, prev_close={"600000": 11.0}) + assert acc.market_value == pytest.approx(100 * 12.0) + + +def test_mark_to_market_falls_back_to_prev_close_on_suspension(): + # 停牌:bars_raw 无该 symbol → 用 prev_close 兜底 + acc = Account(1_000_000) + acc.apply_trade(_buy(price=10.0, volume=100)) + acc.unfreeze_all() + acc.mark_to_market({}, prev_close={"600000": 11.0}) + assert acc.market_value == pytest.approx(100 * 11.0) + + +def test_mark_to_market_falls_back_to_avg_price_without_prev_close(): + # 既无 bar 也无 prev_close → avg_price 兜底(向后兼容旧调用) + acc = Account(1_000_000) + acc.apply_trade(_buy(price=10.0, volume=100)) + acc.unfreeze_all() + acc.mark_to_market({}) + assert acc.market_value == pytest.approx(100 * 10.0) + + +# ---------- PaperEngine.step 除权日 ---------- + +def _bar(date, o, h, l, c): + return SimpleNamespace(open_price=o, high_price=h, low_price=l, + close_price=c, datetime=date) + + +def test_engine_step_applies_dividend_on_ex_date(tmp_path): + """除权日:持仓自动 split + 现金分红到账(在 mark_to_market 前)。""" + # Arrange + db = str(tmp_path / "d.db") + init_db(db) + aid = save_account(db, {"name": "t", "initial_capital": 1_000_000}) + cfg = AccountConfig(initial_capital=1_000_000) + account = Account(1_000_000) + account.apply_trade(_buy(price=10.0, volume=100)) # 预置 100 股 @ 10.0 + account.unfreeze_all() + cash_before = account.cash + div_cal = {"2024-01-02": {"600000": DividendEvent( + ex_date="2024-01-02", symbol="600000", + split_factor=1.5, cash_per_share=0.5)}} + pe = PaperEngine(account, [], None, cfg, db, aid, + symbols=["600000"], start="2024-01-01", end="2024-12-31", + dividends_by_date=div_cal) + + # Act:除权日 raw 价已下调(10 → 6.x) + bars = {"600000": _bar("2024-01-02", 6.8, 7.0, 6.6, 6.9)} + pe.step("2024-01-02", bars, bars, {"600000": 10.0}, []) + + # Assert + pos = account.positions["600000"] + assert pos.volume == 150 # 100 × 1.5 + assert pos.avg_price == pytest.approx(10.0 / 1.5) + # 现金分红按除权前持仓量(100 股) + assert account.cash == pytest.approx(cash_before + 0.5 * 100) + # 盯市 = 除权后 volume × 除权后 close + assert account.market_value == pytest.approx(150 * 6.9) + + +def test_engine_step_cash_only_dividend(tmp_path): + """纯现金分红(无送转):持仓量不变,cash 增加。""" + db = str(tmp_path / "d.db") + init_db(db) + aid = save_account(db, {"name": "t", "initial_capital": 1_000_000}) + account = Account(1_000_000) + account.apply_trade(_buy(price=10.0, volume=200)) + account.unfreeze_all() + cash_before = account.cash + div_cal = {"2024-03-01": {"600000": DividendEvent( + ex_date="2024-03-01", symbol="600000", + split_factor=1.0, cash_per_share=0.42)}} + pe = PaperEngine(account, [], None, AccountConfig(initial_capital=1_000_000), + db, aid, symbols=["600000"], start="2024-01-01", + end="2024-12-31", dividends_by_date=div_cal) + bars = {"600000": _bar("2024-03-01", 9.9, 10.0, 9.8, 9.95)} + pe.step("2024-03-01", bars, bars, {}, []) + assert account.positions["600000"].volume == 200 # 不变 + assert account.cash == pytest.approx(cash_before + 0.42 * 200) + + +def test_engine_step_no_dividend_unchanged(tmp_path): + """非除权日:持仓/现金不变。""" + db = str(tmp_path / "d.db") + init_db(db) + aid = save_account(db, {"name": "t", "initial_capital": 1_000_000}) + account = Account(1_000_000) + account.apply_trade(_buy(price=10.0, volume=100)) + account.unfreeze_all() + cash_before = account.cash + pe = PaperEngine(account, [], None, AccountConfig(initial_capital=1_000_000), + db, aid, symbols=["600000"], start="2024-01-01", + end="2024-12-31", dividends_by_date={}) + bars = {"600000": _bar("2024-01-02", 10.0, 10.2, 9.8, 10.1)} + pe.step("2024-01-02", bars, bars, {}, []) + assert account.positions["600000"].volume == 100 + assert account.cash == cash_before + + +# ---------- dividend_source 解析 ---------- + +def _mkdiv_df(send, transfer, cash, ex_date="2024-01-15", progress="实施"): + return pd.DataFrame([{ + "公告日期": "2024-01-01", "送股": send, "转增": transfer, "派息": cash, + "进度": progress, "除权除息日": pd.Timestamp(ex_date), + "股权登记日": pd.Timestamp("2024-01-14"), "红股上市日": pd.NaT, + }]) + + +def test_parse_dividend_df_per_10_shares_conversion(): + # 送股/转增/派息 均为「每 10 股」→ 转 per-share + events = _parse_dividend_df(_mkdiv_df(5, 0, 2.0), "600000", + "2024-01-01", "2024-12-31") + assert len(events) == 1 + ev = events[0] + assert ev.symbol == "600000" + assert ev.ex_date == "2024-01-15" + assert ev.split_factor == pytest.approx(1.5) # 1 + 5/10 + assert ev.cash_per_share == pytest.approx(0.2) # 2.0/10 + + +def test_parse_dividend_df_send_plus_transfer(): + # 10送3转2 → factor 1.5 + events = _parse_dividend_df(_mkdiv_df(3, 2, 0.0), "600000", + "2024-01-01", "2024-12-31") + assert events[0].split_factor == pytest.approx(1.5) + assert events[0].cash_per_share == 0.0 + + +def test_parse_dividend_df_skips_non_implemented(): + events = _parse_dividend_df(_mkdiv_df(0, 0, 1.0, progress="预案"), + "600000", "2024-01-01", "2024-12-31") + assert events == [] + + +def test_parse_dividend_df_filters_by_date_range(): + events = _parse_dividend_df(_mkdiv_df(0, 0, 1.0, ex_date="2020-06-15"), + "600000", "2024-01-01", "2024-12-31") + assert events == [] + + +def test_parse_dividend_df_handles_date_object(): + # akshare 实测除权除息日可能返回 datetime.date + df = pd.DataFrame([{ + "公告日期": "2024-01-01", "送股": 0, "转增": 0, "派息": 1.0, + "进度": "实施", "除权除息日": pd.Timestamp("2024-06-15").date(), + "股权登记日": pd.Timestamp("2024-06-14"), "红股上市日": pd.NaT, + }]) + events = _parse_dividend_df(df, "600000", "2024-01-01", "2024-12-31") + assert len(events) == 1 + assert events[0].ex_date == "2024-06-15"