trading-monitor:替睡著的我盯住 420 個永續合約
幣市沒有收盤,420 個永續合約沒人盯得完。這個系統替我把每個幣分進生命週期階段,階段一變就推播到手機。
加密貨幣市場沒有收盤這回事。Binance 上光是 USDⓈ-M 永續合約(用 USDT 結算、沒有到期日的期貨)就有 420 多個交易對,多到任何人都不可能用眼睛盯完,而它們在我睡覺和上班的時候照樣在動。
錯過的訊號偏偏最常發生在凌晨。trading-monitor 是我為此寫的自用系統,常駐在一台機器上收 Binance 行情,替每個幣標上生命週期階段,階段一轉換就推播到 Telegram。
它回答的問題:誰正在改變行為
先講清楚它不做什麼。它不回答「該不該買」,也不預測價格,它回答的是「這個幣當下的行為屬於哪個階段」。像機場的航班狀態板,不告訴你飛機好不好搭,只告訴你哪些航班正從「登機中」變成「已起飛」。階段一共六個,盤整、突破初期、上升趨勢、過熱、出貨,加上什麼都不像時兜底的中性,彼此互斥,一個幣在一個時間窗裡只會拿到一個標籤。
推播真正放行的只有其中兩個,突破初期是進場機會,過熱是風險警告。其餘標籤不推播,但全部留在庫裡,供看板顯示和事後稽核「系統在那一刻是怎麼判斷的」。
資料流總覽:行情從 Binance 走到手機的六站
要產出這個標籤,一筆行情得走完六站。下面這張圖回答的是「資料在哪裡被誰接手」:
Binance USDⓈ-M(WebSocket 即時流 + REST 輪詢,共 16 個資料源)
│ 收集層:WS 分片訂閱、REST 分層輪詢,全部通過同一個速率守門員
▼
MarketEvent 佇列(所有事件統一成同一種形狀)
▼
批次寫入器 ──單表寫入失敗──▶ DLQ(Parquet 備援檔,留待事後處理)
▼
DuckDB(單機嵌入式資料庫)
▼ 每 5 分鐘
特徵計算(價格動能 / 資金流向 / 持倉變化 / 微結構)
▼
分類器(六階段規則引擎,照優先序先命中先得)
▼
StageDebouncer(連續確認 + 冷卻,濾掉抖動)
▼ 只有真正的階段轉換
Telegram 推播
並行側路:DuckDB 唯讀連線 ──▶ Streamlit 本地看板
16 個資料源的意思是,除了成交和 K 線,持倉量、多空比、資金費率這些盤面上不直接顯示的籌碼資訊也一起進庫。每個幣種在 5 個時間窗(5 分鐘到 7 天)各算一份特徵,短線爆發和長線趨勢用不同的尺度看。整條管線是單向的,下游不回頭改上游的資料,唯一的並行讀者是看板,拿的是唯讀連線,看多久都不影響主流程。
收集層:所有請求擠同一道閘門
第一站就撞上 Binance 的配額牆。420 個幣、每個要打 4 個籌碼面 endpoint、每 5 分鐘輪一輪,等於 1680 個請求,而 Binance 給單一 IP 的上限是 5 分鐘 1000 個。我的解法是分層,按 24 小時成交量排序,前 200 名列為熱門、維持 5 分鐘一輪,其餘降到 15 分鐘一輪,名單每小時重排一次。代價是冷門幣的籌碼訊號最多晚 15 分鐘到,換來的是整體流量穩穩壓在配額之下。
所有 REST 呼叫都經過同一個速率守門員,滑動視窗計數,用掉八成配額就開始讓路,真正觸頂才等待。WebSocket 那一側走另一條路,成交、K 線這類 Binance 有原生推送的資料一律走 WS,REST 只補 WS 拿不到的持倉量和多空比。這個「WS 優先」的取捨後來真的咬過我一口,留到收尾講。
儲存層:單機 DuckDB,寫壞的資料有地方去
收齊之後的問題是怎麼存。我沒有架資料庫伺服器,選了嵌入式的 DuckDB,理由是這個系統一輩子只會在一台機器上跑,看板和回測需要的是分析型查詢能力,不是多機容錯。單點故障的風險寫進了決策記錄,是刻意接受,不是沒想到。歷史資料每天輪轉成 Parquet 檔封存。
寫入端把所有事件統一成 MarketEvent 一種形狀,攢滿 100 筆或滿 1 秒就批次落庫,哪個先到算哪個。單一張表寫失敗時只回滾那張表,事件丟進 DLQ 備援檔,其餘的表照常提交。一張表的故障不該拖垮整批資料,這條界線劃在寫入器裡,而不是讓每個上游自己處理。
特徵層:每 5 分鐘掃一次全市場
資料躺在庫裡還不是判斷,判斷的原料是特徵。排程器每 5 分鐘對全部幣種、全部時間窗算一份特徵快照,32 個欄位涵蓋價格動能、資金流向、持倉變化和微結構。這一層有兩個容易踩空的地方。
一是各資料源的更新頻率天差地遠,資金費率一天才幾筆,成交一秒可以幾百筆,所以每個源有自己的最小回看窗(資金費率往回抓 24 小時,多空比抓 4 小時),不然更新慢的源在短時間窗裡永遠是空的。二是缺料時不硬算,快照帶著 degraded 旗標和失敗來源清單一起落庫,往下游誠實傳遞「這筆判斷的原料不完整」,讓分類器自己決定要不要信。
分類器:宣告式規則,而不是分群模型
32 個特徵要收斂成六選一的標籤,中間站著規則引擎。這裡有過一個岔路。420 個幣分六類,用 KMeans 這類分群模型可以省掉人工定義門檻,還可能發現我沒想到的模式。我沒有走那條路。推播到手機的每個訊號我都要能回答「為什麼」,規則引擎的每條條件都有名字,訊息裡可以直接列出命中了哪幾條;這個選擇的帳單是每個門檻都要人工回測校準,這筆債的規模下一節攤開。
規則不是 if/else 硬編碼,而是宣告式的條件物件(RuleClause),每條寫明看哪個特徵、朝哪個方向、對哪個具名門檻。突破初期的定義節錄如下:
StageRule(
stage="early_breakout",
clauses=(
RuleClause("ret_zscore", ">", "early_ret_z",
exit_threshold_name="early_ret_z_exit"),
RuleClause("vol_zscore", ">", "early_vol_z"),
RuleClause("oi_delta_pct", ">", "early_oi_delta"),
RuleClause("funding_rate", "<", "early_funding_cap"),
RuleClause("taker_ratio", ">", "early_taker"),
),
)
一個階段就是一組條件的 AND:報酬異常放大、成交量異常放大、持倉在增加、資金費率還沒過熱、主動買盤佔優,五個條件同時成立才算突破初期。條件指向的不是寫死的數字,而是門檻表裡的具名欄位,其中報酬那條還多帶一個較寬鬆的「離開門檻」,用途在防抖那節講。六個階段照固定優先序逐一評估,先命中先得。過熱排在最前面是刻意的,同一根 K 棒可能同時滿足過熱和上升趨勢的條件,風險警告必須比趨勢延續先攔截。中性沒有任何規則,它是所有規則都不命中時的兜底。
命中之後還會算一個 0 到 100 的排名分數,用來在同階段的幣之間排序。正規化的分母全部是寫死的常數,不是當下全市場的最大值,同一個分數在今天和三個月後代表同一件事,回測才有意義。
門檻治理:先全部 NaN,通過影子測試才轉正
不過這一切有個前提,門檻表裡得真的有數字,而這個前提到今天都不成立。z-score 要大於 2 還是 1.5?資金費率多高算過熱?SPEC 裡有一組看起來合理的建議值,但「看起來合理」和「回測驗證過」之間隔著一條溝,這條溝我直接寫進了型別:
_TBD_BACKTEST: Final[float] = math.nan # 未經回測驗證的佔位
@dataclass(frozen=True)
class StageThresholds:
overheat_funding: float = _TBD_BACKTEST # SPEC 建議 0.0008
early_ret_z: float = _TBD_BACKTEST # SPEC 建議 2.0
early_ret_z_exit: float = _TBD_BACKTEST # SPEC 建議 1.5
# ...共 18 個欄位,全部預設 NaN
STAGE_THRESHOLDS_OBJ = StageThresholds() # 生產環境讀的就是這個,全 NaN
生產環境的分類器讀的門檻物件,18 個欄位預設全是 NaN。任何一條規則解析到 NaN 門檻就 fail-closed,整筆判斷降級成中性並標明原因。SPEC 的建議值被隔離在另一個候選值倉庫,只有跑過影子測試(拿歷史資料重放整條分類管線,確認門檻不會亂發訊號)並且通過,才允許把那個值搬進正式物件。寧可一個訊號都不發,也不發沒驗證過的訊號。
防抖與推播:一個訊號要過四道門
就算哪天門檻全數轉正,規則在邊界抖動照樣能把推播變成噪音,所以從標籤到手機還有四道門。第一道在規則求值層,進場用嚴門檻、維持用寬門檻(進突破初期要 z > 2.0,待在裡面只要 z > 1.5),價格在門檻邊緣來回不會讓標籤閃爍。第二道是 StageDebouncer,新階段要連續 2 個週期(等於 10 分鐘)都成立才確認,確認後 15 分鐘冷卻期內同一個幣不再觸發。第三道在推播端,只放行突破初期和過熱。第四道是去重,同幣同階段 1 小時內只推一次,外加全域每分鐘 20 則的上限。
四道門守的是同一件事,推到手機的訊號必須稀少到每一則都值得停下來看。決策記錄裡甚至有一條守則,禁止任何程式碼繞過防抖層直接發推播。
現況:管線通了,閘門還沒開
守則講完,得攤開一個事實。分類器上線到今天,沒有在正式環境發出過任何一個非中性標籤。18 個門檻全是 NaN 佔位,影子測試至今沒有放行過任何一個值,所以每一筆判斷都是 fail-closed 的中性。
這不是 bug,是我自己設的閘門還沒跨過去。
卡住的地方在驗證流程本身。一次 30 天、前 150 大幣種的歷史回填是成功的,185 萬根 K 線零錯誤進庫,但接下來的全市場驗證跑了 3 個小時還沒跑完,被我手動中止,瓶頸在逐幣逐時間窗的 SQL 扇出,還沒回頭優化。前面埋的「WS 優先咬過我一口」也是在這裡爆的。回填模式下原始成交資料根本沒有收,靠它計算的主動買盤比例永遠是 NaN,突破初期的規則因此每一輪都 fail-closed,後來補了預聚合資料的 fallback 才接上。回測環境天生比實盤瘦一圈,這件事我現在會在設計初期就先問。
開發速度本身也是風險的一部分。主體功能是用 multi-agent 流程在一天之內完成的,93 個 commit,這也代表整個系統接受真實市場長時間檢驗的時間非常短。測試碼有 11,616 行,比主程式還多,可是測試證明的是「照設計運作」,不是「設計正確」。
回頭看,這個專案教我最多的是那個 NaN 佔位的寫法。把「還沒驗證」做成程式裡會痛的東西,系統就沒辦法假裝自己已經完成。文件裡的待辦事項會被遺忘,型別裡的 NaN 不會。

