2026年九月11日 · 可复现性

同样的代码、同样的数据,两个不同的 Sharpe:回测需要做的非确定性审计

同样的代码、同样的数据,两个不同的 Sharpe:回测需要做的非确定性审计

同一个 commit、同一批 parquet 文件、同一台机器,间隔一小时运行两次。Sharpe 分别是 1.34 和 1.19。总收益率分别是 41.2% 和 37.8%。交易数分别是 1,418 和 1,421。两条权益曲线连续 11,205 根 bar 上每个显示出来的小数都完全一致,然后才开始分叉。

0.15Sharpe 差距,输入完全相同
3 / 1,418不同的交易数
11,205分叉前完全一致的 bar 数

前三个数字让人头疼。最后一个才有意思,因为它说明问题不是浮点误差在整个运行过程中到处累积。某根特定的 bar 上发生了离散变化,后面的一切都是连锁放大。这篇文章要讲的是如何找到那根 bar,以及这 0.15 的差异会如何影响你跑过的每一次参数扫描。

策略:在成交量最高的 30 个 USDⓈ-M 永续合约中做横截面动量,每 4 小时调仓,做多过去 12 小时收益率最高的 5 个,做空最低的 5 个,使用 18 个月历史数据,按每条腿收取手续费和资金费。

找到两次运行开始分叉的 bar

如果按完整精度记录每根 bar 的权益,大约 4 分钟就能查出来。把两次运行都导出为 (bar_index, equity, open_positions_hash) 的 CSV,读入后找出第一个不一致的索引。我们之前为另一个 bug 写过这个脚本,所以才没在这上面耗上一整个上午。

第 11,206 根 bar,2025-03-14T08:00 UTC。前一根 bar 的权益在 13 位有效数字上都相同。到了第 11,206 根,持仓哈希出现差异:运行 A 做多 SOL,运行 B 做多 AVAX。方向相同,名义金额相同,标的不同。之后的一切都是由此引起的。

于是我打印了两次运行在那根 bar 上用于排序的输入。它们完全一致。逐字节相同,同样的 30 个标的,同样的 30 个分数。其中两个分数是 0.0。恰好为零,因为这两个标的在回看窗口内都出现过没有成交的 4 小时 bar,所以收盘价与前收盘价之比为 1,取对数后就是 0。不是四舍五入成零,而是真正的零。

两个标的并列第五。策略选取前 5 名。并列的两个标的谁能入选,取决于排序后它们留下的顺序,而排序的行为和我以为的不一样。

一个并列、一个不稳定排序,以及我忽视了两年的问题

排序前经过了一次分组聚合,输入数据框则来自一个字典推导式;这个推导式遍历的是每根 bar 都会根据异步抓取结果重新构建的标的集合。集合的迭代顺序会随哈希种子变化,而 Python 默认会在每个进程中随机化字符串哈希种子,除非你固定 PYTHONHASHSEED。因此两次运行的排序前行顺序不同;碰上并列键时,不稳定排序打破并列的方式也就不同。

这种并列并非罕见的意外,而是结构性问题。凡是特征值会饱和或截断的地方,都可能产生完全相等的值:零成交量 bar 会带来恰好为零的收益率,被截断的 z-score 会固定在 ±3.0,只有少量不同值的秩变换会产生大量并列,通过布尔筛选的项则都会得到 1.0 的分数。这次运行覆盖 18 个月的 4 小时 bar,其中有 47 根 bar 在入选边界处出现并列。3 根 bar 改变了入选组合。其余并列涉及的两个标的要么都已入选,要么都未入选。

有大约一天,我一直认定是数据加载器有非确定性,因为那听起来是个更刺激的答案。其实不是。从来都不是。问题就是一个集合、一次并列,以及一个没人写明的排序稳定性假设。

为什么 3 笔交易能造成 0.15 的 Sharpe 差异

这部分往往最容易引起质疑,答案在于:采用权益比例仓位的回测是路径依赖系统。每条腿按当前权益的 8% 配仓,意味着 bar n 上的权益差异,会影响从 bar n 开始的每一笔名义仓位。

第一次分叉本身几乎没造成什么影响。运行 B 的 AVAX 仓位在 9 小时内亏损 2.1%;运行 A 的 SOL 仓位则盈利 0.4%。这笔交易后权益相差 0.21%,微不足道。但从那时起,两次运行就不再是同一个策略了。它们的持仓规模略有不同,因此累计的资金费也略有差异;之后又有两个入选边界的并列再次出现不同结果,因为用于排序的分数如今来自略有差异的持仓。其中一次发生在 2025-03-27,也就是一段持续 6 天的趋势启动前一天;这段趋势贡献了全程总 PnL 的大约三分之一。运行 A 完整参与了这波行情;运行 B 晚一个调仓周期才入场。

收益差距:3.4 个百分点。Sharpe 的差距比收益差距所暗示的更大,因为运行 B 调整顺序后的交易与波动更大的阶段重叠,导致分母上升、分子下降。起因很小,放大因素有两个。

如果你的仓位是固定名义金额,而且入场不依赖当前持仓,你受到的影响就小得多。大多数有意思的策略都不是这样。

实际产生影响的 5 个环节

来源症状解决方法
未固定的 PYTHONHASHSEED,集合/字典的迭代顺序会影响后续排序并列项在不同运行间互换;在特定 bar 上首次出现分叉固定种子;使用明确的次级键(标的)排序,让并列结果确定
对并列键使用不稳定排序(NumPy/pandas 的默认 quicksort)与上面相同,即使固定了种子仍会发生kind="stable",或让排序键形成全序
bootstrap、训练/测试集打乱或模拟成交抖动中使用未设种子的 RNG整个运行过程都出现漂移,没有明确的分叉点为每个组件明确指定一个种子,并记录在运行清单中
并行浮点归约(求和顺序取决于线程数)末尾几位出现差异;通常无害,直到影响阈值判断研究运行时固定线程数;在决策边界处绝不要用 == 比较浮点数
未固定库版本今天可复现,到了 11 月就不行了在清单中记录锁文件哈希和数据快照哈希

第四行的问题并不像人们担心的那样常见;第一行的问题却一直在造成麻烦。

位级可复现是一种工具,不是美德

我们需要确定性,是为了在改动一行代码后,能把权益曲线的变化归因于那一行。这就是全部理由。现在 Stratmill 上每次智能体运行都会写入一份清单,记录数据快照哈希、锁文件哈希和每个随机种子;如果重新运行无法逐位复现之前的曲线,就算构建失败,而不是当作一件趣事。

但能复现之后,就该有意打破它。用 64 个种子运行 64 次,看看结果的分布:

抖动区间。 同一策略、同一数据,使用 64 种带种子的并列打破方式和成交顺序排列。Sharpe 的 p5 为 1.12,中位数为 1.27,p95 为 1.41。区间宽度为 0.29。

现在回头看看参数扫描。得分最高的配置是 1.46;96 个配置中排第 40 的配置是 1.31。两者相差 0.15,只有区间宽度的一半。参数扫描并没有把这两个配置排出高下。它分别从两者的分布中抽取了一次结果,然后给这些抽样结果排序。

这样的重新理解改变了我们的选择方式。只有当配置间的差距大于单个配置自身的抖动时,扫描结果才能称得上排名;而在有 1,400 笔交易的路径依赖策略中,抖动通常大到足以让排行榜前 1/3 都挤成平手。这时就该看区间无法掩盖的指标:更低的换手率、更少的参数、能够经得起质疑的成本假设,或是在你最不看好的滚动验证折中表现更好。这些才是真正能打破平手的依据。0.15 的 Sharpe 优势算不上。

在信任这一切之前,还有一件事值得先做。现在就把回测运行两次,对比每根 bar 的权益,看看自己属于完全一致的那一类,还是 Sharpe 相差 0.15 的那一类。这只需 15 分钟,就能告诉你研究历史中有多少是在衡量策略,又有多少是在衡量哈希种子。

确定性回测数据工程过拟合python
← 全部文章