第三章:数据获取与清洗——从交易所拿到能用的期权链数据
做波动率曲面交易,第一步不是建模,不是回测。
是先把数据搞干净。
我见过太多人,策略逻辑写得漂漂亮亮,结果一跑实盘就崩——为什么?数据源里藏着各种坑。缺失值、异常报价、交易所的奇葩规则……这些才是真正的拦路虎。
今天咱们就聊聊,怎么从交易所拿到期权链数据,怎么清洗,怎么构建一套能直接上模型的标准数据集。
3.1 数据源的选择:不是所有交易所都一样
国内期权市场,目前主要就三个地方:上交所(50ETF、300ETF)、深交所(300ETF)、中金所(沪深300股指期权)。
我个人习惯,优先用上交所的数据。为什么?
- 流动性最好——50ETF期权日均成交几百万张,数据质量相对稳定
- 数据接口规范——上交所的Level-2行情接口,字段定义清晰
- 历史数据完整——从2015年到现在,基本没断过
但要注意,深交所的300ETF期权,虽然流动性差一些,但有时候会出现一些套利机会——因为定价效率没那么高。我在2021年做过一个统计,深交所的平价套利窗口平均比上交所多持续0.3秒。嗯,这个差距在程序化交易里就是真金白银。
核心原则:数据源选择,优先考虑流动性,其次才是数据质量。流动性差的市场,数据本身就有问题——买卖价差大,报价容易被操纵。
3.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 本章知识体系
下面这张图,把整个数据获取与清洗的流程串起来了:
整个流程,说白了就是三步:拿到数据 → 搞干净 → 统一格式。每一步都有坑,但只要你按照上面的方法走一遍,基本能避开90%的问题。
我记得刚开始做波动率交易时,花在数据清洗上的时间,比写策略还多。但现在回头看,那些时间花得值——数据干净了,后面的建模、回测、实盘,都顺很多。
一句话总结:数据清洗不是苦力活,是策略的生命线。你花在清洗上的每一分钟,都会在后面的交易中加倍回报给你。
公众号:蓝海数据掘金营,微信deep3321