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:
@@ -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)
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user