相同 commit、相同 parquet 檔案、同一台機器,前後相隔一小時跑了兩次。Sharpe 分別是 1.34 和 1.19。總報酬率分別是 41.2% 和 37.8%。交易筆數是 1,418 和 1,421。兩條權益曲線連續 11,205 根 K 棒都精確到顯示的小數位,之後才開始分歧。
前三個數字很惱人。最後一個才有意思,因為它表示問題不是浮點誤差在整趟回測中零星累積。某根 K 棒上的某件離散事件造成了變化,後續差異則一路複利擴大。這篇文章要談的是如何找到那根 K 棒,以及這 0.15 的差距會如何影響你跑過的每一次參數掃描。
策略:在成交量最高的 30 個 USDⓈ-M 永續合約中做橫斷面動能,每 4 小時再平衡一次,依 12 小時報酬率做多前五名、做空後五名,使用 18 個月的歷史資料,並逐倉計入手續費與資金費率。
找出兩次回測分歧的 K 棒
只要以完整精度記錄每根 K 棒的權益,約 4 分鐘就能找出答案。把兩次回測輸出成 (bar_index, equity, open_positions_hash) 的 CSV,載入後找出第一個值不一致的索引。我們之前為另一個錯誤寫過這支腳本,否則我可能得花上一個早上。
第 11,206 根 K 棒,2025-03-14T08:00 UTC。前一根 K 棒的權益值完全相同,精確到 13 位有效數字。到了第 11,206 根,持倉雜湊值不同:A 組做多 SOL,B 組做多 AVAX。方向相同、名目金額相同,標的不同。之後的一切差異都由此而來。
於是我把兩次回測該根 K 棒的排名輸入值都印出來。兩邊完全相同,逐位元組一致:相同的 30 個標的,相同的 30 個分數。其中兩個分數是 0.0。正好是 0,兩個都是,因為這兩個標的在回溯區間內都出現過一根 4 小時 K 棒,成交筆數為 0,所以收盤價除以前一收盤價等於 1,取對數後就是 0。不是四捨五入成 0,而是精確的 0。
兩個標的並列第 5 名,只會選前五名。究竟哪個標的入選,取決於排序後它們的順序;而排序的行為和我想的不一樣。
平手、不穩定排序,以及我忽略了兩年的事
排名是透過分組彙總計算的,而輸入資料框是從一組標的構成的集合中,以字典推導式建立的;這組集合則是每根 K 棒根據非同步抓取結果重新建立。集合的迭代順序會隨雜湊種子改變,而 Python 除非固定 PYTHONHASHSEED,否則每個程序都會隨機化字串的雜湊種子。因此排序前的列順序會因回測而異;遇到平手鍵值時,不穩定排序就會用不同方式打破平手。
這種平手並非罕見意外,而是結構上就會發生。只要某項特徵達到飽和或經過截尾,就會產生完全相同的值:零成交量的 K 棒會帶來精確為零的報酬率,截尾後的 z 分數會固定在 ±3.0,只有少數不同值的排名轉換會產生一大堆平手,布林篩選則會讓所有通過的項目都得到 1.0。這次回測使用 4 小時 K 棒、涵蓋 18 個月,共有 47 根 K 棒在選擇門檻處出現平手。其中 3 次改變了入選的投資組合,其餘平手的兩個標的都同時已入選,或同時未入選。
有整整一天左右,我都認定資料載入器有非決定性,因為這個答案聽起來比較精彩。其實不是,從來都不是。就是集合、平手,以及沒有人寫下來的排序穩定性假設。
為什麼 3 筆交易會造成 0.15 的 Sharpe 差距
這段最常讓人提出質疑,而答案是:採用按權益比例配置部位的回測,會受到路徑影響。每個部位按當前權益的 8% 配置,代表第 n 根 K 棒的權益差異,會讓第 n 根 K 棒起之後的每一筆名目金額都不同。
第一次分歧本身造成的影響很小。B 組的 AVAX 部位在 9 小時內虧損 2.1%;A 組的 SOL 部位則獲利 0.4%。這筆交易後的權益差距是 0.21%,微不足道。但從那一刻起,兩次回測就不再是同一個策略了。部位大小略有不同,累計的資金費率也略有不同;後來又有兩次門檻平手因而以不同方式打破,因為用來排名的分數如今來自略有差異的持倉。其中一次發生在 2025-03-27,也就是一波為期 6 天的趨勢行情前一天,這波行情約占整趟回測總損益的三分之一。A 組全程持有,B 組晚了一次再平衡才進場。
報酬差距:3.4 個百分點。Sharpe 差距比報酬差距所暗示的還大,因為 B 組重新排序後的交易碰上波動較大的時期,分母上升、分子下降。小小的起因,經過兩個放大因素。
如果你的部位採固定名目金額,而且進場條件不取決於當前持倉,就能更不受這類問題影響。大多數有意思的策略都不是這樣。
實際造成差異的五個環節
| 來源 | 症狀 | 修正方式 |
|---|---|---|
未固定的 PYTHONHASHSEED,集合/字典的迭代順序會影響排序 | 平手結果在不同回測間翻轉;在特定 K 棒出現首次分歧 | 固定種子;以明確的次要鍵值(標的名稱)排序,確保平手結果一致 |
鍵值平手時使用不穩定排序(NumPy/pandas 的預設 quicksort) | 同上,即使固定種子仍會發生 | kind="stable",或讓鍵值具有全序 |
| bootstrap、訓練/測試資料洗牌,或模擬成交價抖動中使用未設種子的 RNG | 整趟回測逐漸漂移,沒有明確的分歧起點 | 每個元件都使用明確的種子,並記錄在執行清單中 |
| 平行浮點數加總(加總順序取決於執行緒數) | 最後幾個位元有所差異,通常無傷大雅,直到遇上門檻比較 | 研究回測時固定執行緒數;決策門檻處絕不以 == 比較浮點數 |
| 未固定的函式庫版本 | 今天可重現,到了 11 月就不行 | 在執行清單中記錄 lockfile 雜湊,以及資料快照雜湊 |
第四列的影響沒有大家擔心的那麼常見,第一列則是一直在造成問題。
位元級可重現是一種工具,不是美德
我們要有決定性,是為了能在改動一行程式碼時,把權益曲線的變化歸因於那行程式碼。理由就這麼簡單。如今 Stratmill 上的每個 agent 執行任務都會寫入一份清單,列出資料快照雜湊、lockfile 雜湊和所有種子;如果重新執行後無法逐位元重現先前曲線,就算建置失敗,不是值得好奇的現象。
但一旦你能重現,就要刻意打破它。用 64 個種子把同一件事跑 64 次,看看結果的分布:
抖動區間。同一個策略、同一批資料,使用 64 種帶種子的平手處理與成交順序排列。Sharpe 第 5 百分位數為 1.12,中位數為 1.27,第 95 百分位數為 1.41,區間寬度為 0.29。
現在回頭看參數掃描。最佳設定的分數是 1.46,排名第 40 的 96 組設定之一則是 1.31。兩者相差 0.15,只有抖動區間的一半。這次掃描沒有把這兩組設定排出高下,而是各自從分布中抽了一個樣本,再把抽到的分數排序。
這種重新理解改變了我們的選擇方式。只有當各組設定間的差距大於單一設定的抖動程度時,掃描結果才能作為排名;對一個有 1,400 筆交易、路徑相依的策略來說,抖動通常大到足以讓排行榜前三分之一全擠成平手。遇到這種情況,就看抖動區間無法掩蓋的指標:較低的換手率、較少的參數、能向懷疑者說明的成本假設,以及在你最不滿意的 walk-forward 區間中表現更好。這些才是實在的決勝條件。Sharpe 高出 0.15 不算。
還有一件值得在信任結果前先做。現在就把回測跑兩次,比較逐根 K 棒的權益,確認自己屬於位元完全一致的那一邊,還是差距 0.15 的那一邊。這只需 15 分鐘,就能讓你知道過去有多少研究成果是在衡量策略,又有多少是在衡量雜湊種子。
← 所有文章


