"""分红送股事件测试(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"