MarketMaker.cc Team
量化研究与策略
MarketMaker.cc Team
量化研究与策略
你优化了一个策略。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 优化存在的意义。

WFO 是一种在滑动(或扩展)数据窗口上对策略进行顺序优化和验证的程序。其理念是:模拟真实的交易过程,你定期在可用数据上重新优化参数,然后交易直到下一次重新优化。
每个"窗口"由两部分组成:
关键属性:OOS 时期不重叠,且共同覆盖数据的很大一部分。最终的权益曲线仅由 OOS 段构建——这才是对策略的诚实评估。
在锚定式 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 中,固定长度的训练期在数据上"滑动":
窗口 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]
优势:
劣势:
这是 Marcos Lopez de Prado 提出的高级方法。数据被分成 组,从中选择 组用于测试。与普通交叉验证的关键区别在于 清洗(purging,删除训练/测试边界的数据)和 隔离(embargo,防止数据泄漏的额外间隔):
当 时: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 作为额外检验。
训练期太短——数据不足以进行可靠的优化。太长——旧数据稀释当前模式。
**经验法则:**训练期应至少包含 200-300 笔交易。如果策略每天进行 2 笔交易:
对于具有状态切换特征的加密货币,我们建议滚动窗口不超过 6-12 个月。
测试期必须足以进行统计显著性评估,但不能太长——否则参数有时间退化。
**规则:**测试期 = 训练期的 20-33%。如果训练期 = 6 个月,测试期 = 1.5-2 个月。
在滚动式 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 个月。更频繁的重新优化增加过拟合噪声的风险;更不频繁——参数过时的风险。

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:
"""
估计参数退化率。
将 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
如果退化率强烈为负——参数很快过时,你需要更频繁的重新优化或更短的训练期。
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}")
如果所有窗口的 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,参数稳定
良好迹象:
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 接近零 → 过拟合
过拟合迹象:
更多关于参数稳定性分析的内容——请参阅文章 Plateau 分析。如果最优点是"尖锐的"(参数微小变化就急剧下降)——这是过拟合的额外信号。
加密货币为 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 时,务必考虑资金费率成本。
具有 21 个参数(12 个分离参数 + 9 个元参数)、单一交易对 25 个月数据的策略是一个搜索空间巨大的模型。
参数组合数量随参数数量呈指数增长:
如果 21 个参数中每个至少取 10 个值:
即使使用贝叶斯优化(详见 坐标下降 vs 贝叶斯),你也只探索了空间中微不足道的一小部分。找到的最优点是噪声伪像而非真实模式的概率随参数数量增加而增长。
如果你测试了 种参数组合,虚假"发现"的概率(偶然找到好结果):
当 且 种已尝试的组合时:
保证能找到"有效"的参数——但实际上是拟合了噪声。没有 WFO,你无法区分真实的优势和统计伪像。
信任 WFO 结果的经验法则:
对于 21 个参数,你至少需要 210 笔 OOS 交易。如果你的 WFO 产生的更少——结果不可信。
PnL@ML 为 +3342% 的策略:21 个参数,25 个月数据。假设 5 个 OOS 窗口,每个 60 天,每天 2 笔交易——总共 笔 OOS 交易。比率 ——可接受,但仅当 WFER > 0.5 时。
在每个 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 贝叶斯。
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+ 个月 |
回到文章开头的问题:在单一交易对 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. 对每种方法验证:
步骤 5. 对聚合 OOS 收益进行蒙特卡洛 Bootstrap。第 5 百分位 PnL > 0?
如果这些测试中有任何一个失败——+3342% 的策略很可能过拟合了。单一交易对 25 个月上的 21 个参数——这是极高的参数与数据之比。没有通过 WFO,就不能信任。
我们还建议结合 按活跃时间计算的 PnL 来检查策略效率——这将揭示 +3342% 中有多少是由持仓时间贡献的,多少是真实优势。
Walk-Forward 优化不是可选项——它是必需品。这是唯一能系统性验证参数向新数据迁移能力的方法。单次训练/测试分割是抽彩票。在所有数据上的完整回测是自我欺骗。
关键要点:
**WFER < 0.5 = 过拟合。**如果样本外 PnL 低于样本内的一半——参数被拟合了。
**参数稳定性比最大值更重要。**在每个窗口产生 +15% 的参数优于在一个窗口产生 +40% 而在另一个产生 -10% 的参数。
**加密货币用滚动式 WFO。**状态切换使锚定式 WFO 不太可靠。4-6 个月的滚动窗口是最佳平衡。
**参数越多——要求越严格。**21 个参数需要至少 210 笔 OOS 交易和 6+ 个 WFO 窗口。否则结果无法验证。
WFO + 蒙特卡洛 Bootstrap + Plateau 分析 ——三层过拟合防护。每层都能捕获其他层遗漏的问题。
通过 WFO 且所有窗口 WFER > 0.5、参数稳定、第 5 百分位 Bootstrap 为正的策略——这才是你可以用真钱信任的策略。其他一切都是带有漂亮权益曲线的曲线拟合。
@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.} }