滚动窗口回测:固定窗口 vs 扩展窗口
做量化策略,最怕什么?怕过拟合。你辛辛苦苦调出来的参数,回测曲线漂亮得像艺术品,一上实盘就崩。我踩过这个坑,不止一次。
滚动窗口回测,就是专门治这个毛病的。说白了,它模拟的是「策略在真实市场里不断迭代」的过程。你想想看,实盘交易中,我们不可能拿着三年前的模型一直跑,对吧?市场变了,参数也得跟着变。
固定窗口 vs 扩展窗口
这两种方式,我习惯叫它们「滑动门」和「滚雪球」。先看个对比:
| 对比维度 | 固定窗口 | 扩展窗口 |
|---|---|---|
| 训练数据 | 始终用最近 N 天 | 从起点累积到当前 |
| 样本量 | 恒定 | 越来越大 |
| 对市场变化的敏感度 | 高 | 低 |
| 计算开销 | 稳定 | 逐渐增大 |
| 适用场景 | 高频、风格切换快的策略 | 低频、趋势跟踪类策略 |
我个人更常用固定窗口。为什么?因为实盘里,三年前的数据对今天的交易帮助真的不大。我曾经用扩展窗口回测一个动量策略,结果发现早期数据权重太大,导致模型对近期的市场风格变化反应迟钝。嗯,那之后我就学乖了。
滚动回测实现
实现起来其实不复杂。我直接上代码,这是我在生产环境里用的一套模板:
import pandas as pd
import numpy as np
from typing import Callable, Tuple
def rolling_backtest(
data: pd.DataFrame,
train_window: int,
test_window: int,
strategy_fn: Callable,
mode: str = 'fixed'
) -> pd.DataFrame:
"""
滚动回测核心函数
参数:
data: 包含价格、成交量等数据的DataFrame
train_window: 训练窗口大小(天数)
test_window: 测试窗口大小(天数)
strategy_fn: 策略函数,接收训练数据,返回参数
mode: 'fixed' 或 'expanding'
"""
results = []
total_len = len(data)
# 起始位置
if mode == 'fixed':
start = 0
else: # expanding
start = 0
while True:
# 确定训练集和测试集的边界
if mode == 'fixed':
train_end = start + train_window
test_end = train_end + test_window
else: # expanding
train_end = start + train_window
test_end = train_end + test_window
# 检查是否超出数据范围
if test_end > total_len:
break
# 切分数据
train_data = data.iloc[start:train_end]
test_data = data.iloc[train_end:test_end]
# 训练策略,获取参数
params = strategy_fn(train_data)
# 在测试集上回测
perf = evaluate_strategy(test_data, params)
perf['window_start'] = data.index[train_end]
perf['window_end'] = data.index[test_end - 1]
results.append(perf)
# 滑动窗口
if mode == 'fixed':
start += test_window # 固定窗口:整体滑动
else:
start = 0 # 扩展窗口:起点不变,终点后移
train_window += test_window # 训练窗口逐渐扩大
return pd.DataFrame(results)
代码里有个细节要注意——strategy_fn 这个回调函数。它接收训练数据,返回策略参数。这样做的好处是,你可以把任何策略塞进来,回测框架不用改。我在做多策略组合时,这个设计帮了大忙。
滚动绩效指标分析
跑完滚动回测,你会得到一堆窗口的绩效数据。怎么分析?我一般看这几个维度:
- 绩效稳定性——看夏普比率的滚动均值是否平稳。如果忽高忽低,说明策略在不同市场环境下表现差异大。
- 最大回撤分布——每个窗口的最大回撤是多少?如果某个窗口的回撤远超其他,要警惕那段时间的市场特征。
- 参数漂移——最优参数是否在剧烈变化?如果是,说明策略对参数敏感,过拟合风险高。
举个例子,我之前做一个商品期货的跨品种套利策略。滚动回测后发现,2020年3月那个窗口的夏普比率从平时的 2.5 直接掉到 0.3。为什么?因为那段时间原油暴跌,市场流动性枯竭。这个信息在普通回测里根本看不到。
具体到指标计算,我习惯这样组织:
def analyze_rolling_performance(results: pd.DataFrame):
"""分析滚动回测结果"""
metrics = {
'avg_sharpe': results['sharpe'].mean(),
'std_sharpe': results['sharpe'].std(),
'sharpe_stability': results['sharpe'].mean() / results['sharpe'].std(),
'avg_max_dd': results['max_drawdown'].mean(),
'max_dd_worst': results['max_drawdown'].min(),
'param_stability': results['params'].apply(lambda x: np.std(x)).mean()
}
return metrics
这里有个小技巧——sharpe_stability 这个指标是我自己加的。它衡量的是夏普比率的稳定性,值越大越好。如果这个值小于 1,说明策略的绩效波动比收益还大,那就要小心了。
最后说一句,滚动窗口回测不是万能的。它解决的是「策略在不同时间段的表现是否一致」这个问题。但如果你用的数据本身就有问题(比如幸存者偏差、前视偏差),那再好的回测框架也救不了。嗯,数据清洗是另一门课了。