Files
sanguo_vnpy_v2/tests/trader/test_dividend.py
T
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

264 lines
9.4 KiB
Python
Raw 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)。
事件类型:
- 送股/转增:volume ×= factoravg_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"