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 剔除套利机会:让数据符合无套利约束
套利机会的存在,意味着你的数据在数学上是不自洽的。波动率曲面建模的前提是市场无套利,所以我们必须把违反无套利条件的数据点剔除掉。
常见的套利类型有三种:
- 垂直价差套利:相同到期日、不同行权价的期权,价格必须满足单调性。比如行权价低的看涨期权,价格不能低于行权价高的看涨期权。
- 蝶式价差套利:这涉及到凸性条件。简单说,中间行权价的期权价格不能太高,否则可以构造蝶式组合套利。
- 日历价差套利:不同到期日的期权,时间价值必须合理。长期限的期权价格不能低于短期限的。
我曾经在整理沪深 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