回测缓存串号:日期做 key 的隐蔽数据污染

本文最后更新于 2026年7月27日 凌晨

回测跑了一晚上,结果发现两个不同策略的收益曲线一模一样。

这不是巧合,是缓存串号了。

根因很简单:缓存文件名只用日期做 key,同一天跑的回测无论参数怎么变,全部命中同一份缓存。

翻车现场

AutoQuant 的回测 pipeline 有一个日级缓存机制。每天第一次跑回测时,把结果写入一个 JSON 文件。后续同日再跑,直接读缓存,跳过耗时的数据拉取和回测计算。

问题出在缓存文件名的生成逻辑上:

1
2
3
4
5
# 修复前
cache_file = os.path.join(
CACHE_DIR,
f"pipeline_{datetime.now().strftime('%Y%m%d')}.json"
)

文件名长这样: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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import hashlib
import json

# 参数指纹:不同参数缓存隔离
param_fingerprint = hashlib.md5(
json.dumps({
"symbols": sorted(symbols),
"days": days,
"skip_factor": skip_factor,
"skip_strategy": skip_strategy,
}, sort_keys=True).encode()
).hexdigest()[:12]

cache_file = os.path.join(
CACHE_DIR,
f"pipeline_{datetime.now().strftime('%Y%m%d')}_{param_fingerprint}.json"
)

修复后,文件名变成这样:

1
2
pipeline_20260727_a3f8b2c1d9e0.json   ← symbols=["518880"], days=30
pipeline_20260727_7e2f1a9b4c6d.json ← symbols=["518880","512100"], days=60

同一个日期,不同参数,不同文件。互不干扰。

为什么要 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
2
3
4
5
6
7
8
9
def test_cache_fingerprint_isolates_params(self, ...):
"""不同参数使用不同缓存文件,不串号"""
run_pipeline(symbols=["518880"])
run_pipeline(symbols=["518880", "512100"])
write_calls = [c for c in mock_file.call_args_list
if len(c[0]) > 1 and c[0][1] == "w"]
self.assertEqual(len(write_calls), 2, "应有2次写缓存调用")
self.assertNotEqual(write_calls[0][0][0], write_calls[1][0][0],
"不同参数应使用不同缓存文件路径")

152 个测试全过, 新增的这个用例也过了。

教训

缓存 key 的设计原则其实就一句话:key 必须覆盖所有影响输出的输入

听起来像废话。但实际写代码时, 很容易只考虑「最明显的那个维度」(这里是日期), 忽略其他参数。尤其是当你先写了无缓存版本, 后来加缓存时, 往往只拿一个最容易获取的值当 key。

更通用的做法: 如果函数签名里的每个参数都会影响输出, 直接拿整个参数集做指纹。不要手动挑参数, 容易遗漏。

1
2
3
4
5
6
7
8
9
# 更通用的写法
import inspect

def make_cache_key(func, *args, **kwargs):
bound = inspect.signature(func).bind(*args, **kwargs)
bound.apply_defaults()
return hashlib.md5(
repr(bound.arguments).encode()
).hexdigest()[:12]

这样不管以后函数加多少参数, 缓存 key 自动跟着变。

缓存是个好东西, 但 key 设计错了比不用缓存还危险。不用缓存最多慢一点, 缓存串号会让你对着错误数据做决策。


参考文献

  1. Python hashlib 文档
  2. functools.lru_cache — key 生成逻辑参考

回测缓存串号:日期做 key 的隐蔽数据污染
https://normdist.com/2026/07/27/ND-20260727-001-backtest-cache-key-collision/
作者
小瑞
发布于
2026年7月27日
许可协议