iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
Software Development

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

Day 21:積木能排出幾百種組合,那就全跑一遍挑最好的?這正是會虧錢的地方

  • 分享至 

  • xImage
  •  

那個很自然的念頭

昨天的結論是三個策略在真實成本下都不成立:一個毛利就是負的,一個只剩 0.32%,一個需要零手續費。

接下來最自然的念頭是:參數調一調就好了吧。 EMA 換成 8 與 21、RSI 門檻改成 65、偏離門檻改成 −2.5——積木庫讓這件事只是改幾行 YAML,而寫個迴圈把幾百種組合全部跑完只要幾分鐘。

這個念頭是 Day 01 就預告過的那件事:能自由組合,就一定會有人拿它亂試一通,所以這套工具必須連安全鎖一起交付。 今天就是那個安全鎖。

而今天的結論不是「不要做組合搜尋」——搜尋是必要的,參數總得選。結論是:「跑 N 個組合、挑回測最漂亮的那一個」這個動作本身就是一個偏誤來源,而如果不知道那個偏誤有多大,挑出來的東西跟隨機挑沒有差別。

交易概念補課:多重比較

多重比較問題(multiple comparisons) 講的是這件事:試得越多次,純靠運氣冒出漂亮結果的機率越高。

換到回測上:假設有 44 個組合,而它們全部都沒有任何預測力。它們的夏普比率不會剛好是 0——每一個都是從有限的資料估出來的,所以會帶著估計誤差,散在 0 附近。而「取最大值」這個動作會系統性地挑到誤差最大的那一個。

這裡有一個數字可以先建立直覺。夏普比率的估計標準誤大約是「幾年資料的平方根倒數」——一年的資料,標準誤大約 1.0。也就是說,一個真實夏普為 0 的策略,在一年的資料上算出 ±1.0 的夏普是很平常的事。44 個這樣的策略裡,最好的那一個會落在哪?大約 2.6。

「夏普 2.6」聽起來是一個很好的策略,而它可以完全來自雜訊。

另外兩個詞:

參數挖礦(parameter mining) 是上面那件事的自動化版本——用程式把參數空間掃過一遍,挑最好看的。它跟「調參數」的界線不在動作,在有沒有記錄試了幾次

walk-forward 驗證 是應對方式之一:把資料切成前後兩段,前段調參數、後段驗證;再往前滾動一次,重複。它不能消除挖礦,但它讓「挖出來的東西撐不撐得住」變成一個可以被觀察的事實。

搜尋空間怎麼定義

第一件要處理的事:搜尋空間不是全排列。

# quantbot/entrypoints/search_command.py
def trend_search_space(base: StrategySpecification) -> SearchSpace:
    """趨勢策略的搜尋空間:兩條 EMA 的週期加上 RSI 的閾值。

    3 × 4 × 4 = 48 種,剪枝之後剩下講得通的那些(快線週期必須小於慢線)。
    這個數字刻意不大——48 種已經足以讓「挑最漂亮的那一個」失去意義,而那正是
    今天要示範的事。
    """
    return SearchSpace(
        base=base,
        axes=(
            FeatureParameterAxis(feature_index=0, key="period", values=(8, 12, 21)),
            FeatureParameterAxis(
                feature_index=1, key="period", values=(21, 26, 34, 55)
            ),
            ConditionParameterAxis(
                tree=ConditionTree.FILTERS, key="value", values=(60, 65, 70, 75)
            ),
        ),
        constraints=(
            IncreasingPeriodConstraint(faster_feature_index=0, slower_feature_index=1),
        ),
    )

三個軸,48 種組合。剪枝的規則只有一條,而它的理由要說得出來:

# quantbot/domain/values/search_space.py
@dataclass(frozen=True)
class IncreasingPeriodConstraint:
    """兩個特徵的某個參數必須嚴格遞增。

    它存在的理由是領域知識而不是效能:「快線的週期比慢線長」的組合不是一個
    比較差的策略,它是一個**講不通**的策略——快線慢線交叉的意義來自兩者的
    反應速度差,反過來就沒有那個意義了。

    剪枝要用這種說得出理由的規則,NEVER 用「跑起來太慢」當理由。後者會剪掉
    講得通的組合,而那等於偷偷縮小搜尋空間又不記錄。
    """

48 種裡有 4 種踩到這條規則(快線 21 配慢線 21,以及快線 21 配更短的慢線),所以實際跑 44 種。剪掉幾個要印在報告上,否則讀的人會以為搜尋涵蓋了全部組合。

同樣的道理適用於「趨勢的進場配均值回歸的出場」這種跨哲學組合。Day 18 示範過它做得到,但它多數時候不該進搜尋空間——那兩個條件對市場的假設相反,組出來的東西沒有一句話可以解釋它在賭什麼。搜尋空間該放的是「同一個想法的不同參數」,不是「所有可以接起來的東西」。

一個不明顯的實作問題:改參數就改了名字

展開組合的時候會撞到一件事,而它不是統計問題是工程問題:

# quantbot/domain/services/search_space_service.py
class SearchSpaceService:
    """一個搜尋空間展開成一堆策略設定。

    它做三件事,而第二件是唯一不明顯的那個:

    1. **笛卡兒積。** 各軸的值排列組合,數量是各軸長度的乘積。
    2. **名字改寫。** 改了特徵參數就改了特徵的名字(`ema_12` → `ema_8`),
       而條件樹引用的是名字。所以每產生一個組合,都要把條件裡引用舊名字的地方
       換成新名字,否則展開出來的設定會在 Day 17 的對帳那一關全部失敗。
    3. **剪枝。** 講不通的組合直接不產生(例如快線週期比慢線長),而剪掉幾個
       要記錄下來——沒有記錄的剪枝等於偷偷縮小搜尋空間。

    第二件事是「參數與名字綁在一起」這個設計的代價。特徵的名字帶參數(`ema_12`)
    是為了讓同一種特徵能出現多次,而代價就是這裡:改參數是一個牽動兩個地方的
    操作。用序號當名字(`feature_0`)可以避開它,但那樣設定檔就再也讀不懂了。

    它是 domain service:吃註冊表(具體實例,不是 Protocol)與一個值,回一串值。
    """

這是 Day 15 那個「特徵名帶參數」的決定第一次收到帳單。好消息是帳單很小(一段遞迴改寫),而且 Day 17 的特徵對帳會在組裝時就抓到漏改的情況——44 個組合裡有一個沒改到名字,程式會直接失敗而不是安靜地跑出 44 個結果、其中一個是錯的。

兩段分別回測,共用一張表

搜尋的實作有兩個決定值得單獨講,因為它們都是「看起來一樣,其實不一樣」的那種。

第一個:所有組合共用同一張特徵表。

# quantbot/application/search_combinations_application.py
    def table_for(
        self,
        view: MarketView,
        specifications: tuple[StrategySpecification, ...],
    ) -> pd.DataFrame:
        """所有組合共用的一張表:把每一份設定要的特徵取聯集,一次算完。

        兩個理由,第二個比第一個重要:

        1. **省時間。** 48 個組合裡 `ema_21` 出現很多次,算一次就好。
        2. **公平。** 一張共用的表意味著所有組合看到的是**同一段資料**,包含
           同一段暖機期。分開算的話,用 `ema_55` 的組合會比用 `ema_8` 的少掉
           幾十根開頭資料,而兩者的夏普就不再可以並排比較——一個在「資料比較少
           但可能剛好避開一段爛行情」的區間上算出來的夏普,跟別人不是同一件事。

        去重用 describe() 當鍵,跟 Day 18 的交叉組合同一個做法。
        """

第二個理由是這種比較最容易被忽略的失真來源。48 個組合各自算自己的特徵,聽起來乾淨,實際上會讓每個組合的資料區間都不一樣長。

第二個:樣本內與樣本外分別回測,而不是回測整段再切開權益曲線。 後者的樣本外報酬會從樣本內結束時的權益開始算,於是一個樣本內大賺的組合在樣本外拿到的是被放大的絕對金額——而要比較的是報酬率。

還有切分的時機:切分在跑任何組合之前就做完。順序反過來的話,「用樣本外挑策略」會變成一件很容易不小心做到的事,而那跟沒有樣本外一樣。

# quantbot/domain/services/walk_forward_service.py
@dataclass(frozen=True)
class WalkForwardFold:
    """一段樣本內加上緊接在後的樣本外。

    兩段**必須相鄰而且不重疊**,這是這個值唯一的不變式,也是它存在的理由:
    樣本內外重疊一根,樣本外就不再是樣本外了,而那種錯誤在一張時間區間表上
    看不出來。
    """

把不重疊做成建構時的檢查,而不是一段註解,是因為這種錯誤不會有任何症狀——樣本外的績效會變好,而那正是最不容易被質疑的方向。

純靠運氣能到多高

有了搜尋結果之後,第一個要問的問題不是「最好的是哪一個」,是「如果全部都沒有預測力,最好的那個會是多少」。

# quantbot/domain/services/trial_deflation_service.py
class TrialDeflationService:
    """試了 N 次之後,「最好的那個夏普」有多少是運氣。

    直覺是這樣:即使每一個組合都完全沒有預測力,它們的夏普估計值也不會剛好是 0——
    每一個都有估計誤差,所以會散在 0 附近。試 N 次就是抽 N 個樣本,而**取最大值**
    這個動作會系統性地挑到誤差最大的那一個。N 越大,那個最大值越高,跟策略好壞
    完全無關。

    所以「跑 500 個組合挑最漂亮的那一個」的問題不是效率,是**取最大值本身就是
    一個偏誤來源**。要判斷一個搜尋結果值不值得看,得先知道「純靠運氣能到多高」。

    這裡用的是最粗的近似:N 個標準常態的最大值期望值約為 sqrt(2 ln N)。
    真正的 deflated Sharpe ratio 還會處理報酬的偏態、峰態與試驗之間的相關性,
    公式長很多,而這個系列要的是**量級**而不是小數點。等一下 Day 21 會用打亂順序的
    假資料實測一次,看這個近似估得準不準。
    """

sqrt(2 ln N) 這個式子有一個很值得注意的性質:它長得非常慢。從 10 次試驗到 1,000 次,門檻只上升不到一倍。

這件事有兩個方向的意思。好的一面是「多試一點」的代價不是爆炸性的;壞的一面是光靠這個門檻擋不住挖礦,因為多試一百倍只要多贏一點點就過關了。這也是為什麼下一節那個實測比公式重要。

關鍵示範:在完全沒有訊號的資料上搜尋

理論歸理論。要證明「挑最漂亮的那一個」有多不可靠,最直接的方法是在一段確定沒有任何訊號的資料上跑同一套搜尋,看它能找到什麼。

假資料不用隨機數產生,而是把真實報酬的順序打亂

# quantbot/domain/services/return_shuffle_service.py
class ReturnShuffleService:
    """把真實報酬的順序打亂,重建一條「完全沒有訊號」的價格走勢。

    這是 Day 21 那個示範的資料來源,而它比「用隨機數產生價格」好,理由是它保留了
    真實資料的分布:一樣的報酬平均值、一樣的標準差、一樣的厚尾、一樣的極端值。
    被破壞的只有**順序**——而所有技術分析的訊號都建在順序上(趨勢是順序、
    交叉是順序、突破是順序)。

    所以打亂之後的資料有一個很好的性質:**任何依賴順序的策略在上面都不該有效**,
    而其他條件全部相同。在那樣的資料上還「找得到」漂亮的策略,就只能是搜尋本身
    的問題。

    亂數種子是參數而不是全域狀態。理由跟 Day 07 的 Clock 一樣:偷讀全域狀態的
    程式碼寫不出可靠的測試,而「同一個種子產生同一段假資料」是這個示範可以被
    重現的前提。
    """

打亂的是對數報酬,然後累積回價格,所以波動率、厚尾、極端值全部保留。開高低價按「新收盤價除以舊收盤價」的比例縮放,所以每一根的影線長度也保留——不然 ATR 那類看高低價的特徵會歸零,而那不是「沒有訊號」,那是另一種資料。

然後兩邊跑完全相同的路徑:同樣的特徵算法、同樣的引擎、同樣的成本、同樣的切分。只有最前面的 K 線不同。

uv run python -m quantbot.entrypoints.search_command \
    --strategy trend_ema_rsi --timeframe 1h \
    --start 2025-01-01 --end 2026-08-01
[真實資料]
  搜尋空間 48 種組合,剪枝 4 種,實際跑 44 種
  樣本內 9,655 根 / 樣本外 4,139 根
  純靠運氣的夏普期望上限 +2.620(44 次試驗、9,655 根樣本內)

  最佳(依樣本內夏普) trend_ema_rsi__period21_period55_value60
    樣本內夏普 -1.266,扣掉運氣之後 -3.887
    樣本外夏普 -1.959,樣本外報酬 -13.20%
  全部組合的樣本內夏普中位數 -2.191
  樣本內為正的組合裡,樣本外也為正的比例 0.0%

[打亂順序(seed 20261005)]
  搜尋空間 48 種組合,剪枝 4 種,實際跑 44 種
  樣本內 9,655 根 / 樣本外 4,139 根
  純靠運氣的夏普期望上限 +2.620(44 次試驗、9,655 根樣本內)

  最佳(依樣本內夏普) trend_ema_rsi__period21_period55_value60
    樣本內夏普 +1.187,扣掉運氣之後 -1.434
    樣本外夏普 -2.477,樣本外報酬 -13.89%
  全部組合的樣本內夏普中位數 -0.975
  樣本內為正的組合裡,樣本外也為正的比例 0.0%

兩邊的最佳樣本內夏普:真實 -1.266 vs 打亂 +1.187

打亂順序的假資料,挑出來的最佳組合比真實資料好。

換五個亂數種子再跑五次,假資料的最佳樣本內夏普分別是:

亂數種子 打亂後的最佳樣本內夏普
1 −0.860
7 +1.186
20261005 +1.187
99 +0.862
2468 −0.311
(真實資料) −1.266

五次裡有五次,假資料挑出來的最佳值都比真實資料好。

這個結果要小心解讀,它不是說「真實資料比雜訊還糟」。真正的意思是:在這 44 個組合、這段資料、這個成本假設下,搜尋的最大值幾乎完全由雜訊決定,而真實資料的訊號小到看不出來。所以「挑最漂亮的那一個」挑到的是誤差,不是策略。

還有兩個數字比那個最大值更有資訊:

樣本內為正的組合裡,樣本外也為正的比例是 0.0%。 真實資料與假資料都是 0.0%。如果真的有訊號,這個比例應該明顯高於一半——這是比「最佳組合的樣本外表現」穩健得多的判斷,因為它看的是整片分布而不是一個樣本。

理論上界估過頭了。 sqrt(2 ln 44) 給出的期望上限是 +2.620,而實測的雜訊最大值只到 +1.187。差了兩倍多,而原因是那個公式假設 44 次試驗彼此獨立——實際上它們是同一個策略家族的 44 組參數,ema_8ema_21ema_8ema_26 的訊號有八成以上重疊。有效的獨立試驗次數遠小於 44。

這件事的實務結論是:用打亂資料實測出來的分布,比用公式算出來的門檻可信。 公式給的是量級,實測給的是這個搜尋空間、這段資料上真正的門檻。而實測只要多跑一次搜尋,成本很低。

所以該怎麼做

不是不要搜尋。是搜尋的結果要配三樣東西一起看。

一、試驗次數要跟結果一起走。 Day 19 讓 BacktestReportDto.trial_count 沒有預設值,就是為了這一天。一個「試了 44 次挑出來的」夏普 +1.5 與一個「只跑了一次的」夏普 +1.5,可信度差好幾個數量級,而看報告的人沒有辦法從數字反推出那件事。

二、樣本外要在搜尋之前就切好,而且只看一次。 用樣本外反覆調整,樣本外就變成樣本內了。這也是報告把「樣本內為正的組合裡樣本外也為正的比例」印出來、而不只印最佳組合樣本外表現的理由——後者只有一個樣本,很容易剛好。

三、跟雜訊比,不是跟零比。 打亂一次資料重跑一次搜尋,成本很低而資訊量很大。

報告的排版也照這個順序:

# quantbot/infrastructure/reporting/text_search_report_renderer.py
class TextSearchReportRenderer:
    """搜尋結果印成一份「先看可信度、再看排名」的報告。

    排版順序是刻意的:試驗次數、剪枝數、雜訊期望上限這三行在最前面,最佳組合的
    排名在後面。順序反過來的話,讀的人會先記住那個漂亮的夏普,而之後看到的每一句
    警告都只是註腳。
    """

收 Day 18 的伏筆:稀疏訊號最危險

Day 18 提過動能爆發的訊號很稀疏(19 個月 190 筆交易),而均值回歸更稀疏(28 筆),並且說「樣本數少會讓回測的統計意義薄弱」。現在可以把那句話講完。

夏普的估計標準誤跟「幾年資料」有關,但真正決定它的是有效樣本數,而對一個只在少數幾根有部位的策略來說,有效樣本數接近交易筆數而不是 K 線根數。28 筆交易的策略,它的夏普估計誤差大到什麼程度?粗略說,用 28 個樣本估一個平均值,標準誤是標準差的 1/√28 ≈ 0.19——也就是接近兩成的相對誤差,而那還沒算上搜尋帶來的取最大值偏誤。

所以稀疏訊號的策略在組合搜尋裡是最危險的一類:它們的估計誤差最大,於是最容易在取最大值的時候勝出。 一份搜尋結果表如果最漂亮的那幾個都是交易筆數個位數的組合,那個排名幾乎可以直接丟掉。

報告因此把交易筆數印在每一列。

今日交付物

quantbot/
├── domain/
│   ├── values/search_space.py              今天:搜尋軸、剪枝規則、組合數
│   ├── dto/search_report.py                今天:試驗次數、剪枝數、雜訊上界
│   └── services/
│       ├── search_space_service.py         今天:展開 + 名字改寫 + 剪枝
│       ├── walk_forward_service.py         今天:切分,不重疊是不變式
│       ├── performance_metrics_service.py  今天:夏普(其餘指標明天)
│       ├── trial_deflation_service.py      今天:純靠運氣能到多高
│       └── return_shuffle_service.py       今天:打亂順序造假資料
├── application/search_combinations_application.py  今天
├── infrastructure/
│   ├── charting/plotly_search_distribution_renderer.py  今天
│   └── reporting/text_search_report_renderer.py         今天
├── entrypoints/search_command.py           今天
└── tests/domain/services/
    ├── test_search_space_service.py        今天
    ├── test_walk_forward_service.py        今天
    ├── test_performance_metrics_service.py 今天
    ├── test_trial_deflation_service.py     今天
    └── test_return_shuffle_service.py      今天

先把資料補到最新

搜尋跑的是同一段歷史,所以資料越完整越好——尤其樣本外那一段:

docker compose -f docker/docker-compose.yml up -d
uv sync
uv run python -m quantbot.entrypoints.ingest_pipeline_command

補完之後值得換一段時間再跑一次搜尋。如果最佳組合換了一組參數(而且通常會換),那本身就是今天結論的另一個版本。

驗收標準

八項全過才算完成:

  1. uv run pytest 全綠。
  2. 改了特徵參數,條件引用的名字要跟著改,有測試釘住。漏改的話 Day 17 的對帳會失敗,而那是刻意的。
  3. 剪枝有測試:6 種組合、講得通的 3 種,而 pruned_count() 回 3。
  4. 樣本內外重疊要在建構時就丟例外,不是靠註解。重疊的症狀是樣本外變好看。
  5. 打亂之後的報酬排序後與原始完全相同(容忍浮點誤差),有測試釘住。分布沒保留的話比較就不公平。
  6. 同一個種子產生同一段假資料,不同種子產生不同的。示範要能被重現。
  7. 一個從不交易的策略的夏普要是 None 而不是 0,報告印「沒有定義」。當成 0 會讓它排在賠錢的策略前面。
  8. uv run mypy quantbotuv run lint-imports 全過。

第 5 項與第 7 項是今天最容易寫錯的兩條,而它們錯的方向都是「讓結果看起來更好」。

免責聲明:本文為程式與資料工程的技術分享,所有數字皆為歷史資料上的觀察與教學範例,不構成投資建議。文中所有搜尋結果都標注了試驗次數,而它們全部是樣本內的最佳值——那是最不該被當成預期績效的數字。

明天

第三階段的最後一天。今天用夏普當排名依據,而夏普只是一個數字,它擋不住的東西很多。

明天把績效指標補完,而每一個都會回答同一個問題:它在防我們犯什麼錯。 最大回撤防的是「撐不撐得過去」,回撤持續時間防的是「撐得過去但撐多久」,勝率與賠率一起看防的是「60% 勝率的策略也可能是賠錢的」,交易筆數防的正是今天講的那件事。

也會處理組合式框架帶來的最後一個問題:幾十個策略要怎麼並排比較才公平。 同一段資料、同一組成本假設、同樣標注試驗次數與樣本外表現——而最後還有一個容易被忽略的維度:兩個高度相關的策略同時上線,等於把同一個賭注下兩倍。

Reference


上一篇
Day 20:回測賺 300%、實單卻賠錢?把滑價與手續費加回去看真相
下一篇
Day 22:第三階段總結:讀懂權益曲線,以及怎麼公平比較幾十個策略
系列文
量化交易入門:從 K 線到可組合的交易策略引擎23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言