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预拉日历)
This commit is contained in:
2026-07-10 08:44:35 +08:00
parent c01da9f8ed
commit 164690373f
11 changed files with 576 additions and 13 deletions
+112
View File
@@ -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():
# Arrange200 股 @10 → 占用 2000rate=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)
+263
View File
@@ -0,0 +1,263 @@
"""分红送股事件测试(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"