iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
Software Development

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

Day 22:第三階段總結:讀懂權益曲線,以及怎麼公平比較幾十個策略

  • 分享至 

  • xImage
  •  

第三階段的最後一個問題

昨天用夏普當排名依據跑完組合搜尋,而夏普只是一個數字,它擋不住的東西很多。

今天做兩件事。前半段把績效指標補完,而每一個指標都要回答同一個問題:它在防我們犯什麼錯。 一個指標如果講不出它防的是什麼,那它只是報表上的一欄裝飾。

後半段處理組合式框架帶來的最後一個問題:幾十個策略要怎麼並排比較才公平。 這個問題在只有一個策略時不存在,而積木化之後它每天都會遇到。

交易概念補課:怎麼讀一條權益曲線

權益曲線(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 的結論。

驗收標準

八項全過才算完成:

  1. uv run pytest 全綠。
  2. 同一組報酬換順序,最大回撤深度一樣但收復時間不同,有測試釘住。回撤是路徑性質。
  3. 「跌下去就再也沒回來」的回撤長度算到資料結尾,不是回 0。
  4. 勝率用報酬,有測試釘住。毛報酬會系統性高估。
  5. 「60% 勝率配 0.5 賠率是賠錢的」寫成一個測試,直接算出負的期望值。
  6. 相關性只算兩邊都有部位的根,有測試釘住。兩個低曝險策略永遠看起來不相關是假的。
  7. 並排比較不同長度的資料要丟例外。 這是公平比較的第一個條件,由型別保證。
  8. uv run mypy quantbotuv run lint-imports 全過。

第 7 項是今天最值得留著的一條,因為它擋的錯誤在報表上完全看不出來。

免責聲明:本文為程式與資料工程的技術分享,所有數字皆為歷史資料上的觀察與教學範例,不構成投資建議。文中五個策略在真實成本下都不賺錢,這是照實記錄的結果;這段期間 BTC 下跌 35.20%,所有數字都要放在那個背景下讀。

明天

第三階段結束,手上有一套能表達策略、驗證策略、並且判斷驗證結果可不可信的工具。而它到現在為止只在歷史資料上跑過。

明天開始第四階段,主題全部是工程題——而這是工程師最有優勢的一段。第一天處理的是這套東西掛在雲端跑會怎麼壞:網路斷、API 逾時、限流、交易所回 5xx、WebSocket 靜默斷線(沒有錯誤但也沒有資料)、下單回報遺失。每一種對應的處理策略不同,而 except Exception: pass 會讓最危險的那一種變成看不見的。

最危險的那一種不是「程式掛掉」,是**「以為自己有部位其實沒有」**。狀態不一致比停機更糟,而處理它的方式是啟動時跟交易所對帳,以交易所的部位為準而不是本地狀態。

Reference


上一篇
Day 21:積木能排出幾百種組合,那就全跑一遍挑最好的?這正是會虧錢的地方
系列文
量化交易入門:從 K 線到可組合的交易策略引擎23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言