4. 时间序列基础:重采样、滚动窗口与时间特征工程

做量化交易,时间序列就是我们的命根子。

我刚开始接触高频数据时,面对每秒刷新的 tick 数据,整个人是懵的。后来才明白,原始数据就像一堆散落的珍珠,你得学会怎么串起来、怎么打磨、怎么镶嵌到你的策略里。这一章,我们就聊聊这三件事:重采样、滚动窗口、时间特征工程。

4.1 重采样:把不规则数据变规整

说白了,重采样就是把高频数据降频,或者把低频数据升频。在量化里,最常见的是把 tick 数据合成 1 分钟、5 分钟 K 线。

核心要点: 重采样不是简单取平均,而是按时间窗口聚合。

我个人习惯用 pandas 的 resample() 方法。举个例子,把秒级数据变成分钟级:

import pandas as pd
import numpy as np

# 模拟秒级数据
dates = pd.date_range('2024-01-01', periods=1000, freq='S')
data = pd.DataFrame({
    'price': np.random.randn(1000).cumsum() + 100,
    'volume': np.random.randint(100, 1000, 1000)
}, index=dates)

# 重采样为1分钟K线
minute_data = data.resample('1T').agg({
    'price': 'ohlc',  # Open, High, Low, Close
    'volume': 'sum'
})
print(minute_data.head())

这里有个坑,我曾经踩过:重采样时一定要确认时间索引是 datetime 类型。字符串索引直接 resample 会报错,而且报错信息很隐晦,你可能会在数据对齐上浪费半天时间。

小技巧:pd.to_datetime() 转换索引,或者读取数据时指定 parse_dates=True

4.2 滚动窗口:捕捉动态变化

滚动窗口,就是用一个固定大小的窗口在时间序列上滑动,计算窗口内的统计量。比如 20 日均线、布林带、滚动波动率,都是它的应用。

你想想看,为什么不用全量数据算均值?因为市场在变。过去的均值对现在的判断意义不大,滚动窗口能捕捉到近期的变化趋势。

# 计算20期滚动均值和标准差
window = 20
data['rolling_mean'] = data['price'].rolling(window=window).mean()
data['rolling_std'] = data['price'].rolling(window=window).std()

# 布林带
data['upper_band'] = data['rolling_mean'] + 2 * data['rolling_std']
data['lower_band'] = data['rolling_mean'] - 2 * data['rolling_std']
注意: 滚动窗口的起始部分会有 NaN 值。比如 window=20,前 19 个数据点无法计算。我建议用 min_periods 参数控制最小有效数据量,或者直接丢弃前 N 行。

我在项目中遇到过一个问题:用滚动窗口计算波动率时,如果窗口太小(比如 5 期),噪声太大;窗口太大(比如 100 期),反应太迟钝。后来我用了指数加权移动平均(EWMA),效果好了很多:

# 指数加权移动平均,更灵敏
data['ewm_mean'] = data['price'].ewm(span=20, adjust=False).mean()

4.3 时间特征工程:让模型理解时间

时间特征工程,就是把时间戳变成模型能理解的特征。比如,上午 9:30 和下午 14:30 的流动性完全不同,模型需要知道这个差异。

我常用的时间特征分三类:

特征类别 具体特征 用途
周期性特征 小时、星期几、月份、季度 捕捉日内效应、周内效应、季节效应
时间差特征 距开盘分钟数、距收盘分钟数 捕捉开盘/收盘的特殊行为
事件特征 是否财报日、是否节假日、是否美联储议息日 捕捉事件驱动效应

举个例子,提取日内特征:

# 提取时间特征
data['hour'] = data.index.hour
data['minute'] = data.index.minute
data['day_of_week'] = data.index.dayofweek
data['is_morning'] = (data['hour'] < 12).astype(int)

# 距开盘分钟数(假设9:30开盘)
data['minutes_from_open'] = (data['hour'] * 60 + data['minute'] - 570).clip(lower=0)
关键点: 周期性特征要用正弦/余弦编码,不然模型会认为 23 点和 0 点相差 23 小时,实际上只差 1 小时。
# 正弦余弦编码小时特征
data['hour_sin'] = np.sin(2 * np.pi * data['hour'] / 24)
data['hour_cos'] = np.cos(2 * np.pi * data['hour'] / 24)

4.4 实战:构建流动性分层的时间特征

好了,我们把上面三块知识串起来。假设我们要分析某只股票的流动性分层,需要构建一个特征矩阵:

  1. 重采样:把 tick 数据合成 5 分钟 K 线
  2. 滚动窗口:计算 5 期滚动平均买卖价差(流动性指标)
  3. 时间特征:添加小时、星期几、距开盘分钟数等特征
# 完整流程
def build_liquidity_features(tick_data):
    # 1. 重采样为5分钟K线
    ohlc = tick_data['price'].resample('5T').ohlc()
    volume = tick_data['volume'].resample('5T').sum()
    
    # 2. 计算买卖价差(假设有bid/ask数据)
    spread = (tick_data['ask'] - tick_data['bid']).resample('5T').mean()
    
    # 3. 滚动窗口:5期滚动平均价差
    spread_smooth = spread.rolling(window=5, min_periods=3).mean()
    
    # 4. 时间特征
    features = pd.DataFrame(index=ohlc.index)
    features['hour_sin'] = np.sin(2 * np.pi * features.index.hour / 24)
    features['hour_cos'] = np.cos(2 * np.pi * features.index.hour / 24)
    features['day_of_week'] = features.index.dayofweek
    features['minutes_from_open'] = (features.index.hour * 60 + 
                                     features.index.minute - 570).clip(lower=0)
    
    # 5. 合并
    result = pd.concat([ohlc, volume, spread_smooth.rename('spread'), features], axis=1)
    return result.dropna()
避坑指南: 我曾经在滚动窗口计算时忘记设置 min_periods,结果前几个窗口全是 NaN,导致后续模型训练报错。后来我养成了习惯:任何滚动计算后都检查一下 NaN 比例

4.5 知识体系总览

下面这张图,是我自己总结的时间序列处理流程。你看一眼,心里就有谱了:

时间序列处理知识体系 原始时间序列 重采样 滚动窗口 时间特征工程 降频:tick → 分钟K线 升频:日线 → 小时线(插值) 聚合函数:OHLC、sum、mean 滚动均值/标准差 布林带 EWMA 周期性:正弦/余弦编码 时间差:距开盘分钟数 事件特征:节假日等 特征矩阵 → 模型输入

嗯,这张图把整个流程串起来了。从原始数据出发,经过重采样、滚动窗口、时间特征工程三个分支,最终汇聚成特征矩阵,喂给模型。

我个人觉得,时间序列处理是量化交易中最容易被忽视的环节。很多人一上来就调模型参数,却忽略了数据本身的质量和特征。你想想看,如果输入的数据都是错的,模型再牛也没用。

好了,这一章就到这里。记住:重采样解决频率问题,滚动窗口解决动态问题,时间特征解决语义问题。三者缺一不可。


专注资料整理