主题切换
4.10 Walk-Forward 滚动回测
概念详解
Walk-Forward(WF)滚动回测是量化研究领域的"黄金标准",和它对应的就是过拟合的"灵丹妙药"。其核心思想是:永远不用未来数据训练,只用过去数据预测将来。具体做法是把整个数据集切成多个"时间窗口",每个窗口分"训练期"和"测试期",用训练期找出最优参数,在测试期验证;然后整个窗口向前滚动一格,重复这个过程。
名词解释:Walk-Forward 滚动回测
Walk-Forward 回测是时序数据的"滚动评估"方法。核心思想是:训练数据 → 找最优参数 → 测试数据验证 → 窗口向前滚 → 重复。这样得到的"样本外"测试结果在多个不相连时间段上累积,比传统单次分割(training/test split)更稳健。
名词解释:Decay Curve (衰减曲线)
Decay Curve 描述"训练期表现"与"测试期表现"之间的关系。典型形态:训练期夏普 = 2.0,测试期 1 步外夏普 = 1.5,5 步外夏普 = 0.8,10 步外夏普 = 0.3。衰减越快,说明策略对市场变化越敏感。
为什么 WF 这么重要?
| 评估方法 | 评估价值 | 过拟合风险 |
|---|---|---|
| 只看样本内 (In-Sample, IS) | 低(看历史) | 极高 |
| 传统 Train/Test 拆分 | 中等(只能看一次测试集) | 高 |
| K 折交叉验证 (随机) | 中等 | 时序数据陷阱! |
| Walk-Forward + Decay 分析 | 高 | 极低 |
关键洞察:多数策略在样本内都能找到高夏普,但 WF 后留存多少才是"真价值"——这才是策略能否上线的试金石。
与 K 折交叉验证的区别
K 折交叉验证(K-Fold CV)是机器学习的金标准,但对时序数据致命错误:
K-Fold (错误用法):
- 数据随机打散成 K 份
- 第 1 轮: 1 测试,2-9 训练
- 第 2 轮: 2 测试,1+3-9 训练
- ...
❌ 致命问题:第 1 轮用 9 月数据训练,然后预测 1 月样本 — 这就是未来数据时序数据必须用过去预测将来,所以必须用 Walk-Forward:
Walk-Forward (正确用法):
- 第 1 轮: 训练 [1-1000], 测试 [1001-1200]
- 第 2 轮: 训练 [201-1200], 测试 [1201-1400]
- 第 3 轮: 训练 [401-1400], 测试 [1401-1600]
✅ 严格保证每轮只用训练期数据预测测试期Combinatorial Purged Cross-Validation (CPCV)
CPCV 是 Marcos López de Prado 在 Advances in Financial Machine Learning 中提出的更严格的评估方法:
- 数据分 N 段(类似 K-Fold 但段在时序上)
- 所有可能的 K 段组合作为测试集(其他为训练集)
- Crucial point:在训练段和测试段之间留出 purge gap,删除与测试段有时间相关性的训练样本(防止标签泄漏)
优点:用更多组合,减少"运气"成分。 缺点:计算量大,需要 N=8 就有 C(N,K)=70 种组合。
数学原理
1. Walk-Forward 的核心思想
设历史数据
关键:每轮只用
2. 累积样本外收益
每轮 WF 都有一个测试期样本外(OOS)夏普
3. 参数稳定性
WF 还能检验"最优参数是否稳定"。统计每轮最优参数
如果每轮最优参数差异巨大(如 fast=10 与 fast=80 交替出现),说明参数无稳定性,策略没有捕获真实规律。
4. Decay 曲线数学
设 WF 第 1 轮训练得到的参数
典型 Decay 形态:
:Sharpe 接近 IS,偶尔更高(上线初期) :Sharpe 略下降 :Sharpe 显著下降(策略"老化") :Sharpe → 0(策略完全失效,等价随机)
5. Purge Gap 的作用
时间序列中存在标签延续性(autoregressive autocorrelation)。即使训练期在测试期之前,如果标签是"未来 5 日收益",那么训练样本的标签会"延伸"到测试集,产生泄漏。
设标签向前看
训练段: -----------| (purge gap) |------------ 测试段
训练样本 删除 未知样本经验法则:purge gap ≥ 标签向前看的最大步长 + 1 天。
Python实战
📌 案例:双均线策略的 Walk-Forward 实现
此处有展示代码展开 ▼
python
import numpy as np
import pandas as pd
from typing import List, Tuple, Dict
def generate_test_data(n_days: int = 2000) -> pd.Series:
"""生成一段 Heston 风格的合成价格序列(让双均线策略有可优化的空间)"""
np.random.seed(42)
dates = pd.bdate_range('2016-01-01', periods=n_days)
# 模拟不同 regime:前 1000 天是趋势,后 1000 天震荡
mu_regime = np.concatenate([
np.full(1000, 0.0008),
np.full(n_days - 1000, -0.0002),
])
sigma_regime = np.concatenate([
np.full(1000, 0.015),
np.full(n_days - 1000, 0.025),
])
returns = np.zeros(n_days)
for i in range(1, n_days):
returns[i] = np.random.normal(mu_regime[i], sigma_regime[i])
prices = 100 * np.exp(np.cumsum(returns))
return pd.Series(prices, index=dates, name='Price')
def double_ma_backtest(prices: pd.Series, fast: int, slow: int,
fee_rate: float = 0.0003) -> Dict[str, float]:
"""单次回测:给定 fast/slow,返回 Sharpe、收益、回撤"""
fast_ma = prices.rolling(fast).mean()
slow_ma = prices.rolling(slow).mean()
signal = (fast_ma > slow_ma).astype(int).shift(1).fillna(0)
returns = prices.pct_change().fillna(0)
turnover = signal.diff().abs().fillna(signal.iloc[0]) * fee_rate
str_returns = returns * signal - turnover
equity = (1 + str_returns).cumprod()
total_return = equity.iloc[-1] - 1
n_days = len(equity)
ann_return = (1 + total_return) ** (252 / n_days) - 1
ann_vol = str_returns.std() * np.sqrt(252)
sharpe = ann_return / (ann_vol + 1e-9)
max_dd = (equity / equity.cummax() - 1).min()
return {
'fast': fast, 'slow': slow,
'sharpe': sharpe,
'ann_return': ann_return,
'max_drawdown': max_dd,
'turnover': turnover.sum() / n_days,
}
def walk_forward_optimization(prices: pd.Series,
train_window: int = 504, # 2 年训练
test_window: int = 126, # 6 月测试
step: int = 63, # 滚动 3 月
fast_grid: List[int] = None,
slow_grid: List[int] = None,
fee_rate: float = 0.0003) -> Dict:
"""Walk-Forward 优化主函数"""
if fast_grid is None:
fast_grid = list(range(5, 30, 5))
if slow_grid is None:
slow_grid = list(range(20, 100, 10))
# 候选参数组合
param_combos = [(f, s) for f in fast_grid for s in slow_grid if f < s]
n = len(prices)
wf_results = []
chosen_params = []
all_train_results = [] # 用于 decay 分析
i = 0
while i + train_window + test_window <= n:
# 切片
train_data = prices.iloc[i:i + train_window]
test_data = prices.iloc[i + train_window:i + train_window + test_window]
# 在训练期内找最优参数
best_sharpe = -np.inf
best_params = None
for f, s in param_combos:
res = double_ma_backtest(train_data, f, s, fee_rate)
if res['sharpe'] > best_sharpe:
best_sharpe = res['sharpe']
best_params = (f, s)
best_train_result = res
# 在测试期验证
if best_params is not None:
test_result = double_ma_backtest(test_data, best_params[0],
best_params[1], fee_rate)
wf_results.append({
'window_idx': i // step,
'train_start': train_data.index[0],
'train_end': train_data.index[-1],
'test_start': test_data.index[0],
'test_end': test_data.index[-1],
'best_fast': best_params[0],
'best_slow': best_params[1],
'train_sharpe': best_train_result['sharpe'],
'test_sharpe': test_result['sharpe'],
'train_return': best_train_result['ann_return'],
'test_return': test_result['ann_return'],
'test_drawdown': test_result['max_drawdown'],
})
chosen_params.append(best_params)
i += step
return {
'wf_results': pd.DataFrame(wf_results),
'chosen_params': chosen_params,
}
# === 主程序 ===
prices = generate_test_data()
print(f"生成数据 {len(prices)} 天, 时间 {prices.index[0]} 到 {prices.index[-1]}")
print()
result = walk_forward_optimization(prices)
wf_df = result['wf_results']
print(f"总共完成 {len(wf_df)} 轮 Walk-Forward 验证")
print()
print("=" * 80)
print("Walk-Forward 详细报告")
print("=" * 80)
print(wf_df.round(3).to_string())
print("\n" + "=" * 50)
print("样本内 vs 样本外统计")
print("=" * 50)
print(f"训练期平均夏普: {wf_df['train_sharpe'].mean():.2f}")
print(f"测试期平均夏普: {wf_df['test_sharpe'].mean():.2f}")
print(f"夏普衰减率: {(1 - wf_df['test_sharpe'].mean() / wf_df['train_sharpe'].mean()):.2%}")
print(f"测试期胜率(>0): {(wf_df['test_sharpe'] > 0).mean():.2%}")点击展开可浏览运行结果
生成数据 2000 天, 时间 2016-01-01 00:00:00 到 2023-08-31 00:00:00
总共完成 22 轮 Walk-Forward 验证
================================================================================
Walk-Forward 详细报告
================================================================================
window_idx train_start train_end test_start test_end best_fast best_slow train_sharpe test_sharpe train_return test_return test_drawdown
0 0 2016-01-01 2017-12-06 2017-12-07 2018-05-31 15 70 1.325 2.560 0.255 0.339 -0.053
1 1 2016-03-30 2018-03-05 2018-03-06 2018-08-28 25 80 1.386 -0.006 0.254 -0.001 -0.095
2 2 2016-06-27 2018-05-31 2018-06-01 2018-11-23 10 20 1.603 -1.847 0.292 -0.238 -0.134
3 3 2016-09-22 2018-08-28 2018-08-29 2019-02-20 10 20 1.166 0.759 0.211 0.113 -0.084
4 4 2016-12-20 2018-11-23 2018-11-26 2019-05-20 25 30 1.039 3.550 0.187 0.668 -0.071
5 5 2017-03-17 2019-02-20 2019-02-21 2019-08-15 25 30 0.651 5.172 0.116 0.999 -0.074
6 6 2017-06-14 2019-05-20 2019-05-21 2019-11-12 25 90 1.369 0.108 0.204 0.014 -0.117
7 7 2017-09-11 2019-08-15 2019-08-16 2020-02-07 25 90 2.224 0.112 0.374 0.025 -0.145
8 8 2017-12-07 2019-11-12 2019-11-13 2020-05-06 25 90 2.432 2.655 0.457 0.639 -0.070
9 9 2018-03-06 2020-02-07 2020-02-10 2020-08-03 25 90 2.399 -0.734 0.523 -0.179 -0.202
10 10 2018-06-01 2020-05-06 2020-05-07 2020-10-29 25 90 3.390 -1.477 0.850 -0.219 -0.154
11 11 2018-08-29 2020-08-03 2020-08-04 2021-01-26 10 60 2.725 -0.795 0.770 -0.176 -0.210
12 12 2018-11-26 2020-10-29 2020-10-30 2021-04-23 5 20 2.536 -0.825 0.666 -0.184 -0.173
13 13 2019-02-21 2021-01-26 2021-01-27 2021-07-21 5 20 1.841 1.405 0.493 0.416 -0.112
14 14 2019-05-21 2021-04-23 2021-04-26 2021-10-18 5 20 0.941 3.391 0.252 1.137 -0.123
15 15 2019-08-16 2021-07-21 2021-07-22 2022-01-13 10 50 1.066 -0.115 0.342 -0.029 -0.208
16 16 2019-11-13 2021-10-18 2021-10-19 2022-04-12 25 70 1.677 0.000 0.530 0.000 0.000
17 17 2020-02-10 2022-01-13 2022-01-14 2022-07-08 20 40 1.072 3.001 0.346 0.832 -0.089
18 18 2020-05-07 2022-04-12 2022-04-13 2022-10-05 25 70 1.009 2.375 0.278 0.627 -0.107
19 19 2020-08-04 2022-07-08 2022-07-11 2023-01-02 25 70 2.022 -2.040 0.595 -0.326 -0.213
20 20 2020-10-30 2022-10-05 2022-10-06 2023-03-30 25 70 2.984 -0.199 0.884 -0.027 -0.079
21 21 2021-01-27 2023-01-02 2023-01-03 2023-06-27 5 40 2.372 -1.199 0.720 -0.262 -0.216
==================================================
样本内 vs 样本外统计
==================================================
训练期平均夏普: 1.78
测试期平均夏普: 0.72
夏普衰减率: 59.60%
测试期胜率(>0): 50.00%📌 案例:Decay 曲线绘制
此处有展示代码展开 ▼
python
def plot_decay_curve(prices: pd.Series,
chosen_params: List[Tuple[int, int]],
train_window: int = 504,
test_window: int = 126,
step: int = 63):
"""绘制 Decay 曲线:用训练期的最优参数,在接下来的多个未来窗口上做评估"""
n = len(prices)
decay_records = []
for i, (f, s) in enumerate(chosen_params):
test_start_idx = i * step + train_window
if test_start_idx >= n:
break
# 在接下来的多个时间距离上评估该参数
for h in range(1, 6): # 1 步、2 步、...、5 步
future_start = test_start_idx + (h - 1) * test_window
future_end = future_start + test_window
if future_end > n:
break
future_data = prices.iloc[future_start:future_end]
if len(future_data) < 50:
continue
res = double_ma_backtest(future_data, f, s)
decay_records.append({
'params_idx': i,
'horizon_h': h,
'params': (f, s),
'sharpe': res['sharpe'],
'horizon_days': (h - 1) * test_window,
})
decay_df = pd.DataFrame(decay_records)
if decay_df.empty:
print("无法绘制 Decay 曲线(数据不足)")
return decay_df
print("=" * 50)
print("Decay 曲线:不同时间距离下的 OOS 夏普")
print("=" * 50)
grouped = decay_df.groupby('horizon_h')['sharpe'].agg(['mean', 'std', 'count'])
print(grouped.round(3))
return decay_df
# 运行 Decay 分析
decay_df = plot_decay_curve(prices, result['chosen_params'])点击展开可浏览运行结果
📘 本段代码定义了 1 个函数/类:函数 `plot_decay_curve`(绘制 Decay 曲线:用训练期的最优参数,在接下来的多个未来窗口上做评估)。该片段为教学展示(未包含独立运行的输入数据),可在实战练习中结合真实数据调用。
📌 案例:参数稳定性分析
此处有展示代码展开 ▼
python
def param_stability_analysis(chosen_params: List[Tuple[int, int]]) -> Dict[str, float]:
"""分析每轮最优参数的稳定性"""
fast_values = [p[0] for p in chosen_params]
slow_values = [p[1] for p in chosen_params]
def coefficient_of_variation(arr):
mean = np.mean(arr)
std = np.std(arr)
return std / mean if mean > 0 else 0
def stability_score(cv):
return max(0, 1 - cv) # CV 越低,稳定性越高
stability = {
'fast_mean': np.mean(fast_values),
'fast_std': np.std(fast_values),
'fast_cv': coefficient_of_variation(fast_values),
'fast_stability': stability_score(coefficient_of_variation(fast_values)),
'slow_mean': np.mean(slow_values),
'slow_std': np.std(slow_values),
'slow_cv': coefficient_of_variation(slow_values),
'slow_stability': stability_score(coefficient_of_variation(slow_values)),
'n_windows': len(chosen_params),
}
print("\n" + "=" * 60)
print("参数稳定性分析")
print("=" * 60)
print(f"分析窗口数: {stability['n_windows']}")
print(f"\nFast 参数:")
print(f" 均值={stability['fast_mean']:.2f}, 标准差={stability['fast_std']:.2f}, CV={stability['fast_cv']:.2%}")
print(f" 稳定性得分: {stability['fast_stability']:.2%}")
print(f"\nSlow 参数:")
print(f" 均值={stability['slow_mean']:.2f}, 标准差={stability['slow_std']:.2f}, CV={stability['slow_cv']:.2%}")
print(f" 稳定性得分: {stability['slow_stability']:.2%}")
if stability['fast_stability'] < 0.5 or stability['slow_stability'] < 0.5:
print("\n⚠️ 警告:参数稳定性不足,策略可能在样本外失效。")
print(" 建议:增大训练窗口、减少可调参数、使用参数高原分析。")
else:
print("\n✓ 参数稳定性良好,策略可能有真实 alpha。")
return stability
# 假设你已有 chosen_params
stability = param_stability_analysis(result['chosen_params'])点击展开可浏览运行结果
📘 本段代码定义了 1 个函数/类:函数 `param_stability_analysis`(分析每轮最优参数的稳定性)。该片段为教学展示(未包含独立运行的输入数据),可在实战练习中结合真实数据调用。
📌 案例:Combinatorial Purged Cross-Validation (CPCV)
此处有展示代码展开 ▼
python
def combinatorial_purged_cv(prices: pd.Series,
n_splits: int = 6,
n_test_splits: int = 2,
purge_gap_days: int = 21,
fast_grid: List[int] = None,
slow_grid: List[int] = None,
fee_rate: float = 0.0003) -> pd.DataFrame:
"""CPCV:所有 N!/(K!(N-K)!) 种测试集组合
N = 总分组数(默认 6)
K = 测试组数(默认 2)
purge_gap = 21 天(对双均线类策略不需要,但展示用法)
"""
if fast_grid is None:
fast_grid = [5, 10, 15, 20]
if slow_grid is None:
slow_grid = [30, 50, 70, 90]
param_combos = [(f, s) for f in fast_grid for s in slow_grid if f < s]
n = len(prices)
group_size = n // n_splits
# 生成所有 C(6, 2) = 15 种测试组组合
from itertools import combinations
all_combinations = list(combinations(range(n_splits), n_test_splits))
print(f"CPCV 配置: N={n_splits}, K={n_test_splits}, 总组合数={len(all_combinations)}, "
f"每组大小={group_size} 天, purge gap={purge_gap_days} 天")
all_results = []
for combo_id, test_groups in enumerate(all_combinations):
train_idx = []
test_idx = []
for g in range(n_splits):
start = g * group_size
end = (g + 1) * group_size if g < n_splits - 1 else n
if g in test_groups:
# 测试组:留出 purge gap
test_start = start
test_end = end
test_idx.extend(range(test_start, test_end))
# purge gap:从相邻训练组中删除尾部
if g > 0 and g - 1 not in test_groups:
purge_start = max(0, start - purge_gap_days)
train_idx.extend([i for i in range(purge_start, start) if i not in set(test_idx)])
else:
train_idx.extend(range(start, end))
if not train_idx or not test_idx:
continue
train_data = prices.iloc[train_idx]
test_data = prices.iloc[test_idx]
# 在训练集找最优参数
best_sharpe = -np.inf
best_params = None
for f, s in param_combos:
res = double_ma_backtest(train_data, f, s)
if res['sharpe'] > best_sharpe:
best_sharpe = res['sharpe']
best_params = (f, s)
best_train_res = res
# 在测试集评估
if best_params is not None:
test_res = double_ma_backtest(test_data, best_params[0], best_params[1])
all_results.append({
'combo_id': combo_id,
'test_groups': test_groups,
'best_fast': best_params[0],
'best_slow': best_params[1],
'train_sharpe': best_train_res['sharpe'],
'test_sharpe': test_res['sharpe'],
'test_return': test_res['ann_return'],
'test_drawdown': test_res['max_drawdown'],
})
return pd.DataFrame(all_results)
cpcv_result = combinatorial_purged_cv(prices)
print("\n" + "=" * 60)
print("CPCV 结果汇总")
print("=" * 60)
print(f"成功运行 {len(cpcv_result)} 组 CPCV 组合")
print(f"训练期平均夏普: {cpcv_result['train_sharpe'].mean():.2f}")
print(f"测试期平均夏普: {cpcv_result['test_sharpe'].mean():.2f}")
print(f"夏普衰减率: {(1 - cpcv_result['test_sharpe'].mean() / cpcv_result['train_sharpe'].mean()):.2%}")
print(f"PBO 估约: {(cpcv_result['test_sharpe'] < 0).mean():.2%}")点击展开可浏览运行结果
📘 本段代码定义了 1 个函数/类:函数 `combinatorial_purged_cv`。该片段为教学展示(未包含独立运行的输入数据),可在实战练习中结合真实数据调用。
常见误区
"Walk-Forward 等于 K 折交叉验证":错。K 折是随机的,WF 是时序的。金融数据有时序相关性,混淆两者就引入了前视偏差。
"一次 WF 就够了":不够。应该用 100+ 组合(可以 CPCV),单次 WF 仍有运气成分。多组 WF 结果的分布更可靠。
**"训练期太短"😗*短训练期(如 1 年)可能因 regime 变化而失效,长训练期(3-5 年)更能覆盖不同市场状态。但过长会导致训练期包含太多"老市场状态"。
"测试期等于训练期":错。常见配置是 训练:测试 = 3:1 或 4:1。例:训练 2 年、测试 6 月。
"不看 Decay 曲线":错。Decay 曲线揭示参数失效速度。快速衰减说明策略对市场变化敏感,即使 OOS 短期有效也意味着短寿命。
"训练期不需要 purge":对。但测试段如果用了带未来信息的标签(如未来 5 日收益),应在训练段尾部留 purge gap。
"IS 夏普 - OOS 夏普 = 过拟合":不能直接等同。衰减率(IS/OOS 比) 才是更合适的指标。50% 以内算稳健,80% 以上警戒,90%+ 几乎肯定过拟合。
Walk-Forward 时间轴图
时间 ──────────────────────────────────────────────→
[=========== 训练期 1 ===========][== 测试 1 ==]
[=========== 训练期 2 ===========][== 测试 2 ==]
[=========== 训练期 3 ===========][== 测试 3 ==]
[=========== 训练期 4 =====...
每轮 WF 拼出 OOS 样本外测试期:
[测试 1 | 测试 2 | 测试 3 | 测试 4 | ... ]
最终取全部 OOS 测试期累积,作为策略真实表现。小测验
题目 1:Walk-Forward 中测试窗口放在训练窗口紧邻位置后,为什么需要 purge 间隔?
A. 因为 purge 能降低训练成本 B. 因为真实标签可能"延伸"到测试段,造成标签泄漏 C. 因为开发者个人偏好 D. 因为影响 Decay 曲线形态
查看答案与解析
答案:B
如果标签是"未来 5 日收益",训练样本标签
正确做法:在训练段尾部删除与测试段在 label 长度范围内的样本。
A 是荒谬的答案;C 是偏好的说法;D 不是 purge 的目的。
题目 2:双均线策略 200 组 Walk-Forward 后,最稳的参数 FAST 出现在 5-15 之间,SLOW 出现在 50-90 之间,最差结果:FAST=5,SLOW=30。你如何理解?
A. 策略大概率过拟合 B. 策略大概率有效 C. 参数稳定性高,说明捕获了市场机制 D. 训练样本不足
查看答案与解析
答案:C
参数集中且稳定(都在 FAST 5-15, SLOW 50-90)说明 Walk-Forward 在不同训练窗口都选了类似参数,这通常意味着捕获了市场的真实机制。这是好兆头,与过拟合相反。
A 错:过拟合表现为参数在不同窗口剧烈漂移。 B 不准确:"有效"需配合测试期表现综合看。 D 错:训练样本不足会加剧参数漂移。
题目 3:哪个场景下不宜使用 Walk-Forward?
A. 日频策略,需要长期稳定参数 B. 分钟频策略,只有 3 个月历史数据 C. ETF 轮动,需要换手控制 D. 期权对冲,每天动态调整
查看答案与解析
答案:B
只有 3 个月数据做不出 2 年训练 + 6 月测试的多轮 WF,严重情况下甚至只能做 1 轮 WF,失去统计意义。
D 选项(期权对冲)其实也难,因为每天 Gamma 调整涉及日内参数,日频 WF 不适用。但题目问的是"不宜用",B 是更典型的"数据不足无法 WF"。
互动组件:WF 评估价值对比
| 评估方法 | 速度 | 评估价值 | 适用阶段 | 适用数据类型 | 推荐度 |
|---|---|---|---|---|---|
| In-Sample Only | 极快 | ★ 极低 | 不可生产 | - | 不可用 |
| Single Train/Test | 快 | ★★ 中低 | 概念验证 | 时序 | ★★ |
| K-Fold (随机) | 中 | ★★★ 中 | 仅非时序 | 非时序 | 仅非时序 |
| Walk-Forward | 中 | ★★★★ 高 | 生产前评估 | 时序 | ★★★★ |
| CPCV | 慢 | ★★★★★ 极高 | 上线前最终 | 时序 | ★★★★★ |
色块含义:
- 绿色:推荐/高效
- 黄色:有适用条件
- 红色:不推荐/有缺陷
核心洞察:Walk-Forward 和 CPCV 是时序数据的"金标准评估方法"。任何"非时序"的交叉验证方法对金融数据都是反模式。
实战练习
完整 Walk-Forward 流程:用 Tushare 拉取 000001.SH 2014-2024 年日线,在双均线策略上完成完整 WF:
- 训练期:3 年(756 天)
- 测试期:6 月(126 天)
- 参数网格:fast∈{5,10,15,20},slow∈
- 输出:测试期累计净值曲线
参数稳定性分析:统计练习 1 中每轮最优参数,计算 fast 和 slow 的 CV。画"fast 随轮次变化"的折线图,观察是否存在趋势。
Decay 曲线可视化:取练习 1 的最优参数,在训练期结束后的 1、2、3、4、5 测试窗口分别评估,画"夏普 vs 时间距离"曲线。
CPCV 实现:用本节的代码,对练习 1 的数据进行 CPCV(N=6, K=2)。比较与 WF 的 OOS 夏普分布。
过拟合识别:对一段纯随机游走价格(无 alpha)做 WF,得到训练期平均夏普和测试期平均夏普的差距,作为基线。比较你的真实策略在该基线下的表现。
延伸阅读
- Advances in Financial Machine Learning — Marcos López de Prado, 第 11-12 章:Walk-Forward、CPCV、PBO 的完整框架。
- Pardo, R. (2008). The Evaluation and Optimization of Trading Strategies. 第 4-7 章:策略评估的工程化方法。
- Evidence-Based Technical Analysis — David Aronson, 第 8-10 章:统计学严谨性 + Walk-Forward 的早期方法。
mlfinlab包: https://github.com/hudson-and-thames/mlfinlab —— Marcos López de Prado 团队的官方 Python 实现,包含 CPCV。scikit-learnTimeSeriesSplit: https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html —— 标准的时序交叉验证工具。- QuantStart Walk-Forward 文章:https://www.quantstart.com/articles/Walk-Forward-Model-Validation —— 工程实现细节。
本章要点
- Walk-Forward 是时序回测的金标准:它严格保证"用过去预测将来",每一次只用训练期找参数、在测试期验证,是过拟合的最强防线之一。
- 与 K-Fold 的根本区别:K-Fold 是随机的(对金融数据致命),WF 是时序的(对金融数据正确)。
- 核心三件套:Walk-Forward(产生 OOS 评估) + Decay 曲线(看参数寿命) + 参数稳定性(看是否捕获机制)。三个一起用,组合策略的稳健性评估才完整。
- CPCV 是更严格的评估:用更多组合减少运气成分,但计算量大,适合上线前最终验证。
- 衰减率是核心指标:IS/OOS 夏普的比值。50% 以内非常稳健,80% 警戒,90%+ 几乎肯定过拟合。
- Purge gap 防标签泄漏:当标签涉及未来 N 日时,必须在训练段尾部删除与测试段有重叠的样本。
- 不要把 WF 当神奇按钮:WF 是工具,需要配合合理的策略设计、合理的参数网格、合理的成本假设,才能产出可信的 OOS 评估。
- 新手策略开发建议:1. 单次回测找信号 → 2. WF 看稳健性 → 3. Decay 看寿命 → 4. CPCV 确认 PBO → 5. 三轮通过后可上线。