滚动窗口回测:固定窗口 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)
我的经验:test_window 不要设得太小。我见过有人设成 5 天,结果每个窗口的绩效波动巨大,根本看不出策略好坏。一般建议 test_window 至少 20 个交易日(约一个月)。

代码里有个细节要注意——strategy_fn 这个回调函数。它接收训练数据,返回策略参数。这样做的好处是,你可以把任何策略塞进来,回测框架不用改。我在做多策略组合时,这个设计帮了大忙。

滚动绩效指标分析

跑完滚动回测,你会得到一堆窗口的绩效数据。怎么分析?我一般看这几个维度:

  1. 绩效稳定性——看夏普比率的滚动均值是否平稳。如果忽高忽低,说明策略在不同市场环境下表现差异大。
  2. 最大回撤分布——每个窗口的最大回撤是多少?如果某个窗口的回撤远超其他,要警惕那段时间的市场特征。
  3. 参数漂移——最优参数是否在剧烈变化?如果是,说明策略对参数敏感,过拟合风险高。

举个例子,我之前做一个商品期货的跨品种套利策略。滚动回测后发现,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,说明策略的绩效波动比收益还大,那就要小心了。

最后说一句,滚动窗口回测不是万能的。它解决的是「策略在不同时间段的表现是否一致」这个问题。但如果你用的数据本身就有问题(比如幸存者偏差、前视偏差),那再好的回测框架也救不了。嗯,数据清洗是另一门课了。

专注资料整理