3. 数据清洗实战:处理异常点、剔除套利机会、处理缺失值

数据清洗这步,说实话,是波动率曲面建模里最容易被低估的环节。很多人一上来就想着怎么拟合、怎么插值,结果数据里全是坑,模型再漂亮也是白搭。我自己的习惯是,花至少一半的时间在数据清洗上——嗯,一点都不夸张。

你想想看,期权数据是从交易所或者数据商那里拿来的,里面什么妖魔鬼怪都有:价格打错了、买卖价差大得离谱、存在明显的套利机会、某个行权价干脆没成交……这些不处理干净,后面的曲面平滑就是空中楼阁。

今天我们就来聊聊,怎么把这堆“脏数据”变成可用的干净数据。我会结合我实际项目中的经验,把每一步的坑和技巧都讲清楚。

波动率曲面数据清洗流程 原始期权数据 异常点检测与处理 价格异常 · 波动率异常 · 买卖价差过大 套利机会剔除 垂直价差 · 蝶式价差 · 日历价差 缺失值处理 线性插值 · 样条插值 · 模型填充 输出:干净可用的波动率曲面数据

3.1 异常点检测:先把“一眼假”的数据干掉

异常点,说白了就是那些明显不合理的数据。比如某个深度虚值期权的价格比实值期权还高,或者隐含波动率突然飙到 200%——这种数据你要是留着,模型直接崩给你看。

我在项目中遇到过最离谱的一次,某只股票期权在收盘前最后一笔交易的价格比前一笔高了 30 倍。后来查了一下,是交易员手误输错了价格。这种数据如果不剔除,整个曲面都会被带偏。

异常点检测我一般分三步走:

  • 价格合理性检查:期权价格不能低于内在价值,也不能高于标的资产价格。这个是最基本的,违反了直接剔除。
  • 波动率边界检查:隐含波动率如果超过历史波动率的 5 倍,或者低于 0.5%,基本可以判定为异常。我一般设一个 [0.05, 2.0] 的硬边界。
  • 买卖价差检查:如果买卖价差超过中间价的 30%,说明流动性太差,这种数据点我建议直接扔掉。

核心原则:宁可少一个数据点,也不要留一个错误的数据点。波动率曲面建模对异常值极其敏感,一个坏点就能毁掉整个曲面。

import pandas as pd
import numpy as np

def detect_anomalies(df):
    """
    异常点检测函数
    df: 包含 'strike', 'option_price', 'underlying_price', 'implied_vol', 'bid', 'ask' 等字段
    """
    # 1. 价格合理性:期权价格 >= max(0, 内在价值)
    df['intrinsic'] = np.maximum(df['underlying_price'] - df['strike'], 0)
    df['price_ok'] = df['option_price'] >= df['intrinsic'] - 1e-6
    
    # 2. 波动率边界
    df['vol_ok'] = (df['implied_vol'] >= 0.05) & (df['implied_vol'] <= 2.0)
    
    # 3. 买卖价差检查
    df['spread_ratio'] = (df['ask'] - df['bid']) / ((df['ask'] + df['bid']) / 2)
    df['spread_ok'] = df['spread_ratio'] <= 0.3
    
    # 综合标记
    df['is_anomaly'] = ~(df['price_ok'] & df['vol_ok'] & df['spread_ok'])
    
    return df[~df['is_anomaly']].copy()

小技巧:我习惯把异常点标记出来而不是直接删除,先看看它们的分布。如果某个行权价附近异常点特别多,那可能是那个合约本身有问题,需要整体排查。

3.2 剔除套利机会:让数据符合无套利约束

套利机会的存在,意味着你的数据在数学上是不自洽的。波动率曲面建模的前提是市场无套利,所以我们必须把违反无套利条件的数据点剔除掉。

常见的套利类型有三种:

  1. 垂直价差套利:相同到期日、不同行权价的期权,价格必须满足单调性。比如行权价低的看涨期权,价格不能低于行权价高的看涨期权。
  2. 蝶式价差套利:这涉及到凸性条件。简单说,中间行权价的期权价格不能太高,否则可以构造蝶式组合套利。
  3. 日历价差套利:不同到期日的期权,时间价值必须合理。长期限的期权价格不能低于短期限的。

我曾经在整理沪深 300 期权数据时,发现连续三个月的合约都存在垂直价差套利。后来一查,是因为那天市场波动太大,报价系统延迟了。嗯,这种情况直接剔除那几天的数据是最省事的。

def check_vertical_arbitrage(df, option_type='call'):
    """
    检查垂直价差套利
    对于看涨期权:行权价越高,价格越低
    """
    df_sorted = df.sort_values('strike')
    prices = df_sorted['option_price'].values
    
    # 检查是否单调递减
    for i in range(len(prices) - 1):
        if prices[i] < prices[i+1] - 1e-6:
            return False  # 存在套利机会
    return True

def check_butterfly_arbitrage(df, option_type='call'):
    """
    检查蝶式价差套利
    要求:C(K1) + C(K3) - 2*C(K2) >= 0
    其中 K1 < K2 < K3
    """
    df_sorted = df.sort_values('strike')
    strikes = df_sorted['strike'].values
    prices = df_sorted['option_price'].values
    
    for i in range(len(strikes) - 2):
        k1, k2, k3 = strikes[i], strikes[i+1], strikes[i+2]
        c1, c2, c3 = prices[i], prices[i+1], prices[i+2]
        
        # 蝶式组合价值
        butterfly_value = c1 + c3 - 2 * c2
        if butterfly_value < -1e-6:
            return False  # 存在套利机会
    return True

注意:套利检查要分看涨和看跌分别做。另外,实际交易中买卖价差的存在会“吸收”一部分套利空间,所以检查时建议加上一个容忍阈值(比如 0.01 元),避免把边界情况误判为套利。

3.3 处理缺失值:把“坑”填上

缺失值在期权数据里太常见了。某个行权价今天没成交、某个期限的合约流动性不足……这些都会导致数据出现空洞。如果不处理,插值的时候就会出问题。

处理缺失值,我一般按优先级排序:

  • 线性插值:最简单也最常用。在行权价维度上,用相邻两个点的波动率做线性插值。适合数据点比较密集的情况。
  • 样条插值:如果数据点分布不均匀,或者曲面形状比较复杂,我会用三次样条。效果比线性好,但要注意过拟合。
  • 模型填充:如果缺失的区域比较大,比如整个深度虚值区域都没有数据,那就需要用 SVI 或者 SABR 模型先拟合一个初始曲面,再用模型值填充。

我个人习惯是:先用线性插值填上小范围的缺失,再用样条做一次平滑。如果还有大块缺失,就上模型。这样既快又稳。

from scipy.interpolate import interp1d, CubicSpline

def fill_missing_values(df, method='linear'):
    """
    填充缺失的隐含波动率
    df: 包含 'strike', 'implied_vol' 字段,按行权价排序
    """
    # 找出缺失值的位置
    mask = df['implied_vol'].isna()
    if mask.sum() == 0:
        return df
    
    # 用非缺失值做插值
    known_strikes = df.loc[~mask, 'strike'].values
    known_vols = df.loc[~mask, 'implied_vol'].values
    
    if method == 'linear':
        interpolator = interp1d(known_strikes, known_vols, 
                                kind='linear', fill_value='extrapolate')
    elif method == 'cubic':
        interpolator = CubicSpline(known_strikes, known_vols, 
                                   bc_type='natural', extrapolate=True)
    
    # 填充缺失值
    missing_strikes = df.loc[mask, 'strike'].values
    df.loc[mask, 'implied_vol'] = interpolator(missing_strikes)
    
    return df

避坑指南:我曾经在填充深度虚值期权时,直接用线性外推,结果波动率变成了负数。后来我加了一个边界条件——外推时波动率不能低于 0.05,也不能高于 2.0。这个经验让我养成了给插值结果加约束的习惯。

3.4 实战流程:把三步串起来

在实际项目中,我不会把这三步分开做。我会写一个 pipeline,按顺序执行:先清异常点,再剔套利,最后填缺失。每一步的输出都是下一步的输入。

def clean_option_data(df):
    """
    完整的数据清洗流程
    """
    print(f"原始数据量: {len(df)}")
    
    # Step 1: 异常点检测
    df = detect_anomalies(df)
    print(f"剔除异常点后: {len(df)}")
    
    # Step 2: 套利检查(按到期日分组)
    clean_dfs = []
    for expiry, group in df.groupby('expiry_date'):
        # 检查看涨期权
        calls = group[group['option_type'] == 'call']
        if not check_vertical_arbitrage(calls, 'call'):
            print(f"警告: {expiry} 看涨期权存在垂直价差套利")
            # 这里可以进一步定位具体是哪个行权价有问题
        clean_dfs.append(group)
    
    df = pd.concat(clean_dfs)
    
    # Step 3: 缺失值填充
    df = fill_missing_values(df, method='cubic')
    print(f"最终数据量: {len(df)}")
    
    return df

你看,整个流程其实不复杂,但每一步都很关键。数据清洗做得好,后面的建模工作就轻松多了。我见过太多人花大量时间调模型参数,结果问题出在数据上——数据干净了,模型自然就好了。

好了,这一章的内容就到这里。记住一句话:数据清洗不是体力活,是技术活。你花在清洗上的每一分钟,都会在后面的建模中加倍回报给你。


公众号:蓝海资料掘金营,微信deep3321