2026年八月27日 · 資料工程

你的交易標的池是一台時光機:加密貨幣永續合約回測中的存活者偏誤

你的交易標的池是一台時光機:加密貨幣永續合約回測中的存活者偏誤

你週日晚上把 notebook 傳給我,從那時起我就一直把它開在第二個螢幕上。橫斷面動能,依 30 天美元成交量選出 Binance USDⓈ-M 永續合約前 150 名,每週再平衡,做多前 10%、放空後 10%,期間是 2021-01 到 2026-06。Sharpe 2.31,最大回撤 14.2%,成本模型看起來也很紮實:進場和出場都用 taker 費率,按區間累計資金費率,滑價項則依你的下單量相對於訂單簿深度調整。難的部分你都做對了。接著你問我,為什麼 6 週的模擬交易結果毫無起色,以及這個優勢是否已經衰退。

它沒有衰退。它根本就不曾出現在回測裡。看看第 4 個儲存格:

info = requests.get(BASE + "/fapi/v1/exchangeInfo").json()
symbols = [s["symbol"] for s in info["symbols"]
           if s["status"] == "TRADING" and s["quoteAsset"] == "USDT"]

你在 2026 年 6 月呼叫了那個端點,並用回傳結果來定義 2021 年 3 月有哪些標的可以交易。清單上的每個標的都存活到 2026 年 6 月,這是由建構方式決定的。這就是整個問題,影響比你的其他成本模型加起來還大。

2.31 → 0.74Sharpe,存活者清單與時點資料比較
約 1/5你 2021 年交易標的池中的標的已不復存在
380 KB每日 exchangeInfo 快照,gzip 壓縮後

這個端點不會告訴你的事

exchangeInfo 裡沒有歷史資料。沒有 asOf 參數、沒有封存資料,也沒有變更紀錄。它只呈現當下狀態,而 Binance 從未承諾提供其他資訊。合約下架後,該項目就會從回應中刪除;就 API 而言,那個交易對也不復存在。自 2019 年以來,Binance 上架過 600 多種 USDⓈ-M 永續合約,也下架了遠超過 100 種。BTS、COCOS、TOMO、RAY、FTT、SC,還有一長串 2021 年山寨幣旺季的合約,曾有過一季的風光,之後交易量逐漸萎縮,最後被交易所下架。

想想看,30 天動能篩選器最喜歡哪些標的。不是 BTC。它喜歡的是剛因上架炒作和 Twitter 熱潮而翻了 3 倍的幣種。這類標的和最終遭下架的標的高度重疊,而你的交易標的篩選器把這些重疊標的全都排除了。

我用我們的快照封存資料,依時點交易標的池重跑了你的策略,訊號和成本都相同,結果 Sharpe 是 0.74,回撤 31%。差距大約有五分之二來自你根本不可能持有的下架標的。另外四分之一來自反方向的問題,這個問題比較隱晦,我猜你會更不喜歡。

時光機的另一端:當時還不存在的交易標的

你的特徵是 90 天報酬率。你的滾動視窗設成了 min_periods=20,因為你當初只設定一次,想避免暖機期把樣本第一季資料整段排除,之後就再也沒回頭檢查。因此,上架才 21 天的合約會用上架後 3 週的價格走勢計算動能分數;只要上架後表現不錯,就很容易排進前 10%,然後進入你的投資組合。

這部分勉強可以說合理。真正不合理的是:你的資料供應商回補了部分標的在永續合約問世之前的現貨或指數資料,所以有些合約的 K 線歷史早於它自己的 onboardDate。大約 1 分鐘就能查出來:將價格資料表與上架日期連接,統計上架日期之前的資料列。你的資料裡有 41 個標的在上架前就有 K 棒,其中有 1 個標的在 2023 年夏天,因一筆持倉替權益曲線貢獻了單週 6% 漲幅;但那份合約要再過 9 天才會存在。

交易代碼字串不是識別碼,而是交易所出租的標籤,有時同一個標籤還會租給兩個標的。

這也帶出更名問題。MATICUSDT 改成 POLUSDT。FTMUSDT 經過 1:1 轉換後改為 SUSDT。2022 年 5 月的 LUNA 事件留下了 LUNCUSDT,之後又出現全新的 LUNAUSDT;兩者共用相同的代碼前綴,但其中一個幾乎跌掉所有價值。如果你的載入器以交易代碼字串為鍵,把找到的檔案全部串接起來,資料裡至少會有一段序列包含非價格變動造成的不連續,而你的動能特徵會把這段不連續解讀成橫斷面中最強的訊號。

其他所有隨時間變動的欄位

一旦你接受交易標的清單會隨時間變動,同樣的道理就適用於回應中的其他欄位。你現在把今天的欄位值套用到所有日期。

欄位如何變動造成的問題
statusTRADING → SETTLING → 消失存活者偏誤;以從未成交過的收盤價假裝平倉
onboardDate每週都有新上架;已下架標的則不再有此欄位在合約存在之前就交易它
tickSize / stepSize隨價格水準變化而調整訂單取整,以及當時實際會遭拒的限價單
minNotional流動性差的訂單簿會逐漸提高門檻實盤路由器會拒絕的小部位
fundingIntervalHours多年來都是 8h,後來許多標的改成 4h 或 1h你持有的那些山寨幣,持倉成本會差到 2–3 倍
leverage brackets級距和維持保證金經過修訂強制平倉模型與保證金容量

就你的情況來說,資金費率問題影響最大。你的累計程式假設整段歷史每天都有 3 次付款。有相當一部分山寨幣投資組合改成每 4 小時支付一次,而你模擬損益的很大一部分來自高資金費率標的的空頭部位。這不是四捨五入的誤差,而是差了一個倍數。

下架本身就是一個事件,而你的模型沒有處理

在你的時點資料重跑中,我讓策略可以很寬裕地出場。實際下架會按既定流程進行:先公告,通常提前 7 到 14 天,接著進入只減倉時段,最後按標記價格強制結算。公告是公開資訊,你可以據此行動,所以合理的模擬方式是在公告當天收盤時出場。但對典型的山寨幣下架案來說,當天收盤價已比前一週低 10-20%,訂單簿也很薄,你的滑價項必須反映市場已進入不同狀態。如果你的成交引擎直接用結算價成交,完全不計衝擊,就等於悄悄把垂死資產當成能以公允價值交易。

如果你從來沒封存過 exchangeInfo,也還有補救方法。 公開資料集 data.binance.vision/data/futures/um/monthly/klines/ 仍保留已下架標的的目錄,API 早就忘了它們,目錄卻還在。列出目錄中的檔案,每個標的第一個和最後一個月檔,就能估出可用的上架和下架時間範圍,不需要任何資料供應商。這是重建結果,不是原始紀錄,也無法還原 tick size 或資金費率間隔。但至少它能告訴你每個標的何時存在,而這已經涵蓋了你週一所需資訊的 80%。

在你再次碰訊號之前,我會先建好這些東西

  1. 設定一個 cron,每天從你研究的每個交易所抓取 exchangeInfo,並以日期為鍵寫入物件儲存。gzip 壓縮後不到半 MB。封存 10 年在 S3 上只佔極小成本,卻能讓你具備日後無法購得的研究能力。
  2. 根據這些快照建立資產主檔:每個 (venue, symbol, valid_from, valid_to) 一列,並包含完整欄位。比對連續快照來產生主檔,每當欄位有變更,就建立新的一列。
  3. 建立一個必須帶入時間戳記的交易標的池函式。universe(ts),絕不允許 universe()。讓預設值不可能被使用,這樣任何人,包括凌晨 1 點的未來版你,都不會不小心拿存活者清單來用。
  4. 在資料載入器裡加入上架前檢查:不得存在早於 onboard_ts 前一天的 K 棒。直接讓執行失敗,不要只發出警告。
  5. 建立更名後仍保持不變的穩定內部商品 ID,並將交易代碼降為屬性。把 POL 和 MATIC 對應到同一個 ID,並標記面額重定義,讓連續性邏輯可以拒絕跨越兩者串接資料。

照這些做完再重跑。我猜結果會接近我算出的 0.74,接下來真正值得問的是:包含已消失標的的 0.74,是否還有值得拿來做模擬交易的東西。也許有。永續合約的橫斷面動能並非全無效果,而修正交易標的池後剩下的優勢,有一部分確實來自空頭端的持倉收益。你也會發現模擬交易結果和回測開始一致,因為模擬交易一直都是用時點交易標的池。它從來沒有其他選擇。

附註:這不是加密貨幣特有的問題,只是在這裡更明顯。自從 CRSP 開始提供資料以來,股票研究者就一直在處理下市報酬和重複使用的交易代碼;預測市場則把這個問題推到極致:每份合約都會按設計到期,因此交易標的池全由上架和下架構成。如果你之後把這個篩選器移植到 Kalshi,先建立資產主檔。那裡不存在其他形式的歷史資料。

存活者偏誤時點資料加密貨幣期貨回測資料工程
← 所有文章