第二階段到今天結束。手上的東西攤開來看:
| 來自 | 特徵 | 能得到什麼資訊 | 適合用在 | 需要的原料 | 形狀 |
|---|---|---|---|---|---|
| Day 04–05 | SMA、EMA | 價格相對於近期均值偏高還是偏低,趨勢往哪一邊 | 定方向:快慢線交叉當進場條件,或當「只做順勢」的過濾 | K 線 | 連續值 |
| Day 06 | RSI | 這一段走得多急,是不是漲太多或跌太深 | 抓過熱:超買超賣當出場或反向條件,也用來擋掉追高的進場 | K 線 | 連續值 |
| Day 10 | OBI | 掛單簿當下買賣兩側的壓力誰比較重 | 秒級的短線壓力判讀。已量過它比手續費小 54 倍,不足以單獨當進場依據 | K 線 + 掛單簿 | 連續值,中間有洞 |
| Day 11 | VWAP | 這段時間的資金平均成本落在哪個價位 | 當基準價:判斷現價在多數人的成本之上或之下 | K 線 | 連續值 |
| Day 11 | 偏離度 | 現價離那個成本多遠,以加權標準差為單位 | 均值回歸類的條件:偏離過大當反向進場或加碼的門檻 | K 線 | 連續值 |
| Day 12 | 活躍度 | 這一根的成交熱度相對於基準偏離幾個標準差 | 當情境開關:同一個訊號在冷清與活躍時段分開處理,或直接擋掉冷清時段 | K 線 | 連續值 |
| Day 12 | ATR | 這段時間典型的波動幅度有多大 | 定量級:停損距離、部位大小的分母,以及把不同特徵的幅度換成可比較的單位 | K 線 | 連續值 |
| Day 13 | 前高 | 上方最近一個曾經擋住價格的價位在哪裡 | 畫出關鍵價位:突破的判斷基準,也可當停損參考 | K 線 | 連續值 |
| Day 13 | 突破 | 這一根有沒有站上那個價位 | 事件型的進場觸發。在本文的定義下 90.78% 會被打回來,所以要搭配幅度或活躍度過濾 | K 線 | 事件(0 與 1) |
| Day 13 | 流動性擺盪 | 消失的掛量是被成交吃掉的,還是被撤走的 | 分辨真假突破:突破當下若以撤單為主就降低可信度(實測 75.83% 是撤單) | K 線 + 成交 + 掛單簿 | 連續值 |
| Day 14 | Volume Profile | 成交量在價格軸上的分布,籌碼集中在哪幾個價帶 | 事後分析與畫圖:找支撐壓力與價值區間。它進不了管線,不能直接當策略條件 | K 線或成交 | 不是時間序列 |
| Day 14 | 距離 POC | 現價離籌碼最密集的那個價位多遠 | 把上面那張分布收成一個能放進條件式的數字:離籌碼區太遠當回歸或風險訊號 | K 線 | 連續值 |
表上有 13 個名字,而後面會出現三個不同的數字,先在這裡對齊:
Feature 類別——SMA、EMA、RSI 共用同一個轉接器類別(今天才寫),而 Volume Profile 根本不是 Feature。13 − 3 + 1 − 1 = 10。kind: rsi,所以各自要有自己的字串。ema、兩個 breakout、兩種活躍度)。每一個都能用,但要一起用得自己動手串。今天要做的事就是把它們收成一條管線,讓一份 YAML 能算出所有東西。
而這一天有一個很硬的期限:明天 Day 16 的策略積木會用字串去這張註冊表取特徵,所以介面要在今天訂死。
今天沒有新的交易術語,但有一件關於工作分配的事值得先講。
一個策略的邏輯常常只有十行:「快線在慢線之上、RSI 沒有超買、活躍度夠高,就做多」。而支撐那十行的東西是這十四天做的所有事——把資料抓乾淨、把每個指標算對、驗證每個特徵值不值得用、把未來函數一個一個擋掉。
比例大概就是這樣:策略邏輯佔一兩成,其餘都在特徵與資料。 這件事對從軟體工程進來的人算是好消息,因為那八成正好是工程能力直接派上用場的部分;反過來說,一個「策略想法很多但資料處理很隨便」的組合,八成的力氣花在錯的地方。
還有一個更長期的理由。策略會過期——市場結構變了、有人做了一樣的事、參數不再適用,一個策略可能兩年就失效。但「BTC 的時段節奏差 2.71 倍」「深度變化有四分之三是撤單」「1 分鐘 K 線做 Volume Profile 夠準」這些東西不會,它們是對這個市場的認識,而且會被下一個策略、下下一個策略重複使用。
所以特徵庫是資產,策略是消耗品。一個能穩定產出、可驗證、可重用的特徵管線,價值比任何單一策略高——今天做的就是把那個資產從一堆散落的檔案變成一個能被反覆使用的東西。
前面那張表列的是「有哪些特徵」,但真正麻煩的不是數量,是它們彼此不一致的地方。這些不一致有四類,而管線的每一個設計決定都對應其中一類。
一、原料不一致。 有的只要 K 線,有的要逐筆成交,有的三種都要。這件事的代價很具體:一份只有 EMA 與 RSI 的設定不需要七十幾萬列的逐筆成交,但如果程式一律先把三種資料都撈出來,就得等那幾秒。所以「要讀什麼」必須是算得出來的,而不是寫死的。
二、暖機期不一致,而且有兩個算不出精確值。 Day 12 的鐘點基準與 Day 14 的距離 POC 都只答得出一個下限,因為真正的暖機期取決於「一天幾根」,而那是 timeframe 的事,特徵類別不知道。所以管線不能只信那個宣告的數字。
三、缺值的語意不一致。 暖機期是 NaN,掛單簿沒錄到的那幾段也是 NaN。前者可以整段切掉,後者不行——OBI 只有 45 分鐘有資料,照 NaN 切會把整年的資料切光。同一個符號承載兩種意思,而處理方式相反。
四、形狀不一致。 大部分是連續值,突破是 0 與 1 的事件,Volume Profile 根本不是時間序列。前兩者可以用同一個介面(Day 13 已經證明了:事件不是另一種型別,是另一種取值),第三個進不來。
把問題這樣拆開之後,接下來的每一段程式碼都有一個明確的對象:required_inputs 對第一類,trimmed() 對第二與第三類,Feature 協定那條「與 K 線等長、index 相同」的契約對第四類。
收尾這一天也適合把七天下來反覆用到的判斷收成幾句話。它們不是今天才發明的,是每一天各用了一次之後才看得出是同一條。
Day 10 的 require_depth() 缺料時丟例外而不是回空的,Day 11 的零成交量回 NaN 而不是 0,Day 13 的流動性擺盪不把比例 clip 到 1,今天的管線不把中間的洞當暖機期——四件事是同一條原則。
理由是可判別性:一旦「資料沒錄到」與「資料還不夠」都用 NaN 表達,之後任何人都分不出這一格是哪一種,而它們的正確處理方式完全相反。同樣地,「兩側一樣多」與「兩側都沒有掛單」都用 0 表達,下游就沒辦法只對後者做特殊處理。
實務上的判準很簡單:如果兩種情況之後要用不同的方式處理,它們就不能共用同一個值。 沒有多餘的值可用時(例如 NaN 只有一個),就要在型別上分開——用例外、用不同的欄位、或用一個明確的旗標。
Day 04 訂的 Indicator 契約很窄:吃 K 線的一個欄位、回一條序列。正因為窄,它的 compute() 才能統一處理欄位檢查、型別轉換、命名——那是它作為 ABC 存在的全部理由。
今天要把它接進 Feature 協定,最直覺的做法是把 Indicator 改成吃 MarketView,這樣三個指標就直接是特徵,不需要轉接器,程式碼還更少。但那樣做會讓那個窄契約消失:每個指標都得自己從 view 裡挖出 K 線再挖出欄位,共用實作就沒了,而且三個已經發佈的指標、它們的測試、Day 04–06 的每個範例都要一起改。
所以是轉接器。二十行,不動任何既有的東西,而且它把「指標是特徵的一個特例」這件事寫成了程式碼。
這是 Day 06 說「介面要在早期訂死」的兌現方式:早期訂的那個介面不必是萬能的,它只要在自己的範圍內正確,範圍之外用轉接器接上去。 反過來,一個為了容納未來需求而預先加寬的介面,通常兩邊都做不好。
一個錯誤可以在三個時間點被發現,而它們的代價差幾個數量級:
今天的三個設計——builder 取代反射式分派、ensure_used() 抓拼錯的參數、required_inputs 在讀資料前檢查原料——都是把錯誤從第一層或第二層搬到第三層。
值得注意的是這件事有時候要多寫程式碼才辦得到。反射式分派 cls(**parameters) 比一堆 builder 短得多,但它把「這個特徵有哪些合法參數」這個知識丟掉了,所以錯誤只能在執行期以 TypeError: unexpected keyword argument 的形式出現,而且說不出合法值是什麼。多出來的那幾十行,換的是錯誤訊息能講人話。
今天沒有新的特徵要算,要做的是一條把前面十個串起來的流程。從一份設定檔到一張可以直接拿去回測的表,中間有七件事。
第 3 步與第 4 步的順序是刻意的:先把管線建起來,才知道要讀什麼。反過來寫也跑得動,只是會讀進根本用不到的東西。
Day 06 已經有一張註冊表,三行就寫完了——一個 {"sma": SMA, "ema": EMA, "rsi": RSI} 的對映,取出來 cls(period) 就能用。那樣寫得通,因為三個指標的參數形狀完全一樣。
今天註冊表上有十二種 kind,參數從 period 一個,到 side + window + bucket_count 三個都有。同樣的做法會變成 cls(**parameters),也就是上一節說的那種反射式分派。
所以中間多一層 FeatureBuilder(一個 Protocol,只要求 kind 與 build)。有了它,「obi 這個字串需要 depth_level 與 aggregation 兩個參數、後者只能是 mean 或 last」這件事變成一段有型別的普通程式碼,mypy 檢查得到,而且參數不合法時的錯誤訊息可以說清楚合法值是什麼。
每個 builder 跟它的特徵住同一個檔案,因為它只為那個特徵存在——跟 Day 06 的 WilderSmoother 一開始跟 RSI 同住是同一個判斷。三個指標則共用一個 builder,因為它們的參數形狀真的一樣,寫三份一模一樣的沒有意義。
註冊表本身刻意不手寫字串,而是用每個 builder 自己宣告的 kind 當鍵。手寫的話會有兩份真相(builder 裡的 kind 與表的鍵),它們一旦不一致,錯誤訊息會指向一個註冊表裡不存在的名字。重複的 kind 也在建表時就擋掉。
設定檔的值是弱型別的。YAML 的 period: 14 是 int,period: "14" 是 str,而 perid: 14 是一個完全合法的 YAML。三種都不該讓程式跑到一半才炸。
前兩種靠取值時轉型(integer()/number()/text() 負責轉型與報錯);第三種需要另一個機制,而它是 FeatureParameters 這個型別存在最重要的理由——少了它,一個拼錯的參數會被安靜忽略,於是使用者以為自己在跑 period=50,實際上跑的是預設值 14。
做法很簡單:記下哪些鍵被取用過,收工時比對。
# quantbot/domain/values/feature_parameters.py
def ensure_used(self) -> None:
"""所有給進來的參數都被取用過了嗎。
沒被取用的參數幾乎一定是拼錯的欄名,所以這裡丟例外而不是警告。
Day 17 的設定檔載入器會在載入時呼叫它,讓錯誤在啟動時就出現,
NEVER 等到跑完一輪回測才發現參數沒生效。
"""
unused = set(self.values) - self._consumed
if unused:
raise ValueError(f"沒有被使用的參數:{sorted(unused)}")
這裡選「丟例外」而不是「印警告」,理由是警告在有十幾個特徵的輸出裡會被滑過去,而這個錯誤的後果是跑了一整輪回測、結果建立在錯誤的參數上。
型別轉換還有一個容易漏的地方:Python 的 bool 是 int 的子類別,所以 isinstance(True, int) 是真的,YAML 的 period: true 會安靜地變成 period=1。任何「檢查是不是整數」的程式碼都要額外擋 bool——Day 09 解析 WebSocket 的 JSON 時處理過同一件事。
這是今天最需要小心的一段,因為它同時碰到前面說的第二與第三個不一致。
直覺的做法是問所有特徵誰的暖機期最長,切掉那麼多根。這在 Day 04–06 的三個指標上完全正確,因為它們答得出精確的根數;但今天有兩個答不出來的,它們回的是下限,照那個數字切會留下 NaN。
那就改成「照 NaN 切」,把有 NaN 的列全部丟掉?也不對,因為 NaN 有兩種意思。OBI 只有錄製那 45 分鐘有資料,其餘全是 NaN;照 NaN 切會把整年的資料切光。
所以是第三種做法:
# quantbot/domain/features/feature_pipeline.py
def trimmed(self, view: MarketView) -> pd.DataFrame:
"""算完之後切掉暖機期。
切法是「丟掉第一個所有欄位都有值的位置之前的所有列」,而不是
`iloc[declared_warmup_bar_count:]`。兩個理由:
- 宣告值只是下限(見 declared_warmup_bar_count),照它切會留下 NaN。
- 有些特徵的 NaN 不在開頭。掛單簿只有錄製的那幾段有資料,所以 OBI 中間
就是 NaN,而那不是暖機期——照 NaN 切會把整段資料切光。
所以它用 first_valid_index 找那個位置,而中間的 NaN 保持原樣交給呼叫端。
「哪些列可以用」是策略的決定(有些條件容忍缺值),不是管線的。
"""
table = self.compute(view)
start = table.dropna().first_valid_index()
if start is None:
return table.iloc[0:0]
return table.loc[start:]
最後那句是這個設計的關鍵:中間的 NaN 不處理,交給呼叫端。 管線不知道策略容不容忍缺值——一個「OBI 有資料時才進場」的條件跟一個「OBI 缺值就當中性」的條件都合理,而那是 Day 16 的積木要決定的事。管線只負責把開頭那段確定不能用的切掉。
兩種情況各有一個測試:一個驗「照宣告值切會留下 NaN,照 first_valid_index 切不會」,另一個把掛單簿中間三小時挖掉,斷言那三根還是 NaN 而其餘十根沒有被切光。
順帶一個寫測試時撞到的事,因為它是老規矩的兌現:第一版的測試資料把深度的索引建成 tz-naive,而 K 線是 tz-aware 的。結果不是例外,是一整欄 NaN——reindex 對不上,pandas 安靜地填了 NaN。這正是 Day 02 訂「時間一律 tz-aware UTC」那條規矩要防的東西,而它連在測試資料裡都會咬人。
三件比較短的事收在一起講。
快取的鍵是「這份 view 的身分」加「特徵的名字」,用 id(view) 而不是雜湊整張 DataFrame——後者要走過每一個值,對幾十萬列的資料來說比重算特徵還慢。用 id 當鍵通常不是好做法(物件回收之後 id 會被重用),這裡可以接受,因為這個快取的生命週期跟 pipeline 物件一樣,而 pipeline 是每次分析建一個新的。
要注意它解決的是哪個問題:不是「同一個特徵被兩個地方用到」,是 trimmed() 內部會呼叫 compute(),而報告與圖表常常兩個都要。沒有快取的話 Day 14 那個逐根重算分布的特徵會被算兩次,而它是這批裡面最慢的。
同名的特徵在建構時就擋掉。 這是「特徵的名字帶參數」那個慣例(Day 10 開始)的直接後果:名字帶參數 → 同參數就同名 → concat 之後互相蓋掉。兩個 ema 都用 period=12 就會踩到,而使用者以為自己設了兩個不同的東西。與其讓人在一張少一欄的表上找原因,不如在建構時就說清楚。
設定檔刻意很淺——一個 features 清單,每項一個 kind 加上參數,參數跟 kind 平放在同一層,不另外包一層 parameters:
# quantbot/infrastructure/configuration/features.yaml
features:
- kind: sma
period: 20
- kind: ema
period: 12
- kind: ema
period: 26
- kind: rsi
period: 14
- kind: vwap
mode: session
- kind: vwap_deviation
mode: session
- kind: activity
measure: trade_count
baseline: rolling
window: 168
- kind: activity
measure: absolute_return
baseline: rolling
window: 168
- kind: atr
period: 14
- kind: prior_extreme
side: high
window: 20
- kind: breakout
side: high
window: 20
- kind: breakout
side: low
window: 20
- kind: distance_to_poc
window_days: 5
十三項,對應等一下輸出的十三欄;ema 出現兩次、breakout 兩次、activity 兩次,這是同一個 kind 帶不同參數的樣子,也是為什麼名字裡一定要帶參數。
理由是使用者要寫的東西越少越好,而「kind 是保留字」這件事只要講一次。代價是 kind 不能當參數名,而那不是任何特徵需要的參數名。
載入器只負責「YAML 長得對不對」,NEVER 驗證參數的值——那是 builder 的事。分開的好處是錯誤訊息不會混在一起:格式錯誤說「第 3 項少了 kind」,參數錯誤說「aggregation 只能是 mean 或 last」。而專案內附的那份設定檔本身有測試,因為它同時是文件,而一份跑不起來的範例比沒有範例更糟。
用例做的第一件事是先建管線、再讀資料。順序反過來也能跑,但那會讀進根本不會用到的東西——這就是前面第一個不一致的解法。
管線建好之後 required_inputs 就問得出來,所以要讀什麼是算得出來的。Day 10 訂介面時把它放進去,當時給的理由是「讓管線能在載入設定時就擋掉跑不起來的組合」;今天它多了第二個用途:決定要讀哪些資料。一個介面上的欄位有兩個獨立的用途,通常表示它訂對了。
uv run python -m quantbot.entrypoints.features_command \
--symbol BTC/USDT --market spot --timeframe 1h \
--start 2025-01-01 --end 2026-08-01
設定檔 quantbot/infrastructure/configuration/features.yaml
13 個特徵,需要原料:candles
宣告的暖機期 168 根
算完 13 欄 × 13,848 列
切掉暖機期之後剩 13,679 列(丟掉 169 根)
每一欄的可用比例與最後一個值
sma_20 可用 99.86% 最後 63,423.3875
ema_12 可用 99.92% 最後 63,132.5395
ema_26 可用 99.82% 最後 63,510.0870
rsi_14 可用 99.90% 最後 32.5531
vwap_session 可用 100.00% 最後 63,589.3512
vwap_session_deviation 可用 95.83% 最後 -0.9790
activity_trade_count_rolling_168 可用 98.79% 最後 -0.2376
activity_absolute_return_rolling_168 可用 98.78% 最後 -0.5006
atr_14 可用 99.90% 最後 293.0471
prior_high_20 可用 99.86% 最後 64,496.6400
breakout_high_20 可用 100.00% 最後 0.0000
breakout_low_20 可用 100.00% 最後 0.0000
distance_to_poc_5d 可用 99.99% 最後 -0.0177
落地:data/features/spot_BTCUSDT_1h_features.parquet
幾件事可以讀,而它們正好對上前面那四個不一致。
「需要原料:candles」是算出來的。 這份設定沒有 OBI 與流動性擺盪,所以逐筆成交與掛單簿完全沒被讀進來。加一行 kind: obi 進去,這一行就會變成 candles, depth,而讀取的行為跟著變。
丟掉 169 根,而宣告的暖機期是 168。 多出來的那一根來自 activity_absolute_return_rolling_168:絕對報酬本身要拿前一根的收盤價算對數報酬,所以它比 activity_trade_count_rolling_168 再晚一根才有值(兩者的可用比例 98.78% 對 98.79%,差的就是那一根)。管線取所有欄位裡最晚的那一個,於是切 169。這正是「宣告值只是下限」的實際樣子——照宣告的 168 切,剩下的表第一列就會留著一個 NaN。
vwap_session_deviation 只有 95.83% 可用。 缺的那 4.17% 不在開頭——是每天的第一根。日內 VWAP 在一天的第一根只累積了一根 K 線,加權標準差是 0,偏離度因此沒有定義(Day 11 那個「NEVER 回 inf」的處理)。這一段有 577 天,每天缺一根就是 577 根,正好是那 4.17%。這是第三種不一致的另一個實例:那些 NaN 不是暖機期,而它們散在整段資料裡。
兩個 breakout 的可用比例是 100%。 事件式特徵在暖機期是 0 而不是 NaN(Day 13 訂的),所以它們永遠「有值」。這也是為什麼暖機期不能只看有沒有 NaN——一個全 0 的欄位跟一個算好的欄位在 NaN 上看不出差別。
管線的價值不只在成功的時候。三種最常見的設定錯誤:
一、缺原料。 設定檔要 OBI,但那段時間沒有錄掛單簿:
ValueError: 缺少原料:depth。需要它們的特徵:{'obi_5_mean': ['depth']}
它指名了是哪個特徵在要那份資料。少了這個訊息,使用者看到的會是一整欄 NaN,而那跟「暖機期特別長」長得一樣。
二、參數拼錯。
features:
- kind: rsi
period: 14
windo: 5
ValueError: rsi(period=14, windo=5):沒有被使用的參數:['windo']
錯誤訊息前面那段 rsi(period=14, windo=5) 是 FeatureSpecification.describe() 產生的,它讓「哪一行有問題」不必去數行號。
三、參數值不合法。
features:
- kind: obi
depth_level: 7
ValueError: obi(depth_level=7):沒有錄前 7 檔。錄下來的深度是 (5, 10, 20),
而這是 schema 的一部分——換一個深度要重新錄,不是重算
這個訊息是 Day 09 那個「不可逆的決定」一路傳到設定層的樣子。使用者在 YAML 裡寫了一個看起來很合理的數字,而回答不只是「不合法」,還說明了為什麼——以及要付什麼代價才能改。
三種都在讀資料之前失敗,也就是前面說的第三層。這件事的價值在一年半的 1 小時線上還不明顯(讀資料一兩秒),到了 1 分鐘線與逐筆成交就是幾十秒對零秒的差別。
七天下來,這一階段做的事可以收成三句話。
第一,這一階段的特徵都不是看盤軟體上有的東西。 第一階段的 MA、EMA、RSI 任何平台都有;OBI 要自己錄掛單簿、流動性擺盪要把成交跟掛單簿對起來、Volume Profile 的精算版要處理七十幾萬列 tick。這是「自己寫」相對於「用現成工具」的實際差別,而它換到的不只是特徵本身——是知道那些特徵怎麼算出來的、以及它們什麼時候不可靠。
第二,這一階段量出來的東西,一半是限制而不是能力。 攤開來看:
| 發現 | 數字 |
|---|---|
| OBI 在秒級有資訊 | 秩相關 0.43(t = 23.8) |
| 它比手續費小 | 0.37 bp 對 20 bp,54 倍 |
| 深度變化主要是撤單不是成交 | 75.83% |
| 假突破的比例(在本文的定義下) | 90.78% |
| K 線近似 Volume Profile 在分鐘級夠用 | 價值區間重疊 97.79% |
| 同一個近似在小時級不夠用 | 重疊只剩 52.74% |
| 時段節奏的振幅 | 最熱與最冷差 2.71 倍 |
七項裡有四項是「這件事沒有想像中好用」。這不是壞消息——沒量過的特徵看起來永遠比實際上有用,而第三階段要用這些東西組策略,先知道每一個有多大,比多知道三個特徵有價值。
第三,這一階段擋掉了五個不會報錯的錯誤。 一個一個列:
| 錯誤 | 症狀 |
|---|---|
| 先算比例再平均,順序寫反(Day 10) | 數字照樣落在 −1 到 +1 |
| 加權變異數不平移(Day 11) | 精度少一半,值看起來正常 |
| 當根算進自己的基準(Day 12) | 暴量的 z-score 被系統性低估 |
| 鐘點基準用整段樣本(Day 12) | 100 倍暴量被壓成 5 以下 |
rolling().max() 含當根(Day 13) |
突破訊號完全消失 |
五個都不會噴例外。它們的共同點是只有並排對數字才看得出來,而那就是為什麼這一階段每一天都有一個對照組:迴圈版對向量化版、精算對近似、正確寫法對一行寫法。
這條習慣本身可能是這七天最值得帶走的東西。單元測試能守住「這個函式的行為有沒有變」,但守不住「這個寫法本來就是錯的」——後者只有拿另一個獨立算出來的答案並排才看得出來。
quantbot/
├── domain/
│ ├── values/
│ │ ├── feature_parameters.py 今天:型別檢查 + 抓拼錯
│ │ └── feature_specification.py 今天:設定檔的一行
│ ├── interfaces/feature_builder.py 今天:取代反射式分派
│ └── features/
│ ├── candle_indicator_feature.py 今天:Indicator 的轉接器
│ ├── feature_registry.py 今天:字串 → 特徵
│ ├── feature_pipeline.py 今天:原料檢查、暖機、快取
│ └── *.py 各日的特徵,今天各加一個 builder
├── application/compute_features_application.py 今天:先建管線再讀資料
├── infrastructure/configuration/
│ ├── features.yaml 今天:13 個特徵的設定範例
│ └── yaml_feature_specification_loader.py 今天
├── entrypoints/features_command.py 今天
└── tests/
├── domain/features/test_feature_pipeline.py 今天
├── domain/features/test_feature_registry.py 今天
└── infrastructure/configuration/test_yaml_feature_specification_loader.py 今天
八項全過才算完成:
uv run pytest 全綠。uv run pytest tests/domain/features/test_feature_registry.py 裡那個「走過整張註冊表把每個 kind 都建一次」的測試要過。它抓的是「新增 builder 但忘記給參數預設值」。period: true 會被擋下來而不是變成 1。features.yaml 必須載得起來並建得出所有特徵——它是文件,跑不起來的文件比沒有文件糟。uv run python -m quantbot.entrypoints.features_command --symbol BTC/USDT --market spot --timeframe 1h --start 2025-01-01 --end 2026-08-01 印出 13 欄、需要的原料只有 candles、並落地一份 parquet。kind: obi,同一段時間再跑一次:要在讀資料之後、算特徵之前失敗,訊息指名 obi_5_mean 需要 depth。trimmed() 的邏輯壞了。反過來,有掛單簿特徵時中間的 NaN 必須被保留。uv run mypy quantbot 與 uv run lint-imports 全過。特別確認 domain/features/ 沒有 import 到 yaml——設定檔的格式是 infrastructure 的事,domain 只認得 FeatureSpecification。第 3 項與第 6 項是今天的重點。它們守的是同一件事:設定錯誤要在啟動時失敗,而不是產生一張看起來合理的錯誤結果。
免責聲明:本文為程式與資料工程的技術分享,所有數字皆為教學範例,不構成投資建議。
第二階段結束,手上有一條「一份設定 → 一張特徵表」的管線。而那張表現在還沒有任何用途——它只是十三欄數字。
明天開始第三階段,而第一天就是整個 quantbot 最關鍵的一天。
問題是這樣:如果每個策略都寫成一個獨立的 class,三個策略就有三份重複的「算特徵、判斷條件、決定部位」邏輯,而且想試「這個策略的進場條件配那個策略的出場條件」時完全沒辦法。所以策略不會是 class,會是一組可以互相組合的積木:一個策略等於三組條件(進場、出場、過濾)加上組合運算。
那些條件會用字串去今天這張註冊表取特徵,所以使用者寫設定檔時不必碰 Python——這是今天要把介面訂死的理由。
Day 16 也會處理一件跟未來函數有關的事,而且處理方式跟前面幾天不同:把「用第 t 根的資訊、第 t+1 根成交」做成引擎的行為,而不是靠每個策略自己記得。積木化之後策略數量會暴增,靠人記得一定會漏。
Protocol 是結構型的,實作不需要繼承也不需要 import 它 — Python documentation, typing.Protocol
DataFrame.first_valid_index 與 dropna 的組合,用來找第一個完整的列 — pandas documentation, DataFrame.first_valid_index
reindex 在時區不一致時會產生 NaN 而不是報錯 — pandas documentation, Time zone handling
bool 是 int 的子類別,所以 isinstance(True, int) 為真 — Python documentation, Boolean Values