昨天的結論是三個策略在真實成本下都不成立:一個毛利就是負的,一個只剩 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_8 配 ema_21 與 ema_8 配 ema_26 的訊號有八成以上重疊。有效的獨立試驗次數遠小於 44。
這件事的實務結論是:用打亂資料實測出來的分布,比用公式算出來的門檻可信。 公式給的是量級,實測給的是這個搜尋空間、這段資料上真正的門檻。而實測只要多跑一次搜尋,成本很低。
不是不要搜尋。是搜尋的結果要配三樣東西一起看。
一、試驗次數要跟結果一起走。 Day 19 讓 BacktestReportDto.trial_count 沒有預設值,就是為了這一天。一個「試了 44 次挑出來的」夏普 +1.5 與一個「只跑了一次的」夏普 +1.5,可信度差好幾個數量級,而看報告的人沒有辦法從數字反推出那件事。
二、樣本外要在搜尋之前就切好,而且只看一次。 用樣本外反覆調整,樣本外就變成樣本內了。這也是報告把「樣本內為正的組合裡樣本外也為正的比例」印出來、而不只印最佳組合樣本外表現的理由——後者只有一個樣本,很容易剛好。
三、跟雜訊比,不是跟零比。 打亂一次資料重跑一次搜尋,成本很低而資訊量很大。
報告的排版也照這個順序:
# quantbot/infrastructure/reporting/text_search_report_renderer.py
class TextSearchReportRenderer:
"""搜尋結果印成一份「先看可信度、再看排名」的報告。
排版順序是刻意的:試驗次數、剪枝數、雜訊期望上限這三行在最前面,最佳組合的
排名在後面。順序反過來的話,讀的人會先記住那個漂亮的夏普,而之後看到的每一句
警告都只是註腳。
"""
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
補完之後值得換一段時間再跑一次搜尋。如果最佳組合換了一組參數(而且通常會換),那本身就是今天結論的另一個版本。
八項全過才算完成:
uv run pytest 全綠。pruned_count() 回 3。None 而不是 0,報告印「沒有定義」。當成 0 會讓它排在賠錢的策略前面。uv run mypy quantbot 與 uv run lint-imports 全過。第 5 項與第 7 項是今天最容易寫錯的兩條,而它們錯的方向都是「讓結果看起來更好」。
免責聲明:本文為程式與資料工程的技術分享,所有數字皆為歷史資料上的觀察與教學範例,不構成投資建議。文中所有搜尋結果都標注了試驗次數,而它們全部是樣本內的最佳值——那是最不該被當成預期績效的數字。
第三階段的最後一天。今天用夏普當排名依據,而夏普只是一個數字,它擋不住的東西很多。
明天把績效指標補完,而每一個都會回答同一個問題:它在防我們犯什麼錯。 最大回撤防的是「撐不撐得過去」,回撤持續時間防的是「撐得過去但撐多久」,勝率與賠率一起看防的是「60% 勝率的策略也可能是賠錢的」,交易筆數防的正是今天講的那件事。
也會處理組合式框架帶來的最後一個問題:幾十個策略要怎麼並排比較才公平。 同一段資料、同一組成本假設、同樣標注試驗次數與樣本外表現——而最後還有一個容易被忽略的維度:兩個高度相關的策略同時上線,等於把同一個賭注下兩倍。
numpy.random.Generator.permutation 的重排語意與 default_rng 的種子行為 — NumPy documentation, Random Generator
DataFrame.loc 用 DatetimeIndex 取子集時包含兩端,切樣本內外要注意 — pandas documentation, Indexing