3. 数据预处理与滚动切片:时间序列数据的滚动切片实现、避免未来函数、数据对齐与重采样

做量化策略,最怕什么?

我个人最怕的就是——回测跑得漂亮,实盘一塌糊涂。后来我发现,十有八九的问题出在数据预处理上。说白了,数据没处理好,后面的一切都是空中楼阁。

这一章,我们就来聊聊数据预处理和滚动切片。嗯,这两个东西是滚动优化的地基。地基不稳,楼盖得再高也得塌。

3.1 滚动切片:时间序列的“滑动窗口”

滚动优化,核心就是“滚动”二字。什么意思?就是用一个固定大小的窗口,在时间轴上一步一步往前滑。

举个例子。假设我们有2010年到2023年的日线数据。我习惯用3年作为训练窗口,1年作为验证窗口。那么第一次切片:训练集是2010-2012年,验证集是2013年。第二次切片:训练集是2011-2013年,验证集是2014年。以此类推。

核心概念:滚动切片 = 固定窗口 + 固定步长 + 时间顺序

你想想看,这跟机器学习里的交叉验证很像,但有一个本质区别——时间序列不能打乱顺序。你不能把2015年的数据拿到2010年去训练,那叫穿越。

下面是我在项目中常用的切片实现方式:

import pandas as pd
import numpy as np
from typing import List, Tuple

def rolling_window_slice(
    df: pd.DataFrame,
    train_window: int = 252 * 3,  # 3年交易日
    val_window: int = 252,        # 1年交易日
    step: int = 252               # 每年滚动一次
) -> List[Tuple[pd.DataFrame, pd.DataFrame]]:
    """
    滚动切片实现
    """
    slices = []
    total_len = len(df)
    start = 0
    
    while start + train_window + val_window <= total_len:
        train_end = start + train_window
        val_end = train_end + val_window
        
        train_slice = df.iloc[start:train_end].copy()
        val_slice = df.iloc[train_end:val_end].copy()
        
        slices.append((train_slice, val_slice))
        start += step
    
    return slices

# 使用示例
# slices = rolling_window_slice(price_data)
# for i, (train, val) in enumerate(slices):
#     print(f"窗口 {i+1}: 训练 {train.index[0]} ~ {train.index[-1]}, 验证 {val.index[0]} ~ {val.index[-1]}")

这段代码看起来简单,但我曾经踩过一个坑——窗口边界没对齐。有一次我用的是分钟数据,窗口大小设成了2520(10天),结果因为节假日数据缺失,实际窗口只有不到8天。从那以后,我每次切片都会先检查一下实际数据量。

小技巧:切片前先打印一下每个窗口的起止日期和样本量,肉眼扫一遍。虽然土,但管用。

3.2 避免未来函数:量化回测的“生死线”

未来函数,说白了就是你在回测时用到了“未来”的数据。比如你用今天的收盘价去预测今天的涨跌,或者用整个月的数据去计算这个月的均值。

为什么会这样?很多时候是无意的。我记得有一次,我在计算移动平均线时,用了整个序列的均值,结果回测收益高得离谱。后来一查,原来是pandas的rolling().mean()默认是中心窗口,包含了未来数据。

警告:未来函数是回测中最隐蔽、最致命的错误。它会让你的策略看起来完美无缺,实盘却一败涂地。

避免未来函数,我总结了三条铁律:

  1. 计算指标时,只用历史数据。比如计算20日均线,只能用前20天的数据,不能用当天或未来的。
  2. 滚动切片时,确保切片边界清晰。训练集和验证集不能有重叠。
  3. 重采样时,注意数据对齐。别把未来的信息带进当前时刻。

来看一个常见的错误示例:

# ❌ 错误:使用了未来数据
df['ma_20'] = df['close'].rolling(window=20, center=True).mean()

# ✅ 正确:只使用历史数据
df['ma_20'] = df['close'].rolling(window=20, center=False).mean()

嗯,这里要注意,center=True会让窗口以当前点为中心,前后各取10天。这在回测里就是典型的未来函数。我刚开始做量化时,这个坑踩了不下三次。

3.3 数据对齐与重采样:让不同频率的数据“说同一种语言”

实际项目中,数据来源五花八门。日线数据、分钟数据、财报数据、宏观经济数据……频率不一样,怎么对齐?

我的做法是:统一降频到最低频率。比如日线策略,就把所有数据都对齐到日线级别。

重采样时,有几点要注意:

  • 价格数据:用收盘价,或者用最后一条记录
  • 成交量数据:用总和
  • 财务数据:用最新值(前向填充)
  • 宏观经济数据:用插值或前向填充

下面是我常用的重采样函数:

def resample_to_daily(df: pd.DataFrame, freq: str = 'D') -> pd.DataFrame:
    """
    将任意频率数据重采样到日线
    """
    # 价格类用最后一条
    price_cols = ['open', 'high', 'low', 'close']
    price_resampled = df[price_cols].resample(freq).last()
    
    # 成交量用总和
    volume_resampled = df['volume'].resample(freq).sum()
    
    # 其他指标用前向填充
    other_cols = [c for c in df.columns if c not in price_cols + ['volume']]
    other_resampled = df[other_cols].resample(freq).ffill()
    
    # 合并
    result = pd.concat([price_resampled, volume_resampled, other_resampled], axis=1)
    return result

数据对齐还有一个容易忽略的点——时区问题。我曾经处理过美股和A股的数据,一个用美东时间,一个用北京时间。如果不做时区转换,直接对齐,那结果就是牛头不对马嘴。

建议:所有数据统一使用UTC时间,或者统一使用交易所本地时间。别混用。

3.4 本章知识体系

下面这张图,是我自己总结的滚动切片与数据预处理的核心流程。你看一眼,应该就能明白整个逻辑。

滚动切片与数据预处理流程 原始时间序列数据 数据预处理:对齐 + 重采样 + 去未来 滚动切片:固定窗口 + 固定步长 切片结果:[(训练集1, 验证集1), (训练集2, 验证集2), ...] 滚动优化:每个窗口独立训练 + 验证

这张图里,最关键的其实是第二步——数据预处理。很多人一上来就切片,结果数据没对齐,未来函数没去掉,后面全白干。我建议你,每次做滚动优化前,先花30分钟把数据检查一遍。

3.5 实战中的避坑指南

最后,分享几个我踩过的坑,希望能帮你少走弯路:

  • 坑一:切片时用了df.sample()随机抽样。时间序列不能随机抽样,必须按时间顺序切。
  • 坑二:重采样时用了interpolate()插值。插值会引入未来信息,除非你用的是前向插值。
  • 坑三:数据对齐时忽略了节假日。A股和美股节假日不同,直接对齐会导致数据缺失或错位。

我曾经有一个策略,回测年化收益30%,实盘只有5%。查了三天,最后发现是数据对齐时,把美股的非交易日数据用前向填充填上了,导致信号滞后了一天。嗯,从那以后,我对数据对齐这件事就格外小心了。

好了,这一章的内容就到这里。数据预处理和滚动切片,是滚动优化的基本功。练好了,后面的路就好走了。


专注资料整理