ウォークフォワード最適化:唯一の誠実な戦略テスト
戦略を最適化しました。分離パラメータ12個、メタパラメータ9個 — 合計21個。1つのペアで25ヶ月のバックテストを行い、PnLは最大レバレッジで+3342%。エクイティカーブはほとんどドローダウンなく上昇。Sharpeは3以上。すべてが完璧に見えます。
ボットを起動します。2週間後、戦略は資本の18%を失います。1ヶ月後 — 34%。履歴データで「機能した」パラメータは、特定の市場イベントの並びにフィットしていたことが判明。パターンを見つけたのではなく — ノイズを記憶していたのです。
これは典型的なオーバーフィッティングです。本番環境に移行する前にこれを検出する唯一の体系的な方法が、ウォークフォワード最適化(WFO)です。
単一トレイン/テスト分割の罠

標準的なアプローチ:データをトレイン70%とテスト30%に分割。トレインで最適化し、テストで検証。結果がプラスなら — 本番へ。
問題点:これは1つの分割での1回のテストです。結果は境界をどこに引くかに依存します。境界を1ヶ月ずらすと — アウトオブサンプルのPnLが+40%から-15%に変わることもあります。
Data: |===== Train (70%) =====|== Test (30%) ==|
Split 1: |===2024-01..2025-09====|==2025-10..26-01==| → OOS PnL: +38%
Split 2: |===2024-01..2025-06====|==2025-07..26-01==| → OOS PnL: -12%
Split 3: |===2024-04..2025-12====|==2026-01..26-04==| → OOS PnL: +7%
3つの異なる分割 — 3つの異なる結論。どれを信じるべきか?どれも信じるべきではありません。単一のトレイン/テスト分割は、モンテカルロ・ブートストラップで説明した問題のある単一点推定と同じです。1回のチェックではなく、連続するデータセグメントでの体系的な一連のチェックが必要です。
これこそが、ウォークフォワード最適化が存在する理由です。
ウォークフォワード最適化とは何か

WFOは、スライディング(または拡張)データウィンドウ上で戦略の逐次的な最適化と検証を行う手順です。アイデア:利用可能なデータでパラメータを定期的に再最適化し、次の再最適化まで取引する実際の取引プロセスをシミュレートします。
各「ウィンドウ」は2つの部分で構成されます:
- インサンプル(IS) — パラメータが最適化される期間
- アウトオブサンプル(OOS) — 見つかったパラメータがフィッティングなしでテストされる期間
重要な特性:OOS期間は重複せず、データの大部分をカバーします。結果のエクイティカーブはOOSセグメントのみから構築されます — これが戦略の誠実な評価です。
アンカード WFO(拡張ウィンドウ)

アンカードWFOでは、トレイン期間の開始が固定され、各ウィンドウで終了が拡張されます:
Window 1: Train [2024-01] → Test [2024-04]
Window 2: Train [2024-01..04] → Test [2024-07] (growing train)
Window 3: Train [2024-01..07] → Test [2024-10]
Window 4: Train [2024-01..10] → Test [2025-01]
Window 5: Train [2024-01..2025-01] → Test [2025-04]
利点:
- 各後続のトレイン期間にはより多くのデータが含まれる — 最適化がより安定
- 初期のパターンが失われない — 常にトレーニングセットに含まれる
- 実装が容易
欠点:
- 古いデータが現在のパターンを「薄める」可能性がある
- 市場が構造的に変化した場合 — 古いデータは有害
- トレイン期間が無制限に増加し、最適化時間が増加
ローリング WFO(スライディングウィンドウ)
ローリングWFOでは、固定長のトレイン期間がデータ上を「スライド」します:
Window 1: Train [2024-01..06] → Test [2024-07..09]
Window 2: Train [2024-04..09] → Test [2024-10..12]
Window 3: Train [2024-07..12] → Test [2025-01..03]
Window 4: Train [2024-10..2025-03] → Test [2025-04..06]
Window 5: Train [2025-01..06] → Test [2025-07..09]
利点:
- 現在の市場レジームに適応
- 最適化時間が一定
- 古い、無関係なデータが結果に影響しない
欠点:
- トレーニングデータが少ない — 最適パラメータの分散が高い
- ウィンドウ長の選択に敏感
- まれだが重要なイベント(フラッシュクラッシュ)を「忘れる」可能性がある
組合せパージド交差検証(CPCV)

Marcos Lopez de Pradoが提案した高度な手法。データをグループに分割し、そこから個をテスト用に選択します。標準的な交差検証との主な違いは、パージ(トレイン/テスト境界のデータ除去)とエンバーゴ(データリーケージを防ぐための追加ギャップ)です:
の場合:45のトレイン/テスト組合せ。各組合せはOOS結果を生成し、最終推定はすべての組合せの平均です。
from itertools import combinations
import numpy as np
def cpcv_splits(n_groups: int, k_test: int, purge_pct: float = 0.01):
"""
Generate CPCV splits with purging.
Args:
n_groups: number of groups
k_test: number of test groups in each split
purge_pct: fraction of data for purging (at the train/test boundary)
"""
groups = list(range(n_groups))
splits = []
for test_groups in combinations(groups, k_test):
train_groups = [g for g in groups if g not in test_groups]
splits.append({
"train": train_groups,
"test": list(test_groups),
"purge_groups": _get_purge_groups(train_groups, test_groups),
})
return splits
def _get_purge_groups(train, test):
"""Groups at the train/test boundary for purging."""
purge = set()
for t in test:
if t - 1 in train:
purge.add(t - 1)
if t + 1 in train:
purge.add(t + 1)
return list(purge)
CPCVはデータが少ない場合にローリングWFOより優れていますが、計算コストが高くなります。21パラメータと25ヶ月のデータを持つ戦略の場合、まずローリングWFOから始め、追加チェックとしてCPCVを使用することをお勧めします。
WFOの主要パラメータ

トレイン期間の長さ
トレインが短すぎると — 信頼性の高い最適化に十分なデータがありません。長すぎると — 古いデータが現在のパターンを薄めます。
**経験則:**トレインには少なくとも200-300のトレードが含まれるべきです。戦略が1日2トレードを行う場合:
レジームスイッチのある暗号通貨の場合、ローリングウィンドウは6-12ヶ月以内を推奨します。
テスト期間の長さ
テスト期間は統計的に有意な評価に十分でなければなりませんが、長すぎてはいけません — さもなければパラメータが劣化する時間があります。
**ルール:**テスト = トレインの20-33%。トレイン = 6ヶ月の場合、テスト = 1.5-2ヶ月。
オーバーラップ
ローリングWFOでは、ウィンドウがオーバーラップできます。オーバーラップはOOSデータポイントの数を増やしますが、推定間の相関を導入します:
Without overlap:
Train [01..06] → Test [07..09]
Train [07..12] → Test [01..03]
With 50% overlap:
Train [01..06] → Test [07..09]
Train [04..09] → Test [10..12]
Train [07..12] → Test [01..03]
推奨:トレイン期間の50%オーバーラップ — ウィンドウ数と推定の独立性の良いバランスです。
再最適化頻度
パラメータをどのくらいの頻度で再計算するかを決定します。暗号通貨市場では、最適な頻度は1-3ヶ月ごとです。より頻繁な再最適化はノイズへのオーバーフィッティングのリスクを高め、頻度が低いと — パラメータの陳腐化のリスクが高まります。
ウォークフォワード効率比率とデグラデーション率

ウォークフォワード効率比率(WFER)
WFOの主要指標 — OOSリターンとISリターンの比率:
解釈:
| WFER | 解釈 |
|---|---|
| > 0.8 | 優れた堅牢性。パラメータが新しいデータに移転可能。 |
| 0.5 — 0.8 | 許容範囲の堅牢性。戦略は機能するが劣化あり。 |
| 0.3 — 0.5 | ボーダーラインケース。部分的なオーバーフィッティングの可能性。 |
| < 0.3 | オーバーフィッティング。パラメータがISデータにフィット。 |
| < 0 | 戦略がOOSで不採算。完全なオーバーフィッティングまたはロジックエラー。 |
**WFER < 0.5の場合 — 戦略は最も可能性が高くオーバーフィットです。**これが私たちの主要フィルターです。
デグラデーション率
最適パラメータが時間とともにどのくらい速く効果を失うかを示します:
実際には:テスト期間をサブインターバルに分割し、PnLのダイナミクスを追跡します:
def degradation_rate(oos_returns: np.ndarray, n_subperiods: int = 4) -> float:
"""
Estimate parameter degradation rate.
Splits the OOS period into sub-intervals and computes the slope
of linear regression of PnL against sub-interval number.
Returns:
slope: negative = degradation, positive = improvement
"""
chunk_size = len(oos_returns) // n_subperiods
subperiod_pnls = []
for i in range(n_subperiods):
start = i * chunk_size
end = start + chunk_size
sub_pnl = np.sum(oos_returns[start:end])
subperiod_pnls.append(sub_pnl)
x = np.arange(n_subperiods)
slope = np.polyfit(x, subperiod_pnls, 1)[0]
return slope
デグラデーション率が強くネガティブな場合 — パラメータはすぐに陳腐化し、より頻繁な再最適化またはより短いトレイン期間が必要です。
Python でのフル WFO パイプライン実装

import numpy as np
import pandas as pd
from dataclasses import dataclass, field
from typing import Callable, List, Optional
import warnings
@dataclass
class WFOWindow:
"""A single walk-forward window."""
window_id: int
train_start: int # train start index
train_end: int # train end index (exclusive)
test_start: int # test start index
test_end: int # test end index (exclusive)
best_params: dict = field(default_factory=dict)
is_pnl: float = 0.0 # in-sample PnL
oos_pnl: float = 0.0 # out-of-sample PnL
oos_returns: np.ndarray = field(default_factory=lambda: np.array([]))
wfer: float = 0.0 # walk-forward efficiency ratio
@dataclass
class WFOResult:
"""Result of the entire WFO."""
windows: List[WFOWindow]
aggregate_oos_pnl: float
aggregate_is_pnl: float
wfer: float
degradation_rate: float
oos_equity: np.ndarray
oos_sharpe: float
oos_max_dd: float
n_windows: int
passed: bool # whether the strategy passed the filter
class WalkForwardOptimizer:
"""
Walk-Forward Optimization pipeline.
Supports anchored (expanding) and rolling (sliding) modes.
"""
def __init__(
self,
data: np.ndarray,
optimize_fn: Callable,
evaluate_fn: Callable,
mode: str = "rolling", # "rolling" or "anchored"
train_size: int = 180, # days
test_size: int = 60, # days
step_size: int = 60, # window step size, days
min_trades: int = 30, # min number of trades in OOS
wfer_threshold: float = 0.5, # WFER threshold for accept/reject
):
self.data = data
self.optimize_fn = optimize_fn
self.evaluate_fn = evaluate_fn
self.mode = mode
self.train_size = train_size
self.test_size = test_size
self.step_size = step_size
self.min_trades = min_trades
self.wfer_threshold = wfer_threshold
def generate_windows(self) -> List[WFOWindow]:
"""Generate walk-forward windows."""
n = len(self.data)
windows = []
window_id = 0
if self.mode == "rolling":
start = 0
while start + self.train_size + self.test_size <= n:
w = WFOWindow(
window_id=window_id,
train_start=start,
train_end=start + self.train_size,
test_start=start + self.train_size,
test_end=min(start + self.train_size + self.test_size, n),
)
windows.append(w)
start += self.step_size
window_id += 1
elif self.mode == "anchored":
train_end = self.train_size
while train_end + self.test_size <= n:
w = WFOWindow(
window_id=window_id,
train_start=0,
train_end=train_end,
test_start=train_end,
test_end=min(train_end + self.test_size, n),
)
windows.append(w)
train_end += self.step_size
window_id += 1
return windows
def run(self) -> WFOResult:
"""Run the full WFO pipeline."""
windows = self.generate_windows()
all_oos_returns = []
for w in windows:
train_data = self.data[w.train_start:w.train_end]
test_data = self.data[w.test_start:w.test_end]
best_params, is_pnl = self.optimize_fn(train_data)
w.best_params = best_params
w.is_pnl = is_pnl
oos_pnl, oos_returns = self.evaluate_fn(test_data, best_params)
w.oos_pnl = oos_pnl
w.oos_returns = oos_returns
if is_pnl != 0:
w.wfer = oos_pnl / is_pnl
else:
w.wfer = 0.0
all_oos_returns.extend(oos_returns)
all_oos = np.array(all_oos_returns)
oos_equity = np.cumprod(1 + all_oos)
peak = np.maximum.accumulate(oos_equity)
max_dd = ((oos_equity - peak) / peak).min()
aggregate_is = sum(w.is_pnl for w in windows)
aggregate_oos = sum(w.oos_pnl for w in windows)
wfer = aggregate_oos / aggregate_is if aggregate_is != 0 else 0
if np.std(all_oos) > 0:
oos_sharpe = np.mean(all_oos) / np.std(all_oos) * np.sqrt(252)
else:
oos_sharpe = 0
deg_rate = self._degradation_rate(windows)
passed = wfer >= self.wfer_threshold and aggregate_oos > 0
return WFOResult(
windows=windows,
aggregate_oos_pnl=aggregate_oos,
aggregate_is_pnl=aggregate_is,
wfer=wfer,
degradation_rate=deg_rate,
oos_equity=oos_equity,
oos_sharpe=oos_sharpe,
oos_max_dd=max_dd,
n_windows=len(windows),
passed=passed,
)
def _degradation_rate(self, windows: List[WFOWindow]) -> float:
"""Slope of OOS PnL across window numbers."""
if len(windows) < 3:
return 0.0
pnls = [w.oos_pnl for w in windows]
x = np.arange(len(pnls))
slope = np.polyfit(x, pnls, 1)[0]
return slope
使用例
import numpy as np
np.random.seed(42)
prices = 100 * np.cumprod(1 + np.random.normal(0.0002, 0.02, 750))
def my_optimize(train_data):
"""
Optimize strategy on train data.
Returns (best_params, is_pnl).
"""
best_pnl = -np.inf
best_params = {}
for fast in range(5, 30, 5):
for slow in range(20, 100, 10):
if fast >= slow:
continue
pnl, _ = _run_strategy(train_data, fast, slow)
if pnl > best_pnl:
best_pnl = pnl
best_params = {"fast": fast, "slow": slow}
return best_params, best_pnl
def my_evaluate(test_data, params):
"""
Evaluate strategy on test data with fixed parameters.
Returns (oos_pnl, oos_returns).
"""
pnl, returns = _run_strategy(test_data, params["fast"], params["slow"])
return pnl, returns
def _run_strategy(data, fast_period, slow_period):
"""Simple MA crossover strategy."""
fast_ma = pd.Series(data).rolling(fast_period).mean().values
slow_ma = pd.Series(data).rolling(slow_period).mean().values
position = 0
returns = []
for i in range(slow_period, len(data) - 1):
if fast_ma[i] > slow_ma[i] and position <= 0:
position = 1
elif fast_ma[i] < slow_ma[i] and position >= 0:
position = -1
daily_ret = (data[i + 1] - data[i]) / data[i]
returns.append(position * daily_ret)
total_pnl = np.sum(returns)
return total_pnl, np.array(returns)
wfo = WalkForwardOptimizer(
data=prices,
optimize_fn=my_optimize,
evaluate_fn=my_evaluate,
mode="rolling",
train_size=180, # 6 months
test_size=60, # 2 months
step_size=60, # step = test
)
result = wfo.run()
print(f"Windows: {result.n_windows}")
print(f"OOS PnL: {result.aggregate_oos_pnl:.4f}")
print(f"IS PnL: {result.aggregate_is_pnl:.4f}")
print(f"WFER: {result.wfer:.3f}")
print(f"OOS Sharpe: {result.oos_sharpe:.2f}")
print(f"OOS MaxDD: {result.oos_max_dd:.2%}")
print(f"Degradation: {result.degradation_rate:.5f}")
print(f"Passed: {result.passed}")
for w in result.windows:
print(f" Window {w.window_id}: IS={w.is_pnl:.4f} OOS={w.oos_pnl:.4f} "
f"WFER={w.wfer:.2f} params={w.best_params}")
結果の解釈:いつ信じるか、いつ却下するか
戦略がWFOに合格した場合
すべてのウィンドウでWFER >= 0.5、OOS PnLがプラスで安定:
Window 0: IS=0.0812 OOS=0.0531 WFER=0.65 params={'fast': 10, 'slow': 50}
Window 1: IS=0.0744 OOS=0.0489 WFER=0.66 params={'fast': 10, 'slow': 50}
Window 2: IS=0.0698 OOS=0.0401 WFER=0.57 params={'fast': 15, 'slow': 50}
Window 3: IS=0.0823 OOS=0.0512 WFER=0.62 params={'fast': 10, 'slow': 60}
Window 4: IS=0.0756 OOS=0.0478 WFER=0.63 params={'fast': 10, 'slow': 50}
→ Aggregate WFER: 0.63, all windows > 0.5, parameters are stable
良い兆候:
- WFERがウィンドウ間で安定(急激なジャンプがない)
- ウィンドウ間でパラメータが類似(fast = 10-15, slow = 50-60)
- ほとんどのウィンドウでOOS PnLがプラス
- デグラデーション率がゼロに近い
戦略がWFOに失敗した場合
Window 0: IS=0.2341 OOS=-0.0312 WFER=-0.13 params={'fast': 5, 'slow': 95}
Window 1: IS=0.1987 OOS=0.0089 WFER=0.04 params={'fast': 25, 'slow': 30}
Window 2: IS=0.2156 OOS=-0.0567 WFER=-0.26 params={'fast': 10, 'slow': 90}
Window 3: IS=0.1834 OOS=0.0234 WFER=0.13 params={'fast': 20, 'slow': 40}
→ Aggregate WFER: -0.07, IS is high, OOS is near zero → overfitting
オーバーフィッティングの兆候:
- IS PnLが高く、OOS PnLが低い/マイナス — 典型的なオーバーフィッティング
- ウィンドウ間でパラメータが大きく変動 — 安定した最適解がない
- ほとんどのウィンドウでWFER < 0.3 — パラメータが移転しない
- デグラデーション率が強くネガティブ — 急速な劣化
パラメータ安定性分析の詳細は、記事プラトー分析をご覧ください。最適解が「鋭い」(小さなパラメータ変更で急激に低下する)場合 — これは追加のオーバーフィッティングシグナルです。
暗号通貨におけるWFOの特殊性

暗号通貨は、伝統的な市場には存在しないWFO特有の問題を生み出します。
レジームスイッチ
暗号通貨市場は根本的に異なるレジーム間を切り替えます:ブルトレンド、ベアトレンド、高/低ボラティリティのレンジ相場。あるレジームで最適なパラメータは、別のレジームでは不採算になる可能性があります。
**解決策:**4-6ヶ月のウィンドウでローリングWFO(アンカードではなく)を使用。これにより古いレジームを「忘れる」ことができます。さらに — ボラティリティ別にデータをクラスタリングし、各クラスタに対して個別にWFOを実行します。
短い履歴
ほとんどのアルトコインは3年未満の取引履歴しかありません。トレイン = 6ヶ月、テスト = 2ヶ月で、4-5ウィンドウしか得られず — 統計的に弱い推定です。
**解決策:**ローリングWFOの代わりに、またはローリングWFOに加えてCPCVを使用。CPCVは同じデータからより多くの組合せを生成します。10グループ、k=2の場合:4-5ウィンドウの代わりに45の組合せ。
構造的な流動性の変化
暗号通貨ペアの流動性は非定常です:ペアが6ヶ月間は流動性があっても、その後取引量が10分の1に減少することがあります。流動性の高い市場で最適化されたパラメータは、流動性の低い市場では機能しません。
**解決策:**WFOパイプラインに流動性フィルターを追加。平均日次取引量が閾値以下のウィンドウを除外。テスト期間の流動性がトレイン期間と同等であることを確認。
Funding Rateの影響
レバレッジを使った先物戦略の場合、Funding RateはOOS結果を根本的に変える可能性があります。戦略が2ヶ月間で+5% OOSを示しても、10倍レバレッジではFunding Rateが3.6%を食い潰します。
Funding Rateの影響の詳細分析は、記事Funding Rateがレバレッジを殺すをご覧ください。WFOでOOS PnLを評価する際は、必ずFunding Rateのコストを考慮してください。
マルチパラメータ戦略:12以上のパラメータでWFOが重要な理由

21パラメータ(分離12 + メタ9)を持つ戦略で、1ペアの25ヶ月のデータは、巨大な探索空間を持つモデルです。
次元の呪い
パラメータの組合せ数は、パラメータの数とともに指数関数的に増加します:
21パラメータのそれぞれが少なくとも10の値を取る場合:
ベイズ最適化(詳細は座標降下法 vs ベイズ法)を使用しても、空間のごくわずかな部分しか探索できません。見つかった最適解がリアルなパターンではなくノイズのアーティファクトである確率は、パラメータの数とともに増加します。
多重比較のBonferroni公式
個のパラメータ組合せをテストする場合、偽の「発見」(偶然に良い結果を見つける)の確率:
、の試行組合せの場合:
「機能する」パラメータが見つかることは保証されています — 実際にはノイズにフィットしているだけです。WFOなしでは、本物のエッジと統計的アーティファクトを区別する方法がありません。
ルール:OOSデータポイント数 vs パラメータ数
WFO結果を信頼するための経験則:
21パラメータの場合、少なくとも210のOOSトレードが必要です。WFOがそれ以下を生成する場合 — 結果を信頼することはできません。
+3342% PnL@MLの戦略:21パラメータ、25ヶ月のデータ。5つのOOSウィンドウ各60日、1日2トレードと仮定 — 合計のOOSトレード。比率 — 許容範囲ですが、WFER > 0.5の場合のみです。
WFOとOptunaの統合

各WFOウィンドウでパラメータを最適化する必要があります。21パラメータの場合、グリッドサーチは不可能で、座標降下法は非効率です。最適な選択は、Optunaによるベイズ最適化です。
import optuna
from optuna.samplers import TPESampler
def optuna_optimize(train_data: np.ndarray, n_trials: int = 500) -> tuple:
"""
Optimize strategy parameters using Optuna.
Used inside each WFO window.
"""
def objective(trial):
fast = trial.suggest_int("fast_period", 3, 50)
slow = trial.suggest_int("slow_period", 20, 200)
atr_period = trial.suggest_int("atr_period", 5, 50)
atr_mult = trial.suggest_float("atr_multiplier", 0.5, 4.0)
rsi_period = trial.suggest_int("rsi_period", 5, 30)
rsi_upper = trial.suggest_int("rsi_upper", 60, 85)
rsi_lower = trial.suggest_int("rsi_lower", 15, 40)
vol_window = trial.suggest_int("vol_window", 10, 100)
position_size = trial.suggest_float("position_size", 0.1, 1.0)
take_profit = trial.suggest_float("take_profit", 0.005, 0.05)
stop_loss = trial.suggest_float("stop_loss", 0.003, 0.03)
trailing_pct = trial.suggest_float("trailing_pct", 0.002, 0.02)
if fast >= slow:
return -1e6 # invalid combination
params = {
"fast_period": fast, "slow_period": slow,
"atr_period": atr_period, "atr_multiplier": atr_mult,
"rsi_period": rsi_period, "rsi_upper": rsi_upper,
"rsi_lower": rsi_lower, "vol_window": vol_window,
"position_size": position_size,
"take_profit": take_profit, "stop_loss": stop_loss,
"trailing_pct": trailing_pct,
}
pnl, _ = run_strategy(train_data, params)
_, returns = run_strategy(train_data, params)
if len(returns) < 30 or np.std(returns) == 0:
return -1e6
sharpe = np.mean(returns) / np.std(returns) * np.sqrt(252)
return sharpe
optuna.logging.set_verbosity(optuna.logging.WARNING)
study = optuna.create_study(
direction="maximize",
sampler=TPESampler(seed=42),
)
study.optimize(objective, n_trials=n_trials, show_progress_bar=False)
best_params = study.best_params
best_pnl, _ = run_strategy(train_data, best_params)
return best_params, best_pnl
wfo = WalkForwardOptimizer(
data=prices,
optimize_fn=optuna_optimize, # Optuna instead of grid search
evaluate_fn=my_evaluate,
mode="rolling",
train_size=180,
test_size=60,
step_size=60,
)
result = wfo.run()
重要:WFO内では、PnLではなくSharpeを最適化してください。PnL最適化は、特定のトレードシーケンスで利益を最大化するパラメータを見つけます。Sharpe最適化は、リターン対リスクの比率が最も良いパラメータを見つけます — OOSでより堅牢です。
Optuna TPEと座標降下法の詳細比較は、記事座標降下法 vs ベイズ法をご覧ください。
WFO結果の可視化
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
def plot_wfo_results(result: WFOResult, data: np.ndarray):
"""Visualize Walk-Forward Optimization results."""
fig, axes = plt.subplots(3, 1, figsize=(16, 14))
ax = axes[0]
ax.plot(result.oos_equity, color='#4FC3F7', linewidth=1.5)
ax.axhline(1.0, color='#FF5252', linestyle='--', alpha=0.5, label='Break-even')
ax.set_title(f'OOS Equity Curve (WFER={result.wfer:.2f}, Sharpe={result.oos_sharpe:.2f})')
ax.set_ylabel('Equity')
ax.legend()
ax.grid(True, alpha=0.3)
ax = axes[1]
wfers = [w.wfer for w in result.windows]
colors = ['#69F0AE' if w >= 0.5 else '#FFAB40' if w >= 0.3 else '#FF5252'
for w in wfers]
ax.bar(range(len(wfers)), wfers, color=colors, edgecolor='#1A237E', alpha=0.8)
ax.axhline(0.5, color='#E040FB', linestyle='--', label='Threshold (0.5)')
ax.axhline(0, color='gray', linestyle='-', alpha=0.3)
ax.set_title('Walk-Forward Efficiency Ratio by Window')
ax.set_xlabel('Window')
ax.set_ylabel('WFER')
ax.legend()
ax = axes[2]
x = np.arange(len(result.windows))
width = 0.35
ax.bar(x - width/2, [w.is_pnl for w in result.windows],
width, label='IS PnL', color='#7C4DFF', alpha=0.7)
ax.bar(x + width/2, [w.oos_pnl for w in result.windows],
width, label='OOS PnL', color='#4FC3F7', alpha=0.7)
ax.set_title('In-Sample vs Out-of-Sample PnL')
ax.set_xlabel('Window')
ax.set_ylabel('PnL')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('wfo_results.png', dpi=150)
plt.show()
実践的な推奨事項
本番環境に戦略を投入する前のチェックリスト
1. WFOを実行(ローリング + アンカード)
両モードの結果を比較。ローリングWFOが失敗してアンカードが合格する場合 — 戦略が初期データでのみ機能する可能性が最も高いです。
2. 各ウィンドウのWFERを確認
集約WFERだけでなく、各ウィンドウを個別に。6ウィンドウ中2つでWFER < 0の場合 — 集約 > 0.5でも問題です。
3. ウィンドウ間でパラメータを比較
最適パラメータがウィンドウごとに「ジャンプ」する場合 — 安定したエッジがありません。最適解の安定性を検証するにはプラトー分析を使用してください。
4. デグラデーション率を確認
デグラデーション率が強くネガティブ = パラメータが急速に効果を失う。より頻繁な再最適化または戦略の見直しが必要です。
5. OOS結果にモンテカルロ・ブートストラップを適用
集約OOS PnLも単一点推定です。OOSリターンの配列にモンテカルロ・ブートストラップを適用して信頼区間を取得してください。
6. コストを考慮
OOS PnLには手数料、スリッページ、Funding Rateを含める必要があります。コストなしの見栄えの良いOOS PnLは幻想です。詳細は — Funding Rateがレバレッジを殺す。
最低データ要件
| パラメータ数 | 最低OOSトレード | 最低WFOウィンドウ | 最低データ(1日2トレード) |
|---|---|---|---|
| 2-5 | 50 | 3 | 約6ヶ月 |
| 6-10 | 100 | 4 | 約12ヶ月 |
| 11-15 | 150 | 5 | 約18ヶ月 |
| 16-21 | 210 | 6 | 約24ヶ月 |
| 22+ | 300+ | 8+ | 約36ヶ月以上 |
21パラメータと25ヶ月のデータの戦略
記事の冒頭の問題に戻りましょう:1ペアの25ヶ月のデータで最適化された21パラメータ。PnL@ML = +3342%。どう検証するか?
ステップ1. ローリングWFO:トレイン = 8ヶ月、テスト = 2ヶ月、ステップ = 2ヶ月。約8ウィンドウを取得。
ステップ2. アンカードWFO:最初のトレイン = 8ヶ月、テスト = 2ヶ月。約8ウィンドウを取得。
ステップ3. CPCV:約2.5ヶ月の10グループ、k = 2。45の組合せを取得。
ステップ4. 各方法について検証:
- WFER >= 0.5?
- ウィンドウ間でパラメータは安定か?
- デグラデーション率は許容範囲か?
- OOSトレード / パラメータ >= 10?
ステップ5. 集約OOSリターンにモンテカルロ・ブートストラップ。5パーセンタイルPnL > 0?
これらのテストのいずれかが失敗した場合 — +3342%の戦略は最も可能性が高くオーバーフィットです。1ペアの25ヶ月で21パラメータ — これは極めて高いパラメータ対データ比率です。WFOに合格しなければ、信頼することはできません。
さらに、アクティブ時間別PnLを考慮した戦略効率の確認も推奨します — これにより、+3342%のうちどの部分がポジション保有時間によるもので、どの部分が実際のエッジによるものかが明らかになります。
結論
ウォークフォワード最適化はオプションではありません — 必需品です。パラメータの新しいデータへの移転可能性を体系的に検証する唯一の方法です。単一のトレイン/テスト分割はくじ引きです。全データでのフルバックテストは自己欺瞞です。
重要なポイント:
-
**WFER < 0.5 = オーバーフィッティング。**アウトオブサンプルPnLがインサンプルの半分未満なら — パラメータはフィットしています。
-
**パラメータの安定性は最大値より重要。**毎ウィンドウで+15%を生むパラメータは、1つで+40%、別の1つで-10%を生むパラメータより優れています。
-
**暗号通貨にはローリングWFO。**レジームスイッチにより、アンカードWFOの信頼性が低下します。4-6ヶ月のローリングウィンドウが最適なバランスです。
-
**パラメータが多いほど — 要件は厳しく。**21パラメータには少なくとも210のOOSトレードと6以上のWFOウィンドウが必要です。これなしでは結果を検証できません。
-
WFO + モンテカルロ・ブートストラップ + プラトー分析 — オーバーフィッティング防護の3層。各層は他の層が見逃すものをキャッチします。
すべてのウィンドウでWFER > 0.5、安定したパラメータ、プラスの5パーセンタイル・ブートストラップでWFOに合格する戦略 — それはリアルマネーを託すことができる戦略です。それ以外はすべて、きれいなエクイティカーブを持つカーブフィッティングです。
参考リンク
- Pardo, R. — The Evaluation and Optimization of Trading Strategies (Wiley)
- Lopez de Prado, M. — Advances in Financial Machine Learning, Chapter 12: Backtesting
- Bailey, D.H. et al. — The Probability of Backtest Overfitting
- Lopez de Prado, M. — The Combinatorial Purged Cross-Validation (CPCV)
- Aronson, D.R. — Evidence-Based Technical Analysis
- Optuna: A Next-generation Hyperparameter Optimization Framework
- Kevin Davey — Building Winning Algorithmic Trading Systems: Walk-Forward Analysis
- White, H. — A Reality Check for Data Snooping (2000)
- Harvey, C.R. & Liu, Y. — Backtesting (2015)
- NumPy — numpy.cumprod
Citation
@article{soloviov2026walkforwardoptimization,
author = {Soloviov, Eugen},
title = {Walk-Forward Optimization: The Only Honest Strategy Test},
year = {2026},
url = {https://marketmaker.cc/en/blog/post/walk-forward-optimization},
version = {0.1.0},
description = {Why a single train/test split does not protect against overfitting, how walk-forward optimization systematically verifies parameter robustness, and why a strategy with +3342\% PnL@ML on 21 parameters is a ticking time bomb without WFO.}
}
MarketMaker.cc Team
クオンツ・リサーチ&戦略