Walk-Forward 滚动前推验证实操
Walk-Forward 的做法是:在一段历史里选参数,只在紧接着的下一段未见过的数据上检验,然后整段窗口往前滚,重复十几次,把所有测试段的结果拼成一条完整的样本外曲线。它回答的是「按当时可得的信息做决策,实际会怎样」,这是单次全样本回测永远无法回答的问题。
本文要点
- 单次全样本回测的参数是用全部数据(包括未来)挑出来的,结论天然乐观,不能作为决策依据。
- Walk-Forward 的输出不是一个数字,而是一条由多段样本外结果拼接的净值曲线。
- 常用配置:训练窗 12 个月、测试窗 3 个月、步长 3 个月,一年数据能得到 4 段样本外。
- 样本外年化衰减到样本内的 50%–70% 属于正常,低于 30% 或直接转负说明参数没有泛化能力。
- 与简单 train/test split 的关键区别:WF 有多次独立检验,且允许参数随时间重新适配。
单次回测为什么不可信
假设你在三年数据上把均线周期从 5 试到 100,挑出年化最高的那个 34。这个 34 有多大意义?它是在已知全部三年走势的前提下选出来的。真实交易时,2024 年初的你不可能知道 2025 年的行情,也就不可能知道 34 是好参数。
这个问题的学术名字叫样本内乐观偏差。你不是在检验策略,而是在描述历史:「过去三年里,34 这个参数表现最好」是个事实陈述,不含任何关于未来的信息。把它当成策略有效性的证据,是量化研究中最普遍的逻辑错误。
更隐蔽的是,你试的次数越多,这个偏差越大。在纯随机数据上扫 96 个参数,最好的那个也一定会有正收益和不错的夏普——这完全来自选择偏差。参数扫描的次数本身就是一种成本,具体机制见过拟合识别。
Walk-Forward 到底做了什么
Walk-Forward 用一句话概括:模拟时间的单向流动。它把历史切成一串前后相接的窗口对,每对包含一个训练窗(用来选参数)和一个紧随其后的测试窗(用来检验)。选参数时只能看训练窗,检验时参数已经锁定不能改。
做完第一对,整体往前滚动一个步长,用更新的训练窗重新选参数,在新的测试窗上检验。这样滚十几次,你得到十几段互不重叠的样本外结果,把它们按时间顺序拼起来,就是一条完整的样本外净值曲线。
这条曲线的意义在于:它上面每一个点的交易决策,都只用了该时点之前可得的信息。它是对「如果我两年前真的开始跑这套流程」的最接近模拟。你评估策略应该看这条曲线,而不是全样本回测那条。
- 切窗口:确定训练窗长度 L_train、测试窗长度 L_test、滚动步长 step(通常 step = L_test,保证测试段不重叠)。
- 训练:在第 i 个训练窗内做参数扫描,按事先定好的单一指标选出最优参数(推荐 Calmar 或夏普,不要用总收益)。
- 锁定:参数确定后不得再看测试窗数据修改,这一步的纪律决定整个验证是否有效。
- 测试:用锁定参数在第 i 个测试窗上跑回测,记录该段的收益、回撤、交易次数。
- 滚动:窗口整体前移 step,回到第 2 步,直到数据用尽。
- 拼接与评估:把各测试段收益率序列按时间拼成一条曲线,在这条曲线上计算最终绩效指标。
窗口怎么划分:一张具体的表
窗口长度的选择没有唯一答案,但有原则:训练窗要长到能覆盖至少一轮完整的涨跌与横盘,测试窗要长到能产生足够多的交易样本(建议不少于 30 笔),步长通常等于测试窗以保证测试段不重叠。下面是一个三年数据的具体划分示例。
| 轮次 | 训练窗(选参数) | 测试窗(样本外) | 该轮选出的参数(示例) | 测试段结果(示例) |
|---|---|---|---|---|
| 第 1 轮 | 2023-01 ~ 2023-12 | 2024-01 ~ 2024-03 | MA(28), 止损 4% | +6.2% |
| 第 2 轮 | 2023-04 ~ 2024-03 | 2024-04 ~ 2024-06 | MA(31), 止损 4% | -2.8% |
| 第 3 轮 | 2023-07 ~ 2024-06 | 2024-07 ~ 2024-09 | MA(30), 止损 5% | +9.1% |
| 第 4 轮 | 2023-10 ~ 2024-09 | 2024-10 ~ 2024-12 | MA(26), 止损 5% | +4.4% |
| 第 5 轮 | 2024-01 ~ 2024-12 | 2025-01 ~ 2025-03 | MA(29), 止损 4% | -5.6% |
| 第 6 轮 | 2024-04 ~ 2025-03 | 2025-04 ~ 2025-06 | MA(45), 止损 7% | +1.3% |
读这张表的重点不在最右列的收益数字,而在倒数第二列:前五轮选出的参数都落在 MA 26–31、止损 4%–5% 这个小范围内,说明参数是稳定的。第 6 轮突然跳到 MA(45)、止损 7%,这是一个警告信号:训练窗内的行情性质变了,或者参数平原上出现了新的孤峰。
参数稳定性比样本外收益更能说明策略质量。如果每轮选出的参数都在剧烈跳动(比如在 5 和 90 之间来回),即使拼接曲线是正收益,也应判定为不可靠——那意味着参数空间里没有稳定结构,你每次选到的都是噪声的局部极值。
代码实现
实现上只有两处容易出错。一是切窗口时的索引边界,必须保证测试窗严格在训练窗之后、不重叠一根K线;二是拼接时要拼收益率序列再累乘成净值,不要把各段的净值曲线首尾相接(那样每段的起点资金不一致,等于隐含了不存在的复利)。
import numpy as np
import pandas as pd
from itertools import product
def walk_forward(df, param_grid, run_fn, perf_fn,
train_bars, test_bars, step=None,
select_by="calmar"):
"""
df : 全量 OHLCV
param_grid: {"ma": [10,20,...], "stop": [0.03,0.05]}
run_fn : run_fn(df_slice, **params) -> 净值 Series
perf_fn : perf_fn(equity) -> dict, 含 select_by 键
"""
step = step or test_bars
keys = list(param_grid)
combos = [dict(zip(keys, v)) for v in product(*param_grid.values())]
oos_returns, log = [], []
start = 0
while start + train_bars + test_bars <= len(df):
tr = df.iloc[start: start + train_bars]
te = df.iloc[start + train_bars: start + train_bars + test_bars]
# --- 训练: 只看 tr, 选出单一最优参数 ---
best, best_score = None, -np.inf
for p in combos:
m = perf_fn(run_fn(tr, **p))
s = m.get(select_by)
if s is not None and s > best_score:
best, best_score = p, s
# --- 测试: 参数已锁定, 不再回头看 tr ---
eq_te = run_fn(te, **best)
m_te = perf_fn(eq_te)
# ★ 收集收益率序列而非净值, 供后续正确拼接
oos_returns.append(eq_te.pct_change().dropna())
log.append({"train_end": tr.index[-1],
"test_start": te.index[0],
"test_end": te.index[-1],
**{f"p_{k}": v for k, v in best.items()},
"is_score": round(best_score, 3),
"oos_score": round(m_te.get(select_by, 0), 3),
"oos_ret": round(eq_te.iloc[-1] / eq_te.iloc[0] - 1, 4),
"oos_trades": m_te.get("n_trades")})
start += step
# --- 拼接: 收益率相接后累乘, 而非净值首尾相接 ---
r = pd.concat(oos_returns).sort_index()
oos_equity = (1 + r).cumprod()
return oos_equity, pd.DataFrame(log)
def wf_efficiency(log: pd.DataFrame) -> float:
"""WF 效率 = 样本外均值 / 样本内均值, 越接近 1 越稳健"""
return float(log["oos_score"].mean() / log["is_score"].mean())
python
结果怎么判读:衰减多少算可接受
评估 WF 结果有三个层次,按重要性从高到低排列,不要只看第一条。
第一层是WF 效率,即样本外平均得分除以样本内平均得分。样本外一定比样本内差,这是必然的。经验区间是:0.5–0.7 属于健康,说明参数有真实泛化能力;0.3–0.5 属于勉强,需要减少参数数量再试;低于 0.3 或样本外转负,说明你拟合的是噪声,这条思路应该放弃而不是继续调。
第二层是各段的一致性。看测试段里正收益的比例。六段中四段正、两段负是正常的;如果全部收益都来自其中一段,其余五段横盘或亏损,那么这条策略实际是一次幸运的押注,不是可重复的方法。
第三层是参数漂移程度。计算各轮所选参数的标准差与均值之比。这个比值小于 0.2 说明参数空间稳定;超过 0.5 说明每轮选到的都是不同的局部极值,即使拼接曲线好看也不可信。
| 指标 | 健康区间 | 警告区间 | 应放弃 | 含义 |
|---|---|---|---|---|
| WF 效率(样本外/样本内) | 0.5 – 0.8 | 0.3 – 0.5 | < 0.3 或为负 | 参数的泛化能力 |
| 测试段正收益比例 | ≥ 60% | 40% – 60% | < 40% | 结果是否可重复 |
| 参数变异系数(std/mean) | < 0.2 | 0.2 – 0.5 | > 0.5 | 参数空间是否稳定 |
| 单段贡献占比(最大段/总收益) | < 40% | 40% – 70% | > 70% | 是否依赖一次幸运 |
| 每测试段交易笔数 | ≥ 30 | 10 – 30 | < 10 | 统计结果是否有意义 |
补充一句关于 WF 效率超过 1 的情况:样本外比样本内还好,通常不是好消息,而是巧合或者代码有问题。首先要检查测试窗是否与训练窗重叠、参数是否真的在测试前锁定,以及成本是否两段都加了。
与简单 train/test split 的区别
很多人把数据前 70% 做训练、后 30% 做测试,就认为完成了样本外验证。这比什么都不做要好,但和 Walk-Forward 有三个本质差别。
差别一:检验次数。单次 split 只有一个样本外结果,成败很大程度取决于后 30% 恰好是什么行情。如果那段刚好是一轮大牛市,任何做多策略都能过关。WF 有十几段独立检验,行情类型覆盖更全,单段运气的影响被摊薄。
差别二:参数是否可更新。单次 split 的参数选定后一直用到底,隐含假设是最优参数永不改变。WF 允许每轮重新适配,这更接近真实做法——实盘中你确实会定期重新评估参数。同时它还额外给出了参数漂移这个诊断信息。
差别三:时间衰减信息。WF 能看出策略是逐渐失效还是稳定有效:若测试段收益随时间单调下降,说明这条边缘正在被市场消化。单次 split 完全看不到这个趋势。
| 维度 | 单次 train/test split | Walk-Forward |
|---|---|---|
| 样本外检验次数 | 1 次 | 通常 6 – 20 次 |
| 参数更新 | 固定不变 | 每轮重新适配 |
| 能否看出策略衰减 | 不能 | 能(看测试段时序趋势) |
| 计算成本 | 低(1 次扫描) | 高(N 次扫描) |
| 适用阶段 | 初筛,快速淘汰 | 上实盘前的最终验证 |
实践上两者是配合使用的:用单次 split 快速淘汰明显不行的思路,节省算力;通过初筛的少数策略才值得投入完整的 WF 验证。WF 的计算量是单次扫描的十几倍,对每个想法都跑一遍并不经济。
还有一点纪律问题必须说清:如果你看了 WF 结果不满意,回头改策略逻辑再跑一次 WF,那么这次 WF 的样本外性质已经被污染了——你用了测试段的信息做决策。严格做法是留一段从头到尾都没看过的数据做最终确认,这段数据只允许看一次。
常见问题
训练窗和测试窗应该设多长?
原则是训练窗覆盖至少一轮完整的涨跌横盘,测试窗能产生 30 笔以上交易。日线策略常用训练 12 个月、测试 3 个月;15 分钟级别策略可以缩到训练 3 个月、测试 3 周。关键不是绝对时长,而是交易样本量——测试段只有 5 笔交易,结果没有统计意义。
每轮选参数应该按哪个指标?
推荐 Calmar(年化收益除以最大回撤)或夏普,不要用总收益。按总收益选会系统性偏向高风险参数,因为回测中大回撤不会真的让你停手,实盘中会。选择指标必须事先定好并全程不变,跑完发现按另一个指标结果更好就换,本身就是一种过拟合。
Walk-Forward 结果比全样本回测差很多,是策略不行吗?
差是必然的,差多少才是关键。样本外衰减到样本内的 50%–70% 属正常范围,可以继续推进。若衰减到 30% 以下或直接转负,通常是参数过多或试验次数过密造成的,此时应先减少参数数量,而不是继续在同一片参数空间里寻找。
WF 跑一次要多久?算力不够怎么办?
计算量约等于参数组合数乘轮数。100 个组合乘 12 轮就是 1200 次回测,单次几秒的引擎需要约一小时。降成本的办法是先用粗网格(每个参数 5 个取值)定位大致区域,再在该区域做细网格;同时把数据预加载进内存、指标提前算好,避免每轮重复计算。