4. 数据清洗与预处理:缺失值填充、时间戳对齐、多周期数据重采样

做量化的人都知道一句话:垃圾进,垃圾出。你模型再牛,策略再精巧,数据一塌糊涂,结果就是白忙活。我刚开始做K‑quant那会,就吃过这亏——回测曲线漂亮得像造假,一上实盘立马翻车。查了半天,原来是有个交易所的行情数据缺了半小时,我的策略在那边狂开仓,实际根本没法成交。嗯,从那以后,数据清洗这块我再也不敢糊弄了。

这一章咱们就聚焦三个最常踩坑的环节:缺失值填充、时间戳对齐、多周期重采样。说白了,就是把你手头乱七八糟的原始数据,收拾成能直接喂给量化模型的干净数据。

数据清洗与预处理核心流程 原始数据 缺失值填充 时间戳对齐 多周期重采样 清洗后数据 常用方法 前向填充(ffill) 线性插值 统计量填充 对齐策略 最近邻对齐 前向对齐 后向对齐 重采样方式 降采样(聚合) 升采样(插值) OHLC / 均值 💡 核心原则:先填充 → 再对齐 → 最后重采样 顺序不可逆,否则会引入未来信息或导致数据泄露

4.1 缺失值处理——别让空数据拖后腿

金融数据里缺失值太常见了。交易所断流、行情快照没抓到、节假日停盘……原因五花八门。但模型不认识「缺失」,它只会报错或者偷偷给你算出荒谬的结果。

我个人的习惯是:拿到数据第一件事,先看缺失率。 用 df.isnull().sum() 扫一眼,心里就有数了。缺失率超过 50% 的列,我通常直接扔掉——硬填回去也是噪声。

常用的填充方法有这么几种:

  • 前向填充(ffill):用上一个非空值往下填。做高频数据时我最爱用这个。想想看,行情断了一秒,用上一秒的价格有什么问题?没大毛病。
  • 后向填充(bfill):跟上面相反,用下一个非空值往上填。用得少,但特殊场景有用——比如你知道某个数据点是结算价,后面的缺失可以用它补。
  • 线性插值:在两个有效值之间拉一条直线,填补中间的点。适合价格序列,但不适合跳跃性大的数据(比如成交量)。
  • 统计量填充:用均值、中位数填充。我一般只在非时序数据里用,时序里用这个容易破坏趋势。

实战建议: 做因子计算时,缺失值填充顺序很有讲究。我个人先尝试前向填充,如果还有空位再用线性插值兜底。两层下来,基本上能填干净 99% 的数据。

import pandas as pd

# 前向填充,限制最大填充间距为 5 行
df['close'].ffill(inplace=True, limit=5)

# 剩余缺失用线性插值
df['close'].interpolate(method='linear', inplace=True)

# 实在填不了的,直接删除
df.dropna(subset=['close'], inplace=True)

避坑: 我曾经在批量回测时用了全局均值填充,结果把不同股票的收益率全拉到同一个水平线,策略筛选出来全是无效信号……查了三天才找到原因。从那以后,我坚持「按截面分组填充」,每只股票独立操作。

4.2 时间戳对齐——把不同频道的钟调准

做过多数据源的同学都有体会:A 交易所的时间戳精确到毫秒,B 交易所只到秒,C 数据商给你的 CSV 里时间还是字符串……要把它们拧到一起,靠的就是对齐。

时间戳对齐说白了就三件事:统一格式、统一时区、统一刻度。

我最常用的是 pd.merge_asof,这东西简直是多源时间序列的救星。它可以按「最接近的时间」把两个 DataFrame 拼起来,比普通的 merge 灵活得多。

# 示例:把 tick 数据和 1 秒快照对齐
tick_data = pd.DataFrame({
    'time': pd.to_datetime(['2024-01-05 09:30:00.123',
                            '2024-01-05 09:30:00.456']),
    'price': [101.2, 101.5]
})

snap_data = pd.DataFrame({
    'time': pd.to_datetime(['2024-01-05 09:30:00.000',
                            '2024-01-05 09:30:01.000']),
    'bid': [101.0, 101.3],
    'ask': [101.4, 101.7]
})

# 按时间最近对齐,方向为 'nearest'
aligned = pd.merge_asof(tick_data, snap_data,
                        on='time', direction='nearest')
print(aligned)

direction 参数有三个选项:nearest(找最近的时间点)、forward(往后找最近)、backward(往前找最近)。怎么选?

  • 回测场景:用 backward 或 nearest,避免用到未来数据。
  • 实时场景:用 forward,拿最新的快照匹配当前 tick。

小技巧: 对齐前务必把时间戳设成 index 并排序。我见过太多人 merge_asof 报错,就是忘了 .sort_values('time')。嗯,这个坑我自己就踩过两回。

4.3 多周期数据重采样——统一你的时间刻度

量化里不同策略喜欢不同频率的数据。日内策略用 1 分钟线,CTA 策略用 1 小时线,组合管理可能只看日线。但你拿到的原始数据往往是 tick 级或者固定频率的,这就需要重采样。

重采样分两种:

  • 降采样:从高频到低频(比如 1 分钟 → 5 分钟)。关键是选对聚合函数。
  • 升采样:从低频到高频(比如 日线 → 1 小时)。需要插值或填充。

我把自己常用的几种模式列一下:

目标周期 原始频率 聚合方式 代码示例
5 分钟 K 线 1 分钟 OHLC + 成交量求和 df.resample('5min').ohlc()
1 小时均值 5 分钟 均值(或中位数) df.resample('1H').mean()
日线波动率 1 小时 标准差 df.resample('1D').std()
周线持仓量 日线 最后值 df.resample('W').last()

这里有个关键点:升采样时要小心引入未来信息。 比如你把日线升采样到小时线,用线性插值填充中间的小时数据,这在回测里等于提前知道了收盘价。我一般只用前向填充或者不做升采样,宁可数据稀疏一点,也别作弊。

# 降采样:1min → 5min,生成标准 OHLC
df_5min = df_1min.resample('5min').agg({
    'open':  'first',
    'high':  'max',
    'low':   'min',
    'close': 'last',
    'volume': 'sum'
}).dropna()

# 升采样:日线 → 1小时,仅用前向填充(安全)
df_hourly = df_daily.resample('1H').ffill(limit=4)

注意: 重采样后的数据量会变少(降采样)或变多(升采样)。我吃过亏的是:降采样后忘记 dropna,导致一堆 NaN 行混进模型训练集,loss 直接飞了。建议每次重采样后都检查一下索引是否连续、值是否完整。

4.4 三步骤的配合顺序

这三个步骤不是独立的,它们有严格的先后关系:

  1. 先处理缺失值——不然对齐和重采样都会把缺失放大。
  2. 再做时间戳对齐——让多源数据站在同一时间点上。
  3. 最后重采样——统一到目标频率,供给策略使用。

顺序错了会怎样?我举个真实例子:有一次我先重采样再填充缺失,结果重采样把一段缺失区域「凝聚」成了一个错误的 K 线,开盘价比实际高了一大截,回测信号全部提前……嗯,那周我加了好几天班才把问题定位到。

我的标准 pipeline:
原始数据 → 按股票分组 → ffill(limit=5) → interpolate(linear) → dropna → 统一时间戳(merge_asof) → resample(target_freq) → 输出清洗后数据

这套流程我用了两年,在几十个策略上验证过,基本没再出过数据层面的幺蛾子。