← 返回文章列表
July 8, 2026
5 分钟阅读

证明多时间框架回测中没有look-ahead:扰动未来,证明过去看不到它

证明多时间框架回测中没有look-ahead:扰动未来,证明过去看不到它
#算法交易
#回测
#look-ahead偏差
#多时间框架
#数据泄露
#验证
🎯
Part 2 of 9 · Collection
Backtesting Without Fooling Yourself

“无幻觉回测”系列文章之一。

上一篇文章以一个诊断性测试收尾:单K线偏移测试。把每一次成交都往后推一根K线;如果绩效因此崩溃,说明你其实是在用过去的信息交易。对于单一时间框架的策略,这个测试已经足够——因为look-ahead藏在执行环节:你在K线i上做出决策,也在K线i上成交。

多时间框架策略还有第二个、更隐蔽的信息泄露之处,而偏移测试触及不到它。当1分钟K线上的决策要参考1小时趋势时,泄露就不再发生在成交环节——而是发生在指标本身,因为它是由一根尚未形成完毕的高时间框架K线计算出来的。这篇文章要讲的正是这种泄露,以及如何机械地——而不是靠盯着代码看——证明你的引擎里没有这个问题。

我们最终要给出的结论很强:在86,400根真实ETHUSDT 1m数据的K线上,我们的多时间框架引擎通过了25项一致性与泄露检查中的全部25项,其中包括一个会扰动每一根未来K线、并确认没有任何一个过去决策发生变化的“未来偏移探针”。而且由于该引擎与实盘机器人的规范核心逐位相同,这个证明就是实盘系统本身的性质,而不仅仅是回测的性质。

多时间框架的雷区

一个1分钟决策同时向上触及三个时间框架,其中最高时间框架的K线仍在形成中,其最终收盘价被隐藏在时间边界之后

多时间框架(multi-TF)策略在快时钟上做决策,同时参考慢时钟。我们的引擎就是一个具体实例:一个三重时间框架的动量规则,其中高时间框架(HTF)趋势和中时间框架(MTF)趋势门控入场,而低时间框架(LTF)的交叉则触发入场。在1m这个基础时钟上,只有当1m的HMA/HMA3交叉方向与1h趋势和15m趋势一致时,才会触发入场;当高时间框架趋势相对持仓发生反转,或LTF反向交叉时,则触发出场。

危险是结构性的。每一个高时间框架数值都必须在每一根1m K线上被回答——每小时60次——而且每次回答都只能使用实盘机器人在那一刻本应知道的信息。这大约180次的逐K线读取(三个时间框架、入场和出场趋势、间隔门控)中,只要有一次触及了尚未打印出来的K线,你就有了look-ahead。差一位错误(off-by-one)的暴露面极大,而且与执行环节的泄露不同,它不会通过成交时间的偏移来暴露自己。

正在形成的K线还没有最终收盘价

确切的陷阱是这样的。假设现在是10:37,你正在对刚刚收盘的1m K线做决策。你的规则需要1h趋势。覆盖10:00–11:00的这根1h K线仍在形成中——它的最终收盘价要到10:59:59才会知道。在10:37,你实际上知道什么?只知道这根K线到目前为止的当前收盘价,也就是10:37这根K线的收盘价。这根K线的最终收盘价还在23分钟之后的未来。

朴素的多时间框架回测会做一件看起来完全无害的事:它预先把整个1m序列一次性重采样成1h,然后对每一根1m K线读取“1h收盘价”。但它为10:00到11:00之间每一根1m K线读取的值,都是10:00–11:00这根K线的最终收盘价——这个数字在真实时间里要到这一小时结束才存在。这一小时内的每一个决策都被悄悄地交给了长达59分钟的未来信息。而且这不是一个小泄露:高时间框架的收盘价几乎是你即将交易的近期未来1m收益率最强的可能预测指标,泄露它几乎等同于拿到了答案。这正是分类法中所讲的指标泄露渠道,而且被放大了:偷看的范围不是一根K线,而是长达一整个HTF周期。

前一篇文章中的单K线偏移测试抓不到这个问题。你可以把成交往后推一根K线,但重采样出来的1h序列依然是被污染的——泄露存在于指标的构建方式中,而不在于你何时完成交易。

精确复现实盘机器人的规则:closed-bar语义

K线i处的高时间框架缓冲区:一排已经完成的、已收盘K线的收盘价,后面跟着一根正在形成的K线,其值是当前的基础收盘价,而仍然隐藏的最终收盘价被划掉

正确的规则就是实盘机器人已经在运行的那一套。在我们的代码库里,它是一个很小的类,RunningCandleBuffer,是从实盘tick模拟器中逐字搬过来的。它把基础K线流式地组装成固定周期的K线,并在每一根基础K线上,从一个非常特定的数组中计算HTF指标:

all_closes = np.array(self.closes + [self.current_close], dtype=np.float64)

字面地读这行代码。self.closes是那些已经收盘的K线的最终收盘价——只有当新的周期边界被跨越时,才会往列表里追加一根K线,其存储的值是该周期内最后一根基础K线的收盘价(candle_buffer.py:39–44)。正在形成的K线只贡献恰好一个数字,self.current_close,它是**当前(running)**收盘价——也就是最近一根基础K线的收盘价,base_close[i]。根据定义,这是一个在K线i上就已知的量。正在形成的K线的最终收盘价永远不会被用到,因为它还不存在。

所以10:37时刻的HTF指标看到的是[..., close(9:00那根K线), close(10:37目前为止)]。当10:38这根K线打印出来时,最后一个槽位更新为close(10:38)。当跨过11:00时,close(10:59)成为刚刚收盘的那根K线的最终值,同时打开一个新的形成中槽位。在这一小时之内,任何时刻的决策都不会触碰到10:00–11:00的最终收盘价。就是closed-bar语义:已收盘的K线贡献的是完成的收盘价,而正在形成的K线只贡献它当前的收盘价。

我们的高速引擎(engine_multitf.py)是对这套逻辑的向量化、numba编译版本重新实现。它不再使用带有不断增长列表的Python循环,而是为每一根基础K线i预先计算出已经完全收盘的K线数量(n_closed[i]),并将HMA/HMA3窗口铺成[已收盘K线的收盘价…, base_close[i]]——当前收盘价被固定在最后一个槽位(engine_multitf.py:168–169)。这是完全相同的数学,只是为了速度而在三个时间框架上展开,并带有方向性的间隔门控。约定是明确的:K线i处的值只取决于base_close[0..i]

这就是我们的主张。文章的其余部分讲的是我们如何证明它,因为写在docstring里的主张一文不值。

一致性是必要条件,但不是充分条件

一个把流式类展开成显式循环的向量化numba引擎,恰恰是差一位错误容易滋生的地方。所以第一道关卡,是在一段真实数据切片上——ETHUSDT 1m,2024年1月至2月,86,400根K线——针对权威参考实现做逐位一致性测试。

我们针对两个独立的参考实现检查两件独立的事情:

  • 指标与交叉,与逐K线运行的RunningCandleBuffer对比。对每个时间框架,我们在全部86,400根K线上重放这个实盘类,并比较交叉事件——K线位置、方向、间隔——要求精确匹配,同时HMA/HMA3的值在浮点误差范围内匹配(参考实现使用np.dot,引擎使用显式循环,因此求和顺序不同,差异约在1e-15量级)。交叉事件完全一致:HTF(1h,HMA长度21)上有408次交叉,MTF(15m/14)上有2,792次,LTF(1m/50)上有3,691次。没有一个交叉事件在K线位置或方向上有差异。
  • 交易,与一个独立的纯Python交易规则模拟对比,该模拟由参考实现自身产生的交叉驱动。它复现了实盘回测的循环——入场趋势门控入场,出场趋势反转或反向的LTF交叉平仓,按open[i+1]成交,0.09%的往返手续费,在最后一根K线强制平仓——但完全不使用引擎的numba机制。然后我们逐笔比较:入场/出场K线位置、方向、入场/出场价格、PnL、平仓原因,以及持仓的总活跃时间。

测试中使用的间隔阈值不是无关紧要的默认值。它们被特意选择来命中一个棘手的边界情形——把MTF的出场阈值设置得高于对应的入场阈值——这会强制触发“在持仓期间首次定义出场趋势”这一分支,而参考实现把它当作反转来处理。一致性必须在边界情形上成立,而不仅仅是在容易的路径上成立。

逐字段比较,交易记录完全相同:双重配置下466笔交易,三重配置下211笔交易,总PnL匹配到1e-12,每笔交易的每个字段都在误差范围内相等。两个不共享任何代码的实现——一个编译过的向量化引擎,和一个基于第三个实现所产生的交叉运行的朴素Python循环——产生的交易结果精确到最后一位小数都相同。

这是一个很有力的结果,但它不是没有look-ahead的证明。一致性只能说明,快速引擎忠实地复现了参考实现。如果参考实现本身有泄露——如果RunningCandleBuffer本身偷看了未来——那么一致性测试会忠实地复现这个泄露并且照样通过。两个实现相互一致,只能说明它们是相同的,不能说明它们是因果的。要证明因果性,你需要另一种测试——一种直接质问引擎“过去能否看到未来”的测试。

未来偏移探针:真正的证明

在序列60%处做一条竖直切线:切线右侧的一切都被放大并搅动,切线左侧的一切都被冻结,逐位完全相同

look-ahead的定义是可操作的,所以就应该用可操作的方式去测试它。look-ahead意味着过去的决策依赖于未来的数据。它的逆否命题就是一个可以直接运行的测试:如果你改变未来,而任何一个过去的决策发生了变化,那说明过去在读取未来。所以就去改变未来——狠狠地改变——然后观察过去。

在序列60%处选取一个切点j(86,400根中的第51,840根K线)。扰动从j开始的每一根K线:把所有未来的收盘价和开盘价都乘以1.05。在扰动后的数据上重新计算全部三个时间框架的整个信号栈。然后断言,严格早于j的一切都与未扰动的运行结果逐位相同

j = int(n * 0.6)                       # bar 51,840
cl2 = cl.copy(); cl2[j:] *= 1.05       # shove the future up 5%
op2 = op.copy(); op2[j:] *= 1.05

base = [precompute_tf_signals(cl,  ts, p, L) for (p, L) in tf_params]
pert = [precompute_tf_signals(cl2, ts, p, L) for (p, L) in tf_params]

for s0, s1 in zip(base, pert):
    assert eq_nan(s0.hma[:j],  s1.hma[:j])      # HMA identical, NaNs included
    assert eq_nan(s0.hma3[:j], s1.hma3[:j])
    assert np.array_equal(s0.cross[:j], s1.cross[:j])   # every cross event
    assert np.array_equal(s0.sep[:j],   s1.sep[:j])     # every separation

不是“接近”。不是“在误差范围内”。用np.array_equal,要求NaN必须与NaN匹配——51,840根过去K线上的每一个HMA值、每一个HMA3值、每一个交叉标志、每一个间隔值,都必须是完全相同的浮点数。然后对交易做同样的断言:每一笔出场时间早于j的交易,都必须逐字段不变。如果对未来5%的推动改变了哪怕一个过去HMA值的第十二位小数,那就说明过去的决策查询了未来,探针测试失败。

我们的引擎通过了这项测试——在全部三个时间框架上,在双重和三重两种交易模拟中都通过。扰动34,560根未来K线,会让51,840根过去K线,以及在这些K线中收盘的每一笔交易,都精确保持原样。这不是两个实现之间的一致,而是对时间上的信息边界确实成立的直接证明。

一个不可能失败的测试什么也证明不了

有一种方式可以通过上面的探针测试,却什么也证明不了:如果这个扰动本身是一个空操作——如果把未来乘以1.05在任何地方都没有改变任何东西——那么“过去没有变化”就是一句平凡为真、毫无信息量的话。一个永远不会失败的测试上的绿色对勾,比没有测试还要糟糕,因为它制造了虚假的信心。所以这个探针还带有另外两条断言,让它真正具有约束力。

未来必须真的发生变化。 我们断言这个扰动确实[j, n)区间内的某处改变了交叉:

assert not np.array_equal(s0.cross[j:], s1.cross[j:])   # probe has teeth

现在这个结果才有意义:同样这个5%的推动重写了未来,却让过去保持逐位不变。扰动是真实的,它向前传播,并且在切点处戛然而止。一个单向的泄露——过去读取未来——本应会向后渗透穿过j;但事实并非如此。

边界恰好位于当前这根K线上——既不早一根,也不晚一根。 一种更微妙的失败情形,是一个具有因果性但滞后的引擎:它忽略了当前K线的实时收盘价,反应晚了一根K线(没有泄露,但存在实盘交易中不该有的延迟),或者反应早了一根K线(即一根K线的泄露)。所以我们扰动单独一根K线j(乘以1.02),同时断言两件事:过去[0, j)没有被触碰,而且hma[j]立即做出反应。

cl3 = cl.copy(); cl3[j] *= 1.02        # nudge exactly one bar
s3 = precompute_tf_signals(cl3, ts, p_ltf, L_ltf)
assert eq_nan(s0.hma[:j], s3.hma[:j])  # nothing before j moves
assert s0.hma[j] != s3.hma[j]          # bar j reacts on the same bar

这就精确地钉死了边界。正在形成的K线的当前收盘价,以零延迟零预见性进入K线j处的指标:K线j能立刻看到自己的收盘价,而任何更早的K线都完全看不到它。这正是closed-bar语义理应站立的那个刀锋边缘,而测试证实了这个引擎确实站在这个边缘上。

下面是完整的关卡——这个引擎与一份被伪造出来的回测结果之间,横亘着的全部25项检查:

分组 每项检查断言的内容 数量
指标与交叉(×3个时间框架) 交叉事件与RunningCandleBuffer精确一致;交叉处的间隔;HMA/HMA3的值(rtol 1e-9) 9
交易(双重+三重) 交易笔数;逐字段完全一致;总PnL精确到1e-12;持仓活跃时间 8
未来偏移探针(双重+三重) 过去的信号逐位不变;探针有效(未来确实发生了变化);j之前的交易不变;单K线扰动被局部化 8
总计 25

前两组检查确立了快速引擎就是实盘参考实现本身。第三组确立了参考实现是有因果性的。这三者缺一不可:一个有泄露的快速引擎、一个有泄露却仍具因果性的参考实现、以及一个有因果性但滞后的引擎,是三种不同的失败模式,而这道关卡把每一种都排除掉了。

为什么这个探针与时间框架无关

未来偏移探针的精妙之处在于,它根本不需要知道、也不关心泄露可能藏在哪里。它从未提及时间框架、重采样,或K线边界。它只问一个问题:扰动未来会不会改变过去?这正是它能成为多时间框架泄露的正确检测工具的原因——而这种泄露恰恰是“偏移成交”测试所触及不到的。

直接考虑那个朴素的“整体重采样”bug。如果1h序列是通过预先重采样整个序列得到的,那么在K线j-1000处(远在j之后才收盘的那一个小时内部)读到的“1h收盘价”,就会是一根K线的最终收盘价,而这根K线的最终收盘价依赖于j及其之后的K线。把未来乘以1.05,这个最终收盘价就会变化——于是j-1000处的HTF指标发生变化,j-1000处的门控发生变化,一个过去的决策随之改变。探针会立刻在HTF序列上报警,而且是在切点之前整整一千步的K线上。

我们引擎的HTF序列不会移动,因为在j-1000处,正在形成的K线只贡献base_close[j-1000]——一个过去的收盘价——而这根K线的最终收盘价在跨过边界之前根本不会被查询。这个探针对具体机制是盲的,却依然能抓住这个bug,而这正是一个证明所应具备的特质:它约束的是行为(没有任何过去的决策依赖于未来的数据),而不是审查实现细节(我们是否正确地为重采样建立了索引?)。真正被交易的是行为;实现细节只是你希望它与行为相匹配。

回测与实盘共享同一个真相

两块面板共享一根标记为RunningCandleBuffer的脊柱:一侧是回测参数扫描,另一侧是实盘机器人,两者都接入同一个核心

还有一个原因,使这件事比一次典型的回测审查更加重要。引擎据以证明自身的参考实现——RunningCandleBuffer——并不是为了让回测结果好看而写的一个测试夹具。它就是实盘机器人自己的K线逻辑,原封不动地从生产环境中运行的tick模拟器里搬出来的。探针所验证的closed-bar规则,正是实盘机器人逐K线执行的那条规则。

所以一致性这道关卡起到了双重作用。它证明了快速引擎与参考实现一致,而由于参考实现就是实盘核心,它同时也证明了快速引擎与实盘一致。前一篇文章曾警告说,泄露是解释回测与实盘一致性差距最干净的原因——实盘机器人是唯一一个在机制上不可能偷看未来的地方,所以一个会偷看的回测,一旦上线就会立刻出现分歧。而在这里,这个差距是通过构造方式被消除的:回测和机器人共享同一个K线缓冲区、同一条交叉规则、同一个“在K线i上已知”的定义。搜索所优化的那个数字,就是机器人真正能够交易出来的那个数字。

这正是证明没有look-ahead、而不是想当然地假设没有look-ahead的全部意义所在。一次多时间框架搜索会跑上千种参数配置;如果引擎存在泄露,搜索就会找到那个最激进地利用这个泄露的配置,然后把一个被伪造出来的赢家交到你手上——这正是分类法一文中,从纯噪声中测出Sharpe为15的那种失败模式。正是这个探针,让你能够在把资金真正投入之前,信任这个赢家是真实的。

这个探针证明了什么,又没有证明什么

对测试保持严谨,是一把双刃剑,所以必须精确界定它的适用范围。未来偏移探针证明的是一个单一而具体的性质:在这份数据上,K线j或之前的任何信号或交易决策,都不依赖于j之后的任何一根K线——时间上的信息边界,在指标、交叉、交易这整条路径上都成立。这正是我们着手要消除的那种多时间框架泄露,也是代码审查无法确立的那种性质。

不能证明这个策略具有优势(edge)。一个完全具有因果性的引擎完全可能诚实地亏钱,而探针对此保持沉默——这也正是它应该做的;证明没有泄露和找到真实的优势,是两项完全不同的工作,把它们混为一谈,正是泄露的回测被拿去实盘部署的原因之一。它不涵盖非时间性的偏差:标的物的存活者偏差、只在引擎已经表现良好之后才运行探针所带来的选择偏差,或者过于宽厚的手续费模型。而且它本身也不能证明实盘成交与回测成交是匹配的——滑点和延迟是真实存在的差距,探针看不到它们,因为它作用于决策路径,而不是执行场所。真正弥合那个差距的,是另一个独立的事实:引擎的K线核心与实盘机器人的K线核心逐位相同。

关于这个探针自身设计,还有一个诚实的保留意见:它只在单一的j(序列的60%处)切了一刀。它所验证的性质在j上是均匀的——第51,840根K线并没有什么特别之处——所以单独一次切割,对一个结构性性质来说已经是一个公平的测试,但一个更偏执的版本会在整个序列上遍历j。我们认为一次精心选择的切割,再加上单K线定位检查,已经足够,因为一个能躲过34,560根K线的未来扰动、却在另一个切点上现形的泄露,必然是一个非常古怪的bug。这里的重点是要清楚自己证据的边界,而不是假装单一一次测试就是一个普适的全称量词。

要点总结

  1. 多时间框架策略是通过正在形成的K线泄露的,而不是通过成交环节。 在一个尚未结束的HTF周期内做出的决策,必须只使用这根K线的当前收盘价(最新的基础收盘价),绝不能使用它的最终收盘价。整体重采样式的回测,会把每一个周期内部的决策都交给长达一整个HTF周期的未来信息。
  2. 单K线偏移测试触及不到这种泄露。 它能抓住执行环节的look-ahead;而多时间框架的泄露藏在指标的构建方式里。你需要另一种探针。
  3. 先精确复现实盘规则,然后再去证明它。 我们把机器人的RunningCandleBuffer closed-bar语义重建成了一个向量化引擎,并在其后设置了25项检查作为关卡:交叉事件与参考实现精确一致(408 / 2,792 / 3,691),交易逐字段完全一致(双重466笔,三重211笔),PnL精确到1e-12。
  4. 一致性是必要条件,但不是充分条件。 与参考实现匹配,只能证明你们是相同的,不能证明你们具有因果性。一个存在泄露的参考实现,即便被忠实地复现,依然存在泄露。
  5. 未来偏移探针才是真正的证明。 扰动j及之后的每一根K线;断言j之前的每一个信号和交易都逐位不变。如果未来能够改变过去,你就存在look-ahead。
  6. 让探针真正具有约束力。 断言未来确实发生了变化(扰动不是空操作),并且单K线的微小扰动会在同一根K线上产生反应(既没有泄露,也没有滞后)。一个不可能失败的测试什么也证明不了。
  7. 当回测与实盘共享同一个核心时,证明就会自动转移。 由于引擎与实盘机器人的K线逻辑逐位相同,没有look-ahead这一性质,同样也是实盘系统的性质——回测与实盘之间的一致性差距,因此从构造上就被消除了。

前一篇文章展示了一行代码的泄露如何伪造出Sharpe为15的结果。这一篇展示的是相反方向的严谨态度:不是“泄露如何欺骗你”,而是如何机械地、在真实数据上证明一个具体的引擎并不存在泄露。去扰动未来。如果过去毫不动摇,那么你交易的就是当下。

免责声明:本文提供的信息仅用于教育和参考目的,不构成财务、投资或交易建议。加密货币交易涉及重大损失风险。

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

紧跟市场步伐

订阅我们的时事通讯,获取独家 AI 交易见解、市场分析和平台更新。

我们尊重您的隐私。您可以随时退订。