# 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)** 开始。