← 返回文章列表
March 15, 2026
5 分鐘閱讀

Walk-Forward 最佳化:唯一誠實的策略測試方法

#演算法交易
#回測
#walk-forward
#過擬合
#驗證
#最佳化
🎯
Part 3 of 9 · Collection
Backtesting Without Fooling Yourself

你優化了一個策略。12 個分離參數,9 個元參數——總共 21 個。在單一交易對上 25 個月的回測顯示 PnL 達 +3342%(最大槓桿)。權益曲線幾乎沒有回撤地上升。夏普比率超過 3。一切看起來完美。

你啟動了機器人。兩週後,策略虧損了 18% 的資金。一個月後——虧損 34%。那些在歷史資料上"有效"的參數,原來只是擬合了特定的市場事件序列。你並沒有發現規律——你只是記住了噪聲。

這就是經典的過擬合。而在投入生產之前發現它的唯一系統性方法就是 Walk-Forward 最佳化(WFO)。

單次訓練/測試分割的陷阱

訓練/測試分割陷阱視覺化

標準方法:將資料分為 70% 訓練集和 30% 測試集。在訓練集上最佳化,在測試集上驗證。如果結果為正——就上線。

問題是:這只是在一個分割上的一次測試。結果取決於你在哪裡劃分邊界。將邊界移動一個月——樣本外 PnL 就可能從 +40% 變為 -15%。

数据:   |===== 训练 (70%) =====|== 测试 (30%) ==|
分割 1: |===2024-01..2025-09====|==2025-10..26-01==|  → OOS PnL: +38%
分割 2: |===2024-01..2025-06====|==2025-07..26-01==|  → OOS PnL: -12%
分割 3: |===2024-04..2025-12====|==2026-01..26-04==|  → OOS PnL: +7%

三種不同的分割——三種不同的結論。該信哪個?都不該信。單次訓練/測試分割就是同樣的單點估計,我們在 蒙特卡洛 Bootstrap 中已經討論過它的問題。你需要的不是一次檢驗,而是在連續資料段上的系統性系列檢驗。

這正是 Walk-Forward 最佳化存在的意義。

什麼是 Walk-Forward 最佳化

Walk-forward rolling windows diagram

WFO 是一種在滑動(或擴充)資料視窗上對策略進行順序最佳化和驗證的程式。其理念是:模擬真實的交易過程,你定期在可用資料上重新最佳化參數,然後交易直到下一次重新最佳化。

每個"視窗"由兩部分組成:

  • 樣本內(IS) ——用於最佳化參數的時期
  • 樣本外(OOS) ——用於不做擬合地測試已找到參數的時期

關鍵屬性:OOS 時期不重疊,且共同覆蓋資料的很大一部分。最終的權益曲線由 OOS 段構建——這才是對策略的誠實評估。

錨定式 WFO(擴充視窗)

錨定式 WFO 擴充視窗視覺化

在錨定式 WFO 中,訓練期的起點固定,而其終點隨每個視窗擴充:

窗口 1: 训练 [2024-01]         测试 [2024-04]
窗口 2: 训练 [2024-01..04]     测试 [2024-07]    (训练期增长)
窗口 3: 训练 [2024-01..07]     测试 [2024-10]
窗口 4: 训练 [2024-01..10]     测试 [2025-01]
窗口 5: 训练 [2024-01..2025-01]  测试 [2025-04]

優勢:

  • 每個後續訓練期包含更多資料——最佳化更穩定
  • 早期模式不會丟失——它們始終在訓練集中
  • 實現更簡單

劣勢:

  • 舊資料可能"稀釋"當前模式
  • 如果市場結構性改變——舊資料有害
  • 訓練期無限增長,增加最佳化時間

滾動式 WFO(滑動視窗)

在滾動式 WFO 中,固定長度的訓練期在資料上"滑動":

窗口 1: 训练 [2024-01..06]  测试 [2024-07..09]
窗口 2: 训练 [2024-04..09]  测试 [2024-10..12]
窗口 3: 训练 [2024-07..12]  测试 [2025-01..03]
窗口 4: 训练 [2024-10..2025-03]  测试 [2025-04..06]
窗口 5: 训练 [2025-01..06]  测试 [2025-07..09]

優勢:

  • 適應當前市場狀態
  • 最佳化時間恆定
  • 舊的、不相關的資料不會產生影響

劣勢:

  • 訓練資料較少——最優參數方差更高
  • 對視窗長度選擇敏感
  • 可能"遺忘"罕見但重要的事件(閃崩)

組合清洗交叉驗證(CPCV)

組合清洗交叉驗證視覺化

這是 Marcos Lopez de Prado 提出的高階方法。資料被分成 NN 組,從中選擇 kk 組用於測試。與普通交叉驗證的關鍵區別在於 清洗(purging,刪除訓練/測試邊界的資料)和 隔離(embargo,防止資料洩漏的額外間隔):

组合数=(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):
    """
    生成带清洗的 CPCV 分割。

    Args:
        n_groups: 分组数
        k_test: 每次分割中的测试组数
        purge_pct: 清洗数据比例(在训练/测试边界)
    """
    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):
    """训练/测试边界处需要清洗的分组。"""
    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 筆交易。如果策略每天進行 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 個月。

重疊(Overlap)

在滾動式 WFO 中,視窗可以重疊。重疊增加 OOS 資料點數量,但引入估計之間的相關性:

无重叠:
  训练 [01..06] → 测试 [07..09]
  训练 [07..12] → 测试 [01..03]

50% 重叠:
  训练 [01..06] → 测试 [07..09]
  训练 [04..09] → 测试 [10..12]
  训练 [07..12] → 测试 [01..03]

建議:訓練期 50% 重疊——在視窗數量和估計獨立性之間取得良好平衡。

重新最佳化頻率

決定你多久重新計算一次參數。在加密市場中,最佳頻率為每 1-3 個月。更頻繁的重新最佳化增加過擬合噪聲的風險;更不頻繁——參數過時的風險。

Walk-Forward 效率比和退化率

Walk-forward efficiency ratio and degradation visualization

Walk-Forward 效率比(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:
    """
    估计参数退化率。

    将 OOS 期间分为子区间,计算 PnL 对子区间编号的
    线性回归斜率。

    Returns:
        slope: 负值 = 退化,正值 = 改善
    """
    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

如果退化率強烈為負——參數很快過時,你需要更頻繁的重新最佳化或更短的訓練期。

完整的 WFO 管道 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:
    """单个 walk-forward 窗口。"""
    window_id: int
    train_start: int         # 训练起始索引
    train_end: int           # 训练结束索引(不包含)
    test_start: int          # 测试起始索引
    test_end: int            # 测试结束索引(不包含)
    best_params: dict = field(default_factory=dict)
    is_pnl: float = 0.0     # 样本内 PnL
    oos_pnl: float = 0.0    # 样本外 PnL
    oos_returns: np.ndarray = field(default_factory=lambda: np.array([]))
    wfer: float = 0.0       # walk-forward 效率比

@dataclass
class WFOResult:
    """整个 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             # 策略是否通过了过滤器

class WalkForwardOptimizer:
    """
    Walk-Forward 优化管道。

    支持锚定式(扩展)和滚动式(滑动)模式。
    """

    def __init__(
        self,
        data: np.ndarray,
        optimize_fn: Callable,
        evaluate_fn: Callable,
        mode: str = "rolling",         # "rolling" 或 "anchored"
        train_size: int = 180,         # 天
        test_size: int = 60,           # 天
        step_size: int = 60,           # 窗口步长,天
        min_trades: int = 30,          # OOS 中最少交易数
        wfer_threshold: float = 0.5,   # 接受/拒绝的 WFER 阈值
    ):
        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]:
        """生成 walk-forward 窗口。"""
        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:
        """运行完整的 WFO 管道。"""
        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:
        """OOS PnL 按窗口编号的斜率。"""
        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):
    """
    在训练数据上优化策略。
    返回 (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):
    """
    使用固定参数在测试数据上评估策略。
    返回 (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):
    """简单的 MA 交叉策略。"""
    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 个月
    test_size=60,      # 2 个月
    step_size=60,      # 步长 = 测试期
)

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}
→ 聚合 WFER: 0.63,所有窗口 > 0.5,参数稳定

良好跡象:

  • 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}
→ 聚合 WFER: -0.07,IS 很高,OOS 接近零 → 过拟合

過擬合跡象:

  • IS PnL 高,OOS PnL 低/為負 ——經典過擬合
  • 各視窗間參數差異很大 ——沒有穩定的最優點
  • 大多數視窗 WFER < 0.3 ——參數無法遷移
  • 退化率強烈為負 ——快速退化

更多關於參數穩定性分析的內容——請參閱文章 Plateau 分析。如果最優點是"尖銳的"(參數微小變化就急劇下降)——這是過擬合的額外訊號。

加密貨幣 WFO 的特殊性

加密貨幣 WFO 特殊性視覺化

加密貨幣為 WFO 帶來了傳統市場不存在的獨特問題。

狀態切換

加密市場在截然不同的狀態之間切換:牛市趨勢、熊市趨勢、高/低波動率的橫盤。在一種狀態下最優的參數在另一種狀態下可能是虧損的。

**解決方案:**使用滾動式 WFO(非錨定式),視窗為 4-6 個月。這可以"遺忘"舊狀態。此外——按波動率聚類資料,對每個聚類分別執行 WFO。

歷史資料短

大多數山寨幣的交易歷史不到 3 年。訓練期 = 6 個月、測試期 = 2 個月的情況下,你只能得到 4-5 個視窗——統計估計很弱。

**解決方案:**使用 CPCV 代替或補充滾動式 WFO。CPCV 從相同資料中生成更多組合。對於 10 組、k=2:45 種組合而非 4-5 個視窗。

流動性結構性變化

加密交易對的流動性是非平穩的:一個交易對可能在 6 個月內流動性良好,然後交易量下降 10 倍。在流動性好的市場上最佳化的參數在流動性差的市場上不起作用。

**解決方案:**在 WFO 管道中新增流動性過濾器。排除平均日交易量低於閾值的視窗。驗證測試期的流動性與訓練期可比。

資金費率的影響

對於帶槓桿的期貨策略,資金費率可能從根本上改變 OOS 結果。策略顯示 2 個月 OOS +5%,但在 10 倍槓桿下,資金費率消耗 3.6%。

資金費率影響的詳細分析——請參閱我們的文章 資金費率扼殺你的槓桿。在 WFO 中評估 OOS PnL 時,務必考慮資金費率成本。

多參數策略:為什麼 12+ 參數時 WFO 至關重要

多參數最佳化中的維度災難

具有 21 個參數(12 個分離參數 + 9 個元參數)、單一交易對 25 個月資料的策略是一個搜尋空間巨大的模型。

維度災難

參數組合數量隨參數數量呈指數增長:

Combinations=i=1nPi\text{Combinations} = \prod_{i=1}^{n} |P_i|

如果 21 個參數中每個至少取 10 個值:

1021=10 万亿亿种组合10^{21} = 10\ \text{万亿亿种组合}

即使使用貝葉斯最佳化(詳見 座標下降 vs 貝葉斯),你也只探索了空間中微不足道的一小部分。找到的最優點是噪聲偽像而非真真實模式的機率隨參數數量增加而增長

多重比較的 Bonferroni 公式

如果你測試了 MM 種參數組合,虛假"發現"的機率(偶然找到好結果):

P(false discovery)=1(1α)M1eαMP(\text{false discovery}) = 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 資料點數與參數數之比

信任 WFO 結果的經驗法則:

OOS 交易数参数数>10\frac{\text{OOS 交易数}}{\text{参数数}} > 10

對於 21 個參數,你至少需要 210 筆 OOS 交易。如果你的 WFO 產生的更少——結果不可信。

PnL@ML 為 +3342% 的策略:21 個參數,25 個月資料。假設 5 個 OOS 視窗,每個 60 天,每天 2 筆交易——總共 5×60×2=6005 \times 60 \times 2 = 600 筆 OOS 交易。比率 600/21=28.6600/21 = 28.6——可接受,但僅當 WFER > 0.5 時。

將 WFO 與 Optuna 整合

貝葉斯最佳化與 Optuna 整合視覺化

在每個 WFO 視窗中,你需要最佳化參數。對於 21 個參數,網格搜尋不可行,座標下降效率低下。最優選擇是通過 Optuna 進行貝葉斯最佳化。

import optuna
from optuna.samplers import TPESampler

def optuna_optimize(train_data: np.ndarray, n_trials: int = 500) -> tuple:
    """
    使用 Optuna 优化策略参数。
    在每个 WFO 窗口内使用。
    """

    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  # 无效组合

        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 代替网格搜索
    evaluate_fn=my_evaluate,
    mode="rolling",
    train_size=180,
    test_size=60,
    step_size=60,
)

result = wfo.run()

**重要:**在 WFO 內部最佳化 夏普比率,而非 PnL。PnL 最佳化找到的是在特定交易序列上最大化利潤的參數。夏普比率最佳化找到的是具有最佳收益風險比的參數——它們在 OOS 上更穩健。

Optuna TPE 與座標下降的詳細比較——請參閱文章 座標下降 vs 貝葉斯

WFO 結果視覺化

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches

def plot_wfo_results(result: WFOResult, data: np.ndarray):
    """可视化 Walk-Forward 优化结果。"""
    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 效率比')
    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('样本内 vs 样本外 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. 比較各視窗間的參數

如果最優參數在各視窗間"跳躍"——沒有穩定的優勢。使用 Plateau 分析 來驗證最優點的穩定性。

4. 檢查退化率

強烈為負的退化率 = 參數快速失效。需要更頻繁的重新最佳化或重新審視策略。

5. 對 OOS 結果應用蒙特卡洛 Bootstrap

聚合 OOS PnL 也是單點估計。對 OOS 收益陣列應用 蒙特卡洛 Bootstrap 以獲取置信區間。

6. 考慮成本

OOS PnL 必須包含手續費、滑點和資金費率。沒有成本的漂亮 OOS PnL 是幻覺。更多詳情——資金費率扼殺你的槓桿

最低資料要求

參數數量 最少 OOS 交易數 最少 WFO 視窗數 最少資料量(每天 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 個月資料的策略

回到文章開頭的問題:在單一交易對 25 個月資料上最佳化的 21 個參數。PnL@ML = +3342%。如何驗證?

步驟 1. 滾動式 WFO:訓練 = 8 個月,測試 = 2 個月,步長 = 2 個月。得到約 8 個視窗。

步驟 2. 錨定式 WFO:首個訓練 = 8 個月,測試 = 2 個月。得到約 8 個視窗。

步驟 3. CPCV:10 組,每組約 2.5 個月,k = 2。得到 45 種組合。

步驟 4. 對每種方法驗證:

  • WFER >= 0.5?
  • 各視窗間參數穩定?
  • 退化率可接受?
  • OOS 交易數 / 參數數 >= 10?

步驟 5. 對聚合 OOS 收益進行蒙特卡洛 Bootstrap。第 5 百分位 PnL > 0?

如果這些測試中有任何一個失敗——+3342% 的策略很可能過擬合了。單一交易對 25 個月上的 21 個參數——這是極高的參數與資料之比。沒有通過 WFO,就不能信任。

我們還建議結合 按活躍時間計算的 PnL 來檢查策略效率——這將揭示 +3342% 中有多少是由持倉時間貢獻的,多少是真實優勢。

結論

Walk-Forward 最佳化不是可選項——它是必需品。這是唯一能系統性驗證參數向新資料遷移能力的方法。單次訓練/測試分割是抽彩票。在所有資料上的完整回測是自我欺騙。

關鍵要點:

  1. **WFER < 0.5 = 過擬合。**如果樣本外 PnL 低於樣本內的一半——參數被擬合了。

  2. **參數穩定性比最大值更重要。**在每個視窗產生 +15% 的參數優於在一個視窗產生 +40% 而在另一個產生 -10% 的參數。

  3. **加密貨幣用滾動式 WFO。**狀態切換使錨定式 WFO 不太可靠。4-6 個月的滾動視窗是最佳平衡。

  4. **參數越多——要求越嚴格。**21 個參數需要至少 210 筆 OOS 交易和 6+ 個 WFO 視窗。否則結果無法驗證。

  5. WFO + 蒙特卡洛 Bootstrap + Plateau 分析 ——三層過擬合防護。每層都能捕獲其他層遺漏的問題。

通過 WFO 且所有視窗 WFER > 0.5、參數穩定、第 5 百分位 Bootstrap 為正的策略——這才是你可以用真錢信任的策略。其他一切都是帶有漂亮權益曲線的曲線擬合。


參考連結

  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

引用

@article{soloviov2026walkforwardoptimization,
  author = {Soloviov, Eugen},
  title = {Walk-Forward Optimization: The Only Honest Strategy Test},
  year = {2026},
  url = {https://marketmaker.cc/zh/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.}
}
免責宣告:本文提供的資訊僅用於教育和參考目的,不構成財務、投資或交易建議。加密貨幣交易涉及重大損失風險。

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

緊跟市場步伐

訂閱我們的時事通訊,獲取獨家 AI 交易見解、市場分析和平台更新。

我們尊重您的隱私。您可以隨時退訂。