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预拉日历)
264 lines
9.4 KiB
Python
264 lines
9.4 KiB
Python
"""分红送股事件测试(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"
|