← 返回目录
p4.9动手⏱ 约 16 分钟

引擎交叉验证:与 backtrader 对拍

交易次数差 约 2–7 倍,期末权益只差 1%——而每一条差异都要能解释

🔎 最后验证 2026-07📚 来源:与 backtrader 的两轮对拍于 2026-07-31 在 ECS 实测(5 只沪深300 成分股),输出见 code/outputs/stdout.txt🧰 backtrader 1.9.78、qlab.engine(本课自研)、Python 3.12.13
为什么学这个

自研了一个回测引擎。凭什么让人信它算得对?

我们已经做了两层验证,但它们都是自己验自己:

  • p4.4 单元测试:规则对不对
  • p4.6 双路对账:账算得一致不一致

这一节引入外部参照:和 backtrader(社区最常用的 Python 回测框架之一)跑同一条策略,比结果。

实测:交易次数差 约 2–7 倍,期末权益只差 1% 左右;加上同样的佣金后,差异几乎不变(中位 1.14% → 1.12%)。

而这一节最重要的一句是:对拍的目标不是"完全相等",是"每一条差异都能被解释"。

💡 打个比方

两台不同厂家的血压计,量同一个人,读数不会完全相同。

如果你把其中一台调到跟另一台完全一致,你并没有验证准确性——你只是做了一台复制品。

正确的做法是:量几次,看差异有多大、来自哪里(袖带松紧?测量时机?),然后判断这个差异在不在可接受范围内

一、第一次对拍:零成本,看纯撮合逻辑

先把所有可选项关掉(零佣金、零滑点、不查涨跌停/T+1/整手),隔离变量:

标的qlab 期末backtrader 期末相对差qlab 成交bt 成交
sh.6000002,000,3112,001,809-0.07%2651878
sh.6000091,070,6431,082,942-1.14%2711978
sh.6000101,531,1931,508,655+1.49%8051702
sh.6000111,875,0051,853,332+1.17%5892014
sh.6000151,161,1621,162,503-0.12%3842026

平均相对差 +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 会讲为什么)。

❓ 测验
你的自研引擎与 backtrader 对拍,期末权益差 8%。最合理的下一步是?
✏️ 填空
对拍的目标不是「完全相等」,而是每一条差异都能被 ___ 。

📌 免责:本课为技术教学,标的为沪深300 成分股按代码序取样(非精选),所有回测为历史模拟,不构成投资建议。

✅ 小结

自研引擎的可信度,到这一节才算立住:

  • 零成本基线:相对差中位 1.14%(-1.14% ~ +1.49%)
  • 加同样佣金后差异几乎不变(1.14% → 1.12%)——这证明成本模型一致,比第一张表更重要
  • 交易次数差 约 2–7 倍,期末权益只差 1%——差异来自"最小调仓阈值"这个设计选择,而那些微调在真实成本下是纯损耗
  • 我第一版栽在更基础的地方:两边跑的不是同一个策略(backtrader 写成"没仓位才买"),成交笔数差 6 倍。对拍第一步永远是先对齐策略

判断标准三条:基线差异可解释、加成本后不扩大、每条残留差异都能指出来源

三层验证到此完整:单元测试管规则、双路对账管账、交叉对拍管整体——三层各有盲区,但盲区不重叠

下一节是 P4 的收尾,也是这门课第一个成品的交付:把 qlab 打包成一个 pip 可安装的开源库——模块划分、抽象基类、单元测试、语义化版本、CHANGELOG、CI 与发版。

下一节 → 打包成开源库:模块化、单测、版本与发版
🔎 来源与核验· 4 条,点开核对
本节每个关键论断都对应一个可追溯的来源 —— 这是本课程"靠谱、不过时"的底线。
「零成本对拍(5 只标的):qlab 与 backtrader 期末权益相对差为 -0.07%/-1.14%/+1.49%/+1.17%/-0.12%,平均 +0.27%,绝对值中位 1.14%」
📚 本节 code/cross_validate.py,2026-07-31 于 ECS 实测(backtrader 1.9.78),输出见 code/outputs/stdout.txt✓ 已核验 2026-07
「加同样佣金(2.5bp)后相对差为 -0.05%/-1.12%/+1.48%/+1.20%/-0.07%,绝对值中位 1.12%,与零成本时的 1.14% 基本一致」
📚 同上实测✓ 已核验 2026-07
「成交笔数:qlab 每只 265~805 笔,backtrader 每只 1702~2026 笔,差异来自 qlab 的「目标差额不足一手不动」阈值」
📚 同上实测✓ 已核验 2026-07
「首版对拍因两边策略不一致(backtrader 写成「无仓位才买」、qlab 每日再平衡)导致成交笔数差约 6 倍,修正后重测」
📚 本节 code/cross_validate.py(首版两边策略不一致致成交笔数差约 6 倍的失败原样保留在代码注释 L67 与 L147 的 print 中)✓ 已核验 2026-07
对拍无法发现两个框架共有的错误假设(如「次日开盘一定成交」);该类问题需实盘小仓验证,超出本课边界(见 P8.10)。
智图软件的赞赏码
都看到这了,打个赏呗!
接下来 · p4.10
打包成开源库:第一个成品的交付
继续读下一节 →