Files
sanguo_vnpy_v2/docs/superpowers/specs/2026-07-07-phase3c-paper-trading-design.md
T

359 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 后或下期)** | ~~分红送股事件、归因软限额+占用成本、科创板 200 股手数、集合竞价撮合~~**2026-07-10 全部落地**(1646903 分红送股+占用成本 / 193064c 软限额 / ab703e9 集合竞价 / 05dba7f 科创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