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%的时间写策略——这话虽然夸张,但道理不假。数据干净了,策略才能跑得稳。


专注资料整理