docs(plan): Phase 3c 模拟盘实现计划(C-S0~S3,19 任务)
C-S0 引擎核心 TDD 完整代码(models/limit/position_ledger/matcher); C-S1 回放端到端接口+验收(适配器/引擎/双源/持久化/API/前端); C-S2 分户归因;C-S3 实走。含 self-review。
This commit is contained in:
@@ -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<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():
|
||||
# 创业板 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=<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 1(models)** 开始。
|
||||
Reference in New Issue
Block a user