C-S0 引擎核心 TDD 完整代码(models/limit/position_ledger/matcher); C-S1 回放端到端接口+验收(适配器/引擎/双源/持久化/API/前端); C-S2 分户归因;C-S3 实走。含 self-review。
35 KiB
Phase 3c 模拟盘(Paper Trading)实现计划
For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development(每任务派 fresh subagent,任务间 review)。步骤用
- [ ]跟踪。
Goal: 建 A 股模拟盘引擎(sanguo_trader/),策略在未见过的数据上 forward 跑、纸面撮合、跟踪虚拟账户,支持回放(A)+ 实走(C)两种模式。
Architecture: 独立 PaperEngine(逐根 bar 重放)+ Matcher(A 股撮合纯函数)+ 双层记账(Account 总账 + StrategyRunner 分户)。复用 vnpy 数据模型 + CtaTemplate 策略类(PaperCtaEngine 适配器拦截 send_order)。借鉴 freqtrade dry-run 分支模式 + vnpy_paperaccount 撮合拆分。
Tech Stack: Python 3.10 + pytest(后端);Vue3 + TS + Element Plus + ECharts(前端,沿用 B 期);SQLite WAL(持久化 + 共享 DB 进度);APScheduler(实走定时)。
Global Constraints(所有任务隐含)
- vnpy 零修改:只复用数据模型(
BarData/OrderData/TradeData)+CtaTemplate。vnpy_ctastrategy是 pip 依赖,lazy import + fallback(沿用cta_engine.py:69模式:from vnpy_ctastrategy.backtesting import BacktestingEngine,包在 try/except)。 - 复权双源:撮合/涨跌停/均价强制用 raw;信号/因子用 qfq。Matcher 接收的
prev_close_raw/ bar 必须是 raw。 - 费率默认:
rate=0.0003、min_commission=5.0、stamp_duty_rate=0.0005、transfer_fee_rate=0.00001、slippage=0、pricetick=0.01。全部PaperAccount字段可配(Issue #3)。 - 板块幅度(
limit.py查表):主板±10%、创业(300/301)±20%、科创(688/689)±20%、北交所(8/4/920)±30%、ST±5%(首版按PaperAccount.strategies[].is_st标记,不自动识别)。 - 撮合时点
match_session:next_open(默认,下根 open)/current_close(当根 close,策略不得用当根 OHLC)/call_auction(预留不实现)。 - 资金 T+0 / 股票 T+1:卖出资金当日可再买;买入股票次日才可卖。
- 部署红线:不改容器端口(8000)、不动 frpc/socat/Caddy。前端
npm run build产物挂 FastAPI StaticFiles。 - 测试:pytest,AAA 模式,Matcher/limit 100% 覆盖,整体 ≥80%。
- 代码风格:type annotations、PEP 8、小文件(200-400 行)、immutable dataclass(
@dataclass(frozen=True)for DTOs)。
File Structure
sanguo_trader/ # 新模块
├── __init__.py
├── models.py # PaperOrder/Trade/Reject/AccountConfig 数据类
├── limit.py # 涨跌停纯函数(板块表 + 封板判断) [C-S0]
├── position_ledger.py # 单标的持仓对象(均价/T+1冻结) [C-S0]
├── matcher.py # A 股撮合纯函数(match_session/费率) [C-S0]
├── account.py # 总账(cash资金T+0 / 合并持仓 / 净值) [C-S1]
├── cta_adapter.py # PaperCtaEngine(拦截 send_order) [C-S1]
├── strategy_runner.py # 分户账(持策略实例 + 分户持仓 + PnL) [C-S1]
├── persistence.py # SQLite 4表 + checkpoint + job恢复 [C-S1]
├── engine.py # PaperEngine 主循环 [C-S1]
├── data_source.py # 行情双源(qfq/raw)+ read_parquet_15min [C-S1]
└── scheduler.py # APScheduler 实走定时 [C-S3]
sanguo_data/datareader.py # + read_parquet_15min() [C-S1]
sanguo_orchestrator/runner.py # + submit_paper_replay() [C-S1]
sanguo_api/routes_paper.py # 新路由文件 [C-S1]
sanguo_api/main.py / app.py # 挂载 paper 路由 [C-S1]
frontend/src/
├── api/paper.ts # paper API client [C-S1]
├── views/paper/{New,Progress,Result,Live}.vue # 4 页面 [C-S1/S3]
└── router/index.ts # + paper 路由 [C-S1]
tests/
├── trader/test_limit.py # 板块表+封板 [C-S0]
├── trader/test_matcher.py # 撮合全场景 [C-S0]
├── trader/test_position_ledger.py # 均价/T+1 [C-S0]
├── trader/test_account.py # 双层记账/资金T+0 [C-S1]
├── trader/test_engine.py # 集成 [C-S1]
└── api/test_paper_routes.py # API [C-S1]
C-S0:引擎核心 TDD(先做,业务正确性命脉)
派 1 个 backend-dev Sub Agent,严格 TDD 逐任务执行。每任务独立 commit。
ECC_GATEGUARD=off(已在 settings.local.json)。
Task 1: models.py — 数据类
Files:
- Create:
sanguo_trader/__init__.py(空) - Create:
sanguo_trader/models.py - Test:
tests/trader/__init__.py(空)+tests/trader/test_models.py
Interfaces:
-
Produces:
AccountConfig(费率参数)、PaperOrder(含match_session)、PaperTrade、PaperReject -
Step 1: 写测试(
tests/trader/test_models.py)
from sanguo_trader.models import AccountConfig, PaperOrder, MatchSession, OrderSide
def test_account_config_defaults():
cfg = AccountConfig(initial_capital=1_000_000)
assert cfg.rate == 0.0003
assert cfg.min_commission == 5.0
assert cfg.stamp_duty_rate == 0.0005
assert cfg.transfer_fee_rate == 0.00001
assert cfg.slippage == 0
assert cfg.pricetick == 0.01
def test_paper_order_defaults_next_open():
o = PaperOrder(strategy_id="s1", symbol="600000", side=OrderSide.BUY,
price=10.0, volume=100, is_market=True)
assert o.match_session == MatchSession.NEXT_OPEN
- Step 2: 跑测试验证失败 —
pytest tests/trader/test_models.py -v→ ModuleNotFoundError - Step 3: 实现(
sanguo_trader/models.py)
"""模拟盘数据模型(immutable DTOs)。"""
from dataclasses import dataclass, field
from enum import Enum
class MatchSession(str, Enum):
NEXT_OPEN = "next_open"
CURRENT_CLOSE = "current_close"
CALL_AUCTION = "call_auction"
class OrderSide(str, Enum):
BUY = "buy"
SELL = "sell"
@dataclass(frozen=True)
class AccountConfig:
initial_capital: float
rate: float = 0.0003 # 佣金率
min_commission: float = 5.0 # 最低佣金 5 元
stamp_duty_rate: float = 0.0005 # 印花税(仅卖,2023.8.28 起 0.05%)
transfer_fee_rate: float = 0.00001 # 过户费(沪深双向)
slippage: float = 0.0
pricetick: float = 0.01
size: float = 1.0
@dataclass(frozen=True)
class PaperOrder:
strategy_id: str
symbol: str
side: OrderSide
price: float
volume: int
is_market: bool = True
match_session: MatchSession = MatchSession.NEXT_OPEN
@dataclass(frozen=True)
class PaperTrade:
strategy_id: str
symbol: str
side: OrderSide
price: float
volume: int
commission: float
stamp_duty: float
transfer_fee: float
bar_date: str
match_session: MatchSession
@dataclass(frozen=True)
class PaperReject:
strategy_id: str
symbol: str
reason: str
bar_date: str
- Step 4: 跑测试通过 —
pytest tests/trader/test_models.py -v→ PASS - Step 5: Commit —
git add sanguo_trader/ tests/trader/ && git commit -m "feat(trader): models 数据类 + AccountConfig 费率(Issue#3)"
Task 2: limit.py — 涨跌停纯函数(板块表 + 封板判断)
Files:
- Create:
sanguo_trader/limit.py - Test:
tests/trader/test_limit.py
Interfaces:
-
Produces:
get_board(symbol) -> str、limit_ratio(board, is_st) -> float、limit_up_price(prev_close_raw, ratio, pricetick)、limit_down_price(...)、is_one_word_lock(bar, limit_price)、is_t_lock(bar, limit_price)、is_locked_for_buy(bar, prev_close_raw, cfg, is_st)、is_locked_for_sell(...) -
Step 1: 写测试(完整覆盖各板块 + 封板形态)
import pandas as pd
from sanguo_trader.limit import (
get_board, limit_ratio, limit_up_price, limit_down_price,
is_one_word_lock, is_t_lock, is_locked_for_buy,
)
def bar(open, high, low, close):
return pd.Series({"open": open, "high": high, "low": low, "close": close})
def test_board_classification():
assert get_board("600000") == "main"
assert get_board("000001") == "main"
assert get_board("300750") == "gem" # 创业板
assert get_board("688981") == "star" # 科创板
assert get_board("830799") == "bse" # 北交所
def test_limit_ratio():
assert limit_ratio("main", is_st=False) == 0.10
assert limit_ratio("gem", is_st=False) == 0.20
assert limit_ratio("star", is_st=False) == 0.20
assert limit_ratio("bse", is_st=False) == 0.30
assert limit_ratio("main", is_st=True) == 0.05
def test_limit_up_price_rounds_to_pricetick():
# 10.00 * 1.10 = 11.00
assert limit_up_price(10.0, 0.10, 0.01) == 11.0
# 9.99 * 1.20 = 11.988 → 11.99
assert limit_up_price(9.99, 0.20, 0.01) == 11.99
def test_one_word_lock_detected():
up = limit_up_price(10.0, 0.10, 0.01)
assert is_one_word_lock(bar(11.0, 11.0, 11.0, 11.0), up) is True
assert is_one_word_lock(bar(11.0, 11.5, 10.8, 11.0), up) is False
def test_t_lock_detected():
up = limit_up_price(10.0, 0.10, 0.01)
# T字板:开=涨停 收=涨停 low<open
assert is_t_lock(bar(11.0, 11.0, 10.5, 11.0), up) is True
assert is_t_lock(bar(11.0, 11.0, 11.0, 11.0), up) is False # 一字板不是T字
def test_locked_for_buy_one_word_and_t():
from sanguo_trader.models import AccountConfig
cfg = AccountConfig(initial_capital=1_000_000)
# 一字板涨停 → 买不进
assert is_locked_for_buy(bar(11.0, 11.0, 11.0, 11.0), 10.0, cfg, is_st=False) is True
# T字板 → 保守拒买
assert is_locked_for_buy(bar(11.0, 11.0, 10.5, 11.0), 10.0, cfg, is_st=False) is True
# 开板(low 远低于涨停)→ 可买
assert is_locked_for_buy(bar(10.5, 10.8, 10.2, 10.6), 10.0, cfg, is_st=False) is False
- Step 2: 跑测试失败 —
pytest tests/trader/test_limit.py -v→ FAIL - Step 3: 实现(
sanguo_trader/limit.py)
"""A 股涨跌停纯函数(板块表 + 封板判断)。用 raw 价格。"""
import pandas as pd
def get_board(symbol: str) -> str:
"""按代码前缀判断板块。"""
if symbol.startswith(("300", "301")):
return "gem" # 创业板
if symbol.startswith(("688", "689")):
return "star" # 科创板
if symbol.startswith(("8", "4", "920")):
return "bse" # 北交所
return "main"
_LIMIT_RATIO = {"main": 0.10, "gem": 0.20, "star": 0.20, "bse": 0.30}
_ST_RATIO = 0.05
def limit_ratio(board: str, is_st: bool) -> float:
return _ST_RATIO if is_st else _LIMIT_RATIO[board]
def limit_up_price(prev_close_raw: float, ratio: float, pricetick: float) -> float:
return round(prev_close_raw * (1 + ratio) / pricetick) * pricetick
def limit_down_price(prev_close_raw: float, ratio: float, pricetick: float) -> float:
return round(prev_close_raw * (1 - ratio) / pricetick) * pricetick
def is_one_word_lock(bar: pd.Series, limit_price: float) -> bool:
"""一字板:开=高=低=收=涨停价。"""
return (bar["open"] == bar["high"] == bar["low"] == bar["close"] == limit_price)
def is_t_lock(bar: pd.Series, limit_price: float) -> bool:
"""T 字板:开=涨停、收=涨停、low<open(盘中砸过板)。保守拒单。"""
return (bar["open"] == limit_price and bar["close"] == limit_price
and bar["low"] < bar["open"])
def is_locked_for_buy(bar: pd.Series, prev_close_raw: float, cfg, is_st: bool) -> bool:
"""涨停封板(一字板或 T 字板)→ 买不进。"""
ratio = limit_ratio(get_board(""), is_st) # board 由 symbol 算,这里调用方传 prev_close
# 注:实际调用用下方 is_locked_for_buy_symbol
raise NotImplementedError # 占位,下方为正式入口
def is_locked_for_buy_symbol(bar: pd.Series, symbol: str, prev_close_raw: float, cfg, is_st: bool = False) -> bool:
up = limit_up_price(prev_close_raw, limit_ratio(get_board(symbol), is_st), cfg.pricetick)
return is_one_word_lock(bar, up) or is_t_lock(bar, up)
def is_locked_for_sell_symbol(bar: pd.Series, symbol: str, prev_close_raw: float, cfg, is_st: bool = False) -> bool:
down = limit_down_price(prev_close_raw, limit_ratio(get_board(symbol), is_st), cfg.pricetick)
return is_one_word_lock(bar, down) or is_t_lock(bar, down)
注:测试里的
is_locked_for_buy(bar, prev_close, cfg, is_st)旧签名保留兼容——实现时把测试统一改为is_locked_for_buy_symbol(bar, symbol, prev_close_raw, cfg, is_st)。Sub Agent 执行时以*_symbol签名为准,上面测试里的调用相应改为传 symbol(如"600000")。
- Step 4: 跑测试通过 —
pytest tests/trader/test_limit.py -v→ PASS - Step 5: Commit —
feat(trader): limit.py 涨跌停板块表+封板判断(T字板保守拒单)
Task 3: position_ledger.py — 单标的持仓对象
Files:
- Create:
sanguo_trader/position_ledger.py - Test:
tests/trader/test_position_ledger.py
Interfaces:
-
Produces:
PositionLedger(volume、frozen、avg_price;apply_buy(trade)、apply_sell(trade)、freeze_today()、unfreeze()) -
Step 1: 写测试(均价/T+1 冻结解冻)
from sanguo_trader.position_ledger import PositionLedger
def test_buy_sets_avg_price_and_freezes():
p = PositionLedger(symbol="600000")
p.apply_buy(price=10.0, volume=100)
assert p.volume == 100
assert p.frozen == 100 # T+1:买入当日冻结
assert p.avg_price == 10.0
def test_avg_price_weighted_on_add():
p = PositionLedger(symbol="600000")
p.apply_buy(10.0, 100)
p.unfreeze() # 次日解冻
p.apply_buy(12.0, 100)
assert p.avg_price == 11.0 # (10*100 + 12*100)/200
def test_cannot_sell_frozen():
p = PositionLedger(symbol="600000")
p.apply_buy(10.0, 100)
assert p.frozen == 100
assert p.available == 0 # 当日不可卖
p.unfreeze()
assert p.available == 100
def test_sell_reduces_volume():
p = PositionLedger(symbol="600000")
p.apply_buy(10.0, 200)
p.unfreeze()
p.apply_sell(11.0, 100)
assert p.volume == 100
- Step 2: 跑测试失败
- Step 3: 实现
"""单标的持仓对象(raw 计均价、T+1 冻结)。mutable,被 Account/StrategyRunner 持有。"""
class PositionLedger:
def __init__(self, symbol: str):
self.symbol = symbol
self.volume: int = 0
self.frozen: int = 0 # T+1 当日买入冻结
self.avg_price: float = 0.0
@property
def available(self) -> int:
return self.volume - self.frozen
def apply_buy(self, price: float, volume: int) -> None:
total_cost = self.avg_price * self.volume + price * volume
self.volume += volume
self.avg_price = total_cost / self.volume if self.volume else 0.0
self.frozen += volume # T+1
def apply_sell(self, price: float, volume: int) -> None:
if volume > self.available:
raise ValueError(f"卖出超过可卖量: want {volume}, available {self.available}")
self.volume -= volume
if self.volume == 0:
self.avg_price = 0.0
def unfreeze(self) -> None:
"""次日开盘前调用:frozen → available。"""
self.frozen = 0
- Step 4: 跑测试通过
- Step 5: Commit —
feat(trader): PositionLedger 单标的持仓(T+1冻结/均价)
Task 4: matcher.py — A 股撮合纯函数(核心)
Files:
- Create:
sanguo_trader/matcher.py - Test:
tests/trader/test_matcher.py
Interfaces:
-
Consumes:
PaperOrder、AccountConfig、limit.* -
Produces:
cross_order(order, match_bar, prev_close_raw, cfg, is_st) -> PaperTrade | PaperReject -
Step 1: 写测试(全场景,AAA 模式)
import pandas as pd
import pytest
from sanguo_trader.matcher import cross_order
from sanguo_trader.models import AccountConfig, PaperOrder, OrderSide, MatchSession
CFG = AccountConfig(initial_capital=1_000_000)
PREV = 10.0 # raw 前收
def mkbar(open, high, low, close):
return pd.Series({"open": open, "high": high, "low": low, "close": close})
def buy(price=0, volume=100, market=True, session=MatchSession.NEXT_OPEN, symbol="600000"):
return PaperOrder("s1", symbol, OrderSide.BUY, price, volume, market, session)
# ---- 撮合时点 ----
def test_next_open_market_fill_uses_next_open():
t = cross_order(buy(market=True), mkbar(10.5, 11, 10.2, 10.8), PREV, CFG)
assert t.price == 10.5
def test_current_close_fill_uses_current_close():
o = buy(market=True, session=MatchSession.CURRENT_CLOSE)
t = cross_order(o, mkbar(10.5, 11, 10.2, 10.8), PREV, CFG)
assert t.price == 10.8
# ---- 涨跌停封板拒单 ----
def test_limit_up_one_word_rejects_buy():
up = 11.0 # 10*1.1
r = cross_order(buy(market=True), mkbar(up, up, up, up), PREV, CFG)
assert isinstance(r, PaperReject := r) or r.reason == "limit_up_locked" if hasattr(r, "reason") else True
assert r.reason == "limit_up_locked"
def test_limit_up_t_lock_rejects_buy_conservatively():
up = 11.0
r = cross_order(buy(market=True), mkbar(up, up, 10.5, up), PREV, CFG)
assert r.reason == "limit_up_locked"
def test_limit_down_rejects_sell():
o = PaperOrder("s1","600000",OrderSide.SELL,0,100,True)
down = 9.0
r = cross_order(o, mkbar(down, down, down, down), PREV, CFG)
assert r.reason == "limit_down_locked"
def test_gem_board_20pct_limit():
# 创业板 300750,10.00 → 涨停 12.00
r = cross_order(PaperOrder("s1","300750",OrderSide.BUY,0,100,True),
mkbar(12.0,12.0,12.0,12.0), 10.0, CFG)
assert r.reason == "limit_up_locked"
# ---- 限价单触价 ----
def test_limit_buy_not_touched_rejected():
o = PaperOrder("s1","600000",OrderSide.BUY,10.0,100,is_market=False)
# open 10.5 > 委托 10.0 → 触不到
r = cross_order(o, mkbar(10.5,11,10.2,10.8), PREV, CFG)
assert r.reason == "limit_not_touched"
# ---- 100 股取整(买入)----
def test_buy_rounds_down_to_100():
t = cross_order(PaperOrder("s1","600000",OrderSide.BUY,0,250,True),
mkbar(10,10,10,10), PREV, CFG)
assert t.volume == 200
def test_buy_below_100_rejected():
r = cross_order(PaperOrder("s1","600000",OrderSide.BUY,0,50,True),
mkbar(10,10,10,10), PREV, CFG)
assert r.reason == "volume_below_min_lot"
# ---- 费用 ----
def test_commission_uses_min_5_yuan():
# 100 股 × 10 元 × 0.0003 = 0.3 → 不足 5 元,收 5
t = cross_order(buy(market=True), mkbar(10,10,10,10), PREV, CFG)
assert t.commission == 5.0
def test_stamp_duty_only_on_sell():
t_buy = cross_order(buy(market=True), mkbar(10,10,10,10), PREV, CFG)
assert t_buy.stamp_duty == 0.0
t_sell = cross_order(PaperOrder("s1","600000",OrderSide.SELL,0,100,True),
mkbar(10,10,10,10), PREV, CFG)
# 100*10*0.0005 = 0.5
assert t_sell.stamp_duty == pytest.approx(0.5)
def test_transfer_fee_both_sides_in_trade():
t = cross_order(buy(market=True), mkbar(10,10,10,10), PREV, CFG)
# 单边 100*10*0.00001 = 0.01;trade 里存单边,Account 算 ×2
assert t.transfer_fee == pytest.approx(0.01)
测试里
PaperReject那行 walrus 写法有误(isinstance(r, PaperReject := r))——Sub Agent 实现时改成:assert hasattr(r, "reason") and r.reason == "limit_up_locked"。统一用isinstance(r, PaperReject)判断。
- Step 2: 跑测试失败
- Step 3: 实现(
sanguo_trader/matcher.py)
"""A 股撮合纯函数。match_bar 必须是 raw 价格。"""
import pandas as pd
from .models import AccountConfig, PaperOrder, PaperTrade, PaperReject, OrderSide, MatchSession
from .limit import is_locked_for_buy_symbol, is_locked_for_sell_symbol
MIN_LOT = 100
def cross_order(order: PaperOrder, match_bar: pd.Series, prev_close_raw: float,
cfg: AccountConfig, is_st: bool = False):
symbol = order.symbol
# 1. 涨跌停封板拒单(raw)
if order.side == OrderSide.BUY and is_locked_for_buy_symbol(match_bar, symbol, prev_close_raw, cfg, is_st):
return PaperReject(order.strategy_id, symbol, "limit_up_locked", str(match_bar.get("date","")))
if order.side == OrderSide.SELL and is_locked_for_sell_symbol(match_bar, symbol, prev_close_raw, cfg, is_st):
return PaperReject(order.strategy_id, symbol, "limit_down_locked", str(match_bar.get("date","")))
# 2. 成交价(按 match_session)
if order.match_session == MatchSession.NEXT_OPEN:
fill_price = match_bar["open"]
elif order.match_session == MatchSession.CURRENT_CLOSE:
fill_price = match_bar["close"]
else:
return PaperReject(order.strategy_id, symbol, "unsupported_match_session", "")
# 3. 限价单触价
if not order.is_market:
if order.side == OrderSide.BUY and fill_price > order.price:
return PaperReject(order.strategy_id, symbol, "limit_not_touched", "")
if order.side == OrderSide.SELL and fill_price < order.price:
return PaperReject(order.strategy_id, symbol, "limit_not_touched", "")
# 4. 100 股取整(买入向下取整;卖出不取整,允许零股)
volume = order.volume
if order.side == OrderSide.BUY:
volume = (volume // MIN_LOT) * MIN_LOT
if volume < MIN_LOT:
return PaperReject(order.strategy_id, symbol, "volume_below_min_lot", "")
# 5. 费用
gross = volume * fill_price
commission = max(gross * cfg.rate, cfg.min_commission)
stamp_duty = gross * cfg.stamp_duty_rate if order.side == OrderSide.SELL else 0.0
transfer_fee = gross * cfg.transfer_fee_rate # 单边;Account 算双向 ×2
return PaperTrade(
strategy_id=order.strategy_id, symbol=symbol, side=order.side,
price=fill_price, volume=volume, commission=commission,
stamp_duty=stamp_duty, transfer_fee=transfer_fee,
bar_date=str(match_bar.get("date", "")), match_session=order.match_session,
)
- Step 4: 跑测试通过 —
pytest tests/trader/test_matcher.py -v→ 全 PASS - Step 5: 覆盖率 —
pytest tests/trader/ --cov=sanguo_trader --cov-report=term-missing→ matcher/limit 100% - Step 6: Commit —
feat(trader): matcher.py A股撮合(match_session/费率/100股/封板) Issue#3
Task 5: C-S0 收尾 + 全量回归
- Step 1: 全量测试 —
pytest tests/trader/ -v→ 全 PASS - Step 2: 跑现有 B 期测试确认无回归 —
pytest tests/ -v(除依赖容器的) → 无新增 fail - Step 3: Commit(若有遗漏)
C-S0 验收:matcher/limit/position_ledger 单测全过,覆盖各板块涨跌停、一字/T字板、next_open/current_close、T+1、资金T+0、最低佣金、印花税仅卖、过户费。
C-S1:回放端到端(派 Sub Agent,基于 spec §5/§9 + B 期模式)
引擎从一开始就支持多 StrategyRunner(spec M-3)。每任务 TDD + commit。
Task 6: data_source.py + sanguo_data/datareader.py:read_parquet_15min
Files:
- Modify:
sanguo_data/datareader.py(加read_parquet_15min(symbol, start, end, cfg) -> list[BarData],复用read_parquet_daily的 parquet 读取模式,路径取cfg.data_paths["minute_15_dir"],文件名shXXXXXX_15min.parquet) - Create:
sanguo_trader/data_source.py
Interfaces:
- Produces:
iter_bars(symbols, start, end, interval, adjust="qfq"|"raw") -> Iterator[dict[symbol, BarData]](按时间对齐多标的,逐"行"yield);fetch_day(symbol, date, interval, adjust)
测试要点:
test_read_parquet_15min:mock parquet 文件,断言返回 BarData 列表 + interval=MINUTEtest_iter_bars_qfq_raw:两个 adjust 参数走不同路径(首版 raw 可 fallback qfq + 标注,或 akshare 下载;首版若 NAS 无 raw parquet,DataSource raw 模式先复用 qfq 并 log warning,C-S3 补真 raw——spec §17 开放项)
实现要点:
-
iter_bars按日期合并多标的 bar 成字典(对齐 vnpy BacktestingEngine 的 cross-section 思路),逐日期 yield -
symbol → 文件名映射:
600000→sh600000_15min.parquet(沪 sh/深 sz,复用cta_engine.guess_exchange) -
TDD + Commit —
feat(data): read_parquet_15min + trader DataSource 双源
Task 7: cta_adapter.py — PaperCtaEngine(策略适配器)
Files:
- Create:
sanguo_trader/cta_adapter.py - Test:
tests/trader/test_cta_adapter.py
Interfaces:
- Produces:
PaperCtaEngine(实现 CtaTemplate 所需的 cta_engine 接口:send_order/cancel_order/buy/sell/set_signal等——参考vnpy_ctastrategyBacktestingEngine 的策略桥接)
实现要点:
- lazy import
vnpy_ctastrategy;本机无则用 mock 策略类 fallback 测试 send_order(strategy, direction, offset, price, volume, ...)→ 构造PaperOrder(match_session 从策略配置读)→ 收集到self.pending_orders- 策略实例
__init__时传入此 engine;on_bar(bar)转发给策略on_bar - 关键:参考
vnpy_ctastrategy/backtesting.py里 BacktestingEngine 怎么做策略桥接(它也是假 cta_engine)
测试:mock 一个简单 CtaTemplate 子类,喂 bar,断言 send_order 被调用 → pending_orders 收到 PaperOrder
- TDD + Commit —
feat(trader): PaperCtaEngine 策略适配器(拦截send_order)
Task 8: account.py + strategy_runner.py — 双层记账
Files:
- Create:
sanguo_trader/account.py、sanguo_trader/strategy_runner.py - Test:
tests/trader/test_account.py
Interfaces:
Account:cash(资金 T+0)、positions: dict[symbol, PositionLedger]、apply_trade(trade)、mark_to_market(bars_raw)、equity属性StrategyRunner:持PaperCtaEngine+positions: dict[symbol, PositionLedger](分户)、apply_trade(trade)、pnl
测试要点:
test_capital_t0:卖出后 cash 立即增加,可立即再买test_share_t1:买入持仓 frozen,当日 available=0,unfreeze 后才可卖test_double_entry_consistency:一笔 trade 同时更新 Account 总账 + StrategyRunner 分户,两者持仓一致(分户之和=总账)test_transfer_fee_double_sided:Account 扣 transfer_fee × 2test_insufficient_cash_reject:买单现金不足 → 拒单(matcher 不处理资金,Account 在 apply 前检查)
实现要点:
-
Account.apply_trade:买扣 cash(price×volume + commission + transfer_fee×2);卖加 cash(price×volume - commission - stamp_duty - transfer_fee×2);持仓更新走 PositionLedger
-
每日开盘前调所有 PositionLedger.unfreeze()(T+1 解冻)
-
mark_to_market:按 raw close 重估 market_value = Σ volume × close;equity = cash + market_value
-
TDD + Commit —
feat(trader): Account总账+StrategyRunner分户(双层记账/资金T0)
Task 9: persistence.py — SQLite 4 表 + checkpoint
Files:
- Create:
sanguo_trader/persistence.py - Test:
tests/trader/test_persistence.py
Interfaces:
init_db(db_path)、save_account(PaperAccount)、save_trade(...)、save_daily_balance(..., is_checkpoint)、load_checkpoint(account_id) -> date、list_trades(account_id)等
实现要点:
- 4 表 schema 严格按 spec §8.1-8.4(含 owner_id / checkpoint_date / scheduler_job_id / match_session / scope / is_checkpoint 字段)
- WAL 模式:
PRAGMA journal_mode=WAL(多进程 worker 写 + 主进程读) - 参考
sanguo_backtest/result_store.py的 sqlite 模式
测试:建临时 db,save/load round-trip,checkpoint 字段正确
- TDD + Commit —
feat(trader): persistence 4表+checkpoint(WAL)
Task 10: engine.py — PaperEngine 主循环
Files:
- Create:
sanguo_trader/engine.py - Test:
tests/trader/test_engine.py
Interfaces:
PaperEngine(account_cfg, strategies_cfg, data_source, persistence):run()(回放,跑完)、step(bar_dict)(实走单步)
主循环逻辑(spec §4 数据流):
for each bar_date (按时间排序):
bars_qfq = data_source.iter_bars(adjust="qfq") # 信号用
bars_raw = data_source.iter_bars(adjust="raw") # 撮合用
prev_close_raw = 上一日 raw close
# 1. T+1 解冻
account.unfreeze_all()
# 2. 喂策略 on_bar(bars_qfq[symbol])
for runner in strategy_runners:
orders = runner.on_bar(bars_qfq) # PaperCtaEngine 收集 pending_orders
# 3. 撮合(用 next_bar raw 或 current bar raw,按 match_session)
for order in all_pending_orders:
match_bar = bars_raw[order.symbol] (next 或 current)
result = matcher.cross_order(order, match_bar, prev_close_raw, cfg)
if Trade:
if account.cash_enough(result): account.apply_trade; runner.apply_trade
else: save Reject("insufficient_cash"/blocked_by)
else: save Reject
# 4. 盯市 + 入库
account.mark_to_market(bars_raw)
persistence.save_daily_balance(..., is_checkpoint=(bar_count % 500 == 0))
测试:构造 5 日简单数据 + mock 策略(固定买 100 股),断言最终持仓 + 净值。可用简单 case 对齐 BacktestingEngine 交叉验证(同策略同数据,净值趋势一致)。
- TDD + Commit —
feat(trader): PaperEngine 主循环(逐bar重放+双层记账)
Task 11: orchestrator/runner.py: submit_paper_replay + 共享 DB 进度
Files:
- Modify:
sanguo_orchestrator/runner.py(加submit_paper_replay(account_cfg, db_path) -> task_id) - Modify:
sanguo_orchestrator/task.py(task_type="paper")
实现要点:
- ProcessPoolExecutor spawn,worker 内跑
PaperEngine.run() - worker 直接写共享 SQLite 文件(NAS 路径,WAL),主进程轮询
paper_accounts.checkpoint_date/paper_daily_balance推 WS stage 级进度 _on_done:更新 status=done- 参考
submit_cta模式
测试:mock ProcessPool,断言 submit 返回 task_id + worker 函数被调度
- TDD + Commit —
feat(orch): submit_paper_replay(ProcessPool+共享DB进度)
Task 12: sanguo_api/routes_paper.py + 挂载
Files:
- Create:
sanguo_api/routes_paper.py - Modify:
sanguo_api/app.py/main.py(include paper router) - Test:
tests/api/test_paper_routes.py
路由(spec §10):POST /paper/create、GET /paper/{id}、GET /paper、GET /paper/{id}/equity、/strategies、/positions、/trades、POST /paper/{id}/start|stop、WS /ws/paper/{id}
实现要点:沿用 routes.py 的 verify_token 依赖、get_orchestrator;返回 JSON 安全值;WS 复用 ws.py 模式轮询 DB
测试:TestClient,mock orchestrator,断言各路由 200 + 数据结构(参考 test_routes.py 模式)
- TDD + Commit —
feat(api): /paper/* 路由(create/equity/strategies/positions/trades)
Task 13: 前端结果页(点亮"模拟"入口)
Files:
- Create:
frontend/src/api/paper.ts、frontend/src/views/paper/{New,Progress,Result}.vue - Modify:
frontend/src/router/index.ts(+ paper 路由)、frontend/src/views/Layout.vue("模拟"入口去灰显)
实现要点:
- 沿用 B 期 backtest 页面模式(New 表单 / Progress WS / Result 图表)
- Result:净值曲线(ECharts line)+ 持仓表(Element Table)+ 成交表(拒单行高亮 el-tag danger)
- New 表单:策略集多选 + match_session 每策略选 + 标的集 + 区间 + interval + 资金 + 费率参数(折叠"高级")
- 参考
views/backtest/Result.vue的 ECharts 封装
验证:cd frontend && npm run build 通过
- 实现 + build 验证 + Commit —
feat(web): 模拟盘前端(新建/进度/结果页+点亮入口)
Task 14: C-S1 部署 + 端到端冒烟
- rsync 到 NAS +
docker restart sanguo_vnpy_v2(按nas-deploy-plan.md§三) - 冒烟:
scripts/smoke_phase3c.py(登录 → POST /paper/create 回放 → WS 进度 → GET equity/positions/trades)→ 全 200 - 真数据验收:跑 DoubleMaStrategy on 600000 一段历史,结果页看净值/持仓/成交;再跑一个抓涨停型策略用 current_close,确认能买入
- Commit smoke 脚本 + 修复
C-S1 验收:回放端到端跑通,结果页功能齐,拒单可见,raw/qfq 双源工作(或 raw fallback 标注)。
C-S2:多策略分户归因(spec §7)
Task 15: 分户归因 + 拒单归因
GET /paper/{id}/strategies返回[{strategy_id, pnl, equity_curve, trade_count, reject_count}]paper_trades.reject_reason含blocked_by_strategy=<id>(Account 拒单时记录是谁占了资金)- Persistence 加查询:分户 PnL 从
paper_positions where scope="strategy:id"+ trades 聚合
Task 16: 前端归因展示
- Result 页加"分策略 PnL"表 + 柱状图;拒单表加 blocked_by 列
- Commit
C-S2 验收:一个账户跑 2 策略,分策略 PnL 正确,拒单归因可见。
C-S3:实走模式(spec §9.2)
Task 17: akshare/tushare DataSource
data_source.fetch_day(symbol, date, interval, adjust):aksharestock_zh_a_hist(adjustflag 1/2/3 对应 hfq/qfq/None);失败 fallback tushare- 限频:间隔 ≥3s
- Commit
Task 18: scheduler.py + 启动恢复
- APScheduler
BackgroundScheduler,每日 20:30 触发PaperEngine.step(当日 bar) Persistence.restore_live_jobs():容器启动遍历status=running AND mode=live重新注册- 在
sanguo_api/main.pystartup event 调 restore POST /paper/{id}/start|stop注册/移除 job- Commit
Task 19: 前端实走态 + 部署冒烟
views/paper/Live.vue:今日信号 + 当前持仓快照- Commit
- 部署 + 创建一个实走盘,连续几天验证每日信号入账 + 重启容器 job 自动恢复
C-S3 验收:实走盘跑通,续跑 OK,重启恢复 OK。
Self-Review(写计划后自检)
Spec 覆盖:
- ✅ 多频率引擎 → Task 6 read_parquet_15min + DataSource interval 参数
- ✅ A 股撮合板块感知 → Task 2/4
- ✅ match_session → Task 1/4
- ✅ 复权双源 → Task 6(首版 raw fallback 标注,C-S3 补真 raw——开放项 §17)
- ✅ 一对多双层记账 → Task 8/10
- ✅ A 回放 + C 实走 → Task 10(run)/Task 18(step)
- ✅ 前端点亮 → Task 13
- ✅ Issue #3 费率 → Task 1/4
- ✅ 资金T+0/股票T+1 → Task 3/8
- ✅ 共享 DB 进度 + checkpoint → Task 9/11
- ✅ APScheduler 启动恢复 → Task 18
- ✅ owner_id/checkpoint_date/scheduler_job_id → Task 9 schema
- ⚠️ 分期项(分红送股/软限额/科创200/集合竞价)→ spec §12 标注,不在本计划
类型一致:PaperOrder.match_session / cross_order(order, match_bar, prev_close_raw, cfg, is_st) / PositionLedger.apply_buy/apply_sell/unfreeze 跨任务签名一致 ✓。is_locked_for_buy_symbol(非 is_locked_for_buy)为正式签名,Task 2 已标注 Sub Agent 统一。
占位符:Task 6 raw 数据首版 fallback 是有意的开放项(spec §17),非占位符。其余步骤含完整代码或明确接口。
Execution Handoff
用户已授权自主(/goal)。采用 Subagent-Driven:每任务派 fresh backend-dev subagent,任务间 review。从 Task 1(models) 开始。