第三节:数据清洗与预处理实战

数据清洗这事儿,说白了就是给原始行情数据“洗澡”。我做了这么多年高频策略,发现80%的策略失效其实不是模型问题,而是数据没洗干净。今天咱们就聊聊跳价、异常值、对齐、重采样、隔夜跳空和复权这些绕不开的坑。

核心观点:高频数据清洗不是“锦上添花”,而是“生死攸关”。一个跳价没处理,可能让你的回测收益翻倍;一个复权没做对,可能让你买在历史最高点。

3.1 跳价与异常值处理

跳价,就是价格在极短时间内出现不合理的跳跃。比如某股票上一笔成交价10元,下一笔直接跳到15元,然后又跳回10.1元。这种数据,你信吗?反正我不信。

为什么会这样? 可能是交易所数据推送异常,也可能是网络传输丢包后重传。我曾在实盘中遇到过,某只股票突然跳涨20%,吓得我差点平仓,后来发现是数据源的问题。

3.1.1 跳价检测方法

我个人习惯用两种方法:

  • 价格变化率法: 计算相邻两笔的价格变化百分比,超过阈值(比如0.5%)就标记为跳价。
  • 中位数绝对偏差法: 用过去N笔的中位数和MAD(中位数绝对偏差)来判断,比标准差更稳健。
def detect_jump(price_series, threshold=0.005):
    """
    检测跳价:价格变化超过0.5%视为异常
    """
    pct_change = price_series.pct_change().abs()
    jump_mask = pct_change > threshold
    return jump_mask

我的经验: 阈值别设太死。大盘股流动性好,0.3%可能就算跳价;小盘股波动大,1%以内都算正常。我一般会按股票分组设置动态阈值。

3.1.2 异常值处理策略

检测到跳价后怎么办?三种常见做法:

  1. 直接删除: 简单粗暴,但会破坏时间序列的连续性。
  2. 前向填充: 用上一笔价格替代,适合流动性好的股票。
  3. 插值法: 用前后价格线性插值,适合处理单个跳价点。

我曾经在回测中用了“直接删除”,结果策略收益虚高。后来发现,删除跳价点相当于“选择性忽略不利数据”,这其实是一种数据窥探偏差。现在我更推荐用插值法。

def handle_jump(price_series, jump_mask):
    """
    处理跳价:用前后均值插值
    """
    cleaned = price_series.copy()
    for idx in jump_mask[jump_mask].index:
        prev = price_series.iloc[price_series.index.get_loc(idx) - 1]
        next_val = price_series.iloc[price_series.index.get_loc(idx) + 1]
        cleaned[idx] = (prev + next_val) / 2
    return cleaned

3.2 数据对齐与重采样

高频数据有个麻烦事——不同股票的交易时间不一样。A股是9:30开盘,但有些股票可能9:31才有第一笔成交。如果你直接按时间戳对齐,会发现很多NaN值。

数据对齐的核心: 把不同频率的数据统一到同一个时间轴上。我常用的方法是“先重采样,再对齐”。

3.2.1 重采样策略

频率 适用场景 聚合函数
1分钟 日内高频策略 OHLC(开盘、最高、最低、收盘)
5分钟 中频趋势策略 VWAP(成交量加权均价)
日频 低频因子计算 收盘价、成交量求和
def resample_to_1min(df):
    """
    将Tick数据重采样为1分钟OHLC
    """
    ohlc = df['price'].resample('1min').ohlc()
    volume = df['volume'].resample('1min').sum()
    return pd.concat([ohlc, volume], axis=1)

注意: 重采样时一定要处理“空桶”问题。比如某分钟没有成交,你是填NaN还是填前值?我建议填前值,因为高频策略通常假设价格是连续的。

3.3 隔夜跳空处理

隔夜跳空,就是今天开盘价和昨天收盘价之间的巨大差距。比如某股票昨天收盘10元,今天开盘直接跳到11元。这个跳空,是信息冲击,不是数据错误。

但问题来了: 很多因子计算(比如动量因子)需要连续的价格序列。隔夜跳空会破坏这种连续性。你想想看,如果昨天收盘买入,今天开盘卖出,你赚了10%,但这10%是隔夜信息带来的,不是日内交易能力。

3.3.1 隔夜跳空的处理方法

我常用的两种方式:

  • 拆分法: 把价格序列拆成“日内部分”和“隔夜部分”,分别计算因子。
  • 调整法: 用复权因子把隔夜跳空“抹平”,让价格序列看起来是连续的。
def adjust_overnight_gap(price_series):
    """
    调整隔夜跳空:用复权因子平滑
    """
    # 计算每日收益率(含隔夜)
    daily_ret = price_series.pct_change()
    # 剔除隔夜跳空(假设跳空是信息冲击)
    intraday_ret = daily_ret.copy()
    intraday_ret[daily_ret.abs() > 0.05] = 0  # 超过5%的跳空视为异常
    # 重建价格序列
    adjusted_price = (1 + intraday_ret).cumprod() * price_series.iloc[0]
    return adjusted_price

避坑指南: 我曾经在计算隔夜动量因子时,直接用了原始价格序列,结果因子收益高得离谱。后来发现,隔夜跳空带来的收益根本不是策略能力,而是运气。所以,处理隔夜跳空不是“可选项”,而是“必选项”。

3.4 复权与分红调整

复权,说白了就是让价格序列“看起来”像没发生过分红送股一样。比如某股票10元,10送10后变成5元。如果你不做复权,回测时就会看到价格“腰斩”,然后你以为是暴跌,其实只是除权。

3.4.1 前复权 vs 后复权

类型 原理 适用场景
前复权 调整历史价格,让当前价格不变 回测时使用,方便计算当前收益
后复权 调整当前价格,让历史价格不变 分析长期趋势,看真实涨幅

我个人习惯用前复权做回测。为什么呢?因为前复权后的价格序列,能直接反映“如果我在过去买入,现在卖出能赚多少”。后复权虽然能看长期涨幅,但计算收益率时容易出错。

def forward_adjust(df, factor_col='adj_factor'):
    """
    前复权:用复权因子调整价格
    """
    df['adj_close'] = df['close'] * df[factor_col]
    df['adj_open'] = df['open'] * df[factor_col]
    return df

注意: 复权因子不是万能的。分红、送股、配股、拆股,每种事件的复权方式都不一样。我见过有人直接用“复权因子”处理所有事件,结果配股时算错了。建议用专业的金融数据API(比如Wind、Tushare)获取复权因子,别自己算。

知识体系总览

下面这张图,是我自己总结的数据清洗流程。每次做新策略前,我都会按这个流程走一遍。

高频数据清洗与预处理流程 原始Tick数据 跳价检测与异常值处理 数据重采样(1min/5min/日频) 多标的数据对齐 清洗后的高质量数据 阈值:0.3%-1% OHLC / VWAP 时间轴统一 可用于因子计算

嗯,数据清洗这块就聊到这儿。记住一句话:数据质量决定策略上限。你模型再牛,数据是脏的,结果也是脏的。我见过太多人花90%时间调参,却只花10%时间洗数据,最后策略上线就崩。别走他们的老路。

专注资料整理