Walk-Forward 与样本内外验证
:::info 学习目标 你将能设计滚动/扩展窗口,保留指标预热历史,拼接所有 OOS 收益,并避免用测试段调参或平均分段夏普造成偏差。 :::
直觉
防止过拟合最有效的工程手段是样本外验证:只在训练集上调参,在从不参与调参的测试集上检验。Walk-Forward 更进一步——滚动地「用过去选参、交易未来」,模拟策略在实盘中不断重新适应的过程,是稳健性检验的金标准。
样本内外划分
- 在 IS 上扫参数、选最优;
- 用该参数在 OOS 上跑一次,报告结果;
- 永远不要反过来在 OOS 上挑参数(那是选择偏差,等于作弊)。
Walk-Forward
把时间切成连续窗口:第 段用「之前的数据」选参,交易「下一段」,拼接所有 OOS 段得到真实可得的累计表现。
「人话」解释:Walk-Forward 为什么可信?
它模拟了「你当时只能看到那些数据」的现实——每一步都只用过去、交易未来,没有任何事后信息。 拼接起来的曲线,是你这个策略真能实现的历史表现,不像单次 IS 拟合那样乐观。 如果 Walk-Forward 夏普还过得去,策略才算「稳健」而非过拟合。
可运行案例:IS 选参 → OOS 检验 + Walk-Forward
import quant
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
df = pd.read_csv('/data/spy_daily.csv', parse_dates=['date']).set_index('date')
close = df['adj_close']
n = len(close); split = int(n * 0.7)
is_p, oos_p = close.iloc[:split], close.iloc[split:]
grid = [(5,20),(10,30),(20,60),(20,120),(40,120)]
def sig(p, f, s):
raw = (p.rolling(f).mean() > p.rolling(s).mean()).astype(float)
return raw.fillna(0.0)
def sh(p, f, s):
return quant.sharpe(quant.vector_backtest(p, sig(p,f,s), cost_bps=2.0, freq=252)['returns'], freq=252)
def full_bt_until(end, f, s):
"""保留 OOS 之前的历史,使滚动均线在测试段起点已经预热。"""
p = close.iloc[:end]
return quant.vector_backtest(p, sig(p, f, s), cost_bps=2.0, freq=252)
# 1) IS 选最优参数, 看 OOS 是否守得住
best = max(grid, key=lambda fs: sh(is_p, *fs))
best_full = full_bt_until(n, *best)
best_oos_r = best_full['returns'].iloc[split:]
print(f"IS 最优参数 {best}: IS 夏普={sh(is_p,*best):.3f} "
f"OOS 夏普={quant.sharpe(best_oos_r):.3f} (通常下降)")
# 2) 选择偏差: 作弊地在 OOS 选最优(高估真实能力)
oos_score = lambda fs: quant.sharpe(full_bt_until(n, *fs)['returns'].iloc[split:])
cheat = max(grid, key=oos_score)
print(f"OOS 作弊最优 {cheat}: 夏普={oos_score(cheat):.3f} ← 用未来选参, 不可信")
# 3) Walk-Forward: 用过去 3 年选参, 交易下 1 年
w, step = 252*3, 252
seg_sharpe, oos_parts, chosen = [], [], []
for start in range(w, n, step):
train = close.iloc[start-w:start]
fs = max(grid, key=lambda x: sh(train, *x))
end = min(start + step, n)
test_r = full_bt_until(end, *fs)['returns'].iloc[start:end]
oos_parts.append(test_r); chosen.append(fs)
seg_sharpe.append(quant.sharpe(test_r))
wf_ret = pd.concat(oos_parts)
print(f"\nWalk-Forward 各年 OOS 夏普: {[round(x,2) for x in seg_sharpe]}")
print(f"各段所选参数: {chosen}")
print(f"拼接 OOS 夏普={quant.sharpe(wf_ret):.3f} "
f"正收益年份比例={np.mean([x>0 for x in seg_sharpe]):.0%}")
print("→ 不依赖某次幸运选参, 才是稳健策略。")
设计选择与报告规范
训练窗长度决定适应速度与估计稳定性,测试步长决定再训练频率和换手。OOS 段开始时必须保留过去数据用于均线预热,但不能把未来测试数据用于拟合;最终表现应拼接逐期 OOS 收益后统一计算,而不是简单平均各段夏普。参数选择次数也应计入多重检验。
实践与延伸
- 比较三年滚动窗与扩展窗,记录所选参数和净值差异。
- 改变训练/测试长度,画出 OOS 夏普与再训练次数的稳定区域。
延伸阅读:rolling-origin evaluation;nested walk-forward 与模型选择偏差。
小结
- 样本外验证:只在 IS 调参,OOS 只评一次;
- 选择偏差:在 OOS 上挑参数 = 作弊,会高估;
- Walk-Forward:滚动「用过去选参、交易未来」,是稳健性金标准。
模块 8 完成,M5(模块 5–8)全部交付。下一批 M6 将进入机器学习、风控与 Capstone。
小测验
1. 在测试集(OOS)上反过来挑最优参数,属于?
2. Walk-Forward 每一步是怎么做的?
3. 为什么 Walk-Forward 比「单次全样本拟合」更可信?