"回測無幻覺"系列文章。
📄 本文成長為了一篇研究論文。 三種細微的前視洩漏在已知真實基準下(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.