← 記事一覧に戻る
March 15, 2026
読了時間: 5分

ウォークフォワード最適化:唯一の誠実な戦略テスト

ウォークフォワード最適化:唯一の誠実な戦略テスト
#algotrading
#backtest
#walk-forward
#overfitting
#validation
#optimization

戦略を最適化しました。分離パラメータ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拡張ウィンドウの可視化

アンカード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が提案した高度な手法。データをNNグループに分割し、そこからkk個をテスト用に選択します。標準的な交差検証との主な違いは、パージ(トレイン/テスト境界のデータ除去)とエンバーゴ(データリーケージを防ぐための追加ギャップ)です:

組合せ数=(Nk)\text{組合せ数} = \binom{N}{k}

N=10,k=2N = 10, k = 2の場合: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の主要パラメータ

WFOの主要パラメータの可視化

トレイン期間の長さ

トレインが短すぎると — 信頼性の高い最適化に十分なデータがありません。長すぎると — 古いデータが現在のパターンを薄めます。

**経験則:**トレインには少なくとも200-300のトレードが含まれるべきです。戦略が1日2トレードを行う場合:

Tmin=300 トレード2 トレード/日=150 日5 ヶ月T_{min} = \frac{300\ \text{トレード}}{2\ \text{トレード/日}} = 150\ \text{日} \approx 5\ \text{ヶ月}

レジームスイッチのある暗号通貨の場合、ローリングウィンドウは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=PnLOOSPnLIS\text{WFER} = \frac{\text{PnL}_{OOS}}{\text{PnL}_{IS}}

解釈:

WFER 解釈
> 0.8 優れた堅牢性。パラメータが新しいデータに移転可能。
0.5 — 0.8 許容範囲の堅牢性。戦略は機能するが劣化あり。
0.3 — 0.5 ボーダーラインケース。部分的なオーバーフィッティングの可能性。
< 0.3 オーバーフィッティング。パラメータがISデータにフィット。
< 0 戦略がOOSで不採算。完全なオーバーフィッティングまたはロジックエラー。

**WFER < 0.5の場合 — 戦略は最も可能性が高くオーバーフィットです。**これが私たちの主要フィルターです。

デグラデーション率

最適パラメータが時間とともにどのくらい速く効果を失うかを示します:

デグラデーション率=d(OOS PnL)dt\text{デグラデーション率} = \frac{d(\text{OOS PnL})}{dt}

実際には:テスト期間をサブインターバルに分割し、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 パイプライン実装

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の特殊性の可視化

暗号通貨は、伝統的な市場には存在しない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ヶ月のデータは、巨大な探索空間を持つモデルです。

次元の呪い

パラメータの組合せ数は、パラメータの数とともに指数関数的に増加します:

組合せ=i=1nPi\text{組合せ} = \prod_{i=1}^{n} |P_i|

21パラメータのそれぞれが少なくとも10の値を取る場合:

1021=10 垓の組合せ10^{21} = 10\ \text{垓の組合せ}

ベイズ最適化(詳細は座標降下法 vs ベイズ法)を使用しても、空間のごくわずかな部分しか探索できません。見つかった最適解がリアルなパターンではなくノイズのアーティファクトである確率は、パラメータの数とともに増加します

多重比較のBonferroni公式

MM個のパラメータ組合せをテストする場合、偽の「発見」(偶然に良い結果を見つける)の確率:

P(偽発見)=1(1α)M1eαMP(\text{偽発見}) = 1 - (1 - \alpha)^M \approx 1 - e^{-\alpha M}

α=0.05\alpha = 0.05M=10000M = 10000の試行組合せの場合:

P1e5001.0P \approx 1 - e^{-500} \approx 1.0

「機能する」パラメータが見つかることは保証されています — 実際にはノイズにフィットしているだけです。WFOなしでは、本物のエッジと統計的アーティファクトを区別する方法がありません。

ルール:OOSデータポイント数 vs パラメータ数

WFO結果を信頼するための経験則:

OOSトレードパラメータ>10\frac{\text{OOSトレード}}{\text{パラメータ}} > 10

21パラメータの場合、少なくとも210のOOSトレードが必要です。WFOがそれ以下を生成する場合 — 結果を信頼することはできません。

+3342% PnL@MLの戦略:21パラメータ、25ヶ月のデータ。5つのOOSウィンドウ各60日、1日2トレードと仮定 — 合計5×60×2=6005 \times 60 \times 2 = 600のOOSトレード。比率600/21=28.6600/21 = 28.6 — 許容範囲ですが、WFER > 0.5の場合のみです。

WFOとOptunaの統合

OptunaとのBayesian最適化統合

各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%のうちどの部分がポジション保有時間によるもので、どの部分が実際のエッジによるものかが明らかになります。

結論

ウォークフォワード最適化はオプションではありません — 必需品です。パラメータの新しいデータへの移転可能性を体系的に検証する唯一の方法です。単一のトレイン/テスト分割はくじ引きです。全データでのフルバックテストは自己欺瞞です。

重要なポイント:

  1. **WFER < 0.5 = オーバーフィッティング。**アウトオブサンプルPnLがインサンプルの半分未満なら — パラメータはフィットしています。

  2. **パラメータの安定性は最大値より重要。**毎ウィンドウで+15%を生むパラメータは、1つで+40%、別の1つで-10%を生むパラメータより優れています。

  3. **暗号通貨にはローリングWFO。**レジームスイッチにより、アンカードWFOの信頼性が低下します。4-6ヶ月のローリングウィンドウが最適なバランスです。

  4. **パラメータが多いほど — 要件は厳しく。**21パラメータには少なくとも210のOOSトレードと6以上のWFOウィンドウが必要です。これなしでは結果を検証できません。

  5. WFO + モンテカルロ・ブートストラップ + プラトー分析 — オーバーフィッティング防護の3層。各層は他の層が見逃すものをキャッチします。

すべてのウィンドウでWFER > 0.5、安定したパラメータ、プラスの5パーセンタイル・ブートストラップでWFOに合格する戦略 — それはリアルマネーを託すことができる戦略です。それ以外はすべて、きれいなエクイティカーブを持つカーブフィッティングです。


参考リンク

  1. Pardo, R. — The Evaluation and Optimization of Trading Strategies (Wiley)
  2. Lopez de Prado, M. — Advances in Financial Machine Learning, Chapter 12: Backtesting
  3. Bailey, D.H. et al. — The Probability of Backtest Overfitting
  4. Lopez de Prado, M. — The Combinatorial Purged Cross-Validation (CPCV)
  5. Aronson, D.R. — Evidence-Based Technical Analysis
  6. Optuna: A Next-generation Hyperparameter Optimization Framework
  7. Kevin Davey — Building Winning Algorithmic Trading Systems: Walk-Forward Analysis
  8. White, H. — A Reality Check for Data Snooping (2000)
  9. Harvey, C.R. & Liu, Y. — Backtesting (2015)
  10. 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.}
}
blog.disclaimer

MarketMaker.cc Team

クオンツ・リサーチ&戦略

Telegramで議論する
Newsletter

市場の先を行く

ニュースレターを購読して、独占的なAI取引の洞察、市場分析、プラットフォームの更新情報を受け取りましょう。

プライバシーを尊重します。いつでも配信停止可能です。