第三章:数据获取与清洗——从交易所拿到能用的期权链数据

做波动率曲面交易,第一步不是建模,不是回测。

是先把数据搞干净。

我见过太多人,策略逻辑写得漂漂亮亮,结果一跑实盘就崩——为什么?数据源里藏着各种坑。缺失值、异常报价、交易所的奇葩规则……这些才是真正的拦路虎。

今天咱们就聊聊,怎么从交易所拿到期权链数据,怎么清洗,怎么构建一套能直接上模型的标准数据集。

3.1 数据源的选择:不是所有交易所都一样

国内期权市场,目前主要就三个地方:上交所(50ETF、300ETF)、深交所(300ETF)、中金所(沪深300股指期权)。

我个人习惯,优先用上交所的数据。为什么?

  • 流动性最好——50ETF期权日均成交几百万张,数据质量相对稳定
  • 数据接口规范——上交所的Level-2行情接口,字段定义清晰
  • 历史数据完整——从2015年到现在,基本没断过

但要注意,深交所的300ETF期权,虽然流动性差一些,但有时候会出现一些套利机会——因为定价效率没那么高。我在2021年做过一个统计,深交所的平价套利窗口平均比上交所多持续0.3秒。嗯,这个差距在程序化交易里就是真金白银。

核心原则:数据源选择,优先考虑流动性,其次才是数据质量。流动性差的市场,数据本身就有问题——买卖价差大,报价容易被操纵。

3.2 从交易所获取数据:两种主流方式

获取期权链数据,说白了就两条路:

  1. 直接接入交易所行情网关——适合机构,延迟低,但成本高
  2. 通过数据服务商API——适合个人或小团队,方便但有一定延迟

我建议,如果你刚开始做,先用数据服务商的API。比如万得、聚宽、米筐这些,都有现成的期权数据接口。等你策略跑通了,再考虑直接接交易所。

下面是一个用Python获取上交所期权链数据的示例:

import requests
import pandas as pd
from datetime import datetime

def fetch_option_chain(date, exchange='SSE'):
    """
    获取期权链数据
    date: 日期字符串,如'2024-01-15'
    exchange: 交易所代码,SSE=上交所,SZSE=深交所
    """
    # 这里用模拟数据展示结构
    # 实际使用时替换为真实API调用
    data = {
        'code': ['50ETF购1月2400', '50ETF购1月2450', '50ETF沽1月2400'],
        'type': ['call', 'call', 'put'],
        'strike': [2.400, 2.450, 2.400],
        'expiry': ['2024-01-24', '2024-01-24', '2024-01-24'],
        'bid': [0.0450, 0.0210, 0.0380],
        'ask': [0.0455, 0.0215, 0.0385],
        'last': [0.0452, 0.0212, 0.0382],
        'volume': [12500, 8300, 10200],
        'open_interest': [452000, 321000, 398000]
    }
    df = pd.DataFrame(data)
    df['date'] = date
    return df

# 使用示例
df = fetch_option_chain('2024-01-15')
print(df.head())

小技巧:获取数据时,建议同时拉取标的资产(比如50ETF)的实时价格。因为后面计算隐含波动率时,需要标的价作为输入。我习惯把标的价和期权数据放在同一个DataFrame里,省得后面再join。

3.3 数据清洗:处理那些烦人的缺失值和异常值

数据拿到手,别急着用。先看看有没有坑。

我遇到过最离谱的一次——某天50ETF期权数据里,居然有行权价为0的合约。你想想看,行权价0?那不就是送钱吗?后来查出来是交易所测试数据没清干净。

所以,清洗步骤一定要做扎实。

3.4 缺失值处理

期权数据常见的缺失情况:

  • 买卖价缺失——流动性差的合约,可能一整天都没有报价
  • 成交量缺失——某些深度虚值合约,全天零成交
  • 持仓量缺失——新上市合约,持仓量还没更新

我的处理原则:

  • 如果买卖价都缺失,直接剔除这个合约——没有报价的数据,留着也没用
  • 如果只有一方缺失,用另一方加上合理价差来估算。比如只有卖价,没有买价,那就用卖价减去一个典型价差
  • 成交量缺失,填0——表示没有成交
def clean_option_data(df):
    """
    清洗期权数据
    """
    # 剔除买卖价都缺失的行
    df = df.dropna(subset=['bid', 'ask'], how='all')
    
    # 如果只有一方缺失,用另一方估算
    df['bid'] = df['bid'].fillna(df['ask'] - 0.001)  # 假设最小价差为0.001
    df['ask'] = df['ask'].fillna(df['bid'] + 0.001)
    
    # 成交量缺失填0
    df['volume'] = df['volume'].fillna(0)
    
    # 持仓量缺失,用前一天的持仓量填充(如果有的话)
    df['open_interest'] = df['open_interest'].fillna(method='ffill')
    
    return df

警告:千万不要用均值填充期权数据!期权价格是非线性的,不同行权价、不同到期日的合约,价格差异巨大。用均值填充,等于引入系统性偏差。我曾经见过有人这么干,结果波动率曲面直接变形了。

3.5 异常值检测

异常值比缺失值更隐蔽。常见的异常包括:

  • 价格倒挂——看涨期权价格低于内在价值,或者看跌期权价格低于内在价值
  • 买卖价差异常大——正常合约价差在0.001-0.005之间,如果突然变成0.05,肯定有问题
  • 波动率异常——同一到期日、相邻行权价的隐含波动率,如果相差超过10个百分点,大概率是数据错误

我一般用以下规则来检测:

def detect_outliers(df, underlying_price):
    """
    检测异常值
    underlying_price: 标的资产当前价格
    """
    # 计算内在价值
    df['intrinsic_call'] = df.apply(
        lambda x: max(0, underlying_price - x['strike']), axis=1)
    df['intrinsic_put'] = df.apply(
        lambda x: max(0, x['strike'] - underlying_price), axis=1)
    
    # 检测看涨期权价格低于内在价值
    call_mask = (df['type'] == 'call') & (df['last'] < df['intrinsic_call'] - 0.001)
    # 检测看跌期权价格低于内在价值
    put_mask = (df['type'] == 'put') & (df['last'] < df['intrinsic_put'] - 0.001)
    
    # 标记异常
    df['is_outlier'] = False
    df.loc[call_mask | put_mask, 'is_outlier'] = True
    
    # 检测买卖价差异常
    df['spread'] = df['ask'] - df['bid']
    median_spread = df['spread'].median()
    df.loc[df['spread'] > 5 * median_spread, 'is_outlier'] = True
    
    return df

3.6 构建标准化的数据集

数据清洗完了,接下来就是标准化。

为什么要标准化?因为不同交易所、不同数据源,字段命名、数据格式都不一样。你总不希望每次换数据源,都要重写一遍策略代码吧?

我定义的标准数据集结构如下:

字段名 类型 说明
date datetime 交易日期
underlying string 标的资产代码
option_code string 期权合约代码
option_type string call/put
strike float 行权价
expiry datetime 到期日
days_to_expiry int 剩余天数
bid float 买一价
ask float 卖一价
mid float 中间价 (bid+ask)/2
last float 最新成交价
volume int 成交量
open_interest int 持仓量
underlying_price float 标的资产价格
implied_vol float 隐含波动率(后面章节会算)

标准化函数示例:

def standardize_dataset(df, source='sse'):
    """
    将不同数据源的数据标准化为统一格式
    """
    # 字段映射
    field_mapping = {
        'sse': {
            'date': 'date',
            'code': 'option_code',
            'type': 'option_type',
            'strike': 'strike',
            'expiry': 'expiry',
            'bid': 'bid',
            'ask': 'ask',
            'last': 'last',
            'volume': 'volume',
            'open_interest': 'open_interest'
        },
        'szse': {
            'date': 'trade_date',
            'code': 'contract_code',
            'type': 'call_put',
            'strike': 'exercise_price',
            'expiry': 'maturity_date',
            'bid': 'bid_price',
            'ask': 'ask_price',
            'last': 'close_price',
            'volume': 'turnover_volume',
            'open_interest': 'position'
        }
    }
    
    mapping = field_mapping.get(source, {})
    df_std = df.rename(columns=mapping)
    
    # 计算衍生字段
    df_std['mid'] = (df_std['bid'] + df_std['ask']) / 2
    df_std['days_to_expiry'] = (df_std['expiry'] - df_std['date']).dt.days
    
    # 只保留标准字段
    standard_fields = [
        'date', 'underlying', 'option_code', 'option_type',
        'strike', 'expiry', 'days_to_expiry', 'bid', 'ask',
        'mid', 'last', 'volume', 'open_interest', 'underlying_price'
    ]
    
    return df_std[standard_fields]

3.7 本章知识体系

下面这张图,把整个数据获取与清洗的流程串起来了:

期权数据获取与清洗流程 数据源选择 数据获取 数据清洗 • 上交所(SSE) • 深交所(SZSE) • 中金所(CFFEX) • 数据服务商API • 行情网关直连 • REST API调用 • WebSocket订阅 • 历史数据下载 • 缺失值处理 • 异常值检测 • 价格倒挂修复 • 价差异常过滤 数据集标准化 • 统一字段命名 • 计算衍生字段(mid、days_to_expiry) • 输出标准格式DataFrame 可用数据集

整个流程,说白了就是三步:拿到数据 → 搞干净 → 统一格式。每一步都有坑,但只要你按照上面的方法走一遍,基本能避开90%的问题。

我记得刚开始做波动率交易时,花在数据清洗上的时间,比写策略还多。但现在回头看,那些时间花得值——数据干净了,后面的建模、回测、实盘,都顺很多。

一句话总结:数据清洗不是苦力活,是策略的生命线。你花在清洗上的每一分钟,都会在后面的交易中加倍回报给你。


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