回测缓存串号:日期做 key 的隐蔽数据污染
本文最后更新于 2026年7月27日 凌晨
回测跑了一晚上,结果发现两个不同策略的收益曲线一模一样。
这不是巧合,是缓存串号了。
根因很简单:缓存文件名只用日期做 key,同一天跑的回测无论参数怎么变,全部命中同一份缓存。
翻车现场
AutoQuant 的回测 pipeline 有一个日级缓存机制。每天第一次跑回测时,把结果写入一个 JSON 文件。后续同日再跑,直接读缓存,跳过耗时的数据拉取和回测计算。
问题出在缓存文件名的生成逻辑上:
1 | |
文件名长这样:pipeline_20260727.json。
看起来没问题?同一天的回测确实应该复用缓存。但如果你在同一天用不同的股票池、不同的回测天数跑了两轮回测呢?
第一轮:symbols=["518880"], days=30
第二轮:symbols=["518880", "512100"], days=60
两轮的缓存文件名都是 pipeline_20260727.json。第二轮直接读到了第一轮的结果。
这比报错可怕多了。报错你会停下来查,缓存串号只会让你对着一份看起来合理但完全错配的数据做决策。

上图:旧方案用日期做唯一 key,不同参数的回测挤进同一个缓存文件。新方案用参数指纹区分,各走各的路。
问题出在哪
缓存 key 的唯一职责是区分「这份缓存对应的输入是什么」。一个正确的 cache key 必须覆盖所有影响输出的参数。
原来的 key 只有日期。但 pipeline 的输出实际依赖这些参数:
| 参数 | 含义 | 改变后输出会变吗 |
|---|---|---|
symbols |
回测的股票列表 | 是,不同的股票产生不同结果 |
days |
回测天数 | 是,时间窗口不同 |
skip_factor |
是否跳过因子计算 | 是,影响因子分析段 |
skip_strategy |
是否跳过策略回测 | 是,影响策略回测段 |
任何一个参数变了,缓存就应该失效。但旧 key 完全忽略了它们。
修复方案
用参数指纹区分不同输入。把所有影响输出的参数拼在一起,算一个 MD5,截取前 12 位拼进文件名。
1 | |
修复后,文件名变成这样:
1 | |
同一个日期,不同参数,不同文件。互不干扰。
为什么要 sorted(symbols)
["518880", "512100"] 和 ["512100", "518880"] 在业务上是等价的——同一批股票,顺序不同不影响回测结果。如果不做排序,同一个股票池换了顺序就会生成不同的指纹,导致缓存 miss。
sorted() 保证了顺序无关。json.dumps(..., sort_keys=True) 保证了字典 key 的顺序也无关。两层保险。
为什么截 12 位
MD5 完整是 32 位十六进制。12 位 = 48 bit, 碰撞概率约 $1/2^{48}$。对于一个每天最多跑几十次回测的系统, 这个碰撞空间绰绰有余。
验证
写了个测试用例, 连续跑两次不同参数的 pipeline, 断言两次写缓存的文件路径不同:
1 | |
152 个测试全过, 新增的这个用例也过了。
教训
缓存 key 的设计原则其实就一句话:key 必须覆盖所有影响输出的输入。
听起来像废话。但实际写代码时, 很容易只考虑「最明显的那个维度」(这里是日期), 忽略其他参数。尤其是当你先写了无缓存版本, 后来加缓存时, 往往只拿一个最容易获取的值当 key。
更通用的做法: 如果函数签名里的每个参数都会影响输出, 直接拿整个参数集做指纹。不要手动挑参数, 容易遗漏。
1 | |
这样不管以后函数加多少参数, 缓存 key 自动跟着变。
缓存是个好东西, 但 key 设计错了比不用缓存还危险。不用缓存最多慢一点, 缓存串号会让你对着错误数据做决策。
参考文献