第四章:数据获取与清洗——用Python搞定期权链数据

做期权波动率套利,数据就是你的弹药库。弹药不行,枪法再好也白搭。

这一章,我们聊聊怎么用Python把期权链数据搞到手,再把它洗得干干净净。我做了这么多年量化,见过太多人策略写得漂亮,结果数据一塌糊涂,最后亏得莫名其妙。

4.1 期权链数据长什么样?

先搞清楚我们要拿什么数据。期权链,说白了就是同一标的、同一到期日的所有期权合约的集合。

一个典型的期权链数据包含这些字段:

字段 说明 示例
code 合约代码 510050C2306M02800
trade_date 交易日期 2023-06-15
expire_date 到期日 2023-06-28
strike 行权价 2.800
call_put 看涨/看跌 C / P
close 收盘价 0.0456
volume 成交量 12345
oi 持仓量 67890
iv 隐含波动率 0.185
delta Delta 0.523

嗯,这里要注意:不同数据源给的字段名可能不一样。我习惯统一成上面这套命名,后面处理起来省心。

4.2 用Python获取期权链数据

国内期权数据源,我常用的是Tushare和AkShare。Tushare需要token,AkShare免费但偶尔抽风。

我个人习惯用Tushare做主力数据源,AkShare做备胎。为什么?Tushare的数据质量更稳定,尤其是历史数据回溯时,缺失值少很多。

下面是一个获取50ETF期权链数据的示例:

import tushare as ts
import pandas as pd
from datetime import datetime, timedelta

# 初始化Tushare
pro = ts.pro_api('your_token_here')

def get_option_chain(ts_code, trade_date):
    """
    获取某标的某日的期权链数据
    """
    df = pro.opt_daily(
        ts_code=ts_code,
        trade_date=trade_date,
        fields='ts_code,trade_date,expire_date,strike,call_put,close,vol,oi,iv,delta'
    )
    return df

# 获取2023年6月15日50ETF期权数据
df = get_option_chain('510050.SH', '20230615')
print(df.head())

你想想看,如果每天只拿一天的数据,那得跑到猴年马月?所以我们要批量拉取。

def batch_get_option_chain(ts_code, start_date, end_date):
    """
    批量获取一段时间内的期权链数据
    """
    date_list = pd.date_range(start=start_date, end=end_date, freq='B')
    all_data = []
    
    for date in date_list:
        date_str = date.strftime('%Y%m%d')
        try:
            df = get_option_chain(ts_code, date_str)
            if df is not None and not df.empty:
                all_data.append(df)
            print(f'{date_str} 获取成功,共{len(df)}条记录')
        except Exception as e:
            print(f'{date_str} 获取失败:{e}')
            continue
    
    return pd.concat(all_data, ignore_index=True)

# 拉取2023年1月到6月的数据
df_all = batch_get_option_chain('510050.SH', '20230101', '20230630')
我的小技巧: 批量拉取时加个sleep(0.5),别把人家服务器搞崩了。我曾经一次性拉3年数据没加延时,结果IP被Tushare封了24小时...血的教训。

4.3 数据清洗——把脏数据揪出来

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

常见的坑有三个:

  • 缺失值:某天某合约没成交,close是NaN
  • 异常值:价格突然跳上天,或者成交量少得离谱
  • 重复值:同一合约同一天出现两条记录

我的清洗流程是这样的:

def clean_option_data(df):
    """
    清洗期权链数据
    """
    # 1. 去重
    df = df.drop_duplicates(subset=['ts_code', 'trade_date', 'strike', 'call_put'])
    
    # 2. 处理缺失值
    # 成交量或持仓量为0的,价格可能不靠谱
    df = df[df['vol'] > 0]
    df = df[df['oi'] > 0]
    
    # 3. 处理异常价格
    # 价格不能为负,也不能太离谱
    df = df[df['close'] > 0]
    df = df[df['close'] < df['strike'] * 0.5]  # 价格不超过行权价的一半
    
    # 4. 处理异常隐含波动率
    df = df[df['iv'] > 0.05]   # IV不低于5%
    df = df[df['iv'] < 0.80]   # IV不高于80%
    
    # 5. 删除到期日当天的数据(流动性差)
    df = df[df['trade_date'] != df['expire_date']]
    
    return df.reset_index(drop=True)

df_clean = clean_option_data(df_all)
print(f'清洗前:{len(df_all)}条,清洗后:{len(df_clean)}条')
注意: 清洗阈值不是死的。比如在极端行情下,IV超过80%是可能的。我一般会先跑一遍看看分布,再定阈值。别一刀切得太狠。

4.4 构建波动率数据库

数据洗干净了,接下来要存起来。我推荐用SQLite,轻量、便携、不用装服务器。

数据库结构设计很关键。我踩过坑——一开始把所有字段塞一张表,结果查询慢得要死。后来拆成两张表:

  • option_daily:日频数据,存价格、成交量、持仓量
  • option_greeks:日频数据,存隐含波动率、希腊字母

为什么要拆?因为有些场景只需要价格,有些场景只需要IV。拆开之后,查询速度快了不止一倍。

import sqlite3

def build_option_db(df, db_path='option_data.db'):
    """
    构建期权数据库
    """
    conn = sqlite3.connect(db_path)
    cursor = conn.cursor()
    
    # 创建日频数据表
    cursor.execute('''
        CREATE TABLE IF NOT EXISTS option_daily (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            ts_code TEXT,
            trade_date TEXT,
            expire_date TEXT,
            strike REAL,
            call_put TEXT,
            close REAL,
            volume INTEGER,
            oi INTEGER,
            UNIQUE(ts_code, trade_date, strike, call_put)
        )
    ''')
    
    # 创建希腊字母表
    cursor.execute('''
        CREATE TABLE IF NOT EXISTS option_greeks (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            ts_code TEXT,
            trade_date TEXT,
            strike REAL,
            call_put TEXT,
            iv REAL,
            delta REAL,
            gamma REAL,
            theta REAL,
            vega REAL,
            UNIQUE(ts_code, trade_date, strike, call_put)
        )
    ''')
    
    # 插入数据
    df[['ts_code', 'trade_date', 'expire_date', 'strike', 
        'call_put', 'close', 'vol', 'oi']].to_sql(
        'option_daily', conn, if_exists='append', index=False
    )
    
    df[['ts_code', 'trade_date', 'strike', 'call_put', 
        'iv', 'delta']].to_sql(
        'option_greeks', conn, if_exists='append', index=False
    )
    
    conn.commit()
    conn.close()
    print(f'数据已存入 {db_path}')

build_option_db(df_clean)
核心思路: 数据库设计要「按需存取」。别一股脑全塞进去,后面查起来你会想哭的。

4.5 数据质量检查——别让脏数据毁了你的策略

数据存进去了,不代表就万事大吉。我每次入库后都会跑一遍质量检查。

检查哪些东西?

  1. 时间连续性:每个合约的交易天数是否连续?有没有跳空?
  2. 价格合理性:看涨期权价格是否随行权价递增?看跌期权是否递减?
  3. 波动率曲面:同一到期日不同行权价的IV是否平滑?有没有尖刺?

举个例子,检查看涨期权价格是否合理:

def check_call_price_consistency(df):
    """
    检查看涨期权价格是否随行权价递增
    """
    calls = df[df['call_put'] == 'C']
    for date in calls['trade_date'].unique():
        daily = calls[calls['trade_date'] == date]
        daily = daily.sort_values('strike')
        prices = daily['close'].values
        # 检查是否严格递减(行权价越高,看涨期权越便宜)
        for i in range(len(prices) - 1):
            if prices[i] < prices[i+1]:
                print(f'{date} 存在价格异常:行权价{daily.iloc[i]["strike"]}的期权价格低于行权价{daily.iloc[i+1]["strike"]}')
                break

为什么会这样?说白了,如果看涨期权价格不随行权价递增,那说明数据有问题,或者市场出现了套利机会。但99%的情况是数据问题。

4.6 本章知识体系

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

期权数据获取与清洗流程 数据获取 Tushare / AkShare 数据清洗 去重 / 缺失值 / 异常值 数据库构建 SQLite 质量检查 连续性 / 合理性 核心数据字段 • 合约代码 (ts_code) • 交易日期 (trade_date) • 行权价 (strike) • 看涨/看跌 (call_put) • 收盘价 / 成交量 / 持仓量 清洗规则 • 去重:同合约同天只留一条 • 剔除成交量/持仓量为0 • 价格 > 0 且 < 行权价*0.5 • IV 在 5% ~ 80% 之间 • 剔除到期日当天数据 输出:干净、结构化的波动率数据库

这张图把整个流程串起来了。从数据获取开始,经过清洗、入库、质量检查,最后得到一个干净的波动率数据库。每一步都有坑,但踩过一次就记住了。

我的经验: 数据清洗花的时间,往往是策略开发的3倍以上。别嫌烦,这一步省了,后面策略跑起来你会更烦。

好了,数据到手、洗好、入库。下一章我们就要用这些数据来做波动率计算了。嗯,到时候你会发现,数据质量直接决定了波动率曲面的平滑度——数据脏,曲面就丑,策略就亏。


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