双路对账:回测里最该有、却几乎没人做的一件事
5/5 标的最大绝对差 0.000000,三种注入 bug 3/3 全抓
一个回测跑完了,给了你一条净值曲线。
你怎么知道它算得对?
不是"策略好不好",是更基础的那个问题:这条曲线本身,有没有算错?
绝大多数回测代码——包括很多商业平台——根本没有回答这个问题的机制。它只有一条曲线,没有第二个来源可以印证。
本节给出答案:用两条互不依赖的路径算同一个数,然后要求它们相等。
实测: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.63 | 2011-01-10 | ✗ 抓住 |
| ② 第 21 笔成交多记 100 股 | 12964.93 | 2011-06-29 | ✗ 抓住 |
| ③ 第 31 笔成交价高 1% | 7747.28 | 2012-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。重点不是公式,是每个指标各自的失效场景——比如夏普在什么情况下会把一个危险策略打成高分。
🔎 来源与核验· 2 条,点开核对
