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
+3
View File
@@ -36,3 +36,6 @@ performance:
max_retries: 3 max_retries: 3
fail_window: 100 fail_window: 100
fail_threshold: 0.8 fail_threshold: 0.8
# 资金占用成本归因(spec §195):年化无风险利率,每策略占用资金按此日扣归因到 PnL
risk_free_rate: 0.02
+5 -1
View File
@@ -194,6 +194,10 @@ def _run_replay(db, aid, req: PaperCreateRequest):
s.name, strategy=strat, paper_cta_engine=cta, symbol=s.symbol, s.name, strategy=strat, paper_cta_engine=cta, symbol=s.symbol,
max_allocation=(s.max_allocation if s.max_allocation is not None max_allocation=(s.max_allocation if s.max_allocation is not None
else req.initial_capital))) else req.initial_capital)))
from sanguo_data.dividend_source import build_dividend_calendar
div_calendar = build_dividend_calendar(req.symbols, req.start, req.end)
pe = PaperEngine(account, runners, _DataSourceWrapper(data_cfg), acc_cfg, pe = PaperEngine(account, runners, _DataSourceWrapper(data_cfg), acc_cfg,
db, aid, req.symbols, req.start, req.end, req.interval) db, aid, req.symbols, req.start, req.end, req.interval,
risk_free_rate=getattr(data_cfg, "risk_free_rate", 0.0),
dividends_by_date=div_calendar)
pe.run() pe.run()
+2
View File
@@ -9,6 +9,7 @@ class DataConfig:
data_sources: dict data_sources: dict
validation: dict validation: dict
performance: dict performance: dict
risk_free_rate: float = 0.02 # 年化无风险利率(spec §195 资金占用成本归因)
def load_config(path: str) -> DataConfig: def load_config(path: str) -> DataConfig:
try: try:
@@ -27,6 +28,7 @@ def load_config(path: str) -> DataConfig:
data_sources=raw.get("data_sources", {}), data_sources=raw.get("data_sources", {}),
validation=raw.get("validation", {}), validation=raw.get("validation", {}),
performance=raw.get("performance", {}), performance=raw.get("performance", {}),
risk_free_rate=float(raw.get("risk_free_rate", 0.02)),
) )
+101
View File
@@ -0,0 +1,101 @@
"""分红送股事件源(spec §295 C-S3)。
akshare `stock_history_dividend_detail(symbol, indicator="分红")` 拉取 A 股
分红送股明细。akshare 字段均为「每 10 股」口径,本模块统一转 per-share:
- 送股 + 转增(每 10 股 X 股)→ split_factor = 1 + (送股+转增)/10
- 派息(每 10 股 X 元) → cash_per_share = 派息/10
- 除权除息日:持仓调整日(当日开盘前持仓享权)
akshare 不可用/拉取失败 → 返回 [](事件源抽象,不抛异常避免阻断回测)。
"""
import logging
from dataclasses import dataclass
import pandas as pd
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class DividendEvent:
"""单次分红送股事件。"""
ex_date: str # 除权除息日 YYYY-MM-DD(持仓调整日)
symbol: str
split_factor: float # 1.0 = 无送转;1.5 = 10送5
cash_per_share: float # 每股现金分红(元);0.0 = 无现金分红
def fetch_dividends(symbol: str, start: str, end: str) -> list[DividendEvent]:
"""拉取 symbol 在 [start, end] 除权除息日内的已实施分红送股事件。
akshare 未装/出错 → 返回 [](不抛异常)。
"""
try:
import akshare as ak
except ImportError:
logger.warning("akshare 未安装,%s 分红事件返回空", symbol)
return []
try:
df = ak.stock_history_dividend_detail(symbol=symbol, indicator="分红")
except Exception as e: # noqa: BLE001 —— 数据源不可控,兜底
logger.warning("拉取 %s 分红失败,返回空: %s", symbol, e)
return []
return _parse_dividend_df(df, symbol, start, end)
def _parse_dividend_df(df: pd.DataFrame, symbol: str,
start: str, end: str) -> list[DividendEvent]:
"""解析 akshare 分红明细 DataFrame → DividendEvent 列表。"""
if df is None or len(df) == 0:
return []
events: list[DividendEvent] = []
for _, row in df.iterrows():
if str(row.get("进度", "")) != "实施":
continue
ex_date = _norm_date(row.get("除权除息日"))
if ex_date is None or not (start <= ex_date <= end):
continue
send = _to_float(row.get("送股", 0)) # 每 10 股送股
transfer = _to_float(row.get("转增", 0)) # 每 10 股转增
cash = _to_float(row.get("派息", 0)) # 每 10 股派息(元)
split_factor = 1.0 + (send + transfer) / 10.0
cash_per_share = cash / 10.0
if split_factor == 1.0 and cash_per_share == 0.0:
continue
events.append(DividendEvent(ex_date, symbol, split_factor, cash_per_share))
return events
def build_dividend_calendar(
symbols: list[str], start: str, end: str
) -> dict[str, dict[str, DividendEvent]]:
"""批量构建 {ex_date: {symbol: DividendEvent}} 日历(回测 preload 用)。"""
calendar: dict[str, dict[str, DividendEvent]] = {}
for sym in symbols:
for ev in fetch_dividends(sym, start, end):
calendar.setdefault(ev.ex_date, {})[sym] = ev
return calendar
def _norm_date(val) -> str | None:
"""除权除息日归一化为 YYYY-MM-DD 字符串;NaT/缺失 → None。"""
if val is None or (isinstance(val, float) and pd.isna(val)):
return None
try:
ts = pd.Timestamp(val)
except (ValueError, TypeError):
return None
if pd.isna(ts):
return None
return ts.strftime("%Y-%m-%d")
def _to_float(val, default: float = 0.0) -> float:
"""安全转 floatNaN/缺失 → default。"""
try:
f = float(val)
except (ValueError, TypeError):
return default
return default if pd.isna(f) else f
+20 -3
View File
@@ -46,10 +46,27 @@ class Account:
for p in self.positions.values(): for p in self.positions.values():
p.unfreeze() p.unfreeze()
def mark_to_market(self, bars_raw: dict[str, float]) -> None: def apply_cash_dividend(self, symbol: str, per_share: float) -> None:
"""按 raw 收盘价重估合并持仓市值。bars_raw: {symbol: close_raw}。""" """现金分红到账:cash += per_share × 持仓量(spec §295)。
按 A 股惯例按除权日前一交易日持仓量派发;无持仓/非正值 no-op。
"""
if per_share <= 0:
return
pos = self.positions.get(symbol)
if pos is None or pos.volume <= 0:
return
self.cash += per_share * pos.volume
def mark_to_market(self, bars_raw: dict[str, float],
prev_close: dict[str, float] | None = None) -> None:
"""按 raw 收盘价重估合并持仓市值(spec §295 停牌盯市兜底)。
bar 缺失(停牌)→ 用前日 close 兜底,再退到 avg_price。
"""
prev_close = prev_close or {}
self.market_value = sum( self.market_value = sum(
p.volume * bars_raw.get(sym, p.avg_price) p.volume * bars_raw.get(sym, prev_close.get(sym, p.avg_price))
for sym, p in self.positions.items() for sym, p in self.positions.items()
) )
+43 -4
View File
@@ -40,7 +40,8 @@ class PaperEngine:
def __init__(self, account: Account, runners: list[StrategyRunner], def __init__(self, account: Account, runners: list[StrategyRunner],
data_source, cfg, db_path: str, account_id: int, data_source, cfg, db_path: str, account_id: int,
symbols: list[str], start: str, end: str, symbols: list[str], start: str, end: str,
interval: str = "d") -> None: interval: str = "d", risk_free_rate: float = 0.0,
dividends_by_date: dict | None = None) -> None:
self.account = account self.account = account
self.runners = runners self.runners = runners
self.data_source = data_source self.data_source = data_source
@@ -51,6 +52,9 @@ class PaperEngine:
self.start = start self.start = start
self.end = end self.end = end
self.interval = interval self.interval = interval
self.risk_free_rate = risk_free_rate # spec §195 资金占用成本归因
# spec §295 分红送股日历 {ex_date_str: {symbol: DividendEvent}}(缺省空=不处理)
self.dividends_by_date = dividends_by_date or {}
def step(self, bar_date, raw_bars, qfq_bars, prev_close, pending): def step(self, bar_date, raw_bars, qfq_bars, prev_close, pending):
"""单根 bar 推进(回放 run 循环调;实走 live_step 调)。 """单根 bar 推进(回放 run 循环调;实走 live_step 调)。
@@ -62,6 +66,8 @@ class PaperEngine:
self.account.unfreeze_all() self.account.unfreeze_all()
for r in self.runners: for r in self.runners:
r.unfreeze_all() r.unfreeze_all()
# 0. 除权除息日:分红送股调整(开盘前持仓享权,spec §295)
self._apply_dividends(bar_date)
# 1. 撮合上一根 pendingnext_open,用当日 raw bar # 1. 撮合上一根 pendingnext_open,用当日 raw bar
if pending: if pending:
for order, runner in pending: for order, runner in pending:
@@ -77,19 +83,31 @@ class PaperEngine:
pending.append((order, runner)) pending.append((order, runner))
else: # current_close 当根撮合(raw else: # current_close 当根撮合(raw
self._match(order, runner, raw_bars, prev_close, bar_date) self._match(order, runner, raw_bars, prev_close, bar_date)
# 3. 盯市 raw + 入库 # 3. 盯市 raw + 入库(停牌缺 bar 用 prev_close 兜底,spec §295
closes = {s: raw_bars[s].close_price for s in raw_bars} closes = {s: raw_bars[s].close_price for s in raw_bars}
self.account.mark_to_market(closes) self.account.mark_to_market(closes, prev_close)
# 资金占用成本归因(spec §195):每策略持仓按无风险利率日扣,仅记 per_strategy_pnl
# 不扣 account.cash —— 总账净值保持真实,占用成本是分策略展示用归因
per_strategy_pnl: dict = {}
if self.risk_free_rate > 0:
for r in self.runners:
cost = r.daily_borrow_cost(closes, self.risk_free_rate)
per_strategy_pnl[r.strategy_id] = {"borrow_cost": cost}
save_daily_balance( save_daily_balance(
self.db_path, self.account_id, str(bar_date), self.db_path, self.account_id, str(bar_date),
self.account.cash, self.account.market_value, self.account.equity, self.account.cash, self.account.market_value, self.account.equity,
per_strategy_pnl=per_strategy_pnl or None,
is_checkpoint=(self._bar_count % 500 == 0), is_checkpoint=(self._bar_count % 500 == 0),
) )
update_checkpoint(self.db_path, self.account_id, str(bar_date)) update_checkpoint(self.db_path, self.account_id, str(bar_date))
return pending, closes return pending, closes
def run(self) -> None: def run(self) -> None:
"""双源 zip(raw, qfq) 同日期对齐,逐根 step。""" """双源 zip(raw, qfq) 同日期对齐,逐根 step。
分红送股日历由调用方经 dividends_by_date 注入(见 __init__);
回测脚本可用 sanguo_data.dividend_source.build_dividend_calendar 预拉。
"""
prev_close: dict[str, float] = {} prev_close: dict[str, float] = {}
pending: list = [] # [(order, runner)] next_open 待下根撮合 pending: list = [] # [(order, runner)] next_open 待下根撮合
raw_iter = self.data_source.iter_bars( raw_iter = self.data_source.iter_bars(
@@ -102,6 +120,27 @@ class PaperEngine:
pending, closes = self.step(rdate, raw_bars, qfq_bars, prev_close, pending) pending, closes = self.step(rdate, raw_bars, qfq_bars, prev_close, pending)
prev_close = closes prev_close = closes
def _apply_dividends(self, bar_date) -> None:
"""除权除息日调整持仓(spec §295):现金分红按除权前持仓量,再 apply_split。
双层记账一致性:Account 与各 StrategyRunner 分户同步 split。
"""
events = self.dividends_by_date.get(str(bar_date))
if not events:
return
for sym, ev in events.items():
pos = self.account.positions.get(sym)
if pos is None or pos.volume <= 0:
continue
# 现金分红用除权前持仓量(apply_split 前的 volume
self.account.apply_cash_dividend(sym, ev.cash_per_share)
pos.apply_split(ev.split_factor)
# 分户同步 split(保持总账=分户之和)
for r in self.runners:
rpos = r.positions.get(sym)
if rpos is not None and rpos.volume > 0:
rpos.apply_split(ev.split_factor)
def _match(self, order, runner, bars, prev_close, bar_date) -> None: def _match(self, order, runner, bars, prev_close, bar_date) -> None:
if order.symbol not in bars: if order.symbol not in bars:
return return
+2 -1
View File
@@ -139,7 +139,8 @@ def live_step(db_path: str, account_id: int, data_source, cfg, today: str | None
transfer_fee_rate=acc["transfer_fee_rate"], min_commission=acc["min_commission"], transfer_fee_rate=acc["transfer_fee_rate"], min_commission=acc["min_commission"],
) )
pe = PaperEngine(account, runners, data_source, acc_cfg, db_path, account_id, pe = PaperEngine(account, runners, data_source, acc_cfg, db_path, account_id,
symbols, acc.get("start_date") or today, today, interval) symbols, acc.get("start_date") or today, today, interval,
risk_free_rate=getattr(cfg, "risk_free_rate", 0.0))
pending_new, _closes = pe.step(today, bars, prev_close, pending) pending_new, _closes = pe.step(today, bars, prev_close, pending)
# 6. 存状态(pending + positions # 6. 存状态(pending + positions
+17 -4
View File
@@ -6,11 +6,11 @@ mutable,被 Account / StrategyRunner 持有(spec §6.3 双层记账)。
- 零股卖出允许零股退出持仓基本操作买入由 matcher 保证 100 股整取 - 零股卖出允许零股退出持仓基本操作买入由 matcher 保证 100 股整取
""" """
class PositionLedger: class PositionLedger:
def __init__(self, symbol: str): def __init__(self, symbol: str, volume: int = 0, frozen: int = 0, avg_price: float = 0.0):
self.symbol: str = symbol self.symbol: str = symbol
self.volume: int = 0 self.volume: int = volume
self.frozen: int = 0 # T+1 当日买入冻结 self.frozen: int = frozen # T+1 当日买入冻结
self.avg_price: float = 0.0 self.avg_price: float = avg_price
@property @property
def available(self) -> int: def available(self) -> int:
@@ -39,6 +39,19 @@ class PositionLedger:
if self.volume == 0: if self.volume == 0:
self.avg_price = 0.0 self.avg_price = 0.0
def apply_split(self, factor: float) -> None:
"""送股/转增:volume ×= factoravg_price /= factor(总市值不变,spec §295)。
factor=(10+送转)/10 100 股整数倍持仓结果恒为整数
factor<=0 非法空持仓 no-op
"""
if factor <= 0:
raise ValueError(f"split factor 必须为正: {factor}")
if self.volume <= 0:
return
self.volume = int(round(self.volume * factor))
self.avg_price = self.avg_price / factor
def unfreeze(self) -> None: def unfreeze(self) -> None:
"""次日开盘前调用:frozen → available。""" """次日开盘前调用:frozen → available。"""
self.frozen = 0 self.frozen = 0
+8
View File
@@ -29,6 +29,14 @@ class StrategyRunner:
for sym, p in self.positions.items() if p.volume > 0 for sym, p in self.positions.items() if p.volume > 0
) )
def daily_borrow_cost(self, closes: dict[str, float] | None = None,
risk_free_rate: float = 0.0) -> float:
"""资金占用日成本 = 占用资金 × 年化无风险利率 / 365(spec §195 归因用)。
纯归因记账返回值由 PaperEngine 计入 per_strategy_pnl不扣 account.cash
"""
return self.used_allocation(closes) * risk_free_rate / 365.0
def _position(self, symbol: str) -> PositionLedger: def _position(self, symbol: str) -> PositionLedger:
if symbol not in self.positions: if symbol not in self.positions:
self.positions[symbol] = PositionLedger(symbol) self.positions[symbol] = PositionLedger(symbol)
+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"