← 返回文章列表
March 15, 2026
5 分钟阅读

Walk-Forward 优化:唯一诚实的策略测试方法

Walk-Forward 优化:唯一诚实的策略测试方法
#算法交易
#回测
#walk-forward
#过拟合
#验证
#优化

你优化了一个策略。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.}
}
免责声明:本文提供的信息仅用于教育和参考目的,不构成财务、投资或交易建议。加密货币交易涉及重大损失风险。

MarketMaker.cc Team

量化研究与策略

在 Telegram 中讨论
Newsletter

紧跟市场步伐

订阅我们的时事通讯,获取独家 AI 交易见解、市场分析和平台更新。

我们尊重您的隐私。您可以随时退订。