引擎交叉验证:与 backtrader 对拍
交易次数差 约 2–7 倍,期末权益只差 1%——而每一条差异都要能解释
自研了一个回测引擎。凭什么让人信它算得对?
我们已经做了两层验证,但它们都是自己验自己:
- p4.4 单元测试:规则对不对
- p4.6 双路对账:账算得一致不一致
这一节引入外部参照:和 backtrader(社区最常用的 Python 回测框架之一)跑同一条策略,比结果。
实测:交易次数差 约 2–7 倍,期末权益只差 1% 左右;加上同样的佣金后,差异几乎不变(中位 1.14% → 1.12%)。
而这一节最重要的一句是:对拍的目标不是"完全相等",是"每一条差异都能被解释"。
两台不同厂家的血压计,量同一个人,读数不会完全相同。
如果你把其中一台调到跟另一台完全一致,你并没有验证准确性——你只是做了一台复制品。
正确的做法是:量几次,看差异有多大、来自哪里(袖带松紧?测量时机?),然后判断这个差异在不在可接受范围内。
一、第一次对拍:零成本,看纯撮合逻辑
先把所有可选项关掉(零佣金、零滑点、不查涨跌停/T+1/整手),隔离变量:
| 标的 | qlab 期末 | backtrader 期末 | 相对差 | qlab 成交 | bt 成交 |
|---|---|---|---|---|---|
| sh.600000 | 2,000,311 | 2,001,809 | -0.07% | 265 | 1878 |
| sh.600009 | 1,070,643 | 1,082,942 | -1.14% | 271 | 1978 |
| sh.600010 | 1,531,193 | 1,508,655 | +1.49% | 805 | 1702 |
| sh.600011 | 1,875,005 | 1,853,332 | +1.17% | 589 | 2014 |
| sh.600015 | 1,161,162 | 1,162,503 | -0.12% | 384 | 2026 |
平均相对差 +0.27%,绝对值中位 1.14%。
二、第二次对拍:加同样的佣金
| 标的 | 零成本相对差 | 加佣金后相对差 |
|---|---|---|
| sh.600000 | -0.07% | -0.05% |
| sh.600009 | -1.14% | -1.12% |
| sh.600010 | +1.49% | +1.48% |
| sh.600011 | +1.17% | +1.20% |
| sh.600015 | -0.12% | -0.07% |
加了成本之后,差异几乎完全不变(中位 1.14% → 1.12%)。
这条比第一张表更重要:它证明两边的成本模型是一致的。如果 qlab 的佣金算错了(比如双边算成单边),加上成本后差异会系统性扩大。
这就是"逐项打开"排查法的价值:先关掉一切看基线,再一项项打开,看哪一项拉大差异。
三、差异从哪来:逐条排查
① 最小调仓阈值——它解释了 约 2–7 倍的交易次数差
- qlab 有一条"目标差额不足一手就不动"的规则 → 每只 265~805 笔
- backtrader 没有这条,每天按小数股微调 → 每只 1702~2026 笔
交易次数差 约 2–7 倍,期末权益却只差 1% 左右。
这个对比本身就是一条结论:那些微调几乎不影响结果,而在真实成本下,它们会是纯损耗(回想 p4.2 的成本网格)。
② 下单口径
qlab 用"目标市值差额 ÷ 开盘价"算股数;backtrader 的 order_target_percent 用它自己的估值口径。
③ 股数精度
qlab 即使关掉整手也按整数股;backtrader 允许小数股。
④ 首日建仓时点
两边对"前 SLOW 根 K 线"的预热处理略有不同。
⑤ 停牌/涨跌停
本次对拍已在 qlab 侧关闭,以隔离变量。
第一版对拍时,我犯了一个更基础的错误。
我给 backtrader 写的策略是"没仓位才买",而 qlab 的引擎是每天把仓位拉回 0.95。
两边跑的根本不是同一个策略。当时的成交笔数是 qlab 265805 笔 vs backtrader 120129 笔——差 6 倍,而我差点把它当成"引擎差异"去分析。
修正:让 backtrader 也每天调用 order_target_percent。
对拍的第一步永远是:先确认两个引擎跑的是同一个策略。否则你比出来的差异,毫无意义——你比的不是引擎,是两个不同的策略。
🔧 动手做:成交笔数差 7 倍,结果只差 1%——那些交易在干嘛?(5 分钟)
跑 python cross_validate.py,盯【一】那张表的成交笔数两列:qlab 每只 265~805 笔,backtrader 1702~2026 笔——差最多 7 倍。可期末权益的相对差中位只有 1.14%。
想明白:backtrader 每天按小数股微调仓位,qlab 有一条"目标差额不足一手就不动"的阈值。多出来的那 6~7 倍成交,几乎不改变结果(权益只差 1%),说明它们是无意义的微调——而在真实成本下(佣金+滑点+印花税),每一笔微调都在吃钱。所以:成交越频繁不代表越精细,很多时候只是把钱一笔笔交给券商。 一个策略的成交笔数,本身就是要审视的东西。
四、怎么算"对拍通过"
❌ 错误标准:要求两个引擎完全相等
两边的默认假设本来就不同。强行调到相等,只能靠把一方改成另一方的复制品——那样对拍就失去了意义。
你验证的不再是正确性,而是相似性。
✅ 正确标准,三条
| # | 标准 | 本次结果 |
|---|---|---|
| 1 | 零成本、关约束时,相对差在可解释的量级内 | 中位 1.14% |
| 2 | 加同样成本后,差异不显著扩大 | 1.14% → 1.12% |
| 3 | 每一条残留差异,都能指出具体来源 | 见上文 ①–⑤ |
做完这三条,自研引擎才有资格说"我算得对"。
五、三层验证,到此完整
| 层 | 管什么 | 抓得住 | 抓不住 |
|---|---|---|---|
| 单元测试(p4.4) | 规则对不对 | 涨跌停/T+1/整手写错 | 规则之间的组合错误 |
| 双路对账(p4.6) | 账算得一致不一致 | 漏记/错记/重记 | 规则本身设错(两条路一致地错) |
| 交叉对拍(p4.9) | 整体结果对不对 | 系统性偏差 | 两个框架共同的错误假设 |
三层各有盲区,但它们的盲区不重叠——这就是分层验证的意义。
注意最后一格:如果 backtrader 和 qlab 共享同一个错误假设(比如都假设次日开盘一定能成交),对拍是发现不了的。那需要的是实盘小仓验证,而那超出了本课的边界(P8.10 会讲为什么)。
📌 免责:本课为技术教学,标的为沪深300 成分股按代码序取样(非精选),所有回测为历史模拟,不构成投资建议。
自研引擎的可信度,到这一节才算立住:
- 零成本基线:相对差中位 1.14%(-1.14% ~ +1.49%)
- 加同样佣金后差异几乎不变(1.14% → 1.12%)——这证明成本模型一致,比第一张表更重要
- 交易次数差 约 2–7 倍,期末权益只差 1%——差异来自"最小调仓阈值"这个设计选择,而那些微调在真实成本下是纯损耗
- 我第一版栽在更基础的地方:两边跑的不是同一个策略(backtrader 写成"没仓位才买"),成交笔数差 6 倍。对拍第一步永远是先对齐策略
判断标准三条:基线差异可解释、加成本后不扩大、每条残留差异都能指出来源。
三层验证到此完整:单元测试管规则、双路对账管账、交叉对拍管整体——三层各有盲区,但盲区不重叠。
下一节是 P4 的收尾,也是这门课第一个成品的交付:把 qlab 打包成一个 pip 可安装的开源库——模块划分、抽象基类、单元测试、语义化版本、CHANGELOG、CI 与发版。
🔎 来源与核验· 4 条,点开核对
