4、数据清洗:缺失值处理、异常值检测(Z-score/IQR)、时间戳对齐、数据重采样(1min/5min)

数据清洗这步,我敢说占了整个回测系统搭建工作量的 40% 以上。很多人一上来就急着写策略,结果回测曲线漂亮得不行,实盘一跑就崩——十有八九是数据没洗干净。

我个人习惯把数据清洗分成四个模块来处理:缺失值、异常值、时间戳对齐、重采样。咱们一个一个来。

4.1 缺失值处理:别让 NaN 毁了你的回测

真实的高频数据里,缺失值太常见了。比如某只股票突然停牌、交易所网络抖动、或者数据源本身就有空洞。你想想看,如果回测时遇到 NaN 就直接报错,那整个流程就断了。

我一般用三种方式处理缺失值:

  • 向前填充(ffill):用上一个有效值填充。适合价格数据,因为价格在短时间内不会突变。
  • 向后填充(bfill):用下一个有效值填充。适合开盘前几分钟的数据。
  • 插值法:线性插值或时间加权插值。适合成交量这类波动较大的指标。

核心原则:高频数据中,缺失值超过连续 5 个 tick 的,建议直接丢弃该段数据。强行填充反而会引入偏差。

import pandas as pd
import numpy as np

# 模拟高频数据
df = pd.DataFrame({
    'time': pd.date_range('2024-01-01 09:30:00', periods=100, freq='1s'),
    'price': np.random.randn(100).cumsum() + 100
})

# 人为制造缺失值
df.loc[10:15, 'price'] = np.nan

# 向前填充
df['price_ffill'] = df['price'].ffill()

# 线性插值
df['price_interp'] = df['price'].interpolate(method='linear')

print(df.head(20))

我的经验:别用均值填充高频数据。我在一个项目中试过用 5 分钟均值填充缺失的 tick 数据,结果回测出来的夏普比率虚高了 0.3。后来实盘才发现,那些缺失的 tick 其实是因为流动性枯竭,根本不是随机缺失。

4.2 异常值检测:Z-score 和 IQR 怎么选?

异常值检测说白了就是找那些「离谱」的数据点。比如某只股票突然从 100 块跳到 1000 块,然后又跳回来——这大概率是数据录入错误,不是真的行情。

我常用的两种方法:

4.2.1 Z-score 方法

Z-score 适合数据分布接近正态的情况。它计算每个数据点偏离均值多少个标准差。一般阈值设为 3,超过 3 个标准差的就认为是异常值。

from scipy import stats

# 计算 Z-score
z_scores = np.abs(stats.zscore(df['price'].dropna()))
threshold = 3

# 标记异常值
df['is_outlier_z'] = False
df.loc[df['price'].notna(), 'is_outlier_z'] = z_scores > threshold

print(f"Z-score 检测到的异常值数量: {z_scores[z_scores > threshold].shape[0]}")

注意:高频数据往往有尖峰厚尾特征,Z-score 的阈值可以适当调高到 4 或 5。我曾经用默认的 3 去过滤股指期货的 tick 数据,结果把正常的跳空开盘都当成异常值给删了,回测结果完全失真。

4.2.2 IQR 方法

IQR(四分位距法)不依赖数据分布假设,更适合高频数据。它用 Q1(25% 分位数)和 Q3(75% 分位数)来定义正常范围。

# IQR 方法
Q1 = df['price'].quantile(0.25)
Q3 = df['price'].quantile(0.75)
IQR = Q3 - Q1

lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR

df['is_outlier_iqr'] = (df['price'] < lower_bound) | (df['price'] > upper_bound)

print(f"IQR 检测到的异常值数量: {df['is_outlier_iqr'].sum()}")

我的建议:两种方法结合使用。先用 IQR 做粗筛,再用 Z-score 做细筛。我在做美股高频回测时,发现 IQR 对尾部极端值更敏感,而 Z-score 对中间区域的微小波动更敏感。两者互补效果最好。

4.3 时间戳对齐:不同数据源的时间统一

高频回测最头疼的问题之一就是时间戳对齐。你想想看,交易所的 tick 数据、订单簿数据、还有第三方数据源,它们的时间戳格式可能完全不同。

我遇到过最离谱的情况:一个数据源用毫秒时间戳,另一个用微秒时间戳,还有一个居然用字符串格式的「2024-01-01 09:30:00.123」。不统一对齐的话,回测结果就是一团浆糊。

对齐步骤:

  1. 统一时间格式:全部转为 pandas 的 Timestamp 或 datetime64
  2. 统一时区:高频数据建议全部转为 UTC,避免夏令时问题
  3. 对齐精度:确定一个基准精度(比如毫秒),所有数据都对齐到这个精度
# 时间戳对齐示例
df['time'] = pd.to_datetime(df['time'], unit='ms')  # 假设原始是毫秒时间戳
df = df.set_index('time')

# 对齐到整秒
df_aligned = df.resample('1S').last().ffill()

print(df_aligned.head())

关键点:对齐时一定要明确「左闭右开」还是「左开右闭」。我习惯用左闭右开,即 [t, t+1) 区间内的数据归到 t 时刻。这个细节如果不统一,回测结果可能差 10 个基点。

4.4 数据重采样:从 tick 到 1min/5min

高频数据量太大了。一只股票一天的 tick 数据可能有几十万条,回测起来慢得要命。所以我们需要重采样,把 tick 数据聚合成 1 分钟或 5 分钟的 K 线。

重采样时常用的聚合方式:

字段 聚合方式 说明
开盘价 first() 该时间段的第一个价格
收盘价 last() 该时间段的最后一个价格
最高价 max() 该时间段内的最高价格
最低价 min() 该时间段内的最低价格
成交量 sum() 该时间段内的总成交量
# 重采样到 1 分钟
df_1min = df.resample('1T').agg({
    'price': 'ohlc',  # 生成 OHLC 四列
    'volume': 'sum'
})

# 重采样到 5 分钟
df_5min = df.resample('5T').agg({
    'price': 'ohlc',
    'volume': 'sum'
})

print(df_1min.head())
print(df_5min.head())

避坑指南:我曾经在重采样时忘了处理空区间。比如某只股票在 10:00-10:01 这一分钟内没有成交,resample 会生成 NaN。如果不做填充,后面的策略计算就会出问题。我的习惯是:重采样后立即用 ffill 填充一次,确保没有空洞。

4.5 实战中的清洗流水线

说了这么多,我总结一下自己在项目中实际用的清洗流水线:

  1. 原始数据入库:先不做任何处理,保留原始数据
  2. 时间戳标准化:统一转为 UTC 毫秒时间戳
  3. 缺失值处理:连续缺失 ≤ 5 个用 ffill,超过的直接丢弃
  4. 异常值检测:IQR 粗筛 + Z-score 细筛,标记但不删除
  5. 时间戳对齐:对齐到整秒或整毫秒
  6. 重采样:根据策略需求生成 1min 或 5min K 线
  7. 最终检查:检查是否有 NaN,确保数据完整性

最后说一句:数据清洗没有银弹。不同的策略、不同的市场、不同的数据源,清洗方式都不一样。我的建议是:先把清洗流程写成可配置的模块,这样换数据源时改几个参数就行,不用重写代码。