20 KiB
Phase 3c:模拟盘(Paper Trading)设计
日期:2026-07-07(v2,吸收架构 + A 股业务双 review) 阶段:Phase 3c(C 期) 状态:设计待审阅 维护:Main Agent
1. 背景与目标
已交付:Phase 1 数据层(A 股日 K / 15min / 5min / 1min parquet + SQLite);Phase 2 因子 + 回测引擎;Phase 3a 研究 API;Phase 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-patch;tick 级撮合 |
| 现有 BacktestingEngine | 策略类 + cta_engine 桥接(send_order 拦截);CtaTemplate on_bar 单根 | 一次性 load_data |
3.3 数据源与复权(关键)
- 历史 parquet:NAS 日线 + 15min(5350 标的,1.5G)+ 5min/1min。
sanguo_data/datafeed.py:117 adjustflag="2"→ 现有数据是前复权(qfq)。 - 复权矛盾(业务 review CRITICAL):策略信号/因子需价格连续(qfq);撮合/涨跌停/持仓成本需真实交易价(raw)。一套数据用到底会让涨跌停判断系统性失真。
- 双数据源设计:DataSource 加
adjust参数("qfq"信号用 /"raw"撮合用)。raw 数据首版通过 akshareadjustflag="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 # Matcher(A股撮合,纯函数)
├── 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 # APScheduler(C 实走)
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+0(review 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, # 续跑 checkpoint(H-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。
容器单 worker(B 期已定)+ 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 后或下期) | 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/均价强制用 raw;DataSource 双源,类型不匹配时报错 |
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)