feat(trader): PaperEngine 主循环(逐bar重放/next_open缓冲/current_close/双层记账/持久化)

- run(): T+1解冻→撮合上根pending(用当前bar)→喂策略收单→current_close当根/next_open缓冲→盯市入库
- 双层记账一致性(总账=分户之和), checkpoint续跑字段
- StrategyRunner +symbol 字段 3 tests passed.
This commit is contained in:
2026-07-07 12:01:37 +08:00
parent f0d8fd2a03
commit 42877213ae
3 changed files with 208 additions and 1 deletions
+112
View File
@@ -0,0 +1,112 @@
"""PaperEngine 模拟盘主循环(逐根 bar 重放 + 双层记账 + 持久化,spec §4/§9)。
run():逐 bar → T+1 解冻 → 撮合上一根 next_open pending(用当前 bar)→ 喂策略
on_bar 收新单 → current_close 当根撮合 / next_open 缓冲到下根 → 盯市 → 入库。
raw 首版 fallback qfqspec §17),信号与撮合共用一套(标注)。
"""
import logging
import pandas as pd
from .account import Account
from .matcher import cross_order
from .models import MatchSession, 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 run(self) -> None:
prev_close: dict[str, float] = {}
pending: list = [] # [(order, runner)] next_open 待下根撮合
bar_count = 0
for bar_date, bars in self.data_source.iter_bars(
self.symbols, self.start, self.end, self.interval, "qfq", None
):
bar_count += 1
self.account.unfreeze_all()
for r in self.runners:
r.unfreeze_all()
# 1. 撮合上一根 pendingnext_open,用当前 bar
if pending:
for order, runner in pending:
self._match(order, runner, bars, prev_close, bar_date)
pending = []
# 2. 喂策略 on_bar → 收新单
for runner in self.runners:
sym = runner.symbol
if sym and sym in bars:
runner.paper_cta_engine.on_bar(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 当根撮合
self._match(order, runner, bars, prev_close, bar_date)
# 3. 盯市 + 入库
closes = {s: bars[s].close_price for s in 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=(bar_count % 500 == 0),
)
update_checkpoint(self.db_path, self.account_id, str(bar_date))
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 self.account.cash_enough(result):
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)
+3 -1
View File
@@ -7,10 +7,12 @@ from .position_ledger import PositionLedger
class StrategyRunner:
def __init__(self, strategy_id: str, strategy=None, paper_cta_engine=None) -> None:
def __init__(self, strategy_id: str, strategy=None, paper_cta_engine=None,
symbol: str = "") -> None:
self.strategy_id: str = strategy_id
self.strategy = strategy
self.paper_cta_engine = paper_cta_engine
self.symbol: str = symbol
self.positions: dict[str, PositionLedger] = {}
self.realized_pnl: float = 0.0
self.commission_paid: float = 0.0
+93
View File
@@ -0,0 +1,93 @@
"""PaperEngine 主循环测试(mock data_source + mock 策略,spec §4)。"""
from types import SimpleNamespace
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, list_trades, save_account,
)
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 _AlwaysBuyStrategy:
def __init__(self, engine, vt_symbol):
self.cta_engine = engine
self.vt_symbol = vt_symbol
def on_bar(self, bar):
self.cta_engine.send_order(self, "LONG", "OPEN", bar.close_price, 100)
def _build(tmp_path, sections, match_session=MatchSession.NEXT_OPEN):
db = str(tmp_path / "e.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)
cta = PaperCtaEngine("s1", match_session=match_session)
strat = _AlwaysBuyStrategy(cta, "600000.SSE")
cta.set_strategy(strat)
runner = StrategyRunner("s1", strategy=strat, paper_cta_engine=cta, symbol="600000")
pe = PaperEngine(account, [runner], _FakeDataSource(sections), cfg, db, aid,
symbols=["600000"], start="2024-01-01", end="2024-12-31")
return pe, db, aid, account, runner
def test_engine_next_open_fills_at_next_bar_open(tmp_path):
sections = [
("2024-01-01", {"600000": _bar("2024-01-01", 10.0, 10.5, 9.5, 10.0)}),
("2024-01-02", {"600000": _bar("2024-01-02", 10.5, 11.0, 10.0, 10.8)}),
("2024-01-03", {"600000": _bar("2024-01-03", 10.8, 11.5, 10.5, 11.0)}),
]
pe, db, aid, *_ = _build(tmp_path, sections)
pe.run()
fills = [t for t in list_trades(db, aid) if not t["rejected"]]
# day1 信号→day2 撮合@10.5(open)day2 信号→day3 撮合@10.8(open)day3 信号无day4
assert len(fills) == 2
assert fills[0]["price"] == 10.5
assert fills[1]["price"] == 10.8
def test_engine_current_close_fills_same_bar(tmp_path):
sections = [
("2024-01-01", {"600000": _bar("2024-01-01", 10.0, 10.5, 9.5, 10.0)}),
("2024-01-02", {"600000": _bar("2024-01-02", 10.5, 11.0, 10.0, 10.8)}),
]
pe, db, aid, *_ = _build(tmp_path, sections, match_session=MatchSession.CURRENT_CLOSE)
pe.run()
fills = [t for t in list_trades(db, aid) if not t["rejected"]]
assert len(fills) == 2
assert fills[0]["price"] == 10.0 # 当根 close
assert fills[1]["price"] == 10.8
def test_engine_daily_balance_and_consistency(tmp_path):
sections = [
("2024-01-01", {"600000": _bar("2024-01-01", 10.0, 10.5, 9.5, 10.0)}),
("2024-01-02", {"600000": _bar("2024-01-02", 10.5, 11.0, 10.0, 10.8)}),
("2024-01-03", {"600000": _bar("2024-01-03", 10.8, 11.5, 10.5, 11.0)}),
]
pe, db, aid, account, runner = _build(tmp_path, sections)
pe.run()
balances = list_daily_balance(db, aid)
assert len(balances) == 3
# 总账持仓 = 分户持仓(day2+day3 各买100 = 200
assert account.positions["600000"].volume == 200
assert runner.positions["600000"].volume == 200