iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Software Development

量化交易入門:從 K 線到可組合的交易策略引擎系列 第 14

Day 13:突破了卻馬上被打回來?從流動性擺盪找出假突破的資料特徵

  • 分享至 

  • xImage
  •  

一個會讓訊號完全消失的寫法

前面四天的特徵都是連續的:每一根 K 線都有一個值,RSI 是 43.2、OBI 是 −0.17、偏離度是 1.4。今天的東西不一樣——它絕大多數時候是 0,偶爾是 1。

先看要做什麼。「突破前高」聽起來是最好寫的條件之一:

prior_high = candles["high"].rolling(20).max()
breakout = candles["high"] > prior_high

兩行,看起來沒有任何問題。跑下去,breakout.sum()0

原因是 rolling(20).max() 包含當根。當根的高點本身就是那 20 個候選值之一,所以它最多只能等於那個最大值,不可能大於。這個條件永遠不成立。

這種錯誤的形狀很值得注意:它不是「數字算錯了」,是事件完全消失。一個回傳空訊號的策略在回測上看起來只是「這段行情沒有機會」,而那句話聽起來完全合理。Day 04 講未來函數的時候處理的是「訊號變得太好」;這裡是反過來,訊號直接不存在。

它跟 Day 12 那個「當根被算進自己的基準」是同一種洩漏的兩種症狀——同樣是把當下混進歷史,在 z-score 那裡讓極端值低估自己,在這裡讓事件整個消失。

所以今天有一個類別的存在理由就是那個 shift(1),其他什麼都不做。

之後是今天真正要回答的問題:假突破有多常見,而它跟真突破在突破當下有什麼看得出來的差別。

交易概念補課

突破:一個價位為什麼會「擋住」價格

「突破」指的是價格越過某個之前守住的價位,最常見的定義就是前 N 根的最高價。它背後的想法是:那個價位之前擋住了價格,現在擋不住了,所以擋它的力量消失了。

值得追問的是:一個價位憑什麼擋得住價格? 圖上的一條水平線本身沒有任何力量。

答案要回到 Day 09 與 Day 10 的掛單簿。價格上不去,唯一可能的機制是那個價位附近堆著賣單——實際掛在簿子上的,或是條件單觸發後會變成賣單的。價格每次漲到那裡就被那些量吸收掉,於是形成一條看起來像天花板的線。

這樣理解之後,「突破」這件事的意義就具體了:不是圖形學上的某個形狀完成了,而是那一堆量被消化掉或撤走了。這句話裡的「或撤走了」是今天後半段的主題,也是真假突破的分水嶺——被消化掉代表有人真的付了錢,被撤走代表沒有。

停損獵殺:為什麼前高前低特別容易出事

假突破特別集中在前高前低附近,有一個結構性的原因。

先把兩條線講明白。前高是上一節那個價位——過去 N 根 K 線的最高價,不含當根;前低是同一個算法翻過來,過去 N 根的最低價。把它們畫出來就是價格走勢圖上的兩條水平線:

前高前低

橫軸一格是一根 K 線,為了看清楚結構,這裡把每根簡化成一個價位、用折線串起來(實際的 K 線是四個數字,而前高取的是含上影線的最高價,這個差別在後面貼標籤那節會變得很重要)。兩條水平線是前高(110)與前低(101)。

第 1 到第 10 根都關在這兩條線之間,而且第 3 根與第 8 根都漲到 110 就轉頭——那正是前高之所以是前高的原因:那個價位上有量把它擋下來。

這兩條線不只是圖上的線,它們是掛單集中的位置。做多的人常把停損掛在前低之下(跌破就承認看錯),等突破的人常把買單掛在前高之上(站上去才進場)。也就是說前高前低附近堆著一大群條件單,而這些單子平常不在掛單簿上——它們要等價格碰到才變成市價單,所以 Day 10 的 OBI 事先量不到它們。

價格一旦碰到那個價位,那些單子會集中成交,於是產生一波看起來很有力的成交量。這一波成交把價格推得更遠,吸引更多人跟進——然後如果原本沒有真實的買盤在後面撐著,價格就掉回來。

圖上第 11 根就是這個過程:衝到 117,越過前高 7 塊,看起來力道很強;但兩根之後就跌回 110 之下,再三根跌到 97——連前低都跌破了,而那裡正是停損單堆著的地方。順行 7、逆行 13,這就是後面統計表裡「打回組」那兩個中位數(651.67 與 857.98)在單一事件上的樣子。

這個現象常被叫做「停損獵殺」(stop hunting)。要注意的是它不必是誰刻意做的:光是「條件單集中在同一個價位」這個結構本身就足以產生這種形狀。把它當成陰謀會導致錯誤的結論;把它當成一個可觀察的市場結構才能寫成程式。

換一個角度看這件事會更有用:對一個要出掉大量部位的人來說,條件單密集的地方是唯一有足夠對手方的地方。要賣掉大單,就得去有人在買的地方賣;而前高之上正好掛著一整排等著追價的買單。所以價格傾向往流動性密集的區域移動,不是因為誰在操縱,而是因為只有那裡成交得掉。這個視角在 Day 14 的 Volume Profile 會再出現一次——成交量堆積的價格區間,同時是支撐壓力,也是大單的出口。

真與假只有回頭看才知道:標籤與特徵的界線

「真突破」與「假突破」是回頭看才知道的。 突破的那一刻,兩者長得一模一樣——都是價格越過了前高。

這句話決定了今天整段程式碼的結構,值得把它的兩邊分清楚:

  • 特徵是突破當下觀察得到的東西:突破了多少、成交量多不多、掛單簿哪一側薄。它可以進策略,因為在做決定的那一刻它就在手上。
  • 標籤要看後來發生什麼,所以它一定用到未來資料。它只能用在分析與統計,NEVER 進策略。

把標籤當條件用是未來函數裡最嚴重的一種:回測會近乎完美,實盤完全不能動。而它比 Day 12 那幾種洩漏更容易發生,因為標籤與特徵在資料表上長得一模一樣——都是一欄數字,對齊在同一個索引上。分辨它們唯一可靠的方式不是看資料,是看那一欄是怎麼算出來的

今天的統計要做的事,正是在這條界線上:拿未來算出來的標籤把突破分成兩組,然後只用當下觀察得到的量去比較兩組。如果對照的量裡混進了任何一個含未來資訊的,這張表就變成一個華麗的同義反覆——用未來解釋未來。

界線在程式碼裡的具體表現是三件事:貼標籤的東西是一個 Service 而不是 Feature、它不實作 Feature 協定、它不會進 Day 15 的特徵註冊表。也就是說策略引擎根本拿不到它——這比寫一行「請不要拿去當條件」的註解可靠得多。

事件式特徵在統計上跟連續特徵不一樣

今天的東西絕大多數時候是 0,偶爾是 1。這個形狀上的差別有幾個統計上的後果,值得在看數字之前先講。

一、樣本數是事件數,不是 K 線數。 一年半的 1 小時線有 13,848 根,聽起來很多;但突破只有一千多次,而分成兩組之後其中一組只有 125 個。所有「守住組的中位數是多少」這類數字,實際的樣本量是那 125,不是 13,848。Day 21 會專門處理「稀疏訊號讓回測的統計意義變薄」這件事,今天先建立這個直覺。

二、要問的是條件機率,不是機率。 「守住的比例 9.22%」這個數字,完整的寫法是「在突破發生的條件下,守住的比例」。它跟「隨便挑一根 K 線,後面十根不跌破某價位的比例」是不同的量,兩者不能互相引用。這聽起來理所當然,但在報告上寫成「9.22%」三個字之後,很容易被讀成後者。

三、基準率會隨定義大幅移動,組間對照相對穩定。 這是今天最重要的方法論。「多少比例算守住」高度取決於怎麼定義守住;但「守住那組的突破幅度是不是比較大」這種兩組之間的對照,對定義的敏感度低得多。所以報告的重點要放在對照,而不是放在那個看起來很好引用的百分比。

四、稀疏事件特別容易被單邊行情騙。 一段一路上漲的行情裡,向上突破自然比較容易守住。防這件事的方法是兩側都做:向上突破與向下跌破各統計一次,只有兩側方向一致的結論才比較可能是結構性的。這個檢查等一下會用到。

工程實作

今天的程式要做什麼

今天的工作分成三段:把突破這件事變成資料、用未來去分類它、再用掛單簿回答一個 Day 10 欠著的問題。

第一段,把突破變成資料(這些都能進策略):

  1. 算前高與前低——過去 N 根的最高價與最低價,而且不含當根。少了這個排除,突破條件永遠不成立,事件數會是 0。
  2. 判斷突破——這一根的高點越過前高(或低點跌破前低),輸出 0 與 1。上下兩側的欄位、取極值的方向、比較的方向三件事要一起翻,少翻一個照樣跑得出合理的布林序列。
  3. 算突破幅度——越過了多少。它是價格單位,所以要除以 ATR 才跨得了時間比較。

第二段,用未來去分類(這些 NEVER 進策略):

  1. 貼標籤——突破之後 N 根之內,價格有沒有跌回被突破的那個價位。這一步刻意用未來資料,所以它跟前面三步分屬不同的東西,策略引擎根本拿不到它。往未來看的視窗同樣不含突破那一根,否則每一次突破都會被標成失敗。
  2. 分組對照——用標籤把突破切成「守住」與「被打回來」兩組,然後只拿突破當下就觀察得到的三個量(突破幅度除以 ATR、成交筆數的 z-score、絕對報酬的 z-score)各取中位數並排。用中位數而不是平均,因為這幾個分布的右尾都很長。
  3. 兩側各跑一次——突破前高與跌破前低分開統計,只有兩側方向一致的結論才比較可能是結構性的。

第三段,深度是被吃掉還是被撤走:

  1. 把成交與掛單簿對起來——某一側的深度減少了多少,同期間那個方向真的成交了多少,兩者相除。接近 1 是被真的買走,接近 0 是掛單自己消失。這一步要把成交那側的累積量取樣到掛單簿的時間點上,而成交的時間戳不是唯一的,所以不能用一般的重新索引。
  2. 畫圖——K 線疊上前高那條階梯線,守住與被打回來標成不同顏色的三角形。

第 8 步的用途不是找訊號,是用眼睛確認第 1、2 步沒寫錯——把 shift 寫掉的版本會產生 0 個標記,把方向寫反的版本會把每個低點標成突破,這兩種在圖上一秒就看得出來。

一個只做 shift 的類別

PriorExtreme 這個類別做的事只有一件:把「過去 N 根的極值」算出來,而且保證不含當根。除此之外什麼都不做。

「一個類別只為了一行 shift(1)」看起來很誇張。但這個決定的依據不是那行程式碼有多長,是它被寫錯的機率有多高、以及寫錯的後果有多難發現。同樣的判斷 Day 06 已經做過一次:WilderSmoother 也只有一個 ewm(alpha=...),而它存在的理由是 α 很容易寫成 2/(n+1)

規則因此是硬的:任何要用「前高」的地方 MUST 走這裡,NEVER 自己寫 rolling().max()

釘住它的測試有兩個。一個直接比對數值(前三根的最高是 12 不是 20,而含當根的寫法會得到 20);另一個示範錯誤的形狀——一段每根都創新高的資料,正確版本找到 25 次突破,把 shift 寫掉的版本找到 0 次。第二個測試比第一個有用,因為它記錄的是症狀而不是數字。

前高與前低不是「同一件事換個方向」

上下兩側的差別有三處,而它們必須一起翻:欄位(highlow)、聚合(maxmin)、比較方向(><)。少翻任何一個,算出來還是一個合理的布林序列,不會有任何異常。

所以它們收在一個值裡(ExtremeSide),三件事綁在一起,呼叫端不必自己記得。寫成一個帶 bool 參數的函式就做不到這件事——那等於把三個要同步的決定交還給每一個呼叫端。

這個值刻意不做 shift。看起來跟前一節矛盾(不是說 shift 很重要嗎),但它們是兩層不同的東西:這個值提供的是「極值怎麼算」,而「要不要含當根」是使用者的決定——畫圖時想含,判斷突破時 NEVER 含。保證不含當根的那一層是 PriorExtreme。把 shift 塞進底層,畫圖那種用法就得繞過它,而繞過去的程式碼就是下一個 bug 的位置。

事件不是另一種型別

Day 10 訂 Feature 協定的時候沒有為事件留任何特別的位置,而今天證明了不需要——事件照樣是「一條與 K 線等長、index 相同的序列」,只是值是 0 與 1。這是介面訂得夠窄的好處:它沒有預先為想像中的需求開洞。

有一個決定要寫成測試:輸出用 float64 的 0.0/1.0 而不是 bool。理由要到 Day 15 才顯現——那天的管線會把所有特徵 concat 成一張表,混入 bool 欄位會讓整張表的 dtype 變成 object,而 object 欄位的算術運算慢一個數量級,也不再支援用 NaN 表達暖機期。

另外,events() 只回答「有沒有突破」,這丟掉了一個資訊:突破了多少。「勉強擦過去 0.5 USDT」與「大幅突破 300 USDT」是完全不同的事件。所以另有一個 excess() 回傳突破幅度(沒突破的那幾根是 0),它是價格單位,要跨時間比較必須先除以某個尺度——今天用的尺度是 Day 12 的 ATR,那正是 ATR 存在的用途之一。

標籤的定義:先挑一個沒有自由度的

貼標籤的定義只有一句:突破之後 horizon 根之內,價格有沒有跌回被突破的那個價位。

「被突破的價位」是前高,不是突破那根 K 線自己的高點。這個選擇很重要:用那根 K 線的高點當基準的話,只要之後沒有繼續往上就算失敗,那個定義太嚴,也不對應任何人的實際處境——在前高掛突破單的人,成本是前高。

刻意用這個最粗的定義,理由是它不需要挑任何門檻參數。「走了多遠算真突破」需要一個數字(幾個 ATR?幾個百分點?收盤價站上去算不算?),而那個數字一挑,統計結果就開始取決於它。每一種定義都合理,而每一種都會給出不同的數字——這就是研究者自由度,它跟參數的自由度一樣會製造假發現。先用一個沒有自由度的定義得到基準,之後要細分才知道細分改變了什麼。Day 21 會把這件事講完整。

往未來看的視窗,最容易差一格

# quantbot/domain/services/breakout_labelling_service.py
    def _forward_extreme(self, values: pd.Series, side: ExtremeSide) -> pd.Series:
        """接下來 horizon 根的極值,**不含當根**。

        寫法是「反轉序列、往前 rolling、再反轉回來」。直覺的寫法是
        rolling(horizon).max().shift(-horizon),但那個 shift 的格數很容易差一格,
        而差一格的症狀是標籤系統性偏向某一邊——那種偏差在統計結果上看不出來。
        """
        reversed_values = values.astype("float64").iloc[::-1]
        extreme = side.rolling_extreme(reversed_values.shift(1), self._horizon)
        return extreme.iloc[::-1]

「反轉、shift、rolling、反轉回來」比 shift(-horizon) 繞,但它跟 PriorExtreme 用的是完全同一個模式(先 shift 再 rolling),所以兩邊要嘛都對、要嘛都錯,不會出現一邊含當根一邊不含的情況。用同一個模式處理往前看與往後看,是這種對稱性錯誤唯一好防的方式。

為什麼「不含當根」在這裡特別重要:突破那一根自己的低點幾乎一定在突破價位之下(它就是那根 K 線的下緣),把它算進「後來有沒有跌回來」的話,每一次突破都會被標成失敗。這是這個 service 最容易寫錯的一格,所以它有一個專屬測試——用一根帶長下影線的突破 K 線,斷言它仍然被標成守住。

還有一個邊界:最後幾根還沒有未來,所以它們不能有標籤。丟掉它們,而不是給一個猜的值。

只用當下觀察得到的量去對照

統計服務的價值全在於它吃什麼。標籤來自未來,但拿來對照的每一個量都必須是突破當下就在手上的:

# quantbot/application/analyze_breakouts_application.py
    def _observations(
        self, view: MarketView, breakout: Breakout
    ) -> dict[str, pd.Series]:
        average_true_range = ATR(self._atr_period).compute(view)
        return {
            "excess_over_atr": breakout.excess(view) / average_true_range,
            "trade_count_z": TradingActivity(
                measure=ActivityMeasure.TRADE_COUNT, window=self._activity_window
            ).compute(view),
            "absolute_return_z": TradingActivity(
                measure=ActivityMeasure.ABSOLUTE_RETURN, window=self._activity_window
            ).compute(view),
        }

三個量全部來自前面幾天,而它們對應三個常見的說法:「勉強擦過去的突破容易失敗」(除以 ATR 的突破幅度)、「沒人跟進的突破容易失敗」(成交筆數的 z-score)、「突破那一根本身要夠猛」(絕對報酬的 z-score)。這些說法很常聽到,今天要做的是給它們一個數字。

統計服務本身不碰 I/O、不知道特徵怎麼算出來的,只吃兩張已經對齊好的表。它用中位數而不是平均:突破當下的成交量分布右尾很長(少數幾次暴量可以是平常的幾十倍),平均會被那幾次主導,而我們要問的是「一般情況下有沒有差」。

實際跑一次

uv run python -m quantbot.entrypoints.breakout_command \
    --symbol BTC/USDT --market spot --timeframe 1h \
    --start 2025-01-01 --end 2026-08-01 --window 20 --horizon 10
spot_BTCUSDT,前 20 根極值

[突破前高]
  突破事件 1,356 次,觀察窗 10 根
  守住 125 次(9.22%)、被打回來 1,231 次

  期間幅度中位數(價格單位)
    守住:順行 2,084.00、逆行 0.00
    打回:順行 651.67、逆行 857.98

  突破當下就觀察得到的量(中位數)
    量                           守住        打回         差
    excess_over_atr          1.433     0.377     1.056
    trade_count_z            1.577     0.247     1.330
    absolute_return_z        2.214     0.033     2.181

[跌破前低]
  突破事件 1,346 次,觀察窗 10 根
  守住 151 次(11.22%)、被打回來 1,195 次

  突破當下就觀察得到的量(中位數)
    量                           守住        打回         差
    excess_over_atr          1.204     0.402     0.802
    trade_count_z            1.743     0.757     0.986
    absolute_return_z        2.079     0.181     1.898

一年半的 1 小時線上有 1,356 次向上突破。四件事要讀。

一、那個 9.22% 不能被讀成「只有 9% 的突破有效」

這是今天最容易誤讀的數字,所以先處理它。

9.22% 是在這個定義下的比例,而這個定義很嚴:突破之後 10 根之內,價格的低點一次都不能回到前高之下。前高只是過去 20 根的最高價,而 BTC 的 1 小時線在 10 小時內回到 20 小時的價格區間裡,是相當普通的事。

換一個寬鬆一點的定義(例如「收盤價站在前高之上」而不是「低點一次都不許跌破」),這個數字會明顯上升。所以它不是「假突破率 90.8%」這種可以拿去引用的常數,它是一個特定定義下的基準值——正是前面說的,基準率會隨定義大幅移動。

那什麼是穩的?下面那個對照。

二、守住的突破,在突破當下就長得不一樣

三個量全部朝同一個方向:

守住 被打回來 倍數
突破幅度 ÷ ATR 1.433 0.377 3.8
成交筆數 z-score 1.577 0.247 6.4
絕對報酬 z-score 2.214 0.033

守住的那些突破,幅度是被打回來那些的 3.8 倍(以 ATR 為單位),而且成交筆數明顯更熱(z-score 1.58 對 0.25)。

也就是說那兩句常聽到的話在這份資料上成立:勉強擦過去的突破容易失敗、沒人跟進的突破容易失敗。而「勉強」與「沒人跟進」現在有數字了——0.38 個 ATR、z-score 0.25。

跌破前低那一側的三個量方向完全一樣(1.204 對 0.402、1.743 對 0.757、2.079 對 0.181)。這就是前面說的兩側檢查:如果只有一側成立,比較可能的解釋是那段行情剛好偏多或偏空,而不是這個現象真的存在。六個對照全部同向,這個結論比那個 9.22% 穩得多。

三、守住組的「逆行 0.00」是定義的結果,不是發現

守住組的逆行幅度中位數是 0.00。這看起來像一個很漂亮的結果,但它是定義推出來的:守住的定義就是「沒有跌回前高之下」,而逆行幅度是從前高往下量的,所以它必然是 0。

這種數字要主動標出來,不然報告會看起來比實際上有資訊。真正有內容的是另外三個:

順行 逆行
守住(125 次) 2,084.00 0.00
被打回來(1,231 次) 651.67 857.98

被打回來的那 1,231 次,順行中位數 651.67、逆行中位數 857.98——也就是先給一點甜頭,然後往回走更多。這正是假突破在帳面上的樣子。

四、還不能算期望值

看到「9.22% 的機率賺 2,084、90.78% 的機率賠 858」很容易想直接算期望值。這裡不算,理由有兩個:

  1. 中位數不能相加。 期望值要用平均,而這幾個分布都嚴重偏斜,平均與中位數差很多。
  2. 這些幅度不是損益。 損益要看實際的進出場價位——出場規則是什麼(碰到逆行多少就走?固定持有 10 根?)、進場滑價多少、手續費多少。這三件事一個都還沒定。

所以今天的產出是兩組突破在突破當下的可觀察差異,不是一個策略的評價。後者需要 Day 19 的回測框架與 Day 20 的成本模型。這條界線要守住——把「守住 9.22%」講成「勝率 9%」,或把幅度中位數當成損益,都是把描述講成結論。

回答 Day 10 留下的問題:深度是被吃掉還是被撤走

Day 10 提過一個沒辦法靠算法解決的限制:掛單可以撤,所以 OBI 量到的是「簿子上長什麼樣」,不是「有多少人真的想成交」。 今天有工具可以量那個限制到底有多大。

方法是把兩種資料對起來。掛單簿告訴我們某一側的深度少了多少,逐筆成交告訴我們同一段時間裡那個方向真的成交了多少,兩者相除:

吃掉的比例 = 同期間該方向的 taker 成交量 / 該側深度的減少量
  • 接近 1:掛單是被真的成交吃掉的。有人用真錢把它買走了。
  • 接近 0:掛單消失了但沒有成交。它被撤單了。

這兩件事在價格圖上長得一樣(深度都變薄、價格都往上),但意義相反。被吃掉代表有真實的買盤,那個上漲是有人付了錢換來的;被撤走代表掛單的人只是把單子拿走,價格因為阻力消失而往上,沒有任何人為它出價。接到前面「一個價位憑什麼擋住價格」那節,這正是真假突破在微觀層面的分野。

值域刻意不封閉在 0 到 1:同一段時間裡可能有人一邊吃、一邊有新單補上,於是分母(淨減少量)比分子(成交量)小很多,比例會大於 1。那不是錯誤,那是「掛單補得比吃得快」,本身就是資訊,所以 NEVER 把它 clip 到 1。

這也是今天唯一需要三種原料的特徵(K 線、逐筆成交、掛單簿),而 Day 10 訂的 required_inputs 在這裡第一次真的派上用場——缺哪一種資料,在算之前就問得出來。

一個只有真實資料才會出現的錯誤

第一版把成交那側的累積量對齊到掛單簿的時間點時,用的是最直覺的寫法 volume.reindex(depth.captured_times, method="ffill")。在合成的測試資料上完全正常,跑真實資料時它丟出 ValueError: cannot reindex on an axis with duplicate labels

原因是成交的時間戳不是唯一的。Day 09 就量過了:aggTrades 每分鐘的列數中位數是 368,同一個毫秒裡有幾十筆成交是常態。而 reindex 在有重複索引的軸上直接拒絕工作。

正確的工具是 merge_asof

# quantbot/domain/features/liquidity_swing.py
    @staticmethod
    def _sampled_at(values: pd.Series, moments: pd.DatetimeIndex) -> pd.Series:
        """把成交那一側的累積量取樣到掛單簿的時間點上。

        這裡 NEVER 用 reindex(method="ffill"):**成交的時間戳不是唯一的**(同一個
        毫秒裡有幾十筆成交是常態,Day 09 量過每分鐘中位數 368 列),而 reindex 在
        有重複索引的軸上會直接丟 ValueError。

        merge_asof 才是「取此刻或之前最近的那一個值」這件事的工具,而且它接受
        重複的鍵。它要求兩邊都已排序,這由 TradeSeries 與 DepthSeries 的建構保證。
        """
        left = pd.DataFrame({"moment": moments})
        right = pd.DataFrame(
            {"moment": pd.DatetimeIndex(values.index), "value": values.to_numpy()}
        )
        merged = pd.merge_asof(left, right, on="moment", direction="backward")
        return pd.Series(
            merged["value"].to_numpy(), index=moments, name=str(values.name)
        )

這個錯誤值得記錄下來,因為它是那種「合成測試資料永遠測不到」的類型:測試裡每秒一筆,真實資料一秒幾十筆。所以它變成一個回歸測試——六筆成交全部落在同一個時間點,斷言對齊仍然正確。Day 12 那個「測試資料不能比真實資料更乾淨」的教訓,在這裡換了一種形狀又出現一次。

實測結果

uv run python -m quantbot.entrypoints.liquidity_swing_command \
    --symbol BTC/USDT --market spot --timeframe 1m \
    --start 2026-08-04T17:20 --end 2026-08-04T18:05
spot_BTCUSDT:K 線 45 根、成交 19,295 筆、深度取樣 2,508 筆

[high] liquidity_swing_high_5s
  可用樣本 1,924 筆
  中位數 0.081、四分位 0.012 / 0.460
  以成交吃掉為主(> 0.5):465 筆(24.17%)
  以撤單為主:1,459 筆(75.83%)

[low] liquidity_swing_low_5s
  可用樣本 1,941 筆
  中位數 0.056、四分位 0.006 / 0.393
  以成交吃掉為主(> 0.5):431 筆(22.21%)
  以撤單為主:1,510 筆(77.79%)

賣方深度變薄的樣本裡,有 75.83% 主要是撤單而不是成交。 中位數 0.081 的意思是:典型情況下,深度減少的量裡只有 8% 是被真的成交吃掉的,其餘 92% 是掛單自己消失的。買方那側幾乎一樣(77.79%、中位數 0.056)。

這個數字把 Day 10 那個口頭上的警告變成了一個量。「掛單可以撤」不是一個理論上的可能性——在這 45 分鐘的資料上,撤單是深度變化的主要來源。所以:

  • OBI 那個 0.43 的秩相關並不因此變成假的,但它的意義更窄了:它反映的是「簿子現在的形狀」,而那個形狀有四分之三的變化來自沒有人付錢的動作。
  • 一個「賣方掛量突然變薄就跟著買」的規則,四分之三的時候跟到的是撤單。
  • 這也解釋了假突破為什麼那麼常見:價格衝過前高的時候,前面的賣單可能只是被撤走了,而撤單不代表有人想買。前面那個「一個價位憑什麼擋住價格」的問題,在這裡有了完整的答案——擋住價格的那堆量消失了,但消失有兩種方式,而只有一種代表有買盤。

要誠實標註限制:這是45 分鐘、單一交易對的樣本。時段(美股盤中)、幣別、市場狀態都可能改變這個比例。它的價值不在那個 75.83% 有多精確,而在於數量級——這件事不是邊緣情況,是主要情況。

視覺化:用眼睛檢查標記邏輯

這張圖的用途不是找訊號,是用眼睛確認標記邏輯正確。統計數字沒辦法告訴我們「突破」這個判斷有沒有寫錯——一個把 shift 寫掉的版本會產生 0 個事件(統計上看起來像「這段行情很平靜」),一個把方向寫反的版本會把每個低點標成突破。這兩種錯誤在圖上一秒就看得出來。

所以它把兩種標籤畫成不同顏色:守住的往上三角、被打回來的往下三角。肉眼掃過去,被打回來的那些應該落在圖形的相對高點附近。

前高那條線用階梯狀(shape="hv")而不是直線,因為它在被突破之前是一個常數,用斜線連起來會讓人以為那個門檻在中間慢慢移動。而那條線用的是判斷用的同一份計算,這件事由 Breakout 自己保證(它提供 prior_level() 給圖表用)——各算一次的話,圖上的線與標記的位置會在某些邊界上對不起來,而那種不一致最難查。

打開產出的 html,把時間軸拉到 2025 年的任一段:往下的紅色三角應該密密麻麻地出現在每個小波段的頂部附近,往上的綠色三角稀疏得多。那個視覺上的密度差,就是 1,231 對 125 的樣子。

今日交付物

quantbot/
├── domain/
│   ├── values/
│   │   ├── extreme_side.py                     今天:HIGH / LOW + 三件事一起翻
│   │   └── breakout_label.py                   今天:標籤,不是特徵
│   ├── features/
│   │   ├── prior_extreme.py                    今天:這個類別只為了 shift(1)
│   │   ├── breakout.py                         今天:第一個事件式特徵
│   │   └── liquidity_swing.py                  今天:吃掉還是撤走
│   ├── services/
│   │   ├── breakout_labelling_service.py       今天:刻意看未來
│   │   └── breakout_statistics_service.py      今天:只吃當下觀察得到的量
│   └── dto/breakout_statistics_report.py       今天
├── application/analyze_breakouts_application.py 今天
├── infrastructure/
│   ├── charting/plotly_breakout_chart_renderer.py               今天
│   └── reporting/text_breakout_statistics_report_renderer.py    今天
├── entrypoints/
│   ├── breakout_command.py                     今天
│   └── liquidity_swing_command.py              今天
└── tests/
    ├── domain/features/test_breakout.py                    今天
    ├── domain/features/test_liquidity_swing.py             今天
    └── domain/services/test_breakout_labelling_service.py  今天

驗收標準

七項全過才算完成:

  1. uv run pytest tests/domain/features/test_breakout.py 全綠。最重要的兩個:前高不含當根(前三根最高是 12,含當根的寫法會得到 20),以及「把 shift 寫掉的版本在一段一路創新高的資料上找到 0 個事件」。另外要有一個斷言輸出 dtype 是 float64 而不是 bool
  2. uv run pytest tests/domain/services/test_breakout_labelling_service.py 全綠,含「往未來的視窗不含突破那一根」(用一根帶長下影線的突破 K 線,它仍要被標成守住)與「窗口不完整的尾巴要被丟掉」。
  3. uv run pytest tests/domain/features/test_liquidity_swing.py 全綠,含重複時間戳那個回歸測試——它守的是一個只在真實資料上出現的例外。
  4. uv run python -m quantbot.entrypoints.breakout_command --symbol BTC/USDT --market spot --timeframe 1h --start 2025-01-01 --end 2026-08-01 --window 20 --horizon 10 印出兩側的統計。兩側的三個對照量方向要一致(守住組的幅度、成交筆數、絕對報酬都比較高)。只有一側成立的話,先懷疑那段行情偏多或偏空。
  5. 手上有 Day 10 錄的那 45 分鐘之後,uv run python -m quantbot.entrypoints.liquidity_swing_command(區間換成自己錄的那段)印出「以撤單為主」的比例明顯過半。
  6. 打開 notebooks/day13-spot_BTCUSDT_1h-breakout.html:紅色往下三角密集出現在小波段頂部、綠色往上三角稀疏,而前高那條線是階梯狀的。
  7. uv run mypy quantbotuv run lint-imports 全過。

第 1 項與第 4 項是今天的重點。第 1 項守的是一個會讓訊號完全消失的錯誤,第 4 項守的是「結論要在兩側都成立」。

免責聲明:本文為程式與資料工程的技術分享,所有數字皆為教學範例,不構成投資建議;文中的守住比例取決於本文採用的定義,NEVER 可當成通用的假突破率,幅度中位數也 NEVER 等於損益。

明天

今天用的是「前 20 根最高價」——時間軸上的極值。明天換一個維度。

Volume Profile 不看時間軸上的成交量,看價格軸上的成交量分布:在 64,000 這個價位總共成交了多少、在 65,000 又是多少。成交量堆積最多的那個價格是多數人的成本區,那裡自然形成支撐或壓力,而它不需要手畫線。

今天那句「價格傾向往流動性密集的區域移動」明天會有一張圖——那些區域正是 Volume Profile 上的高峰。Day 11 講 VWAP 時提過的「平均成本是一個重心,看不出分布的形狀」,明天也會補上。

Day 09 那條逐筆成交的路徑明天會第一次真的被用來算特徵——而且會有一個具體的數字回答「用 K 線近似跟用 tick 精算差多少」。Day 11 提過官方 K 線的 quote_volume / volume 就是那一根真正的 VWAP,明天就是拿它當對照組的時候。

Reference


上一篇
"Day 12:同樣的價格,有時候很危險有時候沒事:用交易活躍度定義市場的節奏
下一篇
Day 14:支撐壓力線可以不用手畫嗎?用 Volume Profile 自動找出籌碼密集區
系列文
量化交易入門:從 K 線到可組合的交易策略引擎15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言