Files
sanguo_vnpy_v2/sanguo_trader/engine.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

188 lines
9.0 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.
"""PaperEngine 模拟盘主循环(逐根 bar 重放 + 双层记账 + 持久化,spec §4/§9)。
双源(分红除权准确方案,task #79 恢复):
- 撮合/涨跌停/盯市用 **raw**(真实价,涨跌停/成交真实)
- 策略 on_bar 信号用 **qfq**(前复权,无除权缺口 → MA 信号准)
run() 双迭代器 zip(raw, qfq) 同日期对齐;step(raw_bars, qfq_bars)。
"""
import logging
import pandas as pd
from .account import Account
from .matcher import cross_order
from .models import MatchSession, OrderSide, PaperTrade
from .persistence import save_daily_balance, save_trade, update_checkpoint
from .strategy_runner import StrategyRunner
logger = logging.getLogger(__name__)
def _to_series(bar) -> pd.Series:
return pd.Series({
"open": bar.open_price, "high": bar.high_price,
"low": bar.low_price, "close": bar.close_price,
"date": getattr(bar, "datetime", ""),
})
def _trade_to_dict(t: PaperTrade, bar_date) -> dict:
return {
"strategy_id": t.strategy_id, "symbol": t.symbol,
"direction": t.side.value, "price": t.price, "volume": t.volume,
"commission": t.commission, "stamp_duty": t.stamp_duty,
"transfer_fee": t.transfer_fee,
"bar_date": str(bar_date), "match_session": t.match_session.value,
}
class PaperEngine:
def __init__(self, account: Account, runners: list[StrategyRunner],
data_source, cfg, db_path: str, account_id: int,
symbols: list[str], start: str, end: str,
interval: str = "d", risk_free_rate: float = 0.0,
dividends_by_date: dict | None = None) -> None:
self.account = account
self.runners = runners
self.data_source = data_source
self.cfg = cfg
self.db_path = db_path
self.account_id = account_id
self.symbols = symbols
self.start = start
self.end = end
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):
"""单根 bar 推进(回放 run 循环调;实走 live_step 调)。
撮合/盯市用 raw_bars(真实价);策略 on_bar 用 qfq_bars(信号准)。
返回 (新 pending, 当根 closes)。
"""
self._bar_count = getattr(self, "_bar_count", 0) + 1
self.account.unfreeze_all()
for r in self.runners:
r.unfreeze_all()
# 0. 除权除息日:分红送股调整(开盘前持仓享权,spec §295)
self._apply_dividends(bar_date)
# 1. 撮合上一根 pendingnext_open,用当日 raw bar
if pending:
for order, runner in pending:
self._match(order, runner, raw_bars, prev_close, bar_date)
pending = []
# 2. 喂策略 on_bar(qfq 信号)→ 收新单 → 当根撮合 raw / 缓冲 next_open
for runner in self.runners:
sym = runner.symbol
if sym and sym in qfq_bars:
runner.paper_cta_engine.on_bar(qfq_bars[sym])
for order in runner.paper_cta_engine.pop_orders():
if order.match_session == MatchSession.NEXT_OPEN:
pending.append((order, runner))
else: # current_close 当根撮合(raw
self._match(order, runner, raw_bars, prev_close, bar_date)
# 3. 盯市 raw + 入库(停牌缺 bar 用 prev_close 兜底,spec §295
closes = {s: raw_bars[s].close_price for s in raw_bars}
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(
self.db_path, self.account_id, str(bar_date),
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),
)
update_checkpoint(self.db_path, self.account_id, str(bar_date))
return pending, closes
def run(self) -> None:
"""双源 zip(raw, qfq) 同日期对齐,逐根 step。
分红送股日历由调用方经 dividends_by_date 注入(见 __init__);
回测脚本可用 sanguo_data.dividend_source.build_dividend_calendar 预拉。
"""
prev_close: dict[str, float] = {}
pending: list = [] # [(order, runner)] next_open 待下根撮合
raw_iter = self.data_source.iter_bars(
self.symbols, self.start, self.end, self.interval, "raw", None
)
qfq_iter = self.data_source.iter_bars(
self.symbols, self.start, self.end, self.interval, "qfq", None
)
for (rdate, raw_bars), (_qdate, qfq_bars) in zip(raw_iter, qfq_iter):
pending, closes = self.step(rdate, raw_bars, qfq_bars, prev_close, pending)
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:
if order.symbol not in bars:
return
match_bar = _to_series(bars[order.symbol])
pc = prev_close.get(order.symbol, order.price)
result = cross_order(order, match_bar, pc, self.cfg)
if isinstance(result, PaperTrade):
if result.side == OrderSide.SELL:
# A 股不能做空:SELL 超过可卖持仓 → 拒单(不开空仓)
pos = self.account.positions.get(order.symbol)
if pos is None or pos.available < result.volume:
save_trade(self.db_path, self.account_id,
{"strategy_id": order.strategy_id, "symbol": order.symbol,
"bar_date": str(bar_date)},
rejected=True,
reject_reason="insufficient_position_no_short")
return
if self.account.cash_enough(result):
# 软限额:BUY 检查该策略已用额度(消除多策略撮合顺序依赖,spec §195)
if result.side == OrderSide.BUY:
closes = {sym: b.close_price for sym, b in bars.items()}
if (runner.used_allocation(closes) + result.price * result.volume
> runner.max_allocation):
save_trade(self.db_path, self.account_id,
{"strategy_id": order.strategy_id,
"symbol": order.symbol,
"bar_date": str(bar_date)},
rejected=True,
reject_reason="max_allocation_exceeded")
return
self.account.apply_trade(result)
runner.apply_trade(result)
save_trade(self.db_path, self.account_id,
_trade_to_dict(result, bar_date))
else:
save_trade(self.db_path, self.account_id,
{"strategy_id": order.strategy_id, "symbol": order.symbol,
"bar_date": str(bar_date)},
rejected=True, reject_reason="insufficient_cash")
else: # PaperReject
save_trade(self.db_path, self.account_id,
{"strategy_id": order.strategy_id, "symbol": order.symbol,
"bar_date": str(bar_date)},
rejected=True, reject_reason=result.reason)