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

双路对账:回测里最该有、却几乎没人做的一件事

5/5 标的最大绝对差 0.000000,三种注入 bug 3/3 全抓

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

一个回测跑完了,给了你一条净值曲线。

你怎么知道它算得对?

不是"策略好不好",是更基础的那个问题:这条曲线本身,有没有算错?

绝大多数回测代码——包括很多商业平台——根本没有回答这个问题的机制。它只有一条曲线,没有第二个来源可以印证。

本节给出答案:用两条互不依赖的路径算同一个数,然后要求它们相等。

实测:5 个标的,最大绝对差 0.000000;故意注入三种 bug,3/3 全部被抓住,而且每次都指出首个对不上的日期

💡 打个比方

复式记账法用了六百年,核心只有一句:每一笔交易都记两次,借方合计必须等于贷方合计。

它不能保证你记的内容是对的(你可能把工资记成了奖金),但它能保证:只要有一处漏记、错记、重记,总账立刻对不上。

双路对账,就是回测的复式记账。

一、两条路径

路径怎么算数据来源
A 账户口径权益 = 现金 + 持仓市值账户状态
B 流水口径从初始资金出发,逐笔重放每一次成交逐笔成交流水

关键在于它们用的是完全不同的数据:

  • A 来自 Account 对象的当前状态
  • B 从 EventLog.fills 里一笔一笔重新算
# 路径 B:从逐笔成交流水重建权益
cash, qty = cash0, 0
for d in dates:
    for f in fills_by_date.get(d, []):
        if f.side is Side.BUY:
            cash -= f.qty * f.price + f.commission
            qty  += f.qty
        else:
            cash += f.qty * f.price - f.commission
            qty  -= f.qty
    equity_b = cash + qty * close[d]

如果引擎有 bug,这两条路几乎不可能同时错成一样。

二、实测:5/5 全部对上

sh.600000   最大绝对差 0.000000 | 最大相对差 0.00e+00 | 超容差 0 天 | ✓
sh.600009   最大绝对差 0.000000 | 最大相对差 0.00e+00 | 超容差 0 天 | ✓
sh.600010   最大绝对差 0.000000 | 最大相对差 0.00e+00 | 超容差 0 天 | ✓
sh.600011   最大绝对差 0.000000 | 最大相对差 0.00e+00 | 超容差 0 天 | ✓
sh.600015   最大绝对差 0.000000 | 最大相对差 0.00e+00 | 超容差 0 天 | ✓

5/5 个标的对账全部通过

注意用的是相对容差 1e-6,不是相等——这是 p1.1 的教训:数值校验用容差。这里恰好完全相等,但容差必须留着,因为一旦引入浮点运算更复杂的路径(比如按份额计价),微小差异是正常的。

三、故意注入三种 bug,验证对账有效

⚠️ 避坑

对账全过,不等于对账有效。

这是 p0.3 那条纪律的第三次出现:一道永远不会失败的检查,等于没有检查。

所以必须反过来测:造出已知的 bug,看它抓不抓得住。

注入的 bug最大绝对差首个对不上的日子结果
① 漏记第 11 笔佣金215.632011-01-10✗ 抓住
② 第 21 笔成交多记 100 股12964.932011-06-29✗ 抓住
③ 第 31 笔成交价高 1%7747.282012-01-10✗ 抓住

3/3 全部抓住。

而且注意第三列:它不只说"对不上",还告诉你第一次对不上是哪一天

这一条在定位 bug 时最值钱。一个 bug 引入之后,差额会一直传下去——看最后一天的差额没有意义,看第一天才能定位

🔧 动手做:找出对账抓不住的那种 bug(8 分钟)

上面 3 个 bug 对账 3/3 全抓——因为它们都发生在某一条路上,两条路一分歧就露馅。那对账是万能的吗?你来注入一个它抓不住的。

code/reconciliation.py 第 37 行 CASH = 1_000_000 改成 2_000_000(假装你把初始资金打错了),重跑 python reconciliation.py

你会看到(实测):对账依然 5/5 全过、最大绝对差还是 0.000000——它根本没发现资金错了。

想明白:两条路都从同一个 CASH 出发,一起错、一起自洽。对账保证的是"账本内部一致",不保证"输入/前提正确"。 凡是两条路共有的错误假设(资金、成交价、甚至策略逻辑本身),对账一律抓不住。这正是为什么光有对账不够——还需要 p4.9 的外部对拍(换个引擎当第三方)和 p8.8 的纸上交易(撞真实约束)去撞它的盲区。每种检查都有它抓不住的东西——知道一个方法的边界,和知道它能干什么,同样重要。

四、对账不通过时,怎么定位

三步,能覆盖绝大多数引擎 bug:

第一步:看首个对不上的日子(不是最后一个)。bug 通常就在那天引入。

第二步:看差额的符号与量级。

差额特征大概率原因
≈ 某笔成交金额漏记 / 重记了一笔成交
≈ 费用量级(几元~几十元)佣金 / 印花税的口径问题
逐日累积放大持仓数量记错了

第三步:把那一天的成交流水打出来,逐笔手算一遍。

上面三种注入的 bug,正好对应三种特征:漏记佣金 → 差额 215 元(费用量级);多记 100 股 → 差额 12964 元(约一笔成交金额);成交价高 1% → 差额 7747 元并持续累积。

五、做成断言,挂进 CI

def assert_reconciled(result, df, cash0, tol=1e-6):
    m = recon(result, df, cash0)
    bad = m[m["rel_diff"] > tol]
    assert bad.empty, (
        f"双路对账失败:{len(bad)} 天超容差,"
        f"首个 {bad.index[0].date()},最大相对差 {m['rel_diff'].max():.2e}")

每次改引擎都跑一遍。对不上就不许合并。

这是 p0.2 判据一("逐笔盈亏累加,等于账户市值算出来的结果吗")在代码层的落法——从一句口号,变成一条会拦住合并的断言。

❓ 测验
你的回测引擎双路对账通过了。这说明什么?
✏️ 填空
双路对账的思想来自会计:每一笔交易记两次,借贷必须相等——这个方法叫 ___ 记账法。

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

✅ 小结

这一节把 p0.2 的判据一变成了可执行的代码:

  • 两条互不依赖的路径:账户口径(现金+市值) vs 流水口径(逐笔重放成交)
  • 实测 5/5 通过,最大绝对差 0.000000(用相对容差 1e-6,不用相等——p1.1 的教训)
  • 三种注入 bug 3/3 全抓,并给出首个对不上的日期——定位 bug 时这一条最值钱
  • 三步定位法:看首个日期 → 看差额量级 → 打出那天流水逐笔手算

以及一条边界:对账检验的是内部一致性,不是规则正确性。它抓不住"你把印花税也加在买入上"这种错——那要靠 p4.4 的单元测试和 p4.9 的交叉验证。

三层验证缺一不可:单元测试(规则对不对)→ 双路对账(账算得一致不一致)→ 交叉对拍(与成熟框架比)。

下一节讲绩效报告:年化、回撤、夏普、Calmar、Sortino。重点不是公式,是每个指标各自的失效场景——比如夏普在什么情况下会把一个危险策略打成高分。

下一节 → 绩效报告:Calmar、Sortino 与它们各自的失效场景
🔎 来源与核验· 2 条,点开核对
本节每个关键论断都对应一个可追溯的来源 —— 这是本课程"靠谱、不过时"的底线。
「5 只标的的双路对账:最大绝对差均为 0.000000,最大相对差 0.00e+00,超容差(1e-6)天数均为 0」
📚 本节 code/reconciliation.py,2026-07-31 于 ECS 实测,输出见 code/outputs/stdout.txt✓ 已核验 2026-07
「三种注入 bug 全部被对账抓住:漏记第 11 笔佣金(最大绝对差 215.63,首个 2011-01-10)、第 21 笔多记 100 股(12964.93,2011-06-29)、第 31 笔成交价高 1%(7747.28,2012-01-10)」
📚 同上实测✓ 已核验 2026-07
双路对账检验内部一致性,不检验规则本身是否符合现实;后者需撮合层单元测试(p4.4)与外部交叉验证(p4.9)。
智图软件的赞赏码
都看到这了,打个赏呗!
接下来 · p4.7
绩效报告:五个指标各自的失效场景
继续读下一节 →