← 回到文章
可重現性

文章裡的每個數字,以及它從哪裡來

方法寫到足以讓你自己把帳算一遍,或用幣安的公開資料重建一份等價的研究。

資料

原始 K 線取自 data.binance.vision/data/futures/um/,公開且不需憑證。每個合約建立六條序列:30 分鐘 OHLCV K 線作為主序列;1 分鐘 K 線,抵達時就化簡成最高價、最低價與各自的分鐘,只用來判定每根 30 分鐘 K 線的高低點是以什麼順序印出的;資金費率事件,帶著真實結算時間戳與每筆事件的 funding_interval_hours;30 分鐘 mark price K 線,用來為資金費率現金流計價;30 分鐘 premium index K 線,作為外生組的基差輸入;以及 5 分鐘的未平倉量與多空比。

資金費率的間隔是逐筆事件讀出來的,不是假設的,因為幣安在 2025 年把若干合約從八小時改成四小時,任何固定常數在部分期間都會是錯的。這裡採用公布的 mark price 而不是拿合約開盤價代替,也直接使用 premium index 而不是從現貨收盤價推導基差,因此面板裡不會混進第二個標的。

價格永遠不會跨越缺失的 K 線向前填補,只要必要輸入缺失就不允許進場,而一個訊號只會為緊接著的下一根 K 線建立委託。如果那根 K 線不存在,委託就取消,而不是延到後面某個有觀察到的開盤價。跨越資料斷點的持倉會繼續持有,而指標必須重新跑完整段暖身,才會再產生下一個訊號。

所有非價格輸入都以 K 線收盤時間做 as-of 對齊,因此第 t 根 K 線上的值,在第 t 根收盤時就是可知的:

# open interest, long-short ratios: last observation at or before the bar close
panel = panel.join_asof(metrics, on="ts", strategy="backward")

# funding: the last settled rate at or before the close, and separately the rate
# whose settlement falls inside the bar, which is what the cost model charges
panel = panel.join_asof(funding.select("ts", "rate"), on="ts", strategy="backward")
panel = panel.with_columns(
    fund_settle_rate=pl.col("rate").filter(
        pl.col("settle_ts").is_between(bar_open, bar_close)
    )
)

標的池

場所是幣安 USDT 保證金永續合約,頻率為 30 分鐘。資格只由排名季度決定:以 USDT 計價、十月到十二月每一天都有正的成交量、不是像 BTCDOMUSDTDEFIUSDT 這樣的指數型商品,也不是已納入合約的 1000X 面額重整版本。141 個合約通過。排名依季度內每日名目成交中位數,標的池取前 30 名與後 30 名。

d = screen.filter(pl.col("rank_days_traded") >= pl.col("rank_days").max())
d = d.sort("median_daily_notional", descending=True)
keep = [s for s in d["symbol"] if s not in excluded]   # index + redenomination rules
H, L = keep[:30], keep[-30:]
ratio = median(notional[H]) / median(notional[L])      # 19.99 against a floor of 10

實際中位數是 H 分層每日 1.388 億美元,對上 L 分層的 694 萬美元,而凍結標的池中最薄的合約仍有每日 500 萬美元的成交。落在兩個分層之間的合約不交易,只用來定義排名。分離倍數的下限 10 是在篩選程式跑之前就登記的,若低於它,做法是放棄分層,而不是把分層縮到剛好通過為止。

檔案存在不等於有在交易。資格認定與合約歷史的結束都採用正的成交量,交易所在最後一筆真實成交之後的補值一律丟棄。在主張期間內死亡的合約會留在標的池裡,並保留它們的死亡,這就是為什麼 MATICUSDT 的資料停在轉為 POL 的遷移,EOSUSDT 停在改名為 Vaulta,而不是停在某次下架清掃。用交易所現行合約清單建的標的池會把兩者都刪掉。

家族

arm C 是八個常見指標家族,全部只用價格:ATREMAMACDPPORSIRSI_LEVELSMASTOCHK。arm M-price 是九個機制家族,同樣只用價格:volcomp_breaktsmomsession_momweekend_revvolclimaxbtc_relstrgap_faderange_revkeltner_trend。arm M-exo 是十一個另外讀取資金費率、未平倉量、基差、訂單流或部位資料的家族:funding_capitliq_snapbasis_fadeoi_breakoutflow_absorbsmart_followlev_stressfunding_settlecvd_exhaustretail_contraoi_elastic

arm M 的每個家族都帶著一段機制陳述,指名對手方、那個對手方在什麼約束下成交,以及用什麼可觀測量當作那個理由的代理變數。這些陳述會被序列化、以 SHA-256 雜湊,並和其餘設定一起在讀取任何一根 K 線之前凍結。它們是依據當初設計紀錄事後重建的,不是當時就封存的文件,這正是文章中的第一項限制。

btc_relstr 讀取第二個標的的價格,因此只用價格但屬於跨資產。它留在 M-price 並被標記出來。每一組的統計量是該組各家族的等權平均,所以八對九的不平衡不需要靠刪掉任何家族來處理。

一個策略是 (arm, family, variant, threshold, max_hold, stop) 這個元組。回看長度與 reward-to-risk 是樣本內可調的旋鈕,不是各自獨立的策略,而且每個視窗都會重新選一次。兩組的每個家族都取自同一個共用函式庫,逐字複製自產生兩份原始語料的那個生成器,並做了雜湊。

TRANSFORMS = ["base", "slope", "normalized_price", "roc", "bias", "volZ", "accel",
              "disFromMedian", "quant_stretch", "rank_resid", "fold_dev"]          # 11
CONFL      = ["RSIge40", "RSIge50", "Pge0.7", "Pge0.8", "BW_filter", "pi", "vr",
              "kurtosis", "kurtosis10", "skew", "skew0.75", "atr_pct", "atr_pct0.8",
              "burstfreq", "TinyBody", "NoNewLowGreen", "RangeSpike", "YesterdayPeak",
              "DeadFlat10", "InsideBar", "SameDirection", "TopOfRange",
              "VolContraction", "EMAHug"]                                          # 24

VARIANTS = TRANSFORMS + CONFL      # 35
THR      = [1.0, 1.5, 2.0]         # entry threshold, in z units
MAXH     = [24, 96, 288]           # 12 hours, 2 days, 6 days
SLS      = [1.0, 2.0, 4.0]         # stop, in entry-ATR units
N_CONFIGS = 35 * 3 * 3 * 3         # 945 attempted configurations

每個家族、每個合約、每個視窗都是 945 次嘗試,兩組皆然。這就是相同的搜尋預算:相同的「嘗試設定數」,而不是相同的旋鈕數量;它刻意不同於原始語料,那裡目錄家族的設定數從 12 到 8,932 不等。每一次嘗試都連同原因一起保存,包括從來沒有交易過的那些,這樣一個常常失敗的家族才不會看起來比一個一直在交易的家族「被搜得比較少」。訊號事件的定義是:當前想要的部位狀態不為零,而前一個狀態是中性或相反,因此一個持續存在的條件不會每根 K 線都重新觸發。

實際的分數表有 28,576,800 列,涵蓋 60 個合約、28 個家族與 18 個視窗,也就是 1,680 個「合約-家族」單位,每個單位帶 945 組設定。

執行

資訊集與交易順序,依序是:來自已完成 K 線 t 的特徵、K 線 t 之後想要的部位狀態、在 K 線 t+1 的開盤價進場、出場障礙自 t+1 起生效,並在停損、目標價,或持有滿 H 根完整 K 線之後的開盤價三者中最早發生者出場。

每個策略最多持有一個部位,持倉期間同方向的訊號會被忽略,反向訊號則在下一個觀察到的開盤價出場並反手,收取兩次成交成本。出場障礙以 ATR 縮放而不是固定百分比,因為在依流動性分層的標的池裡,固定 1% 的停損在主流幣與細小山寨幣上代表的風險距離完全不同,那會讓流動性軸與波動率軸混在一起。

同時觸及兩側障礙的 K 線,是靠走一遍 1 分鐘路徑、取先被穿越的那個障礙來判定。改成看哪個極值先印出來,那是另一個問題,而且實測顯示在無漂移的鞅路徑上,每筆交易最多可偏差 12.5 個基點,比整個成本模型還大。當單一分鐘同時涵蓋兩個障礙時,在這個解析度下順序真的無從得知,此時以停損優先。

成本與資金費率

gross = direction * (exit_price / entry_price - 1.0)
fee   = 5e-4 * fills                                   # 5 bp per fill, both legs
slip  = (0.5 * spread_month + 0.5 * 0.5 * spread_month) * fills
fund  = sum(direction * rate_k * qty * mark_k for k in settlements_held)
net   = gross - fee - slip - fund                      # reconciles to 1e-9 per row

手續費固定為每次成交 5 個基點,即幣安 USDT 保證金永續合約的 VIP0 taker 費率,且不隨合約而變。價差取自 daily/aggTrades,定義為成交之間跳動的中位數,也就是連續兩筆、主動方向翻轉的成交之間的絕對相對價格變動,取每個月 8 日與 22 日兩天,若某天缺資料最多往後遞補三次。每次成交支付實測價差的一半,再加上同樣的一半作為滑價準備,因此一趟來回總共支付一個完整實測價差外加 50% 的衝擊準備。

直接報價來源 bookTicker 在這裡不能用,因為封存檔只從 2023-05-16 開始有它,會讓主張期間的前三分之一沒有成本可算。高低價估計法一律禁用:本研究桌上的實測顯示,在這種 K 線大小下 Corwin-Schultz 會把 BTC 的價差高估 568 倍,也就是說它讀到的是波動率而不是價差。

這個估計式在凍結前用一份獨立的 BTCUSDT 2023 年 7 月 15 日 bookTicker 量測做過驗證,結果精確重現為 0.0330 個基點,樣本為 440,267 筆成交與 172,496 次主動方向翻轉。這個吻合是一道阻斷式檢核,不是附註。

某個「合約-月」若有成交過的 K 線卻量不出價差,就算資料失敗,並使該合約出局。若某個「合約-月」根本沒有成交過的 K 線,那是結構性缺席,不帶價差,因為它也不帶部位。在凍結標的池上,實際的分佈是 3,607 個已量測的合約-月、593 個結構性缺席、零個失敗。

成本項目處理方式
手續費每次成交 5 bp,固定,進出兩邊都收
價差逐合約逐月實測,每次成交收一半
滑價每次成交再收實測價差的一半
資金費率結算 K 線上觀察到的帶符號事件,以公布的 mark price 計價

資金費率為正時扣多單、付空單,計算對象是結算時間戳之前一刻所持有的部位。在該時間戳才新開的部位不付也不收;一路持有到該時間戳並在那裡出場的部位要付。部位數量在整筆交易期間固定為策略資本除以原始進場價。資金費率在任何一組都不會成為策略輸入;在 arm M-exo 中,成本路徑與特徵路徑分別由不同欄位計算,並有明確的檢核斷言沒有任何成本欄位進入特徵矩陣。

Walk-forward

視窗以日曆錨定:12 個日曆月的樣本內區塊,接著 3 個日曆月的樣本外區塊,每次前進三個月,共十八個區塊,涵蓋 2022 年 1 月 1 日到 2026 年 6 月 30 日。

def quarters():
    out, d = [], date(2022, 1, 1)
    while d < date(2026, 7, 1):
        nxt = next_quarter(d)
        out.append((date(d.year - 1, d.month, 1), d, nxt))   # IS start, OOS start, OOS end
        d = nxt
    return out                                               # 18 windows

所有合約共用相同的視窗邊界,因此較晚上市的合約參與較少視窗,中途死亡的參與得更少。兩份原始語料使用的是以 K 線數量錨定的視窗,那會讓每個合約有各自不同的日曆,使整個面板的週叢集失去一致性。

在每個樣本內區塊裡,945 次嘗試全部計分,回看長度與 reward-to-risk 依樣本內 Sharpe 選出,選定的旋鈕隨即凍結,並且只套用一次到樣本外區塊。持有區間跨越邊界的交易會整筆排除在樣本內計分之外,而不是被截斷。指標暖身可以讀取區塊開始之前的 K 線,因為那些 K 線本來就已知,但暖身列絕不會對正在計分的區塊貢獻損益。

配對

一個合格的「策略-視窗」,在樣本內至少要有 20 筆已平倉交易、分佈在至少 4 個不同的日曆週,且樣本內 Sharpe 為有限值。配對在單一 (contract, window) 格子內進行,絕不跨流動性分層,依樣本內 Sharpe 距離的絕對值由小到大貪婪取用,兩邊都不重複取用,並用兩個策略 id 打破平手,使配對成為輸入表格的純函數。

order_vals = np.sort(m_sharpe)                  # candidates within the caliper by
lo = np.searchsorted(order_vals, c_sharpe - 0.05, side="left")   # binary search, not a
hi = np.searchsorted(order_vals, c_sharpe + 0.05, side="right")  # |A| x |B| matrix
# pairs taken in ascending |distance|, without replacement, ties broken by (c_sid, m_sid)

caliper 是 0.05 個年化 Sharpe 單位,若重疊檢核失敗,還有一次已登記、可放寬到 0.10 的機會。實際上沒有用到:在 0.05 之下就配出 6,812,171 對;已登記的重疊檢核要求每個視窗至少 200 對,且整體至少配到較小那一組的 60%,並且逐分層評估。

配對程序只讀取樣本內欄位。把樣本外欄位刪掉之後再跑一次,會產生完全相同的配對雜湊,這是一道阻斷式檢核,不是一句宣稱。配對後的平衡以樣本內 Sharpe 的標準化平均差回報,任何超過 0.10 的值都會被視為配對實作的失敗,而不是一個結果。

策略 id 在任何 join 之前都會先做成跨家族唯一。sid 在每個「合約-家族」都從零重新開始,所以 arm C 的 EMA 家族與 arm M 的動能家族都帶著 0 到 944 的 id。只用 sid 去 join 會產生歧義,在一個只有兩個合約的語料上,那讓 119,178 對變成 8,580,816 列。

統計推論

兩組的配對每日淨報酬序列,是從配對集合建出來的。在每個固定的樣本外視窗內,bootstrap 重抽的是 UTC 週一到週日的日曆週叢集;季度邊界會把一個日曆週切開,兩側的日子各自成為獨立的「視窗-週」片段。被抽中的片段裡,所有合約、家族、策略與組別一起被抽出,如此保留橫斷面相依性,又不改變一個視窗的叢集數量。實際資料在 1,642 個交易日上帶有 250 個片段。

rng = np.random.default_rng(20260810)
stat = []
for _ in range(10_000):
    draw = rng.integers(0, n_clusters, n_clusters)   # week fragments, not rows
    stat.append(diff[rows_in(draw)].mean())
point, (lo, hi) = observed_diff, np.percentile(stat, [2.5, 97.5])

10,000 次重抽,seed 20260810,百分位數區間。逐列重抽是被禁止的,因為重疊的預測視窗並不會產生獨立觀測值。雙尾 p 值取 bootstrap 較小那側尾機率的兩倍,並加上有限重抽次數的修正 (count + 1) / (B + 1),上限為 1,然後才進入 Benjamini-Hochberg。唯一登記的主要對照不做多重性校正,四個次要對照共用一個 5% 的 BH 家族。

衰減斜率是唯一無法用週做叢集的統計量。它的觀測單位是一個「配對-視窗」,一個樣本內 Sharpe 對上一個樣本外報酬,因此只能以視窗本身做叢集,得到 18 個叢集而不是 250 個。文章裡的區間相應地比較寬,這是這個估計量的性質,不是選擇的結果。

檢核

分析之前會跑二十道阻斷式檢核,每一道都帶著一個刻意植入的缺陷,用來證明它真的會失敗。一道在乾淨輸入與壞掉輸入上都通過的檢核,等於根本不會失敗,會被回報為「壞掉」。二十道全數通過,二十個植入的缺陷也全數被抓到。

它們涵蓋:凍結設定的雜湊、標的池與其分離倍數、家族在兩個方向上都真的產生訊號、狀態過濾條件確實可量測地篩掉候選、截斷未來後先前的訊號不變、特徵洩漏、1 分鐘路徑上的先觸及判定、資金費率的正負號、成本恆等式、945 次嘗試的計數、視窗的排序與互斥、配對程序對樣本外欄位的盲性、重疊、配對後的平衡、交換組別標籤後的對稱性、植入的未來結果、視窗內的行動輪替、複製品控制,以及下面的虛無語料。

另有兩項檢查值得一提。引擎在無漂移路徑上賺到零,並與先觸及參考實作在每筆交易 0.08 個基點以內吻合。帳本恆等式 gross - fee - slippage - funding = net 在逐列與總計上都成立;在文章描述的非有限資金費率缺陷之後,它現在會在比較數量級之前,先明確拒絕非有限值,因為非有限的殘差會通過數量級比較,把被汙染的帳本認證成乾淨的。

虛無語料

共用的規則詞彙帶著一個自我參照假影:由交易序列本身推導出的訊號,搭配以同一序列落後波動率縮放的出場障礙,在無漂移語料上並不會賺到零。在那裡,光是假影就讓兩組各虧掉大約 550 個基點。這是詞彙的性質而不是引擎的缺陷,所以如果去測「水準是否為零」,反而會判一個其實正確的設計不及格。

因此登記的檢定是對差值做的,而差值正是估計量:

# same engine, same vocabulary, same matching, driftless GBM paths
D_null = matched_difference(null_corpus)
# 72.92 bp, 95% CI [-15.11, 166.60], 51,124 matched pairs, covers zero

這個區間涵蓋零,正是檢核要求的。它同時也是觀察效果點估計的 33%,而且上界高於觀察效果的下界,這就是為什麼文章會把主結果與假影一起報告,而不是只報主結果。

文章裡每一張圖,都是從 一個公開的 JSON 檔 畫出來的,該檔由上述產出匯出。完整的流程、凍結套件與逐筆帳本不從本頁散布。如果你需要, 寫信來要

回到文章。

← 交易策略需要經濟理由嗎?