Files
sanguo_vnpy_v2/sanguo_trader/engine.py
T
claude_dev 193064c953 feat(trader): 软限额max_allocation(分期项)—每策略资金额度消除顺序依赖
spec §195: 多策略并发下单"先到后到"不可复现 → 每策略独立max_allocation
- StrategyRunner: max_allocation字段(默认inf) + used_allocation(持仓市值)
- engine._match: BUY cash_enough后查 used+成交额>max_allocation → 拒单max_allocation_exceeded
- live_orchestrator: runner传max_allocation(默认initial_capital)
- routes_paper: StrategyCfg加max_allocation(API→DB→live_step数据流)
- test_soft_limit: 3测试(累计超限拒单/默认不限/SELL不受限)

116 passed(113旧+3新), 无回归.
2026-07-09 22:05:35 +08:00

149 lines
6.8 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") -> 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. 撮合上一根 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 + 入库
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)