"回测无幻觉"系列文章。
📄 本文成长为了一篇研究论文。 三种细微的前视泄漏在已知真实基准下(4,000 组模拟历史)接受了受控检验。可在线阅读论文(交互版 + PDF):lookahead.marketmaker.cc,代码与数据见 github.com/suenot/lookahead-inflation。
几周前,我们的参数搜索基准测试一直在骗我们,而我们差点就没有察觉。
引擎看起来很干净:收盘 K 线逻辑、一个诚实的滚动 walk-forward 分割、一次对参数空间的 Sobol/QMC 搜索、一个留出的测试窗口。搜索找到了样本内看起来不错的配置。唯一的问题是:在样本外,几乎所有结果都是负的。我们以为这只是策略本身太弱。
后来我们找到了那一行代码。信号是在第 i 根 K 线收盘时决定的,但成交却记在了同一根 i K 线上,而不是下一根 K 线的开盘。执行索引上的一个差一错误(off-by-one)。我们把成交价改成 open[i+1]——这是在看到第 i 根 K 线收盘价之后,唯一真正能够成交的价格——样本外的结果符号直接反转了。Sobol 搜索从亏损变成了盈利。策略本身没有任何改变,我们只是不再在过去做交易了。
这就是前视偏差,而令人不安的地方在于:错误如此微小,后果却如此巨大。本文是一次受控的自我审查:我们构建一个真相由构造本身就已知的模拟器,逐一注入这些细微的泄漏,并精确测量每一种泄漏究竟能把回测抬高多少。核心结论是:在完全没有任何真实优势的情况下,同 K 线成交就能从纯噪声中凭空制造出 +14.8 的年化夏普比率。
前视偏差究竟是什么

前视偏差是指流程中任何一个环节——决策或测量——使用了在被使用的那一刻、实时情况下本不该可得的信息。教科书式的例子都很粗糙——比如在一月份就使用某只股票整年的财报数据,或者使用一份尚未发布的财务重述。这类问题很容易发现。真正能逃过代码审查的,是那些细微的泄漏,它们藏在三个地方:
- 执行 ——你在第
i根 K 线上做决策,却在同一根iK 线上成交(或者用产生信号的那根 K 线自身的最高价/最低价来触发止损)。你成交的价格,与触发你交易的那个东西是相关的。 - 归一化 ——你用整个序列(包括未来数据)计算出的统计量对特征做 z-score、min-max 或其他缩放。缩放器"知道"测试集里有什么。
- 指标/特征 ——你用一个居中的(或以其他方式向前窥视的)窗口做平滑或滤波,导致第
i根 K 线上的值已经包含了第i+1根 K 线的一部分信息。
这三者都是机器学习文献所称的**泄漏(leakage)**的表现形式:用目标变量的未来信息污染训练/评估过程(Kaufman et al., 2012;Kapoor & Narayanan, 2023)。在金融领域,经典论述来自 López de Prado 的《Advances in Financial Machine Learning》(2018)——清洗式交叉验证(purged cross-validation)、隔离(embargoing)、回测的种种陷阱。这种"时点一致"(point-in-time)的纪律至少可以追溯到 Fama & French(1992),他们刻意将会计数据滞后六个月,以确保这个变量在它所解释的收益之前就已经为人所知。
本文要回答的问题是定量的:不是"泄漏是否有害"(所有人都同意),而是"每一种泄漏能给你带来多少夏普比率,哪些才是真正危险的?"没有具体数字,你就无法对此做出判断——你分不清 +0.3 的虚高只是噪声,还是 +14 的虚高才是铁证。
一个已知真实基准的模拟器

要测量虚高的程度,你必须先知道真相。真实数据永远不会告诉你真相——它只给你一次实现(realization),却没有一个可供比对的神谕。所以我们构建了一个合成市场,其中的优势由我们自己设定。
该数据生成过程(DGP)是严格因果且非爆炸的:
这里 是一个外生的持续性潜在漂移量(一个 的 AR(1) 过程),而每根 K 线的收益 带有一个很小的漂移项 ,它提前一根 K 线就已知。由于 不依赖于过去的收益,这里不存在反馈,也不会发生爆炸。参数 就是控制真实优势大小的旋钮:
- ——零假设:完全没有优势。任何为正的回测夏普比率都 100% 是人为产物。
- ——一个真实、可交易的优势:一条诚实的动量规则真的能赚钱。
策略被刻意设计得很简单——一条动量符号规则。特征是滚动 根 K 线的收益之和( 根),仓位则取其符号:
csum = np.concatenate(([0.0], np.cumsum(r))) # csum[k] = sum r[0..k-1]
mom = np.full(n, np.nan)
tt = np.arange(L - 1, n)
mom[tt] = csum[tt + 1] - csum[tt - L + 1]
signal = np.sign(mom) # position for the next bar
这个动量特征是研究同 K 线泄漏的完美载体,因为它具备真实指标共有的一个特性:它在机制上就包含了当前这根 K 线。 mom[t] 本身就包含 r[t]。所以,如果你把 r[t] 记作你的交易收益,你实际上是在部分押注一个已经存在于你自己信号内部的量。这就是泄漏的具体呈现。
设置:(每根 K 线 1% 的波动率),单边手续费 0.00045(往返 0.09%,与我们的引擎一致),夏普比率按 年化(小时线),4,000 组各含 4,000 根 K 线的独立历史。一切都是有种子的、确定性的。
诚实的流程(唯一可交易的流程)
在第 t 根 K 线收盘时做决策,赚取下一根 K 线的收益,仓位变动时支付手续费:
def sharpe(sig, ret_booked):
dpos = np.abs(np.diff(np.concatenate(([0.0], sig))))
pnl = sig * ret_booked - FEE_ONEWAY * dpos
return pnl.mean() / pnl.std() * np.sqrt(8760)
honest = sharpe(signal[idx], r[idx + 1]) # earn r[t+1]: tradable
三种泄漏,每一种都只是一处精准的改动
same_bar = sharpe(signal[idx], r[idx])
z_full = (mom - mom[valid].mean()) / mom[valid].std()
norm_full = sharpe(np.sign(z_full[idx]), r[idx + 1])
z_sm = (mom[:-2] + mom[1:-1] + mom[2:]) / 3.0 # uses t-1, t, t+1
indicator = sharpe(np.sign(z_sm[idx]), r[idx + 1])
每一种泄漏都只需要在诚实流程的基础上改动一行代码。这正是重点所在:它们不是什么稀奇古怪的错误,而是那种能够顺利通过代码审查的错误。
结果:每种泄漏的量级

在 4,000 个种子上运行后,以下是每种流程分别在零假设(无优势)和真实优势(,调校到使诚实夏普比率达到一个可信的 +1.57)下报告的年化夏普比率:
| 流程 | 零假设(无优势) | 真实优势 |
|---|---|---|
| 诚实(真相) | −0.74 | +1.57 |
| 同 K 线成交 | +14.79 | +15.85 |
| 指标窥视(1 根 K 线) | +4.76 | +6.62 |
| 全序列归一化 | −0.84 | +1.46 |
所有种子上的 95% 置信区间在每个单元格中都是 ±0.05 或更窄;针对虚高幅度的配对 t 检验,在效应真实存在处显著到近乎荒谬(t > 400,p ≈ 0)。
先看零假设那一列,因为它是最干净的实验:这里没有优势,所以诚实流程理应亏钱(−0.74,这是为交易噪声支付手续费所带来的拖累)。现在看看这些泄漏对同一个"什么都没有"做了什么:
- 同 K 线成交:−0.74 → +14.79。 一个预测能力为零、交易的是随机噪声的策略,报出的年化夏普比率接近 15。这已经不是什么细微的偏差了,而是彻头彻尾的捏造。其机制正是我们刻意植入的那一个:动量特征本身包含
r[t],所以记作r[t]就是在押注你自己的信号。 - 指标窥视:−0.74 → +4.76。 让平滑器提前看到一根 K 线的未来,就能从噪声中凭空制造出接近 5 的夏普比率,因为第
t根 K 线上的平滑值现在与你即将赚取的r[t+1]相关了。 - 全序列归一化:−0.74 → −0.84。 基本没有虚高。 这是一个诚实但不那么显而易见的发现(下文详述)。
优势那一列传递的信息更加阴险。当真实优势确实存在时(诚实夏普为 +1.57),泄漏并不只是简单加上一个常数——它们把测得的夏普比率推高到 +15.85 和 +6.62,远远超过你实际能交易到的 +1.57。所以,测得的数字无法区分能力和泄漏。一个泄漏出来的 +6 和一个诚实的 +6,在报告上看起来一模一样。你只有在真金白银投入之后,才会知道自己拿到的究竟是哪一种。
泄漏是一个渐变量,而非开关

一个自然的反驳是:"把整根信号 K 线记入账内,是一个极端、不现实的错误。"于是我们扫描了剂量——即泄漏所捕获的信号 K 线比例 ,从 0(诚实)到 1(完全同 K 线):
| 捕获比例 | 零假设夏普 | 优势夏普 |
|---|---|---|
| 0.00(诚实) | −0.74 | +1.57 |
| 0.25 | +3.90 | +6.41 |
| 0.50 | +9.86 | +12.20 |
| 1.00(完全泄漏) | +14.79 | +15.85 |
仅仅捕获信号 K 线的四分之一,就能把一个毫无优势的策略从 −0.74 拉到 +3.90。你根本不需要一个彻头彻尾的差一错误(off-by-one)就会被骗;一次略微过于有利的成交——信号 K 线上一点点乐观的滑点、用触发止损的那根 K 线本身去检验盘中止损——就足以越过大多数"可部署"的门槛。虚高程度是平滑且单调的,取决于你允许自己交易多少"当下"。
这会以多高的频率把亏钱的策略送上生产环境?
真正应该让从业者担心的数字是误部署率:一个泄漏让真正亏钱的配置越过你用来放行上线的门槛的频率有多高。以"年化夏普比率 ≥ 1.0"作为部署标准,在零假设下:
- 同 K 线成交:68% 的无优势策略看起来可以部署,而且真的会亏钱。 三分之二的纯噪声配置能通过夏普 ≥ 1 的门槛,实盘却会亏钱。(这个比率之所以能被干净地定义,是因为这里的泄漏纯粹发生在执行环节——对应的诚实版本就是同一个信号配上诚实的成交。)
- 指标窥视:它几乎把每一个无优势的配置都推过了部署门槛(99.9% 达到夏普 ≥ 1)——它会直接把噪声挥手放进生产环境。
- 全序列归一化:12% 越过门槛——基本就是噪声本身的基础通过率,没有额外的泄漏溢价。
分类法,以及如何检测每一种泄漏
这三种泄漏的危险程度并不相同,而它们之间的差异很有启发性。
1. 执行泄漏(代价最高的一种)

症状: 成交价格与信号相关,因为二者来自同一根 K 线。量级: 巨大(满剂量时从噪声中制造出 +15,四分之一剂量时也有 +3.9)。为什么它是最糟糕的: 你的信号几乎从定义上讲就是用近期价格走势构建出来的,所以信号 K 线自身的收益,恰恰就是你的特征最相关的那个东西。把它记入账内,几乎等同于直接偷看答案。
检测方法——单 K 线位移测试。 这是本文中最有价值的诊断方法,没有之一。拿你的回测,把每一次成交都向后挪一根 K 线(在第 i 根上决策,在 open[i+1] 上成交)。如果结果几乎不变,说明你的执行是诚实的。如果结果崩溃或者反转符号,那你就一直在过去做交易。这正是我们的 Sobol 搜索所经历的:把成交向后挪一位,一个"盈利"的样本外结果就变成了亏损——或者更准确地说,一旦泄漏被移除,真实的关系就浮出了水面。
entry_price = open_[i + 1] # NOT close[i], NOT open[i]
2. 指标/特征泄漏(悄无声息的一种)
症状: 第 i 根 K 线上的指标依赖于 i+1 或更晚的数据——一条居中的移动平均线、一个没有因果延迟的滤波器、一个需要未来 K 线才能确认的峰/谷标签、一个被喂入未来蜡烛图的 Heikin-Ashi 式变换。量级: 很大(从噪声中制造出 +4.8)。为什么它会隐藏起来: 泄漏被埋在某个库函数调用的内部。scipy.signal.filtfilt 是零相位的——而零相位意味着非因果。一个"这根 K 线是局部极大值"的特征,在下一根 K 线出现之前根本无从知晓。
检测方法: 对每一个指标都问一句,它读取的最高索引是多少? 如果计算 t 处的值时曾经碰到过 t+1,那它就是非因果的。用扩展/滚动的因果窗口来计算指标,并验证第 t 根 K 线上的值——无论数组里是否存在 t 之后的 K 线——都完全一致。(我们的 HMA/ADX 实现通过了这项检验:t 处的每一个输出都只读取 ≤ t 的输入。)
3. 归一化泄漏(依通道而定的一种)
症状: 一个缩放器(StandardScaler、min-max、全局 z-score)是在整个数据集上拟合的,测试集也包含在内。机器学习领域的经典警告对此说得很明确——Hastie、Tibshirani 与 Friedman 所著《Elements of Statistical Learning》第 7.10.2 节("做交叉验证的错误方式与正确方式"),以及 scikit-learn 自己的常见陷阱指南:"这个平均值应该是训练子集的平均值,而不是全部数据的平均值。"
在我们的测试中的量级: ≈ 零(−0.74 → −0.84)。这是一个令人意外但诚实的结果,值得去理解,而不是死记硬背。
为什么它没有造成虚高?因为我们的策略只通过特征的符号(一个零阈值)来使用它。标准差缩放永远不会改变符号,而全局均值中心化只会轻微地挪动一下零点穿越的位置。所以,对一条纯符号规则做全序列标准化,几乎是无害的。
不要把这个结论过度推广。 归一化泄漏是依通道而定的。一旦你的策略用到了特征的大小——按 z-score 比例决定仓位大小、通过观察缩放后的分布来选取一个非零的进场阈值、一个吃进标准化输入的神经网络——那个"看得见未来"的缩放器就开始产生影响,而且全局统计量与因果统计量差异越大,影响就越大。我们的结果并不是"归一化泄漏是安全的",而是"泄漏的量级取决于泄漏的量是通过哪个通道进入决策的,你应该去测量它,而不是想当然地假设。"符号规则只是这种特定泄漏代价低廉的一个特例而已。
它与其他环节的联系
前视偏差是这个系列一直在记录的一条链条上的第一环:
- 它污染了验证的输入。一个已经泄漏的回测会轻松滑过 walk-forward 分割,看起来就像一个宽阔的平台而不是一个过拟合的尖峰——因为泄漏在各折之间是一致的,所以交叉验证根本抓不住它。泄漏是发生在过拟合上游的失效模式,下游再多诚实的验证也救不了你。
- 它与参数搜索相互作用:在泄漏数据上跑数千次试验的搜索,会找到那个最激进地利用了泄漏的配置。所谓的"赢家"其实是罪魁祸首。
- 这也是回测-实盘一致性出现分歧的原因。回测与实盘机器人之间 30%-50% 的差距,最干净的解释就是泄漏,因为从机制上讲,实盘交易恰恰是你唯一没法偷看的地方。
能够捕捉到所有这些问题的纪律,正是学术文献多年来一直在呼吁的那一种:把回测当作一个有着严格信息边界的统计实验来对待。Bailey、Borwein、López de Prado 与 Zhu 展示了过拟合是多么容易就能制造出虚假的业绩表现(2014);Arnott、Harvey 与 Markowitz 的回测规范(2019)则把这套卫生标准正式确立了下来。前视偏差是所有边界中最基本的一种——时间上的边界——也是最容易在不经意间被打破的一种。
要点总结

- 前视偏差在数量上巨大,在性质上却难以察觉。 一个单根 K 线的执行错误,就把 −0.74 的夏普比率(纯噪声,正确地亏钱)变成了 +14.79。错误只有一行代码,后果却是一份被捏造出来的业绩记录。
- 它是一个渐变量。 即便只捕获信号 K 线的 25%,也能凭空制造出 +3.90。你不需要一个明目张胆的 bug——成交上多一点点乐观就足够了。
- 测得的数字无法分辨能力和泄漏。 当真实优势存在时,泄漏会把报告的数字抬高到远超可交易的真相之上。唯一的防线是流程,而不是指标本身。
- 单 K 线位移测试是你最快的诊断工具。 把每一次成交都向后挪一根 K 线。如果表现崩溃,说明你一直在过去做交易。
- 泄漏的量级依通道而定。 执行和指标窥视是毁灭性的;对一条符号规则做全序列归一化则几乎是免费的。要通过泄漏实际进入的那个通道去测量它——而不是想当然。
完整的受控研究——三种泄漏、剂量扫描、误部署分析、正式的方法论,以及每一个可以从单一确定性脚本复现出来的数字——都收录在配套论文中,见 lookahead.marketmaker.cc,代码和数据见 github.com/suenot/lookahead-inflation。
我们零假设实验中的那个策略,完全没有任何优势。它照样报出了 15 的夏普比率。如果你的回测好得不像话,第一个该怀疑的不是你的天才,而是你的时钟。
Authors
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.