回测跑完六周后,我们用相同的代码、配置和日期范围又跑了一次。最终权益变了。变化不算大,但足以让 Sharpe 比率从 1.31 降到 1.08,也改变了看起来最优的参数组合。
策略没有变,变的是历史数据。供应商修订了几天的成交和 K 线数据,而我们第一次运行时没有保存精确的输入快照。接下来的几天,我们一直在查明新结果究竟是数据修正、流水线故障,还是一个无法复现的研究流程造成的。
星期一:重新运行的结果对不上
最先引起我们注意的是一个流动性充足的现货交易对里出现了一处细小差异。原始 K 线的最高价是 62,418,重新运行时则是 62,431。起初这看起来无关紧要,直到我们追查策略的入场条件:触发阈值正好落在这两个价格之间。几根 K 线一变,信号就会不同,几天后的持仓也会随之改变。
我们对比了每日成交笔数,发现差异始于一个特定日期。接着,我们比较了生成该日期数据的原始记录。供应商当前返回的数据中,有几笔成交记录经过修正,汇总 K 线也做了调整。我们当时使用的响应没有附带变更日志。
这是数据的常见情况。交易所会修复记录;供应商会回补缺口、修正错误成交记录,并调整汇总逻辑。3 月查询“1 月数据”,返回的结果可能已经不同。
星期二:我们发现缺少的凭据
运行记录里保存了供应商名称、交易品种和日期范围,但没有保存供应商返回的响应,也没有为每个输入分区保存内容哈希。这意味着我们能证明自己发起了什么请求,却无法证明供应商返回的具体字节内容。
我们找出能找到的最早缓存文件,并与当前响应进行比较。差异不是拆股调整,也不是时区转换,而是一小批修正后的成交记录经过 K 线生成器后造成的变化。这排除了一个猜测,但缓存并不完整,所以我们仍无法精确重建所有受影响的 K 线。
| 我们记录了什么 | 这些记录能回答什么 | 缺少了什么 |
|---|---|---|
| 供应商、交易品种、日期范围 | 本次运行发起了哪个请求 | 响应内容 |
| 流水线版本 | 用哪版代码生成了 K 线 | 精确的输入记录 |
| 运行时间戳 | 任务何时运行 | 当时可用的是供应商的哪个修订版本 |
我们把“相同查询”当成了“相同数据”。这个假设让实验看起来比实际更具可复现性。
星期三:数据修正改变了结论
我们用两个版本的可用数据,重新回放了受影响的日期。修订后的 K 线删掉了 2 笔入场,并让 1 笔出场提前了 1 根 K 线。这 3 笔交易解释了 Sharpe 变化的大部分。尽管整体价格序列在图表上几乎看不出差别,策略对触发点附近的少数观测值仍然很敏感。
这并不意味着原始结果不可信,也不意味着修订后的结果就一定更好。它意味着我们需要限定结论的适用范围:旧结果描述的是当时的数据快照,重新运行的结果描述的是供应商当前的历史数据。我们不能诚实地把其中一个称为对另一个的复现。
星期四:我们改进了研究记录
我们开始为新的研究运行添加不可变的输入快照。每个分区都会生成内容哈希,运行清单会记录数据来源、获取时间、架构版本、调整策略和转换代码版本。对于大型数据集,我们会将实际文件存入内容寻址存储,这样相同的分区可以共享,也不会覆盖旧版本。
如果存储规定或许可条款不允许我们保留原始数据,我们就保存条款允许的最完整凭据:哈希、请求参数、来源修订标识符,以及少量经过解析的行供诊断使用。哈希无法还原数据,但能告诉我们两次运行是否使用了完全相同的字节内容。
可复现的回测需要记录输入的身份,而不只是数据 URL。记录实际收到的内容、接收时间,以及这些内容如何转换为策略使用的行。
星期五:下次我们会省去的步骤
我们不会再只比较汇总指标。Sharpe 变化只能告诉你结果变了,却无法定位原因。先检查行数和分区哈希,再找出第一个出现差异的输入日期。然后依次比较转换后的行、信号、订单和成交记录。
我们也不会再悄悄用修正后的历史数据覆盖旧数据。修正很有价值,但旧运行应继续关联原始输入。新数据集应生成一次新的运行,并清楚标明它与旧运行的关系。
我们现在的实用规则很简单:如果重新运行是为了复现实验,就固定数据快照;如果是为了利用修正后的历史数据,就把它标记为新实验。我们花了几天才弄清这个区别,而现在只需在运行清单里写上一行。
← 全部文章


