證明多時間框架回測中沒有look-ahead:擾動未來,證明過去看不到它
“無幻覺回測”系列文章之一。
上一篇文章以一個診斷性測試收尾:單K線偏移測試。把每一次成交都往後推一根K線;如果績效因此崩潰,說明你其實是在用過去的資訊交易。對於單一時間框架的策略,這個測試已經足夠——因為look-ahead藏在執行環節:你在K線i上做出決策,也在K線i上成交。
多時間框架策略還有第二個、更隱蔽的資訊洩露之處,而偏移測試觸及不到它。當1分鐘K線上的決策要參考1小時趨勢時,洩露就不再發生在成交環節——而是發生在指標本身,因為它是由一根尚未形成完畢的高時間框架K線計算出來的。這篇文章要講的正是這種洩露,以及如何機械地——而不是靠盯著程式碼看——證明你的引擎裡沒有這個問題。
我們最終要給出的結論很強:在86,400根真實ETHUSDT 1m資料的K線上,我們的多時間框架引擎通過了25項一致性與洩露檢查中的全部25項,其中包括一個會擾動每一根未來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語義

正確的規則就是實盤機器人已經在執行的那一套。在我們的程式碼庫裡,它是一個很小的類,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本身偷看了未來——那麼一致性測試會忠實地復現這個洩露並且照樣通過。兩個實現相互一致,只能說明它們是相同的,不能說明它們是因果的。要證明因果性,你需要另一種測試——一種直接質問引擎“過去能否看到未來”的測試。
未來偏移探針:真正的證明

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——並不是為了讓回測結果好看而寫的一個測試夾具。它就是實盤機器人自己的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。這裡的重點是要清楚自己證據的邊界,而不是假裝單一一次測試就是一個普適的全稱量詞。
要點總結
- 多時間框架策略是通過正在形成的K線洩露的,而不是通過成交環節。 在一個尚未結束的HTF週期內做出的決策,必須只使用這根K線的當前收盤價(最新的基礎收盤價),絕不能使用它的最終收盤價。整體重取樣式的回測,會把每一個週期內部的決策都交給長達一整個HTF週期的未來資訊。
- 單K線偏移測試觸及不到這種洩露。 它能抓住執行環節的look-ahead;而多時間框架的洩露藏在指標的構建方式裡。你需要另一種探針。
- 先精確復現實盤規則,然後再去證明它。 我們把機器人的
RunningCandleBufferclosed-bar語義重建成了一個向量化引擎,並在其後設定了25項檢查作為關卡:交叉事件與參考實現精確一致(408 / 2,792 / 3,691),交易逐欄位完全一致(雙重466筆,三重211筆),PnL精確到1e-12。 - 一致性是必要條件,但不是充分條件。 與參考實現匹配,只能證明你們是相同的,不能證明你們具有因果性。一個存在洩露的參考實現,即便被忠實地復現,依然存在洩露。
- 未來偏移探針才是真正的證明。 擾動
j及之後的每一根K線;斷言j之前的每一個訊號和交易都逐位不變。如果未來能夠改變過去,你就存在look-ahead。 - 讓探針真正具有約束力。 斷言未來確實發生了變化(擾動不是空操作),並且單K線的微小擾動會在同一根K線上產生反應(既沒有洩露,也沒有滯後)。一個不可能失敗的測試什麼也證明不了。
- 當回測與實盤共享同一個核心時,證明就會自動轉移。 由於引擎與實盤機器人的K線邏輯逐位相同,沒有look-ahead這一性質,同樣也是實盤系統的性質——回測與實盤之間的一致性差距,因此從構造上就被消除了。
前一篇文章展示了一行程式碼的洩露如何偽造出Sharpe為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.