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

20 KiB
Raw Blame History

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_sessionnext_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_ctastrategyCtaTemplateon_bar(bar) 单根),不是 AlphaStrategyon_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 数据源与复权(关键)

  • 历史 parquetNAS 日线 + 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.pyStrategyRunner 是 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.0003min_commission=5.0stamp_duty_rate=0.0005transfer_fee_rate=0.00001slippage=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_reasonblocked_by_strategy=<id>(谁的持仓占了钱),前端可见。
  • 分期C-S2 后):每策略 max_allocation 软限额 + 资金占用成本(按无风险利率日扣),消除"先到后到"的不可复现性。首版标注此简化。

8. 数据契约

8.1 PaperAccountpaper_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 PaperTradepaper_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 PaperPositionpaper_positions,每日快照)

account_id, scope("account"|"strategy:<id>"), symbol, date,
volume, frozen, avg_price(raw), market_value, updated_at

scope 区分总账行(account)与分户行(strategy:id)。

8.4 DailyBalancepaper_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 引擎核心 TDDlimit.py(板块表+封板)+ Matchermatch_session/费率/T+1/资金T+0+ PositionLedger + models + Issue #3 费率参数化 单测全覆盖(各板块涨跌停、一字/T字板、current_close/next_open、T+1、资金T+0、最低佣金 5 元)
C-S1 A 回放端到端:PaperCtaEngine(策略适配)+ PaperEngine + DataSourceqfq/raw 双源 + 补 read_parquet_15min+ orchestrator + 共享 DB 进度 + API + 前端结果页。引擎从一开始支持多 StrategyRunnerM-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