Files
sanguo_vnpy_v2/docs/superpowers/plans/2026-07-07-phase3c-paper-trading.md
T
claude_dev 1b87d3f7c0 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。
2026-07-07 09:34:41 +08:00

802 lines
35 KiB
Markdown
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.
# 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** 开始。