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

35 KiB
Raw Blame History

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+ CtaTemplatevnpy_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.0003min_commission=5.0stamp_duty_rate=0.0005transfer_fee_rate=0.00001slippage=0pricetick=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_sessionnext_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)、PaperTradePaperReject

  • 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: Commitgit 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) -> strlimit_ratio(board, is_st) -> floatlimit_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: Commitfeat(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: PositionLedgervolumefrozenavg_priceapply_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: Commitfeat(trader): PositionLedger 单标的持仓(T+1冻结/均价)

Task 4: matcher.py — A 股撮合纯函数(核心)

Files:

  • Create: sanguo_trader/matcher.py
  • Test: tests/trader/test_matcher.py

Interfaces:

  • Consumes: PaperOrderAccountConfiglimit.*

  • 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():
    # 创业板 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
"""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: Commitfeat(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_15minmock 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 → 文件名映射:600000sh600000_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, ...) → 构造 PaperOrdermatch_session 从策略配置读)→ 收集到 self.pending_orders
  • 策略实例 __init__ 时传入此 engineon_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.pysanguo_trader/strategy_runner.py
  • Test: tests/trader/test_account.py

Interfaces:

  • Accountcash(资金 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_sidedAccount 扣 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) -> datelist_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.pytask_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.pyinclude paper router
  • Test: tests/api/test_paper_routes.py

路由spec §10):POST /paper/createGET /paper/{id}GET /paperGET /paper/{id}/equity/strategies/positions/tradesPOST /paper/{id}/start|stopWS /ws/paper/{id}

实现要点:沿用 routes.pyverify_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.tsfrontend/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_reasonblocked_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_histadjustflag 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 开始。