"""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") -> 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 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() # 1. 撮合上一根 pending(next_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 + 入库 closes = {s: raw_bars[s].close_price for s in raw_bars} self.account.mark_to_market(closes) save_daily_balance( self.db_path, self.account_id, str(bar_date), self.account.cash, self.account.market_value, self.account.equity, 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。""" 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 _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)