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」。不统一对齐的话,回测结果就是一团浆糊。
对齐步骤:
- 统一时间格式:全部转为 pandas 的 Timestamp 或 datetime64
- 统一时区:高频数据建议全部转为 UTC,避免夏令时问题
- 对齐精度:确定一个基准精度(比如毫秒),所有数据都对齐到这个精度
# 时间戳对齐示例
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 实战中的清洗流水线
说了这么多,我总结一下自己在项目中实际用的清洗流水线:
- 原始数据入库:先不做任何处理,保留原始数据
- 时间戳标准化:统一转为 UTC 毫秒时间戳
- 缺失值处理:连续缺失 ≤ 5 个用 ffill,超过的直接丢弃
- 异常值检测:IQR 粗筛 + Z-score 细筛,标记但不删除
- 时间戳对齐:对齐到整秒或整毫秒
- 重采样:根据策略需求生成 1min 或 5min K 线
- 最终检查:检查是否有 NaN,确保数据完整性
最后说一句:数据清洗没有银弹。不同的策略、不同的市场、不同的数据源,清洗方式都不一样。我的建议是:先把清洗流程写成可配置的模块,这样换数据源时改几个参数就行,不用重写代码。