diff --git a/docs/superpowers/plans/2026-07-07-phase3c-paper-trading.md b/docs/superpowers/plans/2026-07-07-phase3c-paper-trading.md new file mode 100644 index 0000000..c9a4805 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-phase3c-paper-trading.md @@ -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。 +- **测试**: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`) + +```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 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 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(): + # 创业板 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`) + +```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 期模式) + +> 引擎从一开始就支持多 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=MINUTE +- `test_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_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=0,unfreeze 后才可卖 +- `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:买扣 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=`(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 1(models)** 开始。