第二章:数据获取与清洗——期权数据的“脏活累活”

说实话,做隐含波动率预测模型,最磨人的不是算法设计,而是数据准备。我见过太多人一上来就调包跑模型,结果数据里藏着各种坑,最后模型输出一堆垃圾。嗯,这一章我们就来把数据这块硬骨头啃下来。

2.1 用yfinance拉取期权链数据

yfinance这个库,我个人觉得是获取美股期权数据最方便的工具。它不需要API Key,也不用注册账号,直接pip install就能用。但要注意,它拉的是雅虎财经的数据,延迟大概15-20分钟,做回测没问题,实盘交易就别指望了。

核心思路:先拿到标的资产的历史价格,再获取期权链数据,最后把两者对齐到同一个时间轴上。

import yfinance as yf
import pandas as pd
import numpy as np
from datetime import datetime, timedelta

# 以SPY为例,获取期权数据
ticker = yf.Ticker("SPY")

# 获取所有到期日
expirations = ticker.options
print(f"可用到期日数量: {len(expirations)}")
print(f"最近三个到期日: {expirations[:3]}")

# 获取某个到期日的期权链
expiry = expirations[0]  # 最近到期日
opt_chain = ticker.option_chain(expiry)

# 看涨和看跌分开存放
calls = opt_chain.calls
puts = opt_chain.puts

print(f"看涨期权数量: {len(calls)}")
print(f"看跌期权数量: {len(puts)}")

这里有个坑——我刚开始做的时候,以为所有到期日都能拿到完整数据。结果发现有些远月合约流动性极差,行权价稀疏得可怜。我的建议是:只选近三个月的到期日,行权价在标的价上下20%范围内,这样数据质量才有保障。

2.2 计算隐含波动率——从BS公式到数值求解

隐含波动率这东西,说白了就是市场对未来的“恐慌指数”。它没法直接算出来,得用BS公式反推。BS公式长这样:

# Black-Scholes 定价公式
from scipy.stats import norm
import math

def bs_price(S, K, T, r, sigma, option_type='call'):
    """
    S: 标的资产当前价格
    K: 行权价
    T: 剩余时间(年)
    r: 无风险利率
    sigma: 波动率
    """
    d1 = (math.log(S/K) + (r + 0.5*sigma**2)*T) / (sigma*math.sqrt(T))
    d2 = d1 - sigma*math.sqrt(T)
    
    if option_type == 'call':
        price = S*norm.cdf(d1) - K*math.exp(-r*T)*norm.cdf(d2)
    else:
        price = K*math.exp(-r*T)*norm.cdf(-d2) - S*norm.cdf(-d1)
    return price

有了定价公式,接下来就是反解隐含波动率。我习惯用牛顿法,收敛快,但要注意初始值的选择。如果初始值给得太离谱,迭代可能发散。

def implied_volatility(market_price, S, K, T, r, option_type='call',
                       initial_guess=0.3, tol=1e-6, max_iter=100):
    """
    使用牛顿法求解隐含波动率
    """
    sigma = initial_guess
    for i in range(max_iter):
        price = bs_price(S, K, T, r, sigma, option_type)
        vega = S * norm.pdf(d1(S, K, T, r, sigma)) * math.sqrt(T)
        
        diff = price - market_price
        if abs(diff) < tol:
            return sigma
        
        sigma = sigma - diff / vega
        
        # 防止波动率变成负数
        if sigma <= 0:
            sigma = 0.01
    
    return sigma  # 如果没收敛,返回最后一次结果

def d1(S, K, T, r, sigma):
    return (math.log(S/K) + (r + 0.5*sigma**2)*T) / (sigma*math.sqrt(T))

避坑指南:我曾经遇到过深度实值期权(比如行权价远低于标的价)的隐含波动率求解失败。原因是这些期权的vega接近0,牛顿法根本算不动。我的处理方式是:剔除vega小于0.01的期权,或者改用二分法兜底。

2.3 数据清洗——把脏数据挡在门外

数据清洗这件事,我吃过不少亏。有一次模型跑出来结果特别漂亮,回测收益高得离谱。后来一查,原来是数据里混入了几个价格异常低的期权,导致隐含波动率算出来是负数。嗯,从那以后我养成了几个清洗习惯:

  1. 剔除零价格和负价格——这不用多说,市场里不可能有免费期权
  2. 剔除买卖价差过大的期权——价差超过标的价5%的,流动性太差
  3. 剔除剩余期限太短的期权——小于3天的,时间价值衰减太快,波动率容易失真
  4. 剔除隐含波动率异常值——超过3倍标准差或者小于0的,直接扔掉
def clean_option_data(df, S_current, min_days=3, max_spread_pct=0.05):
    """
    清洗期权数据
    df: 包含期权价格、行权价、到期日等字段的DataFrame
    S_current: 标的当前价格
    """
    # 计算剩余天数
    df['days_to_expiry'] = (pd.to_datetime(df['expiration']) - datetime.now()).dt.days
    
    # 条件过滤
    mask = (df['lastPrice'] > 0) & \
           (df['bid'] > 0) & \
           (df['ask'] > 0) & \
           (df['days_to_expiry'] >= min_days) & \
           ((df['ask'] - df['bid']) / S_current < max_spread_pct)
    
    df_clean = df[mask].copy()
    
    # 计算隐含波动率
    df_clean['implied_vol'] = df_clean.apply(
        lambda row: implied_volatility(
            row['lastPrice'], S_current, row['strike'],
            row['days_to_expiry']/365, 0.05,  # 假设无风险利率5%
            'call' if row['option_type'] == 'call' else 'put'
        ), axis=1
    )
    
    # 剔除隐含波动率异常值
    mean_iv = df_clean['implied_vol'].mean()
    std_iv = df_clean['implied_vol'].std()
    df_clean = df_clean[
        (df_clean['implied_vol'] > 0) & 
        (df_clean['implied_vol'] < mean_iv + 3*std_iv)
    ]
    
    return df_clean

特别注意:无风险利率的选择会影响隐含波动率的计算结果。我一般用3个月期国债收益率,但如果你做的是日内高频数据,用隔夜拆借利率更合适。别小看这个细节,利率差0.5%,隐含波动率能差出1-2个百分点。

2.4 异常值处理——那些“离谱”的数据点

清洗完数据后,你会发现还有一批“看起来正常但实际有问题”的数据。比如某个行权价的隐含波动率突然比相邻行权价高出20%,这明显不合理。为什么会这样?可能是那个合约最后一笔交易发生在半小时前,价格已经过时了。

我的处理方法是:用相邻行权价的隐含波动率做插值,把异常点替换掉。具体来说:

def smooth_vol_surface(df, strike_col='strike', vol_col='implied_vol'):
    """
    对波动率曲面做平滑处理,剔除异常点
    """
    # 按行权价排序
    df_sorted = df.sort_values(strike_col)
    
    # 计算相邻点的波动率差异
    df_sorted['vol_diff'] = df_sorted[vol_col].diff().abs()
    
    # 如果差异超过前后平均值的20%,标记为异常
    threshold = df_sorted[vol_col].rolling(3, center=True).mean() * 0.2
    df_sorted['is_outlier'] = df_sorted['vol_diff'] > threshold
    
    # 用前后均值替换异常点
    df_sorted['vol_smoothed'] = df_sorted[vol_col].copy()
    outlier_mask = df_sorted['is_outlier']
    df_sorted.loc[outlier_mask, 'vol_smoothed'] = \
        df_sorted[vol_col].rolling(3, center=True).mean()[outlier_mask]
    
    return df_sorted

个人经验:我一般会保留原始隐含波动率和平滑后的两列数据。建模时用平滑后的,但分析时对比原始数据,能发现市场有没有出现异常交易行为。有一次我就通过这个对比,发现某个行权价出现了大额对倒交易。

2.5 本章知识体系

下面这张图总结了数据获取与清洗的完整流程,你可以把它当作操作手册:

数据获取与清洗流程 步骤1:数据获取 yfinance拉取期权链 步骤2:计算隐含波动率 BS公式 + 牛顿法求解 步骤3:数据清洗 剔除异常价格和期限 步骤4:异常值处理 平滑波动率曲面 步骤5:数据对齐 与标的资产时间轴对齐 步骤6:输出清洗后数据 用于建模的干净数据集 关键检查点: 1. 期权价格是否合理(bid/ask价差 < 5%) 2. 剩余期限是否足够(> 3天) 3. 隐含波动率是否在合理范围(0-100%) 4. 波动率曲面是否平滑(无突兀尖峰) ⚠ 任何一个检查点不通过,数据都不能直接用于建模

数据准备这一步,说白了就是“垃圾进,垃圾出”。你花80%的时间在数据清洗上,一点都不夸张。我见过太多人急着跑模型,结果被数据坑得怀疑人生。记住:干净的数据是模型成功的一半


公众号:蓝海数据掘金营,微信deep3321