docs(spec): Phase 3c 模拟盘设计 v2(吸收架构+业务双 review)

架构 + A 股业务双 sub-review 后修正 6 项 CRITICAL + 4 项 HIGH:
- 复权双数据源(qfq 信号 / raw 撮合),解涨跌停系统性失真
- 涨跌停板块表(主板±10/ST±5/创业科创±20/北交所±30)+ 封板判据收紧(T字板保守拒单)
- match_session 多撮合时点(next_open/current_close),支持尾盘抓涨停类策略
- 资金 T+0、印花税 0.05%(2023.8.28 新规)+ 过户费 + 最低佣金 5 元
- StrategyRunner 明确为 CtaTemplate 适配器(PaperCtaEngine 拦截 send_order)
- 任务模型:回放走共享 DB 进度+checkpoint 续跑;实走 APScheduler 启动恢复
- owner_id/checkpoint_date/scheduler_job_id 字段补齐
- 分红送股/归因软限额/科创200股/集合竞价 标注分期(C-S3 后)
This commit is contained in:
2026-07-07 09:28:58 +08:00
parent 3ee64ae7bb
commit 6c0acd2374
@@ -0,0 +1,358 @@
# Phase 3c:模拟盘(Paper Trading)设计
> 日期:2026-07-07v2,吸收架构 + A 股业务双 review)
> 阶段:Phase 3cC 期)
> 状态:设计待审阅
> 维护:Main Agent
---
## 1. 背景与目标
**已交付**Phase 1 数据层(A 股日 K / 15min / 5min / 1min parquet + SQLite);Phase 2 因子 + 回测引擎;Phase 3a 研究 APIPhase 3b 投研 + 回测 Web 控制台(`vnpy.mysanguo.top`)。
**C 期目标**:建 A 股**模拟盘引擎**——策略在"未见过的数据"上 forward 跑,纸面撮合,跟踪虚拟账户,为 D 期实盘做前置演练。
**4 期路线**(用户确认):投研 → 回测 → **模拟(本期)** → 实盘(D 期,路线待重新评估,见 §3.4)。
**两种模式**(都做,A 先 C 后):
- **A 回放模式**:选历史区间逐根 bar forward 重放,一次跑完。验证策略是否过拟合。
- **C 实走模式**:每日收盘后定时增量喂当天 bar,持续跟踪。实盘前演练。
---
## 2. 范围
| 类别 | 内容 |
|---|---|
| ✅ 多频率引擎 | `interval` 可配:日频 + 15min(数据现成),5min/1min 预留 |
| ✅ A 股撮合(板块感知)| 涨跌停按板块(主板±10/ST±5/创业科创±20/北交所±30)、封板判据、T+1、资金 T+0、100 股、佣金+印花税+过户费+最低佣金 |
| ✅ 多撮合时点 | `match_session`next_open(收盘型)/ current_close(尾盘型)/ call_auction(预留)|
| ✅ 复权双数据源 | 信号/因子用 qfq,撮合/涨跌停/均价用 raw |
| ✅ 一对多账户 | 总账(对齐实盘)+ 分户归因 |
| ✅ A 回放 + C 实走 | 共用引擎 |
| ✅ 前端 | 点亮 B 期预留"模拟"入口 |
| ✅ Issue #3 顺带 | 费率/资金参数化 |
| ⚠️ 分期(首版标注限制)| 分红送股事件、归因软限额、科创板 200 股手数、集合竞价撮合 |
| ❌ 盘中实时(tick 级)/ 实盘交易 | D 期 |
**策略谱系**(用户确认):日频收盘型 + 日内型(尾盘抓涨停、次日开板卖)共存,故不固定频率、需多撮合时点。
---
## 3. 调研依据
### 3.1 vnpy 能力边界
- vnpy **核心库**只给积木(`EventEngine` + 数据模型 + `BaseGateway` + `OmsEngine`)。
- 纸面撮合(`vnpy_paperaccount`)、live CTA 引擎(`vnpy_ctastrategy`)是独立项目。**`vnpy_ctastrategy` 是 pip 依赖**(容器有,本机可能无 → 代码 lazy import + fallback,沿用现有 `cta_engine.py:69` 模式)。
- 项目现有回测用 `vnpy_ctastrategy`**`CtaTemplate``on_bar(bar)` 单根)**,不是 `AlphaStrategy``on_bars dict`)。
- `BacktestingEngine` 本身就是个"假 cta_engine"(回测拦截 `send_order`)——**PaperCtaEngine 可参考它的策略桥接**,区别只是逐根 forward + A 股规则 + 多策略。
### 3.2 借鉴对象
| 对象 | 借鉴 | 不借鉴 |
|------|------|--------|
| **freqtrade dry-run** | live/paper 分支;内存挂单+SQLite 落账;订单 ID 命名;配置驱动 | orderbook 滑点;T+0 假设 |
| **vnpy_paperaccount** | cross_order/update_position/calculate_pnl 拆分;持仓冻结;均价计算 | monkey-patchtick 级撮合 |
| **现有 BacktestingEngine** | 策略类 + cta_engine 桥接(send_order 拦截);CtaTemplate on_bar 单根 | 一次性 load_data |
### 3.3 数据源与复权(关键)
- **历史 parquet**NAS 日线 + 15min5350 标的,1.5G+ 5min/1min。`sanguo_data/datafeed.py:117 adjustflag="2"` → 现有数据是**前复权(qfq)**。
- **复权矛盾**(业务 review CRITICAL):策略信号/因子需价格连续(qfq);**撮合/涨跌停/持仓成本需真实交易价(raw)**。一套数据用到底会让涨跌停判断系统性失真。
- **双数据源设计**DataSource 加 `adjust` 参数(`"qfq"` 信号用 / `"raw"` 撮合用)。raw 数据首版通过 akshare `adjustflag="3"`(不复权)下载补齐,或存复权因子表运行时还原。
- **实走当日**akshare `stock_zh_a_hist`(主)+ tushare(兜底),T 日 20:00 后稳。
### 3.4 ⚠️ D 期风险(不影响 C 期)
miniQMT 据称 2026/7/6 停止新申请,`xtquant` 受影响。C 期数据源(akshare/tushare + 历史 parquet)与此无关。D 期路线启动前需单独讨论。
### 3.5 关键洞察
> 日频/分钟级模拟盘比 tick 级 paper 简单一个量级。核心三件套:逐根 bar 重放 + 纸面撮合 + 账户跟踪。
---
## 4. 整体架构
```
浏览器 (Vue"模拟"入口点亮) ↕ HTTPS vnpy.mysanguo.top
FastAPI sanguo_api (:8000)
├─ /api/v1/paper/* → 模拟盘路由
├─ /ws/paper/{id} → 进度推送(轮询共享 DB)
└─ sanguo_orchestrator → submit_paper(回放,ProcessPool/ APScheduler(实走)
sanguo_trader/(新模块)
PaperEngine ─ Matcher ─ Account(总账) ─ StrategyRunner×N(分户)
│ │ │ │
DataSource limit.py PositionLedger(per-symbol 持仓对象)
Persistence(SQLite 落账 + checkpoint)
sanguo_data(qfq/raw 双源) + vnpy 数据模型 + CtaTemplate 策略类
```
---
## 5. 组件设计(`sanguo_trader/`,单一职责)
> review 修正:明确 PositionLedger 是**单标的持仓对象**(非全局计算模块);涨跌停抽独立纯函数 `limit.py`StrategyRunner 是 **CtaTemplate 适配器**。
| 组件 | 职责 | 关键接口 |
|------|------|---------|
| **PaperEngine** | 主循环:逐根 bar → 喂各 StrategyRunner → 收 OrderRequest → 调 Matcher → 双层记账 → 盯市 → 入库 | `run()``step(bar)`(实走用)|
| **PaperCtaEngine** | **策略适配器**:实现 cta_engine 接口,注入 CtaTemplate,拦截 `send_order()` 转 OrderRequest(参考 BacktestingEngine 桥接)| `send_order(...)`→收集订单;`on_bar(bar)` 转发策略 |
| **Matcher** | A 股撮合纯函数:`cross_order(order, bars, prev_close_raw, cfg) → Trade \| Reject` | 无副作用,TDD 核心 |
| **limit.py** | 涨跌停纯函数:板块幅度查表 + 封板判断(一字板/T 字板)| `limit_price(symbol, prev_close_raw, board)``is_locked(bar)` |
| **Account** | 总账:cash(资金 T+0)、合并持仓 `dict[symbol→PositionLedger]`、净值 | `apply_trade()``mark_to_market(bar)` |
| **StrategyRunner** | 分户账:持 PaperCtaEngine + 策略实例 + 分户持仓 `dict[symbol→PositionLedger]` + PnL | `on_bar()``apply_trade()` |
| **PositionLedger** | **单标的持仓对象**volume / frozen(T+1) / avg_price(raw 计);含 update/freeze 方法 | `update(trade)``unfreeze()` |
| **Persistence** | SQLite 落账(4 表 + checkpoint);启动恢复实走 job | `save_*()``load_checkpoint()``restore_live_jobs()` |
| **DataSource** | 统一行情:`iter_bars(symbols, start, end, interval, adjust="qfq"\|"raw")``fetch_day(symbol, date, interval, adjust)` | 双复权源 |
**持仓状态归属**review H-2):Account 持总账 `dict[symbol, PositionLedger]`,每个 StrategyRunner 持自己的分户 `dict[symbol, PositionLedger]`。PositionLedger 是被持有的对象,非全局单例。均价/T+1 计算是它的方法。涨跌停在 `limit.py` 独立。
**文件组织**200-400 行/文件):
```
sanguo_trader/
├── __init__.py
├── engine.py # PaperEngine
├── cta_adapter.py # PaperCtaEngine(策略适配器)
├── matcher.py # MatcherA股撮合,纯函数)
├── limit.py # 涨跌停(板块表 + 封板判断,纯函数)
├── account.py # Account 总账
├── strategy_runner.py # StrategyRunner 分户
├── position_ledger.py # PositionLedger 单标的持仓对象
├── persistence.py # SQLite + checkpoint + job 恢复
├── data_source.py # 行情双源(qfq/raw
├── models.py # PaperAccount/Order/Trade/Reject 数据类
└── scheduler.py # APSchedulerC 实走)
```
---
## 6. 撮合规则(A 股核心,业务 review 大改)
### 6.1 撮合时点(`match_session`,新增)
策略在 `PaperAccount.strategies[].match_session` 声明,PaperEngine 按此路由:
| match_session | 撮合价 | 适用 | lookahead 约束 |
|---|---|---|---|
| `next_open`(默认)| 下一根 bar 的 open | 收盘型策略 | 安全(信号当根、撮合下根)|
| `current_close` | 当根 bar 的 close | **尾盘抓涨停型**(当日尾盘买、次日卖)| **契约**:策略 `on_bar` 内**不得使用当根 close/high/low**(否则 lookahead|
| `call_auction` | 预留 | 集合竞价 | 首版不实现 |
> 业务 review CRITICAL:抓涨停策略实盘是"当日尾盘买",若推到 next_open,涨停股次日一字板直接拒单,策略永远买不进——逻辑失真。故必须支持 current_close。
### 6.2 涨跌停(板块感知,review CRITICAL
**幅度表**`limit.py` 查表,按 symbol 前缀判断板块):
| 板块 | 普通 | ST/*ST | 新股首日/前 5 日 |
|---|---|---|---|
| 主板(沪/深)| ±10% | ±5% | ±44% |
| 创业板(300| ±20% | ±5% | 前 5 日不设限 |
| 科创板(688| ±20% | ±5% | 前 5 日不设限 |
| 北交所 | ±30% | ±30% | 前 5 日不设限 |
**价位计算**`limit_price = prev_close_raw × (1 ± ratio)`,四舍五入到 pricetick。**必须用 raw 价格**(§3.3),不能用 qfq。
**封板判据**(收紧,review CRITICAL)——用 raw OHLC
- **严格一字板**`open==high==low==close==limit_price`)→ 买单拒单(无对手盘)
- **T 字板 / 秒板**`open==limit 且 close==limit 且 low<open`)→ **保守拒单**(实盘大概率买不进)
- 开板(`low > limit_price` 或非封板形态)→ 按规则成交
- 跌停对称
> OHLC 近似的已知乐观偏差:T 字板实际可能瞬间开板成交,首版保守拒单会在归因里标注。
### 6.3 T+1 与资金 T+0review HIGH
- **股票 T+1**:买入成交量当日进 `PositionLedger.frozen`,次日开盘前 `frozen→available`(解冻后才可卖)。
- **资金 T+0**:卖出回笼资金**当日即可用于再买入**(`Account.cash` 卖出即时增加)。这是 A 股硬规则,对短周期策略(抓涨停→次日卖→再买)影响大。
### 6.4 费用(review HIGH,参数化 + Issue #3
```
commission = max(volume × price × rate, min_commission) # 最低佣金 5 元
stamp_duty = volume × price × stamp_duty_rate # 仅卖出,0.05%2023.8.28 起)
transfer_fee = volume × price × transfer_fee_rate × 2 # 沪深双向,0.001%
total_cost = commission + stamp_duty + transfer_fee
```
默认值:`rate=0.0003``min_commission=5.0``stamp_duty_rate=0.0005``transfer_fee_rate=0.00001``slippage=0`。全部 `PaperAccount` 字段可配(**Issue #3 落地**)。
### 6.5 手数(review MEDIUM
- 买入:主板/创业板向下取整到 100 股;**科创板首版统一按 100 股处理,标注已知限制**(实盘 200 股起 +1 递增)。
- 卖出:允许零股(≤ available 即可),不取整——退出持仓的基本操作。
---
## 7. 账户模型(一对多,对齐实盘)
**双层记账**——一笔带 `strategy_id` 的成交同时更新两层:
| 层 | 内容 | 回答 |
|----|------|------|
| **Account(总账)** | 合并 cash(资金 T+0)、合并持仓、总净值 | "账户整体赚不赚"(= 实盘真实状态)|
| **StrategyRunner(分户)** | 该策略标记持仓 + PnL | "哪个策略在赚/亏" |
**资金占用与归因公平**review MEDIUM):
- **首版**:多策略共享资金池,**先到先得**,买单检查 Account 总现金,不足则拒单(`reject_reason="insufficient_cash"`)。
- **拒单归因**`paper_trades.reject_reason``blocked_by_strategy=<id>`(谁的持仓占了钱),前端可见。
- **分期**(C-S2 后):每策略 `max_allocation` 软限额 + 资金占用成本(按无风险利率日扣),消除"先到后到"的不可复现性。首版标注此简化。
---
## 8. 数据契约
### 8.1 PaperAccount`paper_accounts` 表,review H-1/H-3/H-4 补字段)
```
id, task_id, owner_id(默认"admin",多用户预留), name,
mode("replay"|"live"), interval("d"|"15m"|"5m"),
symbols(JSON), strategies(JSON: [{name, class_name, params, match_session}]),
initial_capital, rate, slippage, size, pricetick,
stamp_duty_rate, transfer_fee_rate, min_commission, # 费用(6.4
status("pending"|"running"|"done"|"failed"),
start_date, end_date, # 回放
last_run_date, next_run_at, scheduler_job_id, # 实走(H-3 恢复用)
checkpoint_date, # 续跑 checkpointH-4
error_msg, created_at, updated_at
```
### 8.2 PaperTrade`paper_trades`,含拒单)
```
id, account_id, strategy_id, datetime, symbol,
direction, offset, match_session, price, volume,
commission, stamp_duty, transfer_fee,
rejected(bool), reject_reason, # "limit_up_locked"/"insufficient_cash"/"blocked_by_strategy=s1"
bar_date, strategy_id_blocked_by(可空)
```
### 8.3 PaperPosition`paper_positions`,每日快照)
```
account_id, scope("account"|"strategy:<id>"), symbol, date,
volume, frozen, avg_price(raw), market_value, updated_at
```
> `scope` 区分总账行(`account`)与分户行(`strategy:id`)。
### 8.4 DailyBalance`paper_daily_balance`
```
account_id, date, cash, market_value, total_equity,
per_strategy_pnl(JSON: {strategy_id: {pnl, equity}}), is_checkpoint(bool)
```
所有接口返回 JSON 安全值(Timestamp→str)。
---
## 9. 任务模型 & 调度(review C-1 修正)
### 9.1 A 回放(orchestrator + 共享 DB 进度)
- `task_type="paper"`ProcessPoolExecutor spawn 一次性任务。
- **进度机制**(解 C-1):worker 直接写 **NAS 共享 SQLite 文件**(sqlite WAL 多进程兼容)——每 N 根 bar 落一次 `paper_daily_balance(is_checkpoint=true)` + 更新 `paper_accounts.checkpoint_date`。主进程轮询 DB(或 WS 推 stage 级进度:"回放中,已处理至 2024-06-15")。
- **续跑**worker 启动读 `checkpoint_date`,从其后继续;中断不丢(状态在 DB 文件)。
- 进度粒度:stage 级(每 N 根 bar 更新一次,非逐 bar)。前端体验可接受(回放非实时)。
### 9.2 C 实走(APScheduler + 启动恢复)
- 创建实走盘 → 注册 APScheduler job(每日 20:30),job_id 存 `paper_accounts.scheduler_job_id`
- 每次触发:`DataSource.fetch_day(adjust="raw")` + `(adjust="qfq")` 拉当日 → `PaperEngine.step(bar)` 增量喂 → 更新 `last_run_date`
- **启动恢复**review H-3):容器启动调 `Persistence.restore_live_jobs()`,遍历 `status="running" AND mode="live"` 的账户重新注册 job。状态全在 SQLite,重启不丢。
- 走停:`POST /paper/{id}/start|stop` 注册/移除 job。
> 容器单 workerB 期已定)+ APScheduler 在 FastAPI 主进程内,兼容。
---
## 10. API`/api/v1/paper/*`JWT,沿用 B 期模式)
| 方法 | 路径 | 用途 |
|------|------|------|
| POST | `/paper/create` | 建盘(mode/interval/策略集含 match_session/标的/资金/费率)|
| GET | `/paper/{id}` | 状态+配置 |
| GET | `/paper` | 列表(?mode=&status=,按 owner_id 过滤)|
| GET | `/paper/{id}/equity` | 账户净值曲线 |
| GET | `/paper/{id}/strategies` | 分策略 PnL 归因 |
| GET | `/paper/{id}/positions` | 持仓(总账/分户)|
| GET | `/paper/{id}/trades` | 成交(含拒单+原因+blocked_by|
| POST | `/paper/{id}/start` `/stop` | 实走走停 |
| WS | `/ws/paper/{id}?token=` | 进度(事件格式:`{type:"progress",bar_date,equity,stage}`|
---
## 11. 前端(点亮"模拟"入口,沿用 B 期技术栈)
```
模拟 ✅
├ 新建 /paper/new 模式 + 策略集(多选,每策略 match_session) + 标的集 + 区间/频率 + 资金 + 费率参数
├ 进度 /paper/progress/:id WS
├ 结果 /paper/result/:id 净值曲线(总) + 分策略归因 + 持仓 + 成交(拒单高亮)
└ 实走 /paper/live/:id 今日信号 + 持仓快照
```
---
## 12. 切片计划(A 先 C 后,每片闭环)
| 切片 | 内容 | 验收 |
|------|------|------|
| **C-S0** | 引擎核心 TDD`limit.py`(板块表+封板)+ `Matcher`match_session/费率/T+1/资金T+0+ `PositionLedger` + `models` + **Issue #3 费率参数化** | 单测全覆盖(各板块涨跌停、一字/T字板、current_close/next_open、T+1、资金T+0、最低佣金 5 元)|
| **C-S1** | A 回放端到端:`PaperCtaEngine`(策略适配)+ `PaperEngine` + `DataSource`qfq/raw 双源 + 补 `read_parquet_15min`+ orchestrator + 共享 DB 进度 + API + 前端结果页。**引擎从一开始支持多 StrategyRunner**M-3 | 跑一个收盘型策略一段历史,净值/持仓/成交(含拒单);抓涨停策略用 current_close 可买入 |
| **C-S2** | 多策略分户归因 + 前端归因展示 + 拒单归因(blocked_by) | 一个账户跑 2 策略,看分策略 PnL + 拒单归因 |
| **C-S3** | C 实走:akshare/tushare DataSource + APScheduler + 启动恢复 + 续跑 + 前端实走态 | 创建实走盘,连续几天看每日信号入账;重启容器 job 自动恢复 |
| **分期(C-S3 后或下期)** | 分红送股事件(送股/转增/现金分红/停牌盯市,事件源 dividend_detail)、归因软限额+占用成本、科创板 200 股手数、集合竞价撮合 | — |
---
## 13. 测试
- **limit.py TDD**:各板块幅度查表、一字板/T 字板/开板判断(raw OHLC)。
- **Matcher TDD**(核心):next_open/current_close 两种时点、各板块涨跌停封板拒单、开板成交、T+1(次日才能卖)、资金 T+0(卖后即买)、100 股取整、卖出零股、佣金 max(.,5)、印花税仅卖、过户费双向。
- **PositionLedger**:加仓/减仓/反手均价(raw)、冻结/解冻。
- **Account/StrategyRunner**:双层记账一致性、资金 T+0、拒单归因。
- **PaperEngine 集成**:已知策略 + 已知数据 → 已知净值;可用简单 case 对齐 BacktestingEngine 交叉验证。
- **回放端到端冒烟**`scripts/smoke_phase3c.py`
- pytest + 覆盖 80%+。
---
## 14. 错误处理
| 场景 | 处理 |
|------|------|
| bar 缺失(停牌)| 跳过信号/拒单;持仓市值按**前一日收盘价**盯市(不算 0)|
| 策略抛异常 | **隔离**catch + 记该 `strategy_id` error_msg,不影响其他策略/账户 |
| 撮合边界(封板/停牌)| 拒单 + `reject_reason` 落表,前端可见 |
| 资金不足 | 拒单 + `blocked_by_strategy`,不部分成交(首版)|
| 实走数据源失败 | akshare→tushare 兜底;连续失败暂停 job + error_msg |
| **分红除权**(首版限制)| **未处理**:净值在除权日有跳变,已知限制,归因标注。C-S3 后补事件处理 |
| 复权一致性 | Matcher/limit/均价强制用 rawDataSource 双源,类型不匹配时报错 |
---
## 15. 与 D 期衔接(预留)
- PaperEngine 留 **live/paper 分支**freqtrade `if dry_run`):接实盘只换 DataSource 为实时 gateway + 加 live 下单。
- ⚠️ D 期实盘路线待重新评估(§3.4),C 完成后单独讨论。
---
## 16. 风险与约束
| 风险/约束 | 处理 |
|---|---|
| 复权 qfq 不能直接撮合 | 双数据源(raw);首版 akshare 补 raw |
| 15min 数据量大 | interval 参数化;checkpoint 续跑 |
| vnpy 零修改 | 只复用数据模型 + CtaTemplate;适配在 sanguo_trader |
| 单 worker + 常驻 scheduler | APScheduler 主进程;回放走 ProcessPool + 共享 DB |
| 涨跌停无 tick | OHLC 近似,T 字板保守拒单,标注乐观偏差 |
| akshare 限频 | 间隔 ≥3s;双源切换 |
| NAS CPU 弱 | 标的集默认关注列表(不默认全 5350)|
---
## 17. 开放项(实现阶段确认)
- raw 数据补齐方式:akshare 重下 vs 复权因子表还原(C-S1 评估)。
- checkpoint 间隔:15min 每 500 根 bar(可调)。
- current_close 的 lookahead 契约如何强制(策略白名单 vs 运行时检测)。
- 标的范围默认空(用户填关注列表)。
---
## 参考文档
- Phase 3b 设计:`docs/superpowers/specs/2026-07-07-phase3b-vue-frontend-design.md`
- 部署实况:`docs/deployment/nas-deploy-plan.md`
- 调研依据:freqtrade dry-run / vnpy_paperaccount / akshare+tushare(§3