← 回到筆記
可重現性

筆記裡的每個數字,以及它從哪裡來

足夠的方法細節,讓你可以手動核對算術,或用同一份授權資料重建一個等價的研究。

資料與來源

資料來源是 Algoseek US Equity Trades and Quotes 的奈秒級資料,取自在 Equinix NY2 與 NY4 收取的合併 SIP 資料流(CTS、CQS、UTDF、UQDF)。每個標的日是一份壓縮 CSV,schema 為 Date, Timestamp, EventType, Ticker, Price, Quantity, Exchange, Conditions,其中 TimestampHH:MM:SS.nnnnnnnnnConditions 則是十六進位表示的 32 位元成交條件位元遮罩。

交易日是從每日清單 .index/YYYYMMDD.csv.gz 列舉出來的,而不是列出 bucket,因為這個產品不允許 列出。一次很小的 GET 就會回傳整天的 bucket, prefix, file, compressed_bytes, uncompressed_bytes,其中第 3 欄是壓縮後大小,第 4 欄 是未壓縮大小。把這個順序讀反,正是當初對一個每天 27 GB 的產品做出每天 300 GB 估計的原因。

區塊交易日數期間壓縮後大小
20196020190201 至 2019042912.4 GB
20226020220201 至 2022042744.9 GB
20246020240201 至 2024042629.3 GB

實際下載的範圍:AAPL、SPY、JPM、XOM 與 F 共 900 個標的日,壓縮後 86.6 GB。檔案是以單純的 gzip GET 取得,而不是走 S3 Select,後者會回傳未壓縮的 CSV,在測試檔上大約讓傳輸量翻倍。

資料源裡的兩個陷阱,都以實測確認

這兩件事只要弄反,就會悄悄毀掉每一個成交量與訂單流特徵,而且從資料商的規格書上都看不出來。

逐筆流是 TRADETRADE NB 的聯集,而且兩者互斥。 規格書把 TRADE NB 描述成在 NBBO 上或之內的成交,TRADE 則是其餘全部,這留下一個 未定的問題:後者是不是重新發送了前者。於是這個問題用量測來解決。

以時間戳、價格、數量與交易所組成的精確四元組,重疊數為 0。另一個近似比對測試問的是:每一筆 TRADE NB 是否在 1 毫秒內有一筆同價格、同數量、同交易所的 TRADE,命中率為 0.0000,而把 TRADE 時間戳往後推 5 秒的對照組同樣是 0.0000。

結構上兩者也是互斥的:TRADE 有 95% 到 98% 的成交帶著第 31 位元的零股旗標,而 TRADE NB 是 0.00%;而且 Algoseek 自己的純成交產品內容剛好就是這個聯集,JPM 在 2019-03-15 是 83,244 列,聯集是 83,243 列。只用其中一半會讓逐筆流少一半,把兩者當成重複資料也一樣。

官方開收盤的更正必須丟掉。第 24 位元(tOfficialClose)與第 26 位元(tOfficialOpen)標示的是某個交易中心重述競價價格,而不是一筆新的成交。F 在 2024-03-15 那天, NYSE 36,626,227 股的收盤成交出現了五次,一次是競價本身,接著在 16:01、16:10、18:30 與 19:00 又各出現一 次,因此天真地把聯集加總會得到 F 當天 238 百萬股,丟掉第 24 與第 26 位元後是 88 百萬股,那才是 F 實際的 成交量。

第三個修正比較小但值得寫下來:原始需求要求排除 FINRA、CRF 與其他場外通報venue,但這個資料源裡根本沒有 CRF 這個標籤。三個年度裡全部 19 個 SIP 交易中心代碼都被列舉過,Algoseek 把所有場外通報都併在 FINRA(SIP 代碼 D)之下,所以排除 FINRA 就是那項指示的可執行形式。

以下是完整的過濾條件,對兩份資料集完全相同地套用,因此分支 A 與分支 B 底下的 OHLC K 棒在位元層級上一模 一樣。

tape = pl.concat([trade, trade_nb])                 # disjoint halves, verified above
tape = tape.filter(
    (pl.col("cond") & (1 << 24) == 0)               # tOfficialClose restatement
    & (pl.col("cond") & (1 << 26) == 0)             # tOfficialOpen restatement
    & (~pl.col("event").is_in(["TRADE CANCELLED", "TRADE NB CANCELLED"]))
    & (~pl.col("Exchange").is_in(["FINRA", "UNKNOWN", "INVALID"]))
    & pl.col("ts").is_between(RTH_OPEN, RTH_CLOSE)
    & (pl.col("Price") > 0) & (pl.col("Quantity") > 0)
)

NBBO 重建

全國最佳買賣報價來自 QUOTE BID NBQUOTE ASK NB 這兩種列,各邊分別向前填補, 而且共用同一個時間戳的列要先塌縮成最後的狀態。這個塌縮很重要,因為一邊可能比另一邊先印出來,直接對原始 序列向前填補會製造出從未存在的鎖價或交叉報價,比例接近所有更新的 5%。

nb = (
    quotes.sort("ts")
    .group_by("ts", maintain_order=True).last()     # collapse before filling, not after
    .with_columns([pl.col("bid").forward_fill(), pl.col("ask").forward_fill()])
)

時間加權的統計量是精確計算的,做法是在每個分鐘邊界插入一列合成的狀態,使每個權重區間都完整落在單一 K 棒之內。

特徵集

分支 B 的 53 個特徵涵蓋 K 棒幾何(振幅、實體、上下影線、close location value 與跳空),Parkinson、 Garman-Klass 與 Rogers-Satchell 波動估計量,5、15、60 與 390 根 K 棒的均線偏離與交叉,最長到 1,950 根的 動能與變動率,移動波動與偏態,14 與 60 的 RSI,帶訊號線與柱狀圖的 MACD,ATR,布林 %B 與帶寬,KD 的 %K 與 %D,Williams %R,CCI,Donchian 位置,TRIX,Ulcer 指數,創新高低的頻率,短期對長期波動比,以及落後報酬。

成交量被刻意排除,因為它不是 OHLC 的量,這也就依定義把 OBV、資金流與以 VWAP 為基礎的指標排除在分支 B 之 外。

分支 A 加上 151 個逐筆特徵,分成三個家族。成交側欄位包含成交筆數、成交量、成交金額、平均、中位與最大單 筆規模,VWAP 相對收盤的偏離,以 Lee-Ready 對照當時 NBBO 分類出的買賣量,帶符號的成交量與金額流,筆數失 衡,在報價兩側成交或穿越、以及嚴格在報價之內成交的比例,簡單與成交量加權的有效價差,由成交價算出的已實現 變異與 bipower 變異,Amihud 非流動性,Roll 隱含價差,分鐘內估計的 Kyle lambda,成交間隔的平均與離散度, 零股與大單佔比,交易所數量、交易所 Herfindahl 指數、最大交易所佔比,以及一秒的 markout。

報價側欄位包含以基點表示的時間加權報價價差及其最小值、最大值與離散度,各邊的時間加權深度,掛單量失衡, microprice 及其相對中價的偏離,中價的開高低收,中價的已實現變異,鎖價或交叉的時間比例,各邊的 NBBO 更新 次數,報價對成交比,以及在連續 NBBO 狀態上累積的 Cont 訂單流失衡。其中十二個訊號還會以落後 1、2、3、5 期,以及 5、15、60 根 K 棒的移動平均進入模型,讓分支 A 擁有和分支 B 的移動指標一樣的歷史深度。

把一檔 11 美元的股票和一檔 520 美元、訊息速率差兩個數量級的 ETF 放進同一個面板,代表位準型欄位不能直接 進場。價格位準改成相對 K 棒收盤的基點偏離,筆數與規模欄位改成五日視窗的移動 z 分數,流量則以移動周轉率 標準化,而每個在 t 結束的移動視窗都只用 t 當下可得的資訊。

# levels -> bps from close; counts/sizes -> trailing z over 5 sessions (1,950 bars)
df = df.with_columns([
    ((pl.col("q_mid_close") / pl.col("close") - 1) * 1e4).alias("a_q_mid_close_bps"),
    ((pl.col("t_n") - pl.col("t_n").rolling_mean(1950)) /
      pl.col("t_n").rolling_std(1950)).alias("a_t_n_z"),
])

模型

三個 gradient boosting 實作,LightGBM、XGBoost 與 CatBoost,全部使用深度 4、15 個葉節點、每葉最少 500 個樣本、學習率 0.02、300 棵樹、欄位抽樣 0.5 與 L2 懲罰 10,並且到處都用 SEED = 20260731

一分鐘的股票報酬訊噪比在 1e-3 這個量級,所以一般的表格模型容量會把訓練視窗背下來:用 63 個葉節點的樹做 的快速測試,樣本外 R 平方是 -0.60,而正則化後的設定是 -0.046,且預測沒有退化。這些數值是在整批跑之前, 依照目標已知的性質固定下來的,從來不是看任何分支的表現決定的,之後對每個分支、預測期、區塊與 fold 都完 全相同地套用,因此沒有任何隱藏的搜尋需要折減。

Walk-forward 機制

每個標的區塊被切成五個連續片段,形成四個擴張式 fold,訓練在時間上一定早於測試。重疊的目標是預測期研究裡 最標準的洩漏方式,因為在 h = 390 時,靠近邊界的訓練列,其目標會深入測試視窗,所以每個 fold 都丟掉訓練區 間最後的 h 根 K 棒;而當 purge 之後訓練列少於 2,000 時,該 fold 直接跳過,這也讓一個月的多數 fold 消失。

分支 B 最長的指標需要 1,950 根 K 棒的歷史,分支 A 只需要 60 根,如果不設共同的暖身期,兩個面板在缺值列 數上就會不同,而那是資料上的差別,不是資訊上的差別。因此每個標的區塊的前 1,950 根 K 棒在所有分支上都被 丟掉。

for fold in expanding_folds(block, n_segments=5):
    train = block[: fold.train_end - HORIZON]        # purge h bars of overlap
    if len(train) < 2000:
        continue                                     # skipped, and logged as skipped
    test = block[fold.train_end : fold.test_end]
    model = fit(ARM_FEATURES[arm], train, params=FROZEN, seed=SEED)
    preds[fold] = model.predict(test[ARM_FEATURES[arm]])

這個迴圈總共產生 459 次擬合,橫跨 3 個區塊 x 5 個預測期 x 最多 4 個 fold x 3 個分支 x 3 個模型。

誠實的樣本數與推論

樣本外 R 平方是相對於零預測來衡量的,而不是相對於測試期的平均值,因為後者是一個帶前視的常數。與它並列的 有 Spearman rank IC、方向準確率與各標的的拆解,而核心指標是每個區塊、預測期、fold 與模型格子內部的配對 差 A 減 B,這樣可以去掉主導絕對水準的期間效應與 fold 效應。

重疊的 h 根 K 棒目標大約只提供 n/h 個獨立觀測,而五檔彼此相關的標的也不提供五條獨立序列。實測的一小時報 酬平均兩兩相關為 0.32,其中 AAPL 對 SPY 是 0.85,依標準的變異數論證得到 2.19 條有效序列。

rho = mean_pairwise_corr(returns_1h)                 # 0.32 measured, not assumed
n_series_eff = n_sym / (1 + (n_sym - 1) * rho)       # 5 -> 2.19
n_eff = (n_rows / horizon) * (n_series_eff / n_sym)

信賴區間來自一個對連續時間切片重抽的 bootstrap,每個切片同時帶著五檔標的。改成對列重抽所建出來的區間窄 了 1.7 倍,95% 寬度是 0.184,而正確的聯合時間切片 bootstrap 給出 0.310。

經驗虛無分布

固定門檻被完全換成經驗虛無分布,它不需要對有效樣本數做任何假設。同一個 fold 會對著在每個標的區塊內部做 環狀旋轉的目標重跑一次,破壞特徵與目標的對應關係,同時保留每個邊際分布、目標的自相關與跨標的相關,之後 真實結果就以它在那個分布中的百分位來判斷。

k = rng.integers(1, len(y))
y_null = np.roll(y, k)                               # circular: no rows are dropped

這總共跑了 480 次重新擬合,每個區塊、預測期與分支各 20 次旋轉。當觀測值不能自由互換時,旋轉是唯一保留結 構的重新標記方式,這也正是為什麼得到的分布才是這個設計該用的虛無分布。

經濟性檢查

統計上可偵測不等於可交易,所以在一分鐘處會交易預測的方向並先報毛額,再扣掉逐筆資料在那一分鐘實際印出來 的有效價差加上一筆費用估計,而成本是從資料量出來的,不是假設的。

pos = np.sign(pred)
turnover = np.abs(np.diff(pos, prepend=0)) / 2
gross_bps = pos * fwd_ret_bps
cost_bps = turnover * (eff_spread_bps + FEE_BPS)     # FEE_BPS = 0.5
net_bps = gross_bps - cost_bps
breakeven = gross_bps.mean() / turnover.mean()       # what the signal can afford to pay

沒有任何盤中掛牌成交的分鐘大約佔 0.01% 的 K 棒,它們沿用前一根的收盤價並標成成交筆數為零,因此有效價差 為空值,只在這個分析裡以樣本中位數填補。

洩漏檢查,以及它們抓到什麼

在任何比較被報告之前,有三項檢查必須通過,而 pipeline 在失敗時會以非零狀態碼結束。

檢查做法結果
因果性用截斷在 12:00 的逐筆流重建一個標的日,並把每一根更早的 K 棒和整日版本相比通過:149 根 K 棒、63 個數值欄位、0 處不一致
植入洩漏把目標本身當成特徵加進分支 B通過:R 平方由 -0.097 變成 +0.609,IC 變成 0.993
旋轉虛無分布在每個標的區塊內部把目標做環狀旋轉,共 480 次重新擬合通過:真實的 1 分鐘 IC 約在虛無分布均值之上 12 個標準差

在筆記中的任何比較被產出之前,三項檢查都在真實資料上通過。

這裡記下檢查本身的兩個臭蟲,因為一個通過的檢查和一個不可能失敗的檢查之間的差別,就是檢查的全部價值。第 一個原本寫成 if python3 leak_tests.py 2>&1 | tee -a "$LOG"; then,測到的是 tee 的離開狀態而不是腳本的,後來用 set -o pipefail ${PIPESTATUS[0]} 修好。

第二個是位移式虛無檢定,它用了 shift(-k),因此把每個群組最後 k 列變成空值;而由於這項檢查 評估的是最後一個 fold,它的測試集是空的,函式就靜靜地什麼都沒回傳。修法是改成環狀旋轉,再加上 fill_nan(None),因為 polars 把 NaN 與 null 視為不同的東西,drop_nulls 會直接 略過旋轉帶進來的 NaN。

修好之後的虛無檢定,接著暴露的是一個推論錯誤,而不是 pipeline 錯誤。它第一次跑在一個不可能含有訊號的目 標上拿到 R 平方 = +0.042,原因是有效樣本數當時被算成列數/h,把五檔標的當成五個獨立重複,使每個 n_eff 都被高估約 2.4 倍,並讓 bootstrap 區間窄了 1.7 倍,兩者都依前述方式修正。

環境

python 3.10.12
polars 1.40.1      numpy 2.2.6        scipy 1.15.3       pandas 2.3.3
lightgbm 4.6.0     xgboost 3.2.0      catboost 1.2.10    scikit-learn 1.7.2
shap 0.49.1        pyarrow 24.0.0     boto3 1.43.9       matplotlib 3.10.9

每個模型都設了種子,bootstrap 與旋轉的抽樣使用 numpy.random.default_rng(SEED),其中 SEED = 20260731,所以在同一份原始資料上重跑會完全重現這些表。整趟跑的成本是 0 美元,因為 S3 的讀取由 Algoseek 付費,總牆鐘時間約 90 分鐘。

筆記裡的圖是從 公開的結果表畫出來的,和內文引用的是同一批表。

索取完整的 pipeline

這一頁足以讓你手動核對筆記裡的每個數字,或用同一份授權資料重建一個等價的研究。生產用的腳本(估量、下 載、逐筆轉 K 棒的特徵建構、面板建構、walk-forward 執行器、虛無分布、分析與繪圖)並未附上,需要的話請寫 信到 daniel@daru.finance,我再寄給你。

← 回到筆記