筆記裡的每個數字,以及它從哪裡來
足夠的方法細節,讓你可以手動核對算術,或用同一份授權資料重建一個等價的研究。
資料與來源
資料來源是 Algoseek US Equity Trades and Quotes 的奈秒級資料,取自在 Equinix NY2 與 NY4 收取的合併 SIP 資料流(CTS、CQS、UTDF、UQDF)。每個標的日是一份壓縮 CSV,schema 為 Date, Timestamp, EventType, Ticker, Price, Quantity, Exchange, Conditions,其中 Timestamp 是 HH:MM:SS.nnnnnnnnn,Conditions 則是十六進位表示的 32 位元成交條件位元遮罩。
交易日是從每日清單 .index/YYYYMMDD.csv.gz 列舉出來的,而不是列出 bucket,因為這個產品不允許 列出。一次很小的 GET 就會回傳整天的 bucket, prefix, file, compressed_bytes, uncompressed_bytes,其中第 3 欄是壓縮後大小,第 4 欄 是未壓縮大小。把這個順序讀反,正是當初對一個每天 27 GB 的產品做出每天 300 GB 估計的原因。
| 區塊 | 交易日數 | 期間 | 壓縮後大小 |
|---|---|---|---|
| 2019 | 60 | 20190201 至 20190429 | 12.4 GB |
| 2022 | 60 | 20220201 至 20220427 | 44.9 GB |
| 2024 | 60 | 20240201 至 20240426 | 29.3 GB |
實際下載的範圍:AAPL、SPY、JPM、XOM 與 F 共 900 個標的日,壓縮後 86.6 GB。檔案是以單純的 gzip GET 取得,而不是走 S3 Select,後者會回傳未壓縮的 CSV,在測試檔上大約讓傳輸量翻倍。
資料源裡的兩個陷阱,都以實測確認
這兩件事只要弄反,就會悄悄毀掉每一個成交量與訂單流特徵,而且從資料商的規格書上都看不出來。
逐筆流是 TRADE 與 TRADE 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 NB 與 QUOTE 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,我再寄給你。

