docs(plan): Phase 3c 模拟盘实现计划(C-S0~S3,19 任务)

C-S0 引擎核心 TDD 完整代码(models/limit/position_ledger/matcher);
C-S1 回放端到端接口+验收(适配器/引擎/双源/持久化/API/前端);
C-S2 分户归因;C-S3 实走。含 self-review。
This commit is contained in:
2026-07-07 09:34:41 +08:00
parent 6c0acd2374
commit 1b87d3f7c0
@@ -0,0 +1,801 @@
# 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。
- **测试**pytestAAA 模式,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`
```python
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`
```python
"""模拟盘数据模型(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: 写测试**(完整覆盖各板块 + 封板形态)
```python
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`
```python
"""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 冻结解冻)
```python
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: 实现**
```python
"""单标的持仓对象(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 模式)
```python
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():
# 创业板 30075010.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.01trade 里存单边,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`
```python
"""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 期模式)
> 引擎从一开始就支持多 StrategyRunnerspec 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=MINUTE
- `test_iter_bars_qfq_raw`:两个 adjust 参数走不同路径(首版 raw 可 fallback qfq + 标注,或 akshare 下载;**首版若 NAS 无 raw parquetDataSource raw 模式先复用 qfq 并 log warningC-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_ctastrategy` BacktestingEngine 的策略桥接)
**实现要点**
- 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=0unfreeze 后才可卖
- `test_double_entry_consistency`:一笔 trade 同时更新 Account 总账 + StrategyRunner 分户,两者持仓一致(分户之和=总账)
- `test_transfer_fee_double_sided`Account 扣 transfer_fee × 2
- `test_insufficient_cash_reject`:买单现金不足 → 拒单(matcher 不处理资金,Account 在 apply 前检查)
**实现要点**
- Account.apply_trade:买扣 cashprice×volume + commission + transfer_fee×2);卖加 cashprice×volume - commission - stamp_duty - transfer_fee×2);持仓更新走 PositionLedger
- 每日开盘前调所有 PositionLedger.unfreeze()T+1 解冻)
- mark_to_market:按 raw close 重估 market_value = Σ volume × closeequity = 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 模式
**测试**:建临时 dbsave/load round-tripcheckpoint 字段正确
- [ ] 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 spawnworker 内跑 `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
**测试**TestClientmock 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)`akshare `stock_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.py` startup 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 1models** 开始。