前面四天的特徵都是連續的:每一根 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 會再出現一次——成交量堆積的價格區間,同時是支撐壓力,也是大單的出口。
「真突破」與「假突破」是回頭看才知道的。 突破的那一刻,兩者長得一模一樣——都是價格越過了前高。
這句話決定了今天整段程式碼的結構,值得把它的兩邊分清楚:
把標籤當條件用是未來函數裡最嚴重的一種:回測會近乎完美,實盤完全不能動。而它比 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 欠著的問題。
第一段,把突破變成資料(這些都能進策略):
第二段,用未來去分類(這些 NEVER 進策略):
第三段,深度是被吃掉還是被撤走:
第 8 步的用途不是找訊號,是用眼睛確認第 1、2 步沒寫錯——把 shift 寫掉的版本會產生 0 個標記,把方向寫反的版本會把每個低點標成突破,這兩種在圖上一秒就看得出來。
PriorExtreme 這個類別做的事只有一件:把「過去 N 根的極值」算出來,而且保證不含當根。除此之外什麼都不做。
「一個類別只為了一行 shift(1)」看起來很誇張。但這個決定的依據不是那行程式碼有多長,是它被寫錯的機率有多高、以及寫錯的後果有多難發現。同樣的判斷 Day 06 已經做過一次:WilderSmoother 也只有一個 ewm(alpha=...),而它存在的理由是 α 很容易寫成 2/(n+1)。
規則因此是硬的:任何要用「前高」的地方 MUST 走這裡,NEVER 自己寫 rolling().max()。
釘住它的測試有兩個。一個直接比對數值(前三根的最高是 12 不是 20,而含當根的寫法會得到 20);另一個示範錯誤的形狀——一段每根都創新高的資料,正確版本找到 25 次突破,把 shift 寫掉的版本找到 0 次。第二個測試比第一個有用,因為它記錄的是症狀而不是數字。
上下兩側的差別有三處,而它們必須一起翻:欄位(high 對 low)、聚合(max 對 min)、比較方向(> 對 <)。少翻任何一個,算出來還是一個合理的布林序列,不會有任何異常。
所以它們收在一個值裡(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% 是在這個定義下的比例,而這個定義很嚴:突破之後 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。
這種數字要主動標出來,不然報告會看起來比實際上有資訊。真正有內容的是另外三個:
| 順行 | 逆行 | |
|---|---|---|
| 守住(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」很容易想直接算期望值。這裡不算,理由有兩個:
所以今天的產出是兩組突破在突破當下的可觀察差異,不是一個策略的評價。後者需要 Day 19 的回測框架與 Day 20 的成本模型。這條界線要守住——把「守住 9.22%」講成「勝率 9%」,或把幅度中位數當成損益,都是把描述講成結論。
Day 10 提過一個沒辦法靠算法解決的限制:掛單可以撤,所以 OBI 量到的是「簿子上長什麼樣」,不是「有多少人真的想成交」。 今天有工具可以量那個限制到底有多大。
方法是把兩種資料對起來。掛單簿告訴我們某一側的深度少了多少,逐筆成交告訴我們同一段時間裡那個方向真的成交了多少,兩者相除:
吃掉的比例 = 同期間該方向的 taker 成交量 / 該側深度的減少量
這兩件事在價格圖上長得一樣(深度都變薄、價格都往上),但意義相反。被吃掉代表有真實的買盤,那個上漲是有人付了錢換來的;被撤走代表掛單的人只是把單子拿走,價格因為阻力消失而往上,沒有任何人為它出價。接到前面「一個價位憑什麼擋住價格」那節,這正是真假突破在微觀層面的分野。
值域刻意不封閉在 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 分鐘的資料上,撤單是深度變化的主要來源。所以:
要誠實標註限制:這是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 今天
七項全過才算完成:
uv run pytest tests/domain/features/test_breakout.py 全綠。最重要的兩個:前高不含當根(前三根最高是 12,含當根的寫法會得到 20),以及「把 shift 寫掉的版本在一段一路創新高的資料上找到 0 個事件」。另外要有一個斷言輸出 dtype 是 float64 而不是 bool。uv run pytest tests/domain/services/test_breakout_labelling_service.py 全綠,含「往未來的視窗不含突破那一根」(用一根帶長下影線的突破 K 線,它仍要被標成守住)與「窗口不完整的尾巴要被丟掉」。uv run pytest tests/domain/features/test_liquidity_swing.py 全綠,含重複時間戳那個回歸測試——它守的是一個只在真實資料上出現的例外。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 印出兩側的統計。兩側的三個對照量方向要一致(守住組的幅度、成交筆數、絕對報酬都比較高)。只有一側成立的話,先懷疑那段行情偏多或偏空。uv run python -m quantbot.entrypoints.liquidity_swing_command(區間換成自己錄的那段)印出「以撤單為主」的比例明顯過半。notebooks/day13-spot_BTCUSDT_1h-breakout.html:紅色往下三角密集出現在小波段頂部、綠色往上三角稀疏,而前高那條線是階梯狀的。uv run mypy quantbot 與 uv 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,明天就是拿它當對照組的時候。
rolling 視窗預設包含當根,以及 closed 參數的語意 — pandas documentation, Windowing operations
merge_asof 做「取此刻或之前最近的值」的對齊,允許重複的鍵 — pandas documentation, merge_asof
reindex 在有重複標籤的軸上會丟 ValueError — pandas documentation, DataFrame.reindex
transact_time 而來) — Binance Public Data