4、数据清洗:处理缺失值、异常值、时间戳对齐、快照重建

数据清洗这步,说白了就是给原始订单簿数据「洗澡」。我见过太多人,模型写得花里胡哨,结果数据一塌糊涂,最后回测曲线漂亮得像假的一样——嗯,其实就是假的。今天咱们就聊聊,怎么把这堆脏数据收拾得服服帖帖。

4.1 缺失值处理:别让空值毁了你的信号

订单簿数据里,缺失值太常见了。比如某只股票流动性差,某个价位上半天没单子;或者交易所数据推送偶尔抽风,丢了一帧。我个人习惯,先分清楚缺失的类型:

  • 完全随机缺失:比如网络抖动丢包,直接扔掉或前向填充就行
  • 非随机缺失:比如大单撤单导致某个价位变空,这得小心处理

我在项目中遇到过最坑的一次:某只小盘股,卖五价位经常是空的,我直接用0填充。结果不平衡度信号算出来全是极端值,回测收益高得离谱——后来发现,那些空价位其实代表「没人卖」,应该用卖一价或者前一个非空值才对。

核心原则:订单簿的缺失值,不要机械地用0或均值填充。要考虑市场微观结构。

常用的填充策略:

  1. 前向填充(ffill):用上一个非空值填充,适合高频场景
  2. 线性插值:适合价格序列,但订单簿的价量数据用这个要谨慎
  3. 标记填充:对缺失位置打标记,作为特征输入模型
import pandas as pd
import numpy as np

# 假设df是订单簿DataFrame,包含bid_price1~5, bid_vol1~5, ask_price1~5, ask_vol1~5
def clean_missing_orderbook(df):
    # 前向填充:用上一个非空值填充
    df = df.ffill()
    
    # 如果某一行所有价格都缺失,直接删除
    price_cols = [col for col in df.columns if 'price' in col]
    df = df.dropna(subset=price_cols, how='all')
    
    # 对成交量缺失,用0填充(表示该价位无挂单)
    vol_cols = [col for col in df.columns if 'vol' in col]
    df[vol_cols] = df[vol_cols].fillna(0)
    
    return df

小技巧:处理前先看看缺失率。如果某个价位缺失超过30%,我建议直接放弃这个价位,别硬填。

4.2 异常值检测:揪出那些离谱的数据点

异常值在订单簿里很要命。比如突然冒出一个卖一价是0.01元,或者买一量是10亿股——这明显是数据错误。我曾经在回测时没处理这些,结果信号在某个时刻直接爆表,仓位管理全乱套。

常用的检测方法:

方法 适用场景 注意事项
Z-score 价格序列 假设正态分布,订单簿数据往往不满足
IQR(四分位距) 成交量、价差 对极端值不敏感,适合厚尾分布
固定阈值 价格涨跌幅限制 需要根据品种设定,比如A股±10%
滚动窗口 高频数据 用过去N个点的均值±3倍标准差
def detect_outliers_iqr(series, factor=3):
    """用IQR方法检测异常值"""
    Q1 = series.quantile(0.25)
    Q3 = series.quantile(0.75)
    IQR = Q3 - Q1
    lower = Q1 - factor * IQR
    upper = Q3 + factor * IQR
    return (series < lower) | (series > upper)

# 对买卖价差做异常检测
df['spread'] = df['ask_price1'] - df['bid_price1']
outliers = detect_outliers_iqr(df['spread'])
df.loc[outliers, 'spread'] = np.nan  # 标记为缺失,后续填充

警告:别一刀切删除异常值。有些异常其实是市场极端事件(比如闪崩),删了反而丢失信息。我建议先标记,再人工判断。

4.3 时间戳对齐:让不同数据源步调一致

做量化交易最头疼的事之一,就是不同数据源的时间戳对不上。交易所的tick数据可能是微秒级,但你的行情软件只给到毫秒。更坑的是,有些数据源的时间戳是本地时间,有些是UTC。

我个人习惯,统一用纳秒级时间戳,全部转成UTC。为什么?因为跨市场交易时,时区差异能把你搞疯。

def align_timestamps(df, freq='100ms'):
    """将订单簿数据对齐到固定时间间隔"""
    # 确保时间戳是datetime类型
    df.index = pd.to_datetime(df.index, unit='ns')
    
    # 按固定频率重采样,取每个区间最后一笔数据
    aligned = df.resample(freq).last()
    
    # 如果某个区间没有数据,前向填充
    aligned = aligned.ffill()
    
    return aligned

# 示例:对齐到100毫秒
df_aligned = align_timestamps(df, '100ms')

这里有个坑:重采样时用last()还是first()?我建议用last(),因为订单簿反映的是该时刻的「最新状态」。但如果你做的是事件驱动策略,可能用first()更合适。嗯,看策略需求。

4.4 快照重建:从增量数据还原完整订单簿

很多交易所为了节省带宽,只推送增量数据(比如「买一价变了」),不推送完整快照。这时候你得自己重建。我记得第一次做这个,没处理好增量叠加,结果订单簿越算越乱,最后价格都变成负数了。

重建的核心逻辑:

  1. 维护一个当前快照的字典
  2. 收到增量事件时,更新对应价位
  3. 定期用全量快照做校准(防止累积误差)
class OrderBookReconstructor:
    def __init__(self, levels=5):
        self.levels = levels
        self.bids = {}  # {price: volume}
        self.asks = {}
        
    def apply_snapshot(self, snapshot):
        """应用全量快照"""
        self.bids = {p: v for p, v in snapshot['bids']}
        self.asks = {p: v for p, v in snapshot['asks']}
        
    def apply_delta(self, delta):
        """应用增量更新"""
        for side, price, volume in delta:
            if side == 'bid':
                if volume == 0:
                    self.bids.pop(price, None)
                else:
                    self.bids[price] = volume
            elif side == 'ask':
                if volume == 0:
                    self.asks.pop(price, None)
                else:
                    self.asks[price] = volume
                    
    def get_top_n(self):
        """获取前N档买卖盘"""
        sorted_bids = sorted(self.bids.items(), reverse=True)[:self.levels]
        sorted_asks = sorted(self.asks.items())[:self.levels]
        return sorted_bids, sorted_asks

关键点:增量更新时,成交量归零表示撤单,要删除该价位。别傻傻地保留0量。

4.5 知识体系总览

下面这张图,是我做数据清洗时的完整流程。你可以把它贴在工位上,每次处理数据前扫一眼。

订单簿数据清洗流程 原始订单簿数据 步骤1:缺失值处理 步骤2:异常值检测与处理 步骤3:时间戳对齐 步骤4:快照重建(增量数据) 注意事项 • 缺失值不要用0硬填 • 异常值先标记再处理 • 时间戳统一用UTC • 快照重建要定期校准 • 保留原始数据备份 • 记录清洗日志

数据清洗这件事,说白了就是「垃圾进,垃圾出」。你花80%的时间在清洗上,一点都不夸张。我见过太多人急着跑模型,结果数据没洗干净,最后白忙活一场。记住:干净的订单簿数据,是交易信号挖掘的地基。

专注资料整理