第四章:数据预处理——从原始数据到可用特征
大家好,我是你们的量化实战讲师。今天咱们聊聊数据预处理。说实话,这章内容看起来没那么「性感」,但我要告诉你一个残酷的事实:我见过太多漂亮的策略模型,最后都死在了数据预处理上。嗯,这不是危言耸听。
高频数据有个特点——又脏又乱。你从交易所拿到的原始数据,说白了就是一堆带时间戳的数字。里面可能有跳空、有缺失、有异常报价。如果你直接拿这些数据去训练模型,结果会很惨。我刚开始做高频策略时,就吃过这个亏。
核心观点:数据预处理占整个量化项目工作量的60%-80%。模型再牛,也救不了垃圾数据。
4.1 异常值检测与处理
高频数据里的异常值,说白了就是那些「明显不对劲」的报价。比如某只股票突然出现一笔成交价是前一笔的10倍,或者卖一价突然变成0.01元。这些数据如果不处理,模型会学出一些奇怪的东西。
我常用的检测方法有三种:
- 3σ法则:假设数据服从正态分布,超出均值±3倍标准差的值视为异常。这个方法简单粗暴,但高频数据往往不服从正态分布,所以效果一般。
- IQR方法:用四分位距来检测。Q1-1.5*IQR 到 Q3+1.5*IQR 之外的值视为异常。这个方法对偏态分布更鲁棒。
- 百分比截尾:直接去掉上下0.5%或1%的极值。这个方法最暴力,但有时候最有效。
我的经验:对于高频Tick数据,我建议先用IQR方法做第一轮检测,再用业务规则做第二轮。比如价格不能为负、买卖价差不能超过某个阈值。我曾经遇到过一个案例,某只股票因为数据源问题,连续出现了几十笔价格为0的成交记录,IQR根本检测不出来,但业务规则一下就抓住了。
import pandas as pd
import numpy as np
def detect_outliers_iqr(df, column, multiplier=1.5):
"""使用IQR方法检测异常值"""
Q1 = df[column].quantile(0.25)
Q3 = df[column].quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - multiplier * IQR
upper_bound = Q3 + multiplier * IQR
outliers = df[(df[column] < lower_bound) | (df[column] > upper_bound)]
return outliers, lower_bound, upper_bound
# 使用示例
outliers, lb, ub = detect_outliers_iqr(tick_data, 'price')
print(f"检测到 {len(outliers)} 个异常值")
print(f"正常范围: [{lb:.2f}, {ub:.2f}]")
处理异常值,我一般用两种方式:
- 直接删除:如果异常值占比很小(比如<0.1%),直接删掉最省事。
- 截尾处理:把异常值替换为边界值。比如把超过上界的值设为上界。这样做的好处是保留了样本数量。
注意:千万不要在回测时「偷看未来」!异常值检测应该基于滚动窗口,而不是全量数据。否则你的回测结果会过于乐观。
4.2 缺失值填充方法
高频数据缺失的原因很多:交易所数据中断、网络延迟、撮合引擎故障等等。你想想看,如果某只股票在10:00:00到10:00:01之间没有成交记录,那这一秒的数据就是缺失的。
我常用的填充方法:
| 方法 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 前向填充(ffill) | 价格、成交量等 | 简单、符合市场逻辑 | 会引入滞后 |
| 后向填充(bfill) | 少量缺失 | 简单 | 会引入未来信息 |
| 线性插值 | 连续缺失较少 | 平滑、合理 | 不适合跳跃行情 |
| 模型预测填充 | 缺失较多 | 精度高 | 计算量大 |
我个人习惯用前向填充。为什么?因为高频数据的缺失往往意味着「没有新的成交」,用上一笔的价格填充是最符合市场实际情况的。但要注意,如果连续缺失超过5笔,我会标记为「无效区间」,而不是强行填充。
def fill_missing_ticks(tick_df, max_gap=5):
"""填充缺失的Tick数据"""
# 先重采样到固定频率
tick_df = tick_df.set_index('timestamp')
tick_df = tick_df.resample('1s').asfreq()
# 前向填充,但限制最大连续缺失数
tick_df['price'] = tick_df['price'].ffill(limit=max_gap)
tick_df['volume'] = tick_df['volume'].fillna(0)
# 标记无效区间
tick_df['is_valid'] = ~tick_df['price'].isna()
return tick_df.reset_index()
避坑指南:我曾经犯过一个错误——用后向填充来处理缺失值。结果在回测时模型表现很好,但实盘一跑就崩。后来才发现,后向填充引入了未来信息,相当于让模型「偷看」了未来的价格。所以,回测时只能用前向填充或插值,千万别用后向填充。
4.3 时间对齐与重采样
不同数据源的时间戳可能不一致。比如交易所A的Tick数据是精确到毫秒的,而交易所B的数据是精确到秒的。如果你想把它们放在一起分析,就必须做时间对齐。
时间对齐的两种思路:
- Tick级对齐:把所有数据对齐到同一个时间轴上。比如以交易所A的时间为基准,把交易所B的数据插值到A的时间点上。
- 重采样对齐:把高频数据重采样到更低频率。比如把Tick数据重采样到1秒、1分钟或5分钟K线。
我个人更推荐重采样对齐。为什么?因为Tick级对齐太容易出错了。你想想看,两个交易所的Tick时间戳可能差几毫秒,你强行对齐反而会引入噪声。
def resample_to_1min(tick_df):
"""将Tick数据重采样为1分钟K线"""
tick_df = tick_df.set_index('timestamp')
# 重采样到1分钟
ohlc = tick_df['price'].resample('1min').ohlc()
volume = tick_df['volume'].resample('1min').sum()
# 合并
minute_df = pd.concat([ohlc, volume], axis=1)
minute_df.columns = ['open', 'high', 'low', 'close', 'volume']
return minute_df.reset_index()
关键点:重采样时要注意「边界对齐」。比如从9:30:00到9:30:59的数据应该归到9:30:00这一根K线,还是9:31:00?不同的对齐方式会影响后续的特征计算。我建议统一使用「左闭右开」的区间,即[9:30:00, 9:31:00)的数据归到9:30:00这根K线。
4.4 特征工程基础
特征工程,说白了就是把原始数据变成模型能「理解」的东西。原始的价格和成交量数据太粗糙了,我们需要从中提取出更有预测能力的特征。
我常用的几类特征:
- 价格衍生特征:收益率、对数收益率、价格动量、移动平均线等。
- 量价关系特征:成交量加权平均价(VWAP)、资金流向、买卖压力等。
- 波动率特征:真实波动幅度(ATR)、已实现波动率、价格极差等。
- 微观结构特征:买卖价差、订单簿不平衡、交易强度等。
def create_features(tick_df):
"""构建高频特征"""
df = tick_df.copy()
# 收益率
df['return'] = df['price'].pct_change()
# 对数收益率
df['log_return'] = np.log(df['price'] / df['price'].shift(1))
# 价格动量(过去5笔)
df['momentum_5'] = df['price'] / df['price'].shift(5) - 1
# 波动率(过去10笔的收益率标准差)
df['volatility_10'] = df['return'].rolling(10).std()
# 成交量变化
df['volume_change'] = df['volume'].pct_change()
# 买卖压力(如果有买卖方向数据)
if 'buy_volume' in df.columns and 'sell_volume' in df.columns:
df['buy_pressure'] = df['buy_volume'] / (df['buy_volume'] + df['sell_volume'])
return df.dropna()
我的经验:特征不是越多越好。我见过有人一口气造了200多个特征,结果模型过拟合得一塌糊涂。我建议从10-20个核心特征开始,逐步增加。另外,高频数据的特征要注意「时效性」——过去1秒的特征比过去1分钟的特征更有预测力。
嗯,到这里数据预处理的核心内容就讲完了。总结一下:先做异常值检测,把明显不对的数据处理掉;然后用前向填充处理缺失值;接着做时间对齐或重采样,让数据在统一的时间轴上;最后构建有预测力的特征。每一步都很关键,缺一不可。
记住一句话:好的数据预处理,是成功策略的一半。