4. 数据清洗与预处理:处理缺失值、处理异常值、数据重采样、时间序列对齐
做量化交易的朋友,咱们都懂一个道理:垃圾进,垃圾出。你策略再牛,模型再炫,喂进去的数据是脏的,结果一定惨不忍睹。我刚开始做数字货币量化那会儿,就吃过这个亏——回测曲线漂亮得不行,实盘一跑直接崩盘。后来排查了三天,发现是交易所的原始数据里有一堆空值和跳变点。
所以今天这一章,咱们就专门聊聊数据清洗与预处理。说白了,就是把你从交易所拉下来的「毛坯房」数据,装修成能直接用的「精装房」。我个人习惯把这四个步骤串成一条流水线:缺失值 → 异常值 → 重采样 → 对齐。顺序别搞反了,不然容易出问题。
核心逻辑图:数据预处理流水线
4.1 处理缺失值:别让空数据坑了你
数字货币交易所的数据,经常会出现缺失。为啥?网络波动、撮合引擎延迟、甚至交易所服务器宕机,都会导致某根K线没生成。我见过最夸张的一次,某小交易所连续3个小时没有BTC/USDT的成交记录——那根K线直接是NaN。
处理缺失值,我个人常用的方法有三种:
- 直接删除:如果缺失比例很小(比如<1%),直接drop掉。简单粗暴,但有效。
- 向前填充:用上一个有效值填充。适合价格数据,因为价格变化是连续的。比如上一根K线收盘价是50000,下一根缺失,那就填50000。
- 插值法:用前后值的平均值填充。适合成交量这类波动较大的数据。
嗯,这里要注意:千万别用「0」去填充缺失的价格数据。我曾经见过一个新手,把缺失的收盘价填成0,结果计算收益率时直接出现负无穷——整个策略直接崩了。
避坑指南:我曾经在回测中遇到过一个坑——用向前填充处理了所有缺失值,结果发现某次交易所宕机期间,价格被「冻结」了3个小时。回测时看起来没问题,实盘时那3小时的价格根本没变,导致策略误判。后来我加了一个判断:如果连续缺失超过5根K线,直接标记为异常区间,不参与回测。
import pandas as pd
import numpy as np
# 示例:处理缺失值
df = pd.read_csv('btc_usdt_1h.csv', index_col='timestamp', parse_dates=True)
# 方法1:直接删除
df_clean = df.dropna(subset=['close'])
# 方法2:向前填充(价格数据推荐)
df['close'] = df['close'].ffill()
# 方法3:插值法(成交量推荐)
df['volume'] = df['volume'].interpolate(method='linear')
# 检查缺失比例
missing_ratio = df.isnull().sum() / len(df)
print(f"缺失比例:\n{missing_ratio}")
4.2 处理异常值:把「妖魔鬼怪」揪出来
数字货币市场,异常值太常见了。你想想看,一个乌龙指、一次大额清算、或者交易所数据推送错误,都能产生离谱的价格。比如BTC突然从50000跳到100000又跳回来——这种数据如果不处理,你的策略会被它带偏。
我常用的异常值检测方法有两个:
| 方法 | 适用场景 | 原理 |
|---|---|---|
| Z-score法 | 正态分布的数据(如收益率) | 计算每个值偏离均值多少个标准差,通常|Z|>3视为异常 |
| IQR法 | 非正态分布的数据(如成交量) | 计算四分位距,超出Q1-1.5*IQR或Q3+1.5*IQR视为异常 |
我个人更偏爱IQR法,因为价格数据往往不是正态分布,有厚尾特征。Z-score容易把正常的极端行情误判为异常。
警告:处理异常值时,别一刀切。比如2020年3月12日「312暴跌」,BTC从8000跌到3800,这看起来像异常值,但它是真实的市场事件。如果你把它删了,回测就失去了对极端行情的反应能力。我的做法是:先标记异常值,然后人工复核,再决定是删除、替换还是保留。
# 使用IQR法检测异常值
def detect_outliers_iqr(data, column, factor=1.5):
Q1 = data[column].quantile(0.25)
Q3 = data[column].quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - factor * IQR
upper_bound = Q3 + factor * IQR
outliers = data[(data[column] < lower_bound) | (data[column] > upper_bound)]
return outliers, lower_bound, upper_bound
# 检测收盘价的异常值
outliers, lb, ub = detect_outliers_iqr(df, 'close')
print(f"检测到 {len(outliers)} 个异常值")
print(f"正常范围:{lb:.2f} ~ {ub:.2f}")
# 处理方式:将异常值替换为边界值(Winsorize)
df['close'] = df['close'].clip(lower=lb, upper=ub)
4.3 数据重采样:统一时间粒度
不同交易所、不同数据源,时间粒度可能不一样。有的给你1分钟K线,有的给你5分钟K线,还有的只有日线。做策略之前,必须统一到一个时间框架上。
重采样说白了就是「升维」或「降维」:
- 降采样:从高频到低频(比如1分钟→1小时)。用聚合函数,比如OHLC(开盘、最高、最低、收盘)和成交量求和。
- 升采样:从低频到高频(比如1小时→15分钟)。需要插值,但要注意——升采样会引入「未来信息」,回测时容易过拟合。
我记得有一次,一个学员把日线数据升采样到小时线,然后用小时线做策略,回测收益高得离谱。我一看代码,发现他用的是「前向填充」,把当天的收盘价填充到了当天所有小时线上——这不就是未来函数吗?实盘肯定亏。
我的建议:尽量用降采样,别用升采样。如果非要用升采样,只能用「仅使用过去数据」的填充方式,比如用前一根K线的值填充,千万别用当天的收盘价去填充前面的小时线。
# 降采样:从1分钟到1小时
df_1min = pd.read_csv('btc_1min.csv', index_col='timestamp', parse_dates=True)
# OHLC重采样
df_1h = df_1min.resample('1H').agg({
'open': 'first',
'high': 'max',
'low': 'min',
'close': 'last',
'volume': 'sum'
})
# 升采样(谨慎使用)
df_15min = df_1h.resample('15T').ffill() # 前向填充,只使用过去数据
4.4 时间序列对齐:让不同数据「步调一致」
做多因子策略时,你可能需要同时使用BTC的价格数据和ETH的价格数据,甚至还要加上链上数据、情绪指数。这些数据的时间戳往往不一致——BTC的K线是整点,ETH的K线可能延迟几秒,链上数据更是按区块时间来的。
时间对齐的核心思想:以最慢的数据为准,把其他数据对齐过来。
举个例子,如果你要同时用1小时K线和链上活跃地址数(每天更新一次),那就把K线也降采样到日线,或者把链上数据填充到每个小时。我个人习惯用「左连接」的方式,以主数据的时间索引为准,把其他数据匹配过来。
对齐原则:永远不要用未来数据去填充过去的时间点。对齐时只能用「前向填充」或「后向填充」,且必须确保填充方向是向后的(即用过去的数据填充当前缺失)。
# 对齐两个不同时间频率的数据
btc_1h = pd.read_csv('btc_1h.csv', index_col='timestamp', parse_dates=True)
eth_1h = pd.read_csv('eth_1h.csv', index_col='timestamp', parse_dates=True)
onchain_data = pd.read_csv('onchain_daily.csv', index_col='date', parse_dates=True)
# 步骤1:将链上数据填充到小时级别(前向填充)
onchain_hourly = onchain_data.resample('1H').ffill()
# 步骤2:以BTC数据为基准,左连接ETH和链上数据
aligned = btc_1h.join(eth_1h, how='left', lsuffix='_btc', rsuffix='_eth')
aligned = aligned.join(onchain_hourly, how='left')
# 步骤3:检查对齐后的缺失值
print(f"对齐后缺失值数量:\n{aligned.isnull().sum()}")
做完这四步,你的数据基本上就「干净」了。我个人习惯在预处理结束后,再跑一遍简单的统计检查——看看均值、标准差、最大最小值是否合理。如果发现某个字段的均值突然变了,那说明预处理可能有问题,得回头查。
好了,数据清洗这块就聊到这儿。记住:花80%的时间处理数据,花20%的时间写策略——这话虽然夸张,但道理不假。数据干净了,策略才能跑得稳。