Day 09 最後看到的深度摘要透露了兩件事:買賣價差是固定的 0.01 USDT,以及兩側的掛量變化很劇烈。第二件事值得拉出來看清楚,但要看清楚得先有夠長的一段資料,所以今天從錄開始。
錄製程式就是 Day 09 那支,一行都不必改,只要換一個長度。這裡跑兩次:
# 先錄 2 分鐘,確認錄製端還活著
uv run python -m quantbot.entrypoints.record_microstructure_command \
--symbol BTC/USDT --market spot --seconds 120
# 沒問題之後,錄 45 分鐘當今天的樣本
uv run python -m quantbot.entrypoints.record_microstructure_command \
--symbol BTC/USDT --market spot --seconds 2700
兩次的回報:
2026-08-04 16:52:00 起 120 秒 聚合成交 858 筆 深度摘要 111 筆 重取快照 0 次
2026-08-04 17:20:00 起 2700 秒 聚合成交 19,295 筆 深度摘要 2,508 筆 重取快照 0 次
深度摘要的設定是每秒一筆,所以 45 分鐘理論上該有 2,700 筆,實際只有 2,508 筆。每一筆都是一次 REST 快照請求,請求本身要花時間,所以平均間隔是 45 × 60 / 2,508 = 1.077 秒。這個 1.077 後面會一直用到:報表上的「往後 1 筆」是「往後大約一秒」,不是剛好一秒。
兩段錄製之間空著的那 26 分鐘(16:54 到 17:20)等一下也會咬人一次,到時候再說。
今天的所有數字都出自第二段那 45 分鐘。先把它的頭四筆撈出來看,只看前五檔的兩側掛量:
SELECT captured_at,
round(bid_quantity_5::numeric, 5) AS bid_quantity_5,
round(ask_quantity_5::numeric, 5) AS ask_quantity_5
FROM order_book_depth
WHERE symbol = 'BTC/USDT' AND market = 'spot'
AND captured_at >= '2026-08-04 17:20:00+00'
ORDER BY captured_at
LIMIT 4;
captured_at | bid_quantity_5 | ask_quantity_5
-------------------------------+----------------+----------------
2026-08-04 17:20:00.247815+00 | 3.01816 | 2.10481
2026-08-04 17:20:01.247985+00 | 5.62843 | 0.04242
2026-08-04 17:20:02.254427+00 | 8.28721 | 0.10843
2026-08-04 17:20:03.349543+00 | 3.46778 | 2.43468
那個 captured_at >= 的條件不能省。少了它,ORDER BY captured_at LIMIT 4 撈回來的是 16:52 那段兩分鐘測試錄製的頭四筆,不是今天要用的樣本。
中間那層 round(...::numeric, 5) 是為了讓輸出讀得下去。掛量欄位存的是 double precision,不轉成 numeric 直接印,第三列的賣方會是 0.10843000000000001,第四列的買方會是 3.4677800000000003,那是 float 的表示誤差,不是錄到的值真的有十七位有效數字。
第二筆的賣方前五檔只剩 0.042 BTC,買方是 5.63 BTC,相差一百三十倍。下一筆賣方回到 0.108,再下一筆回到 2.43。三秒之間,這個市場的樣子換了三次。
直覺的解讀是:買單掛得比賣單多很多,所以短時間內價格傾向往上。這個直覺有名字——Order Book Imbalance(OBI,掛單不對稱)——而且它是這一階段第一個「只有自己錄資料才算得出來」的特徵。
但直覺就只是直覺。一個每秒換一次臉的數字,到底有沒有預測力,是要量的。所以今天做兩件事:把 OBI 寫成一個特徵,以及做這個系列到目前為止沒做過的事——驗證一個特徵值不值得用。
先說結論,因為它同時是好消息與壞消息:在秒的尺度上,這個特徵的資訊量相當明確(秩相關 0.43、t = 23.8、分組報酬單調);而它能抓到的價差是 0.37 個基點,來回手續費是 20 個基點。
流動性(liquidity)講的是「現在想馬上買到或賣掉,代價有多大」。它有兩個面向,Day 09 都已經看過了:
這兩個面向常常被混在一起講,但它們回答的是不同的問題。價差回答「成交一手要付多少」,深度回答「成交一百手要付多少」。一個價差只有一個 tick、但兩側各只掛 0.05 BTC 的市場,對散戶來說很便宜,對想進場十顆 BTC 的人來說貴得嚇人。
BTC/USDT 現貨的價差在剛剛錄下的那 2,508 筆樣本裡每一筆都是 0.01 USDT,也就是最小報價單位。這件事對今天有一個直接的後果:在這個交易對上,價差沒有資訊量,因為它已經窄到不能再窄,沒有變動的空間。會動的只有深度,所以今天的特徵是從深度算出來的。
換到冷門交易對就不是這樣,那裡的價差會忽大忽小,本身就是一個特徵。同一個直覺在不同流動性的市場上,要用不同的欄位去量。
掛單簿是一張兩邊的排隊表。買方那一側,每個價位排著願意用那個價格買的量;賣方那一側相反。中間隔著買賣價差。
價格不會自己動。它動是因為有人不想排隊:主動吃掉賣一的全部掛量,賣一就消失,下一個可成交的報價變成賣二,中間價往上跳一個 tick。反過來,賣壓吃穿買一,價格就往下跳。所以「價格上漲一個 tick」這件事,換成簿子的語言就是「賣方最好的那一檔被清空了」。
這樣一想,OBI 的直覺就有了機制上的說法:清空一個只掛 0.04 BTC 的賣一,跟清空一個掛 5 BTC 的賣一,需要的主動買量差一百多倍。當賣方薄、買方厚,把價格往上推所需的力氣比往下推小很多。(開頭那張表印的是前五檔的加總,不是賣一,但同一個道理逐檔都成立。)
這裡有一個區別要先立起來,因為後面所有解讀都靠它:OBI 量的是阻力的不對稱,不是動能的方向。它沒有說「等一下會有人來買」,它說的是「如果來了一樣大的單,往哪一邊走比較省力」。真正推動價格的是主動成交,那是 Day 11 的主場;OBI 只告訴我們地面哪一邊比較滑。
這個區別決定了這個特徵能宣稱的事情有多大。把它當成「預測價格的訊號」會高估它;把它當成「短時間內的路徑阻力」,它的每一個數字都對得上機制。
OBI 的定義只有一行:
OBI = (買方掛量 − 賣方掛量) / (買方掛量 + 賣方掛量)
分母是兩側的總量,所以它是比例而不是差額。這一步不能省:同樣「買方多掛 10 BTC」,在總掛量 20 BTC 的市場與 2,000 BTC 的市場是完全不同的事件,而差額看不出差別。除以總量之後值域固定在 −1 到 +1,不同交易對、不同時段之間才可比。
把開頭那四列代進去,一列一列算一次:
| captured_at | 買方掛量 | 賣方掛量 | 分子(買 − 賣) | 分母(買 + 賣) | OBI |
|---|---|---|---|---|---|
| 17:20:00 | 3.01816 | 2.10481 | 0.91335 | 5.12297 | +0.178 |
| 17:20:01 | 5.62843 | 0.04242 | 5.58601 | 5.67085 | +0.985 |
| 17:20:02 | 8.28721 | 0.10843 | 8.17878 | 8.39564 | +0.974 |
| 17:20:03 | 3.46778 | 2.43468 | 1.03310 | 5.90246 | +0.175 |
第二列最能說明這個公式在做什麼:賣方只剩 0.04242 BTC,分子(5.58601)幾乎等於分母(5.67085),相除之後貼到 +0.985,離上界只差 0.015。第一列與第四列的買方也都比賣方多,但只多四成左右(3.018 / 2.105 = 1.43、3.468 / 2.435 = 1.42),所以算出來只有 +0.18。
三秒之間 OBI 從 +0.178 跳到 +0.985、再回到 +0.175,走完了大半個值域。這是後面「它是一個秒級的東西」那個結論的第一個徵兆。
兩個極端有明確的意義:+1 是賣方一張單都沒有,−1 是買方一張單都沒有。0 是兩側等量,而不是「沒有資訊」。
還有一個設計選擇值得說明:分子分母用的是掛量(多少 BTC),不是檔位數(有幾個價位有單)。理由回到上一節的機制——決定價格會不會跳一格的是那一檔要吃掉多少量,不是有幾檔。同理,有一種常見的變體是距離加權:越靠近最佳報價的掛量越可能在短時間內被吃到,所以給它更高的權重,越遠的越輕。這個系列先用最簡單的等權加總,理由是 BTC/USDT 的價差只有一個 tick,兩側最好的那幾檔本來就集中了主要的量,加權與不加權的差別有限。等一下實測「深度取 5 檔、10 檔、20 檔幾乎不影響結論」會替這個判斷背書。
從深度摘要算 OBI,有兩個地方要決定:取前幾檔,以及一分鐘內那五十幾筆取樣要怎麼壓成一個值(1.077 秒一筆,一分鐘大約 56 筆)。
這兩個決定看起來像參數,但它們量的是不同的東西。前 5 檔測的是最貼近成交的那一層壓力,前 20 檔測的是更深一層的意願;平均測的是「這一分鐘整體偏哪一邊」,收盤取樣測的是「這一分鐘結束時偏哪一邊」。一分鐘裡壓力翻面的話,兩者會給出方向相反的答案,而兩個答案都不算錯——它們回答的問題本來就不同。
所以在這個專案裡它們進到特徵的名字(obi_5_mean 與 obi_5_last),而不是躲在設定檔裡。這不只是命名潔癖:等到 Day 19 之後要標「試了幾個組合才得到這個結果」,一個藏在設定裡的選項會被漏掉,一個出現在名字裡的選項不會。
聚合這一步還藏著一個容易寫反、而且不會報錯的地方:要先算比例再平均,還是先把兩側掛量平均起來再算比例。 這兩件事算出來的是不同的量。用一根 1 分鐘 K 線來看,為了好算假設這一分鐘剛好取到 60 筆、前 30 筆買 1 賣 9、後 30 筆買 90 賣 10,兩條路徑各走一次。
先算比例再平均(正確):前 30 筆每一筆都是 (1 − 9) / (1 + 9) = −0.8,後 30 筆每一筆都是 (90 − 10) / (90 + 10) = +0.8。60 筆取平均,得到 (30 × (−0.8) + 30 × (+0.8)) / 60 = 0.000。
先平均掛量再算比例:買方平均是 (30 × 1 + 30 × 90) / 60 = 45.5,賣方平均是 (30 × 9 + 30 × 10) / 60 = 9.5,再相除得到 (45.5 − 9.5) / (45.5 + 9.5) = 36 / 55 = +0.654。
| 寫法 | 結果 |
|---|---|
| 比例的平均(正確) | 0.000 |
| 先平均掛量再算比例 | +0.654 |
一個說「這一分鐘兩邊力道相當」,一個說「這一分鐘買方明顯佔優」。兩個都落在 −1 到 +1 之間,畫出來的圖也都像 OBI,跑起來也不會有任何錯誤訊息。
為什麼會差這麼多,從中間那個 45.5 就看得出來:它幾乎全部來自後 30 筆的 90(貢獻 45),前 30 筆的 1 只貢獻 0.5。也就是說「先平均掛量」那條路徑把後半段的掛量絕對量級當成了權重,前半段那三十筆等於被稀釋掉了;而「先算比例」那條路徑上,兩段各自先被壓成 ±0.8,再以三十比三十的相同權重相加。
根本原因是比例是一個非線性的轉換,而非線性轉換的平均不等於平均的非線性轉換。寫反的那一條實際上變成了一個以掛量大小加權的指標,那是另一個特徵,只是它剛好也長得像 OBI。
這種只有並排對數字才抓得到的錯誤,值得一個測試釘死,不能靠複審時多看一眼。
前面那個直覺——買單多所以會漲——有一個很大的漏洞:掛單不是承諾。掛出來的單隨時可以撤,而且撤單是免費的。這個漏洞展開來有三層。
一、意圖可以是假的。 有一種操作就是刻意掛出大量單子製造某一側很厚的假象,等別人跟進之後撤掉,這叫 spoofing。它在多數受監管的市場是違法的,但加密貨幣市場的監管程度差異很大,而且要證明「那是意圖誤導」很難。
二、看得到的量不是全部。 掛單可以設成冰山單,只露出一小部分、成交多少補多少。所以簿子上顯示 0.5 BTC 的那一檔,實際上可能撐得住 5 BTC。這個方向的誤差跟 spoofing 相反:spoofing 讓深度看起來比實際厚,冰山單讓深度看起來比實際薄。兩種都存在,而 L2 摘要分不出來。
三、一大張單與五十張小單長得一模一樣。 L2 資料是按價位聚合的,同一檔的 5 BTC 可能來自一個人,也可能來自五十個人。這兩種情況的穩定度完全不同——一個人撤單只要一個動作,五十個人不會同時反悔——但在資料上無法區分。要分得出來得有 L3(逐單)資料,那不是免費資料源給得起的東西。
這三件事合起來的意思是:OBI 量到的是「現在簿子上長什麼樣」,不是「有多少人真的想成交」。這兩件事在多數時候接近,在某些時候差很多,而後者剛好是最容易虧錢的時候。這個限制沒有辦法靠算法解決,它是這個資料源的性質。真正抵得住這件事的是成交資料(成交是既成事實,撤不掉),那是 Day 11 到 Day 14 的主場。
Day 09 已經用到這一組詞,這裡補完整。掛單在簿子上等成交的是 maker,主動吃掉掛單的是 taker。價格會動是因為 taker 吃穿了某一側,所以 taker 的方向才是「資金流」的方向。
OBI 看的是 maker 那一側(還沒成交的意願),Day 11 的 VWAP 與資金流看的是 taker 那一側(已經成交的事實)。兩邊都有用,但它們的可信程度不同,理由就是上一節那三條。
還有一件事要在看數字之前先講:這類特徵的資訊衰減得很快,而且它應該衰減得很快。
理由不複雜。掛單簿是公開的,全世界看到的是同一份,延遲差在毫秒。一個定義只有一行、任何人都算得出來的量,如果能穩定預測未來五分鐘的價格,它早就被套走了——做市商會據此調整報價,套利者會據此下單,而這兩件事本身就會把那個不對稱抹平。
所以能留在資料裡的那部分,多半不是「別人不知道的資訊」,而是機制上的必然:賣方薄的時候價格本來就比較容易往上跳一格,這件事跟有沒有人發現無關。機制性的東西不會被套利掉,但它的時間尺度就是機制本身的時間尺度——那一檔被吃掉需要幾秒,這個訊號的壽命就是幾秒。
這給了一個在看報表之前就能寫下的預期:OBI 對「未來一秒」應該有明顯的相關,對「未來三十秒」應該衰減,聚合到分鐘級應該幾乎不剩。等一下的數字會用來檢驗這個預期,而不是反過來讓數字替我們決定該相信什麼。
Day 09 查證過一件事,這裡要再強調一次,因為它決定了今天能做到什麼程度:data.binance.vision 的現貨批次資料裡沒有任何掛單簿資料(只有 klines、aggTrades、trades)。掛單簿的批次檔只有永續合約那邊有,而現貨與永續 NEVER 混用。
所以今天算 OBI 用的就是開頭錄的那 45 分鐘、2,508 筆取樣,而且它得自己錄才有。這個樣本量對「量一個秒級特徵」是夠的(2,507 個配對),對「量一個分鐘級特徵」完全不夠(45 根 K 線、配對後 44 根)。這個落差本身就是今天的一個結論,等一下會用數字說明。
這也是為什麼 Day 11 到 Day 14 的特徵全部建在成交資料上:那一層有完整歷史,回測時間夠長。OBI 是這一階段唯一受限於「從今天開始累積」的特徵。
到今天為止,這個系列驗證的都是「算得對不對」——跟對照組對數字、跟官方 K 線對帳。那些是正確性問題,有明確的對錯,錯了就是有一個 bug。
「這個特徵有沒有用」不是那種問題。它沒有標準答案,只有證據,而且證據會隨著樣本、期間、市場狀態變。所以需要一組工具,而這組工具本身要先站得住。
判斷一個特徵有沒有資訊,最少要三種東西一起看。
第一是秩相關係數,在這一行常被叫做 IC(information coefficient)。 它問的是:把所有時點按特徵值排序,再按未來報酬排序,這兩個排序有多一致。IC 是一個 −1 到 +1 的數,0 代表兩個排序無關。
要注意 IC 的量級直覺跟一般的相關係數不一樣。在報酬預測上,IC 0.05 就算是有東西了,0.1 以上在日頻策略裡是很強的訊號。今天會看到的 0.43 之所以那麼大,是因為它量的是秒級、而且如同前面說的,其中有一部分是機制上的必然而非預測。數字大不代表值錢,這件事最後算帳那節會兌現。
第二是 t 值與樣本數。 相關係數在小樣本上天生就會偏離 0,四十幾個樣本要湊出 0.2 的相關係數,純靠運氣就辦得到。t 值把樣本數一起納進來,回答「這個相關係數跟 0 分不分得出來」——同一個相關係數,樣本愈多,t 值愈大。
用它只要記一件事:|t| 小於 2 就當成跟 0 分不出來。這是沿用了幾十年的慣例門檻,不是定理,但它足以擋掉「樣本太少所以看起來有東西」這一整類誤判。所以報告裡永遠不能只印一個相關係數,樣本數與 t 值要跟著出來。
這裡要誠實補一句:t 值的標準算法假設樣本彼此獨立,而每秒取樣一次的掛單簿顯然不獨立——這一秒的 OBI 跟下一秒的 OBI 高度相關。樣本自相關會讓 t 值偏大,所以 23.78 這個數字要打折看。打多少折沒有簡單的答案(要做 Newey-West 之類的調整),但這裡的餘裕很大:它離門檻 2 有十倍以上的距離,要讓結論翻過來,得假設有效樣本只剩實際的百分之一。反過來說,如果某天量到一個 t = 2.5 的特徵,在這種取樣結構下就該當成「還不知道」而不是「有」。
第三是分組報酬。 把特徵值由低到高分成等量的幾組,看各組的平均未來報酬。這一項是前兩項補不了的:相關係數是一個線性摘要,它看不出「只有最極端的那一組有效、中間完全沒差」這種形狀,而那種形狀在交易上的意義完全不同。
它的算法是三步:按特徵值排序、切成筆數相同的五組(pd.qcut,等量而不是等寬)、每組各自把未來報酬取平均。報表上的「頭尾差」就是最高組的平均減最低組的平均,「單調」標記的意思是五組的平均報酬由低到高嚴格遞增。
分組報酬如果單調遞增,這個特徵比較可能是真的有資訊——它在整個值域上都說得出話。如果頭尾差很大但中間亂跳,通常表示那個差距來自少數幾個極端值,換一段資料就不見了。
分組報酬還有一個附帶好處:它是唯一一個帶單位的數字。IC 與 t 值都是無單位的統計量,沒辦法拿去跟手續費比較;最高組與最低組的報酬差是實實在在的基點,那是後面算帳唯一用得上的量。
三個收在一起:
| 數字 | 它回答什麼 | 怎麼判讀 | 單獨看會漏掉什麼 | 今天的值 |
|---|---|---|---|---|
| IC(秩相關) | 特徵排序與未來報酬排序有多一致 | −1 到 +1,0 是無關。報酬預測上 0.05 就算有東西,量級直覺跟一般相關係數不同 | 看不出這個關係是不是小樣本的巧合,也看不出它值多少錢 | 0.4291 |
| t 值與樣本數 | 這個 IC 跟 0 分不分得出來 | |t| < 2 當成分不出來。樣本數要跟著印,不然分母不知道從哪來 | 它只說「不是雜訊」,沒說關係有多強、能賺多少 | 23.78(n = 2,507) |
| 分組報酬 | 從最低到最高這五組,未來報酬各是多少 | 看形狀:單調遞增代表整個值域都說得出話;頭尾差大但中間亂跳,多半是幾個極端值撐出來的 | 它不告訴我們這個形狀穩不穩,那要靠 t 值與樣本數 | −0.21 → 0.16 bp |
三個要一起看的理由,就是最右邊那兩欄互相補的地方。 IC 大但 t 小,是樣本太少;IC 大、t 也大、分組卻亂跳,代表那個相關來自少數極端值;三個都好看,但頭尾差只有 0.37 個基點——那就是今天最後會遇到的情況,統計上成立、經濟上不成立。
另外只有分組報酬那一欄帶單位(基點),所以它是唯一能跟手續費放在同一張表上比的數字。IC 與 t 值再漂亮,都沒辦法回答「這樣做會不會賺錢」。
先把這兩個名字講清楚,因為後面整份報告都靠它們。
相關係數量的是「兩條數列是不是一起動」,值域 −1 到 +1:+1 完全同向、−1 完全反向、0 沒關係。它有好幾種,今天只用到兩種。
Pearson(皮爾森)相關係數是預設的那一個——平常講「相關係數」沒特別說明時,指的就是它。它吃的是實際數值,而且量的是直線關係:兩條數列要沿著一條直線一起動,它才給高分。
Spearman(斯皮爾曼)相關係數又叫秩相關,「秩」就是名次。它先把兩條數列各自換成名次(最小的第 1 名、次小的第 2 名,依此類推),再看這兩組名次有多一致。所以它量的不是直線關係,是單調關係:只要「這個大、那個也傾向大」,中間的曲線是什麼形狀都無所謂。
兩者的差別:假設某一秒的報酬是其他時點的五十倍。在 Pearson 眼裡,那一筆的數值本身就帶著五十倍的份量,整個係數幾乎由它一個人決定;在 Spearman 眼裡,它只是「排第一名的那一個」,跟第二名差五十倍還是差一點點,算出來都一樣。
| Pearson | Spearman(秩相關) | |
|---|---|---|
| 吃什麼 | 實際數值 | 名次 |
| 量的是 | 直線關係 | 單調關係,大小順序一致就好 |
| 遇到極端值 | 被少數幾筆主導 | 幾乎不受影響 |
| 適合回答 | 「X 大一單位,Y 會大多少」 | 「X 大的時候,Y 是否傾向比較大」 |
回到今天為什麼選 Spearman,有兩個理由。
一是報酬的分布有厚尾——少數幾個時點的報酬會比典型值大上幾十倍,這正是上面那個「五十倍」的情境。Pearson 在這種分布下會被那幾個極端值主導,於是整個結論其實建立在幾筆資料上,換一段資料就變了。
二是我們要問的問題本來就是排序問題。 進場與否是「挑最有利的那些時點」,不是「預測報酬的絕對值」,所以表格最後一列右邊那個問法才是我們要的。
實作上,Spearman 就是「排名上的 Pearson」——先把兩條序列各自轉成名次,再對那兩欄名次算一次 Pearson。同分的處理方式要留意:慣例是給平均名次(並列第二的兩筆都算 2.5 名),而這件事對 OBI 特別重要,因為它有大量的值落在 ±1(某一側被清空),同分處理方式不同會改變結果。
驗證一個特徵,一定要拿「當下的特徵」對「之後的報酬」。這件事在程式裡就是一次 shift(-horizon),而它是整套驗證裡唯一可以往未來看的地方,且必須出現在被解釋的那一側。
這一步錯一格的後果不成比例:如果特徵那一側偷看了未來,相關係數會漂亮得不像話,而且不會有任何錯誤訊息。這是回測領域最典型的一種缺陷(look-ahead bias),它的特徵就是「結果好到不合理」。因此對齊的邏輯只寫在一個地方、只寫一次,其他人一律呼叫它——不是為了少打幾行字,是為了讓「往未來看」這件事只有一個入口可以審。
shift() 還有第二個坑,而這一個是不規則取樣獨有的:它只認得「第幾格」,不認得時間。
開頭錄的是兩段:16:52 起的兩分鐘測試,跟 17:20 起的四十五分鐘。第一次跑分析時圖方便,--start 直接給了 16:50,於是區間橫跨兩段,中間空著 26 分鐘。shift(-1) 不知道那件事,於是空白前的最後一筆(16:54 左右)跟空白後的第一筆(17:20:00)被配成一對,那個「往後一筆」的報酬實際上是 26 分鐘的報酬,跟一秒的報酬根本不在同一個量級上。這種列只有一筆,但一筆就足以把相關係數帶偏。
K 線資料不會遇到這個問題:K 線每根等距,缺漏在 Day 08 就被偵測並補掉了。不規則取樣沒有這種保障,所以驗證工具需要一個時間上限,把間隔過大的配對直接作廢。
工具的三個輸出,等一下會在同一張表上並排出現。先把每一欄的意思與用法講一遍,看到數字才不必回頭猜。
| 欄位 | 它在說什麼 | 怎麼用 |
|---|---|---|
| 特徵 | 哪一個特徵,名字裡帶著參數(obi_5 是前 5 檔算的) |
同名就是同一組設定,不必去翻設定檔 |
| 往後 | 拿多久之後的報酬來對。原始取樣是「往後幾筆」(一筆約一秒),聚合之後是「往後幾根」 | 同一個特徵換幾個跨度跑,看它衰減得多快 |
| 樣本 | 實際配得出對的筆數 | 先看這一欄。它應該等於取樣筆數減掉「往後幾筆」,對不上就是對齊出了事 |
| IC | 秩相關。0 是沒關係,愈接近 ±1 關係愈強 | 報酬預測上 0.05 就算有東西,量級直覺跟一般相關係數不一樣 |
| t | 這個 IC 跟 0 分不分得出來 | |t| < 2 當成分不出來,不必再看後面幾欄 |
| 頭尾差(bp) | 最高組與最低組的平均未來報酬差多少,單位是基點 | 唯一帶單位、能拿去跟手續費比的欄位 |
| 分組報酬(bp) | 五組各自的平均未來報酬,由低到高排 | 看形狀,不只看頭尾。後面標「單調」表示這五個數字是遞增的 |
還有兩個約定要先講,因為它們決定了表上的數字長什麼樣。
價格用中間價,也就是買一與賣一的平均。用它而不是用最新成交價,是因為中間價每一筆快照都有,而且不會因為「最後一筆剛好是主動買」而系統性偏高。
報酬一律換成基點(bp),一個基點是萬分之一。不換的話表上會是一整排 0.0000——秒級的報酬本來就很小。順帶把這個尺度定下來:BTC/USDT 現貨的價差是 0.01 USDT,在 63,900 這個價位上,價格跳一個 tick 大約是 0.0016 個基點。等一下算帳會用到這個換算。
在拿工具去判斷特徵之前,工具自己要先過兩個測試,因為之後所有「這個特徵沒用」的結論都建在它們上面:
一個作弊的特徵要拿到滿分,一個純雜訊要拿到接近 0。兩端都對,中間的數字才有意義。這兩個測試比任何一個特徵的結果都重要。
前面幾天的報告(資料完整性、逐欄對帳)都有一個布林的 passed,因為那些是對錯問題。這一份沒有。
「IC 0.03 算不算有用」取決於交易成本、訊號頻率、部位大小,而那三件事到 Day 20 與 Day 24 才會定下來。現在給一個門檻,只會變成一個被拿去當結論的魔術數字——而且是一個沒人記得它從哪來的魔術數字。報告的責任是把證據攤開,判斷留給有足夠上下文的那一天。
動手之前先把工作攤開來。今天只有兩條線:一條把 OBI 算出來,一條判斷它值不值得用。
第一條線,把掛單簿變成一個特徵:
這兩步要能分開呼叫,因為第二條線只需要第 1 步的結果:驗證要在原始的秒級粒度上做,先聚合再驗,驗到的是「聚合之後還剩多少」,不是這個特徵本身有多少。
第二條線,判斷它值不值得用:
順序上只有一個硬性依賴:第 3 步必須在第 4、5 步之前,而且它是整條流程裡唯一往未來看的一步。把它收在一個地方,之後要審「有沒有偷看未來」就只有一個地方要看。
上面那七步要放進什麼形狀裡,是今天第二個要決定的事。今天寫的是第一個「特徵」,而不是第四個「指標」——這兩個詞在這個專案裡是兩個型別,差別要講清楚。
Day 04 訂的 Indicator 是一個 ABC,契約是「吃 K 線的一個欄位、回一條等長的序列」,compute() 會先取出欄位、轉成 float64、算完之後把名字貼上去,子類別只實作 name 與 _compute。那個共用實作就是 ABC 存在的理由。
OBI 裝不進那個契約。它的輸入不是 K 線的某一欄,是掛單簿;而掛單簿的索引是不規則的,跟 K 線對不上。硬要塞進去只有兩條路:破壞 Indicator 的契約,或在 OBI 裡繞過基底類別。兩種都比「它是另一種東西」糟。
所以有第二個介面 Feature,而它是 Protocol 而不是 ABC——理由是它的實作之間沒有共用骨架可分,從掛單簿算的、從逐筆成交算的、從 K 線算的,中間沒有一行程式碼是一樣的。它要求四件事:name(輸出序列的名字)、warmup_bar_count(前幾根不能用)、required_inputs(需要哪些原料)、compute(view)。
required_inputs 是這四個裡最重要的一個。它讓「這個特徵需要什麼資料」變成算之前就問得出來的事,Day 15 的管線因此可以在載入設定時就擋掉跑不起來的組合,而不是跑到一半丟例外。
搭配它的是 MarketView,一個把所有原料裝起來的值(K 線必要,逐筆成交與掛單簿可選)。特徵的簽章因此固定下來,加一種資料不必改所有特徵。K 線之所以是必要的,是因為它定義了輸出的索引:掛單簿與逐筆成交的索引不規則,要跟策略對得起來就必須對齊到一條共同的時間軸,而那條時間軸只能是 K 線。缺料的時候 MarketView 丟例外而不是回一份空的,因為回空的會算出一整欄 NaN,而 NaN 在這個系列裡的意思是「暖機期還沒到」——兩件事都用 NaN 表達,就再也分不出「資料沒錄到」與「資料還不夠」。
最後交代一個時機上的選擇:介面在今天訂,不留到 Day 15。 Day 15 的主題是把散落的東西收斂成一條管線,聽起來是訂介面的好時機。但 Day 04 已經走過這條路,當時三個指標的基底類別提前到第一個指標那天訂,理由是「中途改介面的成本是所有實作、所有測試、所有呼叫端一起改」。今天要寫五個特徵裡的第一個,同樣的判斷成立。Day 15 要做的事因此更具體:註冊表、參數驗證、暖機期與快取、以及把 Indicator 接進來的轉接器。那些是管線的事,不是介面的事。
計算只有一行:
# quantbot/domain/features/order_book_imbalance.py
def ratio(self, depth: pd.DataFrame) -> pd.Series:
"""原始取樣頻率下的 OBI,不做任何聚合。
驗證這個特徵有沒有預測力時要用這一個:它衰減得很快,先聚合到 K 線再驗,
驗到的是「聚合之後還剩多少」,而不是這個特徵本身有多少。
名字裡沒有 aggregation,因為這一層還沒有聚合。名字要說實話——
叫它 obi_5_mean 會讓報告上兩個不同粒度的結果看起來像同一個東西。
"""
bid = depth[DepthColumns.bid_quantity(self.depth_level)]
ask = depth[DepthColumns.ask_quantity(self.depth_level)]
total = bid + ask
# 兩側都空的時候補 NaN 而不是 0:0 的意思是「兩側一樣多」,
# 而「兩側都沒有掛單」是另一回事,那時候這個特徵沒有定義。
return ((bid - ask) / total).where(total > 0).rename(f"obi_{self.depth_level}")
建構子還有一個檢查值得一提:要求的深度不在錄下來的清單裡(5/10/20)就直接拒絕,錯誤訊息寫明代價是重錄不是重算。這是 Day 09 那個「不可逆的決定」在程式碼裡的樣子——深度清單一旦錄成前 5/10/20 檔的加總,事後就再也算不出前 7 檔,原始的逐檔資料沒有被留下來。這種錯誤不該在算到一半才被發現。
掛單簿每秒一筆、K 線每分鐘一根,所以要壓。順序就是前面那張表講的那件事,寫在程式裡是這樣:
# quantbot/domain/features/order_book_imbalance.py
def aligned(self, view: MarketView) -> pd.Series:
"""先算比例、再按 K 線聚合。
順序不能顛倒。先把兩側掛量平均起來、再算比例,算的是「這一分鐘的平均買方
掛量對平均賣方掛量」,那是另一個量——比例的平均不等於平均的比例。兩種寫法
都跑得出 −1 到 +1 的數字,也都畫得出圖,只有並排對數字才看得出差別。
"""
depth = view.require_depth()
timeframe = view.candles.instrument.timeframe
resampled = self.ratio(depth.frame).resample(
timeframe.pandas_frequency, label="left", closed="left"
)
return (
resampled.mean()
if self.aggregation is DepthAggregation.MEAN
else resampled.last()
)
ratio() 與 aligned() 分成兩個方法,是因為驗證預測力時要用前者。先聚合到 K 線再驗,驗到的是「聚合之後還剩多少」,而不是這個特徵本身有多少——這兩個問題今天都會問,但要分開問。
# quantbot/domain/services/predictive_power_service.py
@staticmethod
def forward_returns(
prices: pd.Series, *, horizon: int, maximum_step: pd.Timedelta | None = None
) -> pd.Series:
"""往後 horizon 格的報酬率,對齊到「現在」這一格。
shift(-horizon) 是這整套驗證唯一允許往未來看的地方,而它必須出現在
被解釋的那一側(報酬),NEVER 出現在特徵那一側。特徵一旦偷看未來,
後面所有數字都是假的,而且看起來會特別好。
maximum_step 處理的是另一個問題:**shift() 只認得「第幾格」,不認得時間。**
掛單簿的取樣是不規則的,而且錄製中斷過的話中間會有一段空白。那時候
shift(-1) 會把空白前後的兩筆配成一對,於是一個「往後一秒」的報酬實際上
跨了二十幾分鐘。這種列不多,但它們的報酬大得離譜,足以主導相關係數。
給了 maximum_step 就把間隔過大的配對變成 NaN。這是 K 線不會遇到、
而不規則取樣一定會遇到的事,所以它是參數而不是預設行為。
"""
if horizon < 1:
raise ValueError(f"horizon 必須 >= 1,收到 {horizon}")
returns = prices.shift(-horizon) / prices - 1.0
if maximum_step is not None:
times = pd.Series(pd.DatetimeIndex(prices.index), index=prices.index)
elapsed = times.shift(-horizon) - times
returns = returns.where(elapsed <= maximum_step)
return returns.rename(f"forward_{horizon}")
秩相關那一段照定義寫成「先取排名,再算 Pearson」,沒有引入 scipy。pandas 的 corr(method="spearman") 會轉去呼叫 scipy,而這個專案沒有那個依賴;與其為了一行相關係數多裝一整包,不如照定義寫,而且這樣寫出來的程式碼還比呼叫別人的更說明它在做什麼。
開頭那 45 分鐘已經在庫裡,直接跑分析。區間就給那一段的頭尾,刻意不含 16:52 那兩分鐘測試錄製——上一節那個 26 分鐘的空白就是這樣避開的:
uv run python -m quantbot.entrypoints.imbalance_power_command \
--symbol BTC/USDT --market spot --timeframe 1m \
--start 2026-08-04T17:20 --end 2026-08-04T18:05
掛單簿取樣 2,508 筆,K 線 45 根
原始取樣(每秒一筆,未來報酬用中間價)
特徵 往後 樣本 IC t 頭尾差(bp) 分組報酬(bp)
obi_5 1筆 2,507 0.4291 23.78 0.37 -0.21 -0.04 0.01 0.10 0.16 單調
obi_5 5筆 2,503 0.3911 21.25 0.89 -0.49 -0.21 0.10 0.27 0.39 單調
obi_5 30筆 2,478 0.1379 6.93 1.25 -0.83 -0.23 0.24 0.59 0.41
obi_10 1筆 2,507 0.4258 23.55 0.37 -0.22 -0.04 0.01 0.10 0.16 單調
obi_10 5筆 2,503 0.3884 21.08 0.89 -0.51 -0.19 0.07 0.31 0.38 單調
obi_10 30筆 2,478 0.1352 6.79 1.28 -0.81 -0.18 0.09 0.60 0.48
obi_20 1筆 2,507 0.4199 23.16 0.37 -0.21 -0.03 0.00 0.09 0.16 單調
obi_20 5筆 2,503 0.3951 21.51 0.95 -0.53 -0.19 0.10 0.25 0.42 單調
obi_20 30筆 2,478 0.1421 7.14 1.41 -0.75 -0.27 0.13 0.40 0.67 單調
聚合到 K 線(未來報酬用收盤價)
特徵 往後 樣本 IC t 頭尾差(bp) 分組報酬(bp)
obi_5_mean 1根 44 -0.0932 -0.61 -2.04 1.19 0.21 -0.26 -0.69 -0.86
obi_5_mean 3根 42 -0.0493 -0.31 -3.25 1.54 -2.98 -0.16 1.81 -1.71
obi_10_mean 1根 44 -0.1063 -0.69 -2.12 1.27 0.13 -0.26 -0.69 -0.86
obi_10_mean 3根 42 -0.0497 -0.31 -3.25 1.54 -2.98 -0.16 1.81 -1.71
obi_20_mean 1根 44 -0.1170 -0.76 -2.25 1.50 -0.23 0.63 -1.46 -0.75
obi_20_mean 3根 42 -0.0560 -0.35 -3.82 1.54 -3.01 0.12 2.20 -2.28
看數字之前先做一件事:確認表頭跟開頭的錄製對得起來。2,508 筆與 45 根一模一樣,中間沒有掉東西。樣本那一欄則是取樣筆數扣掉「往後幾筆」——往後 1 筆剩 2,507、5 筆剩 2,503、30 筆剩 2,478,因為最後那幾筆沒有未來可以配對。這一欄要是對不上,其他數字都不必看了。
(順帶一句:頭尾差是用未四捨五入的值算的,拿表上印出來的兩位小數自己相減偶爾會差 0.01,那不是筆誤。)
這張表有四件事可以讀出來,一件一件講。
obi_5 對未來一筆取樣(約一秒)的秩相關是 0.4291,t 值 23.78,2,507 個配對。五組分位數的平均未來報酬是 −0.21、−0.04、0.01、0.10、0.16 個基點,單調遞增。
三個證據方向一致:相關係數不小、t 值即使按前面說的打折仍遠離 2、分組報酬沒有中間亂跳。這不是一個模稜兩可的結果。
要對這個 0.43 保持警覺的地方,正是前面「阻力不是動能」那一節:OBI 與中間價是從同一張快照算出來的。賣方前五檔只剩 0.04 BTC 的時候,那幾檔很快會被吃掉,而下一秒的中間價因此往上——這個關聯有一部分是機械性的,不是「預測」了什麼新資訊。話說回來,這正是 OBI 這個特徵的定義,它量的就是「哪一側比較容易被吃穿」。所以數字是真的,只是它的意義比「能預測價格」窄得多。
同一個 obi_5,換三個時間跨度:
| 往後 | IC | t | 頭尾差(bp) |
|---|---|---|---|
| 1 筆(約 1 秒) | 0.4291 | 23.78 | 0.37 |
| 5 筆(約 5 秒) | 0.3911 | 21.25 | 0.89 |
| 30 筆(約 30 秒) | 0.1379 | 6.93 | 1.25 |
相關係數從 0.43 掉到 0.14,30 秒之後只剩三分之一。t 值還在 6.93,所以它沒有歸零,但它是一個秒級的東西。這條曲線跟「一個微觀結構特徵能活多久」那節事先寫下的預期一致,而事先寫下預期再看數字,跟看到數字再回頭解釋,是兩種很不一樣的做法。
有意思的是頭尾差往反方向走:0.37 → 0.89 → 1.25 個基點。時間拉長,能抓到的價差變大(價格有更多時間移動),但預測的準確度下降。這兩件事的取捨,到 Day 20 加上成本之後才有辦法算出哪邊划算。
這是一個負面結果,但它省下很多時間:
| 深度 | 往後 1 筆的 IC |
|---|---|
| 前 5 檔 | 0.4291 |
| 前 10 檔 | 0.4258 |
| 前 20 檔 | 0.4199 |
三個差在小數第二位。原因回到前面談距離加權時說的:BTC/USDT 的價差是一個 tick,兩側最好的那幾檔本來就是主要的量,再往深處加十幾檔不會改變「哪一側比較薄」這個判斷。同一個理由也預告了距離加權在這個交易對上不會有太大差別——真正需要它的是深度分散、價差寬的市場。
這件事的實務意義是:不要花時間調這個參數。 Day 09 把深度清單定成 5/10/20 三個、開頭那 45 分鐘三個一起錄,就是為了回答這個問題,而答案是「不重要」。知道一個參數不重要,跟知道一個參數的最佳值一樣有價值,而且前者更穩定。
下半張表的相關係數是 −0.05 到 −0.12(往後 1 根那三列是 −0.09 到 −0.12,往後 3 根那三列只有 −0.05 上下),t 值全部在 ±1 以內。
這裡要非常小心不要過度解讀。 負號看起來像是「聚合之後反向了」,但 t 值 −0.61 的意思是:這個數字跟 0 分不出來。它不是「反向」的證據,它是「沒有證據」。
而且樣本只有 44 根 K 線。45 分鐘的錄製只能產生 45 根 1 分鐘 K 線,分成五組之後每組不到 9 個。這種樣本量上的任何結論都不能信——包含「它沒用」這個結論在內。
正確的說法是兩句話:
這也是 Day 09 那個「先想清楚要算什麼特徵,再決定存什麼」的另一半:不只是存什麼,還有用什麼粒度用它。
前面都在講統計。現在講一件更實際的事:那 0.37 個基點是多少錢。
基點是無單位的,所以要跟成本比就得先換回錢。在 63,900 這個價位上,收益與成本兩側各換一次:
| 項目 | 大小(bp) | 在 63,900 上(USDT) |
|---|---|---|
| OBI 前後五分之一組的報酬差(1 秒) | 0.37 | 2.36 |
| 同上(30 秒) | 1.25 | 7.99 |
| 買賣價差來回(0.01 USDT) | 0.0016 | 0.01 |
| 手續費來回(一般費率,每邊 0.1%) | 20 | 127.80 |
第一列的 2.36 USDT 是最高組與最低組的平均未來報酬之差,也就是「完美地在 OBI 最高的時候做多、最低的時候做空」能抓到的每次價差。注意它已經是最樂觀的版本:它假設每次都準確地站在分佈的兩端,實際的訊號不會這麼聽話。
價差那一列幾乎可以忽略——BTC/USDT 現貨窄到只有一個 tick,也就是前面那個 0.0016 個基點。真正的牆是手續費:它是那 0.37 個基點的 54 倍,就算用 30 秒那個較大的 1.25 個基點,也還差 16 倍。
這張表也回頭解釋了前面那句「IC 大不代表值錢」。IC 0.43 是一個非常高的統計相關,而它對應的可交易幅度是 0.37 bp。統計上的強度與經濟上的價值之間沒有固定的換算,中間隔著訊號的幅度、成交的頻率與成本結構,而這三件事只有把數字攤在同一張表上才看得出來。
所以今天的完整結論是:這個特徵在統計上有資訊,而那個資訊在一般費率下換不到錢。
這不是「白做工」。它有三個實際用途:
Day 19 之後每一個回測結果都要標「試了幾個組合才得到這個」,而今天這張表是那條規矩的前身:先量清楚一個特徵的大小,再決定要不要把它放進策略。
輸出是上下兩格:上格把 OBI 與中間價疊在一起(一個在 ±1 之間、一個在 63,900 附近,差了將近五個數量級,所以用左右雙軸),下格是分組報酬長條圖,y 軸用基點而不是原始比例值,不然刻度上會全是 0.0000。
兩張圖並排在同一份輸出裡,因為它們回答兩個不同的問題:上格回答「這個特徵長什麼樣子」,下格回答「它有沒有用」。只看上格會誤以為一個劇烈擺動的序列一定有資訊。
打開產出的 html,把上格的 OBI 曲線拉近看,會看到它幾乎沒有一段是平的——每一秒都在動,而且經常直接貼到 ±1。這正是「一個特徵劇烈擺動」與「一個特徵有預測力」是兩件事的畫面版本:那條線看起來充滿資訊,實際上能換到 0.37 個基點。
quantbot/
├── domain/
│ ├── values/
│ │ ├── market_input.py 今天:三種原料
│ │ ├── market_view.py 今天:特徵的輸入
│ │ └── depth_aggregation.py 今天:MEAN / LAST
│ ├── interfaces/feature.py 今天:Feature Protocol
│ ├── features/order_book_imbalance.py 今天:OBI
│ ├── services/predictive_power_service.py 今天:預測力檢查
│ └── dto/
│ ├── predictive_power_report.py 今天(刻意沒有 passed)
│ └── imbalance_power_report.py 今天
├── application/
│ └── evaluate_imbalance_power_application.py 今天:兩種粒度各跑一次
├── infrastructure/
│ ├── charting/plotly_imbalance_power_chart_renderer.py 今天
│ └── reporting/text_imbalance_power_report_renderer.py 今天
├── entrypoints/imbalance_power_command.py 今天
└── tests/
├── domain/features/test_order_book_imbalance.py 今天
└── domain/services/test_predictive_power_service.py 今天
正文只貼了其中三段程式碼,其餘的形狀在前面各節都描述過了,在此我建議讀者直接 clone GitHub 專案就好了。
六項全過才算完成:
uv run pytest tests/domain/features/test_order_book_imbalance.py 全綠。這一支要涵蓋值域邊界(±1)、兩側都空回 NaN、尺度不變(掛量同時放大十倍結果不變)、沒錄的深度在建構時就被拒絕,以及「比例的平均 ≠ 平均的比例」那一個——用前 30 筆買 1 賣 9、後 30 筆買 90 賣 10 的那根 K 線,斷言正確寫法得到 0、寫反得到 0.654。uv run pytest tests/domain/services/test_predictive_power_service.py 全綠。最重要的是那兩個驗工具本身的測試:作弊特徵的 IC 要接近 1、純雜訊的 |t| 要小於 2。另外要有一個橫跨錄製空白的案例,斷言沒給 maximum_step 時那一對會產生離譜的報酬、給了之後變成 NaN 而其他列不受影響。evaluate() 要丟 ValueError 而不是回一個數字。實測拿一個只涵蓋 9 分鐘錄製的窗去跑,9 根 K 線配對後剩 8 筆,少於分 5 組所需的 10 筆,它會直接拒絕——這是預期行為。uv run python -m quantbot.entrypoints.imbalance_power_command --symbol BTC/USDT --market spot --timeframe 1m --start <錄製起點> --end <錄製終點> 印出兩張表,原始取樣那一半的樣本數要接近取樣筆數,而區間 NEVER 跨到另一段錄製上——跨過去的話,接縫那一筆的「往後一筆」會變成幾十分鐘的報酬。uv run mypy quantbot 與 uv run lint-imports 全過。特別確認 domain/features/ 底下沒有 import 到 asyncpg 或 plotly——特徵是純計算,它不知道資料從哪來。第 2 項與第 5 項是今天的重點。第 2 項守的是工具,第 5 項守的是「結論要有形狀,不只有一個數字」。
免責聲明:本文為程式與資料工程的技術分享,所有數字皆為教學範例,不構成投資建議;文中的相關係數與分組報酬取自 45 分鐘的單一樣本,NEVER 可推論為未來的表現。
今天的特徵有一個很硬的限制:它只有我們自己錄下來的那 45 分鐘。 現貨的掛單簿歷史在免費資料源裡不存在,所以 OBI 沒辦法拿去驗證一年的資料。
明天換一邊。掛單可以撤,成交不能撤——成交是既成事實,而且它有完整的歷史(Day 09 那條 aggTrades 路徑一天七十幾萬列,要幾天有幾天)。
Day 11 從一個看起來很無聊的問題開始:均價要用哪個。 算術平均把成交 1 顆 BTC 跟成交 100 顆當成一樣重要,這顯然不對;用成交量加權之後得到的 VWAP 代表「市場的平均成本」,而「現在的價格對已經進場的人是賺還是賠」是一個跟均線完全不同的問題。
也會處理一個看起來只是實作細節、實際上會吃掉數值精度的地方:加權標準差如果照課本那條恆等式直接寫,在 BTC 的價位上會把 float64 的有效位數吃掉大半。
/api/v3/depth 回傳的 lastUpdateId 與 Diff. Depth Stream 的 U/u 序號語意 — Binance Spot API Documentation, WebSocket Streams
icebergQty(冰山單),也就是簿子上看到的量不一定是全部 — Binance Spot API Documentation, REST API Trading Endpoints
Series.rank
qcut 做等量分組、duplicates="drop" 處理分位點重複 — pandas documentation, qcut