昨天用夏普當排名依據跑完組合搜尋,而夏普只是一個數字,它擋不住的東西很多。
今天做兩件事。前半段把績效指標補完,而每一個指標都要回答同一個問題:它在防我們犯什麼錯。 一個指標如果講不出它防的是什麼,那它只是報表上的一欄裝飾。
後半段處理組合式框架帶來的最後一個問題:幾十個策略要怎麼並排比較才公平。 這個問題在只有一個策略時不存在,而積木化之後它每天都會遇到。
權益曲線(equity curve) 就是帳戶淨值隨時間的變化。看它有三件事,而多數人只看第一件。
斜率:整體賺不賺。這是總報酬率,也是最沒有資訊的那一個——它不說風險、不說時間、不說在場上待了多久。
回撤深度:從最高點跌下來最多幾成。這是最大回撤(maximum drawdown, MDD),它回答的是「撐不撐得過去」。一個總報酬 +50% 但中間曾經跌掉 70% 的策略,實際上沒有人抱得住——因為抱到那個低點時,帳上已經虧掉大半,而那時候沒有人知道它之後會回來。
回撤持續時間:最長多久沒有回到前一個高點。這一項最常被忽略,而它是實際上讓人放棄一個策略的原因。回撤 20% 但兩週就收復,跟回撤 20% 但拖了八個月,數字上一樣,忍受度完全不同。
然後是兩個必須配對看的:
勝率(win rate) 是賺錢的交易佔幾成。賠率(payoff ratio) 是平均獲利除以平均虧損。它們單獨都沒有意義,因為期望值是兩者的組合:
期望值 = 勝率 × 賠率 − (1 − 勝率)
60% 勝率配 0.5 的賠率是賠錢的:0.6 × 0.5 − 0.4 = −0.1。而 30% 勝率配 3.0 的賠率是賺錢的:0.3 × 3 − 0.7 = +0.2。只看勝率的排行榜會把前者排在前面。
最後一個:交易次數。它決定上面每一個數字有多可信,而那正是 Day 21 整天在講的事。
指標都掛在同一個 service 上,而不是散在幾個地方:
# quantbot/domain/dto/performance_summary.py
@dataclass(frozen=True)
class PerformanceSummaryDto:
"""一個策略的一頁式績效摘要。
每一個欄位都在防一個特定的誤判,而它們**必須一起看**:
- total_return:最沒有資訊的那一個。它不說風險、不說時間、不說曝險。
- maximum_drawdown:從最高點跌下來最多幾成。它回答「撐不撐得過去」。
- longest_drawdown_bars:最長多久沒有回到前一個高點。它回答「撐得過去,
但要撐多久」——這一項最常被忽略,而它是實際上讓人放棄一個策略的原因。
- sharpe_ratio / sortino_ratio:承擔的波動划不划算。後者只算下跌的波動,
所以一個「往上跳得很兇」的策略不會被罰。
- win_rate / payoff_ratio:兩個必須配對看。60% 勝率配 0.5 的賠率是賠錢的。
- trade_count:樣本數。它決定上面每一個數字有多可信(Day 21)。
- trial_count:試了幾次才得到這一份。它決定上面每一個數字該打幾折。
exposure 也在裡面,因為報酬率的比較沒有它就不成立:曝險 1.35% 與 99.99% 的
兩個策略,同樣的總報酬是完全不同的兩件事。
"""
三個實作細節值得單獨講,因為它們都是「算錯了不會報錯」的那種。
# quantbot/domain/services/performance_metrics_service.py
def maximum_drawdown(self, equity: pd.Series) -> float:
"""從歷史最高點跌下來最深的一次,回傳負數(-0.35 就是跌了 35%)。
用權益曲線而不是報酬序列,因為回撤是一個**路徑**性質:同一組報酬換個順序
會得到完全不同的最大回撤,而那正是它要量的東西。
"""
cleaned = equity.dropna()
if cleaned.empty:
return 0.0
peak = cleaned.cummax()
return float((cleaned / peak - 1.0).min())
「同一組報酬換個順序會得到不同的回撤」這句話跟 Day 21 那個打亂順序的示範是同一件事的兩面:夏普只看報酬的分布(換順序不變),回撤看的是路徑(換順序就變)。所以兩個夏普一樣的策略可以有完全不同的回撤,而那是兩個獨立的資訊。
# quantbot/domain/services/performance_metrics_service.py
def longest_drawdown_bars(self, equity: pd.Series) -> int:
"""最長有幾根沒有回到前一個高點。
它是最常被忽略的一個指標,而它是實際上讓人放棄一個策略的原因:
一個回撤 20% 但兩週就收復的策略,跟一個回撤 20% 但拖了八個月的策略,
數字上一樣,忍受度完全不同。
還沒收復的那一段也要算進來(結尾那段的長度就是它的長度),
否則一個「跌下去就再也沒回來」的策略會回一個很小的數字。
"""
最後那句是這個指標最容易寫錯的地方。如果只統計「已經收復」的回撤區間,一個從第一根就一路往下的策略會回 0——因為它從來沒有「收復」過任何一次回撤,於是完整的回撤區間一個都不存在。而那個 0 會被讀成「沒有回撤」。
# quantbot/domain/services/performance_metrics_service.py
def win_rate(self, trades: pd.DataFrame) -> float | None:
"""賺錢的交易佔幾成。用**淨**報酬判斷,不是毛報酬。
用毛報酬算勝率會系統性地高估:一筆賺 0.1% 的交易在扣掉 0.3% 來回成本
之後是虧的,而它在毛報酬的帳上是「贏」。
"""
Day 20 那三個策略的來回成本是 0.2%。一個平均獲利只有 0.1% 的策略,用毛報酬算會有一個漂亮的勝率,而每一筆「贏」實際上都是虧的。
uv run python -m quantbot.entrypoints.compare_strategies_command \
--timeframe 1h --start 2025-01-01 --end 2026-08-01
五個策略(三份設定加上 Day 18 的兩個交叉組合)並排,基準是 BuyAndHold。BTC/USDT 現貨 1 小時 K 線,資料來自 Day 07 的 TimescaleDB:
| 策略 | 交易 | 曝險 | 總報酬 | 最大回撤 | 最長回撤 | 夏普 | 索提諾 | 勝率 | 賠率 | 超額 |
|---|---|---|---|---|---|---|---|---|---|---|
| 均值回歸 | 28 | 1.35% | −2.45% | −5.64% | 13,174 | −0.33 | −0.06 | 53.6% | 0.64 | +32.75% |
| 動能進場+趨勢出場 | 124 | 35.08% | −54.11% | −57.21% | 13,395 | −1.90 | −1.55 | 26.6% | 1.44 | −18.90% |
| 趨勢跟隨 | 231 | 48.61% | −63.31% | −66.55% | 13,395 | −2.10 | −1.99 | 23.8% | 1.86 | −28.10% |
| 動能爆發 | 190 | 6.67% | −42.97% | −44.85% | 13,302 | −2.45 | −1.03 | 34.2% | 0.98 | −7.77% |
| 趨勢進場+動能出場 | 221 | 3.46% | −44.28% | −44.85% | 13,383 | −3.58 | −1.21 | 20.8% | 1.35 | −9.08% |
| BuyAndHold(基準) | 1 | 99.99% | −35.20% | −53.74% | 7,157 | −0.40 | −0.51 | 0.0% | —— | +0.00% |
贏過基準的策略:1 / 5。
先講那張表怎麼讀,再講結論。
均值回歸與趨勢跟隨是兩個相反的失敗。 前者勝率 53.6% 但賠率只有 0.64——期望值 0.536 × 0.64 − 0.464 = −0.12,也就是「常常小賺、偶爾大賠」。後者剛好相反:賠率 1.86(賺的時候賺得多)但勝率只有 23.8%——期望值 0.238 × 1.86 − 0.762 = −0.32。
兩個都賠錢,而只看勝率會挑到前者、只看賠率會挑到後者。 這就是那兩個數字必須配對看的原因,而它不是一個理論上的例子,是這份表上真實的兩列。
最長回撤那一欄很殘忍。 均值回歸的 13,174 根、趨勢跟隨的 13,395 根——資料總共 13,679 根,也就是這兩個策略在整段期間裡幾乎從頭到尾都在回撤中(從很早的一個高點之後就沒有再回去過)。而 BuyAndHold 的 7,157 根(約 298 天)反而是最短的。
唯一贏過基準的策略還是賠錢的。 均值回歸的 −2.45% 對基準的 −35.20%,超額報酬 +32.75%——這是這份表上唯一一個好看的數字,而它的絕對值是負的。這件事要照實講:在一個下跌 35% 的市場裡少賠 32 個百分點是有價值的,但那個價值幾乎完全來自「它有 98.65% 的時間不在場上」,而不是來自它的進出場判斷。
索提諾與夏普的差距透露結構。 均值回歸的夏普 −0.33、索提諾 −0.06,差距很大——它的虧損很集中在少數幾筆(下跌波動小、但平均報酬被幾筆大虧拉低)。趨勢跟隨兩者接近(−2.10 對 −1.99),表示它的虧損是均勻分布的,也就是持續在流血而不是幾次重擊。
眼睛細一點會發現:趨勢跟隨在這張表上是 231 筆交易、曝險 48.61%,而 Day 17 到 Day 20 一直是 232 筆、48.42%。
原因是這張表跑在共用的特徵表上,而共用表包含五個策略要的所有特徵——其中活躍度的視窗是 168 根。所以整段資料的開頭被切掉更多(13,823 → 13,679 根),而那 144 根裡剛好有一筆交易。
這不是誤差,是公平的代價:
# quantbot/application/compare_strategies_application.py
class CompareStrategiesApplication:
"""幾份策略設定 → 一份並排比較的報告。
**一張表跑完所有策略**,這是公平比較的第一個條件,而它也是唯一一個容易做錯的:
每個策略各自呼叫一次 run() 會各自讀一次資料,而不同的設定宣告不同的特徵,
於是暖機期不同、資料長度不同、報酬率不可比。所以這裡先把所有設定的特徵取聯集
算成一張表,再讓每個策略在同一張表上跑。
基準策略也在同一張表上跑,所以「贏過基準幾個百分點」是同一段時間的比較。
"""
要「趨勢策略的真實數字」就單獨跑它(Day 19 的指令);要「五個策略公平比較」就接受大家一起少 144 根。兩個都對,但混用兩份數字就錯了——所以並排比較的那張表裡每一列都必須來自同一次計算。
「同一段資料、同一組成本、標注試驗次數、有基準」這四件事可以寫成一段註解提醒自己,也可以做成建構時的檢查。這裡選後者:
# quantbot/domain/dto/strategy_comparison_report.py
@dataclass(frozen=True)
class StrategyComparisonReportDto:
"""幾個策略並排比較的結果。
公平比較的四個條件都由這份 DTO 的形狀保證,而不是靠使用者記得:
1. **同一段資料。** 所有摘要的 bar_count 相同(建構時檢查)。
2. **同一組成本假設。** 由呼叫端傳同一份 BacktestSpecification 保證。
3. **同樣標注試驗次數。** 每一列都有 trial_count,那個欄位沒有預設值。
4. **有基準。** baseline 是必填的,所以一份沒有基準的比較根本建不出來。
第四點是這份 DTO 最重要的欄位。少了基準,「總報酬 −2.45%」看起來像個壞結果;
放上基準(同期間 BuyAndHold −35.20%)之後它是另一件事。而反過來也成立:
一個 +40% 的策略在一個 +80% 的市場裡是災難。
"""
第一條是一個 if:所有摘要的 bar_count 必須相同,否則建構時丟例外。這種檢查很便宜,而它擋掉的錯誤(拿兩段不同長度的回測並排比較)在報表上完全看不出來。
第四條是欄位的必填性。baseline 沒有預設值,所以一份沒有基準的比較報告在型別層面就不存在。
還有一個維度,是排行榜的形狀本身答不出來的。
# quantbot/domain/services/strategy_correlation_service.py
class StrategyCorrelationService:
"""幾個策略的報酬序列兩兩相關。
它回答的是一個排行榜答不出來的問題:**同時上線的兩個策略,是不是同一個賭注。**
兩個報酬相關性 0.9 的策略各下一半資金,風險跟只下一個策略但全額投入幾乎一樣,
而報表上會顯示「已分散到兩個策略」。
只算兩邊都有部位的那些根,是這裡唯一需要判斷的事。空手的根報酬是 0,而一堆 0
會把相關係數往 0 拉——兩個曝險都很低的策略(Day 18 的均值回歸只有 1.35%)
幾乎永遠算出接近 0 的相關性,即使它們每次進場都在同一個時間點。
"""
實測的相關性矩陣:
相關性矩陣(只算兩邊都有部位的那些根)
0 1 2 3 4
0: trend_ema_rsi +1.00 +0.01 +0.30 +0.30 +0.65
1: mean_reversion_vwap +0.01 +1.00 +0.04 -0.00 +0.00
2: momentum_breakout +0.30 +0.04 +1.00 +0.17 +0.54
3: trend_ema_rsi_entry_x_momentum_breakout_exit +0.30 -0.00 +0.17 +1.00 +0.13
4: momentum_breakout_entry_x_trend_ema_rsi_exit +0.65 +0.00 +0.54 +0.13 +1.00
兩個觀察。
趨勢跟隨與「動能進場配趨勢出場」相關 +0.65。 這兩個共用出場條件,所以它們的持有期高度重疊——同時上線就是把同一個賭注下兩倍,而排行榜上它們是兩列。
均值回歸跟所有人幾乎零相關(+0.00 到 +0.04)。 它在場上的時間只有 1.35%,而那些時候別人多半不在場。這個零相關是真的(不是被空手的根稀釋出來的假零,因為算法已經排除了兩邊都空手的根),而它是這個策略除了「少賠」之外唯一的優點——一個跟其他策略都不相關的來源,即使自己平庸,加進組合裡也可能有價值。
那句「排行榜第一名不等於最該上線的那一個」的意思就在這裡:上線的決定要看它跟現有的東西一起放會怎樣,而排名只看它自己。
四格,順序對應讀一份績效報告的順序:
# quantbot/infrastructure/charting/plotly_performance_report_renderer.py
class PlotlyPerformanceReportRenderer:
"""單一策略的一頁式報告,以及多策略的比較視圖。
一頁式報告有四格,而它們的順序對應「讀一份績效報告的順序」:先看權益曲線
(整體形狀),再看回撤(痛的部分),再看月報酬(穩不穩定),最後看單筆交易的
分布(賺賠的結構)。
回撤畫在權益曲線下面而不是疊在上面,因為它的單位不同(百分比 vs 金額)。
但兩格共用時間軸,所以「哪一段在回撤」對得回權益曲線上的哪一段。
"""
月報酬那一格有一個容易寫錯的地方:月報酬要用月底權益相除,不能把日報酬加總。 加總會忽略複利,而在月內報酬有正有負時那個誤差不是四捨五入的等級。測試裡有一條斷言把它釘住——所有月報酬複利起來必須等於整段的總報酬。
多策略的比較視圖上下兩格:權益曲線疊圖,加上相關性熱力圖。兩格放在一起是刻意的,只看上面那格會得到「已經分散到五個策略」的錯覺。
七天下來,quantbot 從「一張特徵表」長成這樣:
| Day | 產出 | 它解決的問題 |
|---|---|---|
| 16 | 條件積木 + 組合運算子 + 引擎 | 策略不再是複製貼上的類別;訊號位移只寫一次 |
| 17 | YAML 載入器 + 特徵對帳 | 新策略的成本降到一份設定檔,而拼錯的欄名在啟動時就失敗 |
| 18 | 兩個哲學相反的策略 + 交叉組合 | 驗證抽象切得對,並補上時間類規則 |
| 19 | 向量化回測 + 兩個對照組 | 有了第一份績效數字,而它的語意跟業界主流一致 |
| 20 | 滑價實測 + 費率敏感度 | 成本從「拍腦袋的假設」變成「查證過的數字」 |
| 21 | 組合搜尋 + 打亂資料的對照 | 知道「純靠運氣能挖到多好」,所以看得懂搜尋結果 |
| 22 | 績效指標 + 公平比較 | 排名不再由單一數字決定,而相關性讓「分散」不是錯覺 |
而三個策略沒有一個賺錢。這件事要放在最後講清楚:這一階段交付的是一套判斷工具,不是一個賺錢的策略。 那三個策略的價值在於它們證明了工具可用——同一組積木表達得出三種哲學、交叉組合真的能換一半、回測跟業界引擎對得起來、成本估得出來、搜尋結果判斷得出可信度。
用這套工具找到一個能上線的策略,是這 30 天結束之後的事,而且是持續研究的產物。
quantbot/
├── domain/
│ ├── dto/
│ │ ├── performance_summary.py 今天:一頁式摘要
│ │ └── strategy_comparison_report.py 今天:公平比較的四個條件
│ └── services/
│ ├── performance_metrics_service.py 今天補上回撤/索提諾/勝率/賠率
│ └── strategy_correlation_service.py 今天:只算兩邊都有部位的根
├── application/compare_strategies_application.py 今天
├── infrastructure/
│ ├── charting/plotly_performance_report_renderer.py 今天
│ └── reporting/text_strategy_comparison_report_renderer.py 今天
├── entrypoints/compare_strategies_command.py 今天
└── tests/domain/services/
├── test_performance_metrics_summary.py 今天
└── test_strategy_correlation_service.py 今天
並排比較的公平性建立在「同一段資料」上,所以跑之前先把管線補完:
docker compose -f docker/docker-compose.yml up -d
uv sync
uv run python -m quantbot.entrypoints.ingest_pipeline_command
uv run python -m quantbot.entrypoints.compare_strategies_command \
--timeframe 1h --start 2025-01-01 --end 2026-08-01
跑完會在 data/charts/ 產出每個策略各一份一頁式報告,加上一份多策略比較。換一段時間再跑一次,看排名會不會換人——通常會,而那件事本身就是 Day 21 的結論。
八項全過才算完成:
uv run pytest 全綠。uv run mypy quantbot 與 uv run lint-imports 全過。第 7 項是今天最值得留著的一條,因為它擋的錯誤在報表上完全看不出來。
免責聲明:本文為程式與資料工程的技術分享,所有數字皆為歷史資料上的觀察與教學範例,不構成投資建議。文中五個策略在真實成本下都不賺錢,這是照實記錄的結果;這段期間 BTC 下跌 35.20%,所有數字都要放在那個背景下讀。
第三階段結束,手上有一套能表達策略、驗證策略、並且判斷驗證結果可不可信的工具。而它到現在為止只在歷史資料上跑過。
明天開始第四階段,主題全部是工程題——而這是工程師最有優勢的一段。第一天處理的是這套東西掛在雲端跑會怎麼壞:網路斷、API 逾時、限流、交易所回 5xx、WebSocket 靜默斷線(沒有錯誤但也沒有資料)、下單回報遺失。每一種對應的處理策略不同,而 except Exception: pass 會讓最危險的那一種變成看不見的。
最危險的那一種不是「程式掛掉」,是**「以為自己有部位其實沒有」**。狀態不一致比停機更糟,而處理它的方式是啟動時跟交易所對帳,以交易所的部位為準而不是本地狀態。
Series.cummax 用來算「到目前為止的最高點」,是回撤計算的骨架 — pandas documentation, Series.cummax
resample("ME") 的 ME 是月底(month end),pandas 2.2 之後取代 M — pandas documentation, Offset aliases
Series.corr 預設用 Pearson,且會自動排除任一邊為 NaN 的配對 — pandas documentation, Series.corr