2. 数据清洗实战:缺失值处理、异常值检测与时间戳对齐
数据清洗这事儿,说白了就是给原始数据“洗澡”。我做了这么多年量化,见过太多人模型调得飞起,结果数据一塌糊涂——那真是白费功夫。订单簿数据尤其脏,交易所的行情流偶尔断一下、网络抖动丢几个包、甚至撮合引擎自己抽风,都会留下各种坑。今天咱们就把这些坑一个个填平。
核心原则:宁可丢掉10%的脏数据,也别让1%的坏数据污染模型。这是我在一次实盘回测中血泪换来的教训。
2.1 缺失值处理:别急着删,先看看为什么缺
订单簿的缺失值,通常不是随机的。我遇到过三种典型场景:
- 盘前盘后:交易所还没开盘,或者已经收盘,数据源自然为空。这种直接标记或删除就行。
- 流动性枯竭:某个价位没人挂单,买一价或卖一价突然消失。嗯,这种情况在冷门币种上特别常见。
- 网络丢包:中间某个时间戳的数据包没收到,前后都有数据,中间缺了一块。
处理策略上,我个人习惯分三步走:
- 先检查缺失比例。如果超过30%,这列数据基本废了,建议直接扔掉。
- 再判断缺失类型。是连续缺失还是零星缺失?连续缺失超过5个时间步,就别用插值了,容易引入偏差。
- 最后选方法。零星缺失用前向填充(ffill)最稳妥,因为订单簿是状态持续的——上一秒的挂单,下一秒大概率还在。
import pandas as pd
import numpy as np
# 模拟订单簿数据
df = pd.DataFrame({
'timestamp': pd.date_range('2024-01-01 09:30:00', periods=100, freq='1s'),
'bid_price': [100.0 + i*0.01 for i in range(100)],
'ask_price': [100.05 + i*0.01 for i in range(100)],
'bid_volume': [1000 + np.random.randint(-10, 10) for _ in range(100)],
'ask_volume': [800 + np.random.randint(-10, 10) for _ in range(100)]
})
# 人为制造缺失
df.loc[10:12, 'bid_price'] = np.nan
df.loc[50, 'ask_volume'] = np.nan
# 前向填充(我最常用的方法)
df_filled = df.fillna(method='ffill')
# 如果开头就有缺失,用后向填充兜底
df_filled = df_filled.fillna(method='bfill')
print(f"缺失值数量: {df.isnull().sum().sum()}")
print(f"填充后缺失值: {df_filled.isnull().sum().sum()}")
避坑指南:我曾经在回测中用了线性插值处理订单簿缺失,结果模型学到了“价格平滑上涨”的假规律,实盘直接亏了。记住,订单簿是离散状态,不是连续函数。
2.2 异常值检测:3-sigma 和 IQR,哪个更靠谱?
异常值在订单簿里很常见。比如某笔成交突然把价格拉高几个tick,或者某个大单撤单导致深度瞬间变化。检测异常值,我常用两种方法:
2.2.1 3-sigma 法则(适合正态分布的数据)
说白了就是:超过均值±3个标准差的值,就认为是异常。这个方法对价格变化率(return)这种近似正态的变量很有效。但注意,订单簿的买卖价差(spread)往往不是正态的——它经常是尖峰厚尾分布。
def detect_outliers_3sigma(series, n_sigma=3):
mean = series.mean()
std = series.std()
lower_bound = mean - n_sigma * std
upper_bound = mean + n_sigma * std
outliers = (series < lower_bound) | (series > upper_bound)
return outliers
# 检测买卖价差的异常
spread = df['ask_price'] - df['bid_price']
outliers_3sigma = detect_outliers_3sigma(spread)
print(f"3-sigma 检测到 {outliers_3sigma.sum()} 个异常值")
2.2.2 IQR 方法(更适合非正态分布)
IQR 不依赖正态假设,它看的是四分位距。通常把低于 Q1-1.5*IQR 或高于 Q3+1.5*IQR 的值视为异常。我在处理订单簿深度数据时,更偏爱 IQR——因为深度分布经常是偏态的,3-sigma 会漏掉很多尾部异常。
def detect_outliers_iqr(series, multiplier=1.5):
Q1 = series.quantile(0.25)
Q3 = series.quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - multiplier * IQR
upper_bound = Q3 + multiplier * IQR
outliers = (series < lower_bound) | (series > upper_bound)
return outliers
outliers_iqr = detect_outliers_iqr(spread)
print(f"IQR 检测到 {outliers_iqr.sum()} 个异常值")
注意:别盲目用默认参数。我做过测试,对于高频订单簿数据,1.5倍IQR太敏感了,经常把正常的波动也标成异常。建议调成2.0或2.5倍,具体看你的数据波动率。
2.3 时间戳对齐:不同交易所的时间,得先“对表”
做多交易所数据时,时间戳对齐是个大坑。不同交易所的时钟可能差几毫秒甚至几十毫秒。我见过有人直接把两个交易所的 tick 数据按时间合并,结果发现“同一时刻”的买卖价差竟然是负的——因为一个交易所的卖价比另一个的买价还低,这明显是时间没对齐。
我的做法是:
- 先统一时区:全部转成 UTC 时间,别用本地时间。
- 再对齐到最近的整秒或整毫秒:用 round() 或 floor() 函数。
- 最后做外连接:保留所有时间点,缺失的用前向填充。
# 模拟两个交易所的数据
exchange_a = pd.DataFrame({
'timestamp': pd.to_datetime(['2024-01-01 09:30:00.123',
'2024-01-01 09:30:01.456',
'2024-01-01 09:30:02.789']),
'price_a': [100.0, 100.1, 100.2]
})
exchange_b = pd.DataFrame({
'timestamp': pd.to_datetime(['2024-01-01 09:30:00.500',
'2024-01-01 09:30:01.700',
'2024-01-01 09:30:02.900']),
'price_b': [100.05, 100.15, 100.25]
})
# 对齐到最近的整秒
exchange_a['aligned_ts'] = exchange_a['timestamp'].dt.round('1s')
exchange_b['aligned_ts'] = exchange_b['timestamp'].dt.round('1s')
# 合并
merged = pd.merge(exchange_a, exchange_b, on='aligned_ts', how='outer')
merged = merged.sort_values('aligned_ts').fillna(method='ffill')
print(merged[['aligned_ts', 'price_a', 'price_b']])
2.4 重采样:把不规则数据变成规整的时间序列
订单簿数据是事件驱动的——有成交才产生一条记录。但模型训练需要规整的时间序列。重采样就是把不规则数据“压”到固定时间间隔上。
我常用的重采样策略:
| 频率 | 适用场景 | 聚合函数 |
|---|---|---|
| 1秒 | 高频策略、做市 | last(最后一笔)、mean(平均价) |
| 1分钟 | 日内趋势、统计套利 | OHLC(开高低收) |
| 5分钟 | 中频策略、风险控制 | OHLC + volume(成交量) |
# 模拟不规则时间序列数据
irregular_ts = pd.DataFrame({
'timestamp': pd.to_datetime([
'2024-01-01 09:30:00.100',
'2024-01-01 09:30:00.250',
'2024-01-01 09:30:01.000',
'2024-01-01 09:30:01.800',
'2024-01-01 09:30:02.500'
]),
'price': [100.0, 100.02, 100.05, 100.03, 100.08],
'volume': [100, 200, 150, 300, 250]
})
# 设置时间索引
irregular_ts.set_index('timestamp', inplace=True)
# 重采样到1秒,用last取最后一笔价格,sum汇总成交量
resampled = irregular_ts.resample('1s').agg({
'price': 'last',
'volume': 'sum'
}).fillna(method='ffill')
print(resampled)
个人经验:重采样时别忘了处理空窗口。如果某个1秒窗口内没有数据,用前向填充比用0填充更合理——因为订单簿状态是持续的,没新数据不代表价格归零。
2.5 本章知识体系总览
下面这张图,是我做数据清洗时的完整流程。你可以把它当成一个检查清单:
嗯,数据清洗这块儿,说白了就是“磨刀不误砍柴工”。我见过太多人急着跑模型,结果被脏数据坑得怀疑人生。把上面这套流程走一遍,你的订单簿数据基本就干净了,后面建模会省心很多。