第二章:金融数据获取——用yfinance搞定期权链数据
做波动率曲面分析,第一步就是拿数据。这步要是没做好,后面再漂亮的模型也是白搭。我个人习惯用 yfinance 这个库,免费、轻量、够用。今天我们就聊聊怎么用它拉期权链数据,怎么清洗,怎么存。
2.1 为什么选yfinance?
市面上能拿期权数据的渠道不少,但 yfinance 有几个好处:
- 免费——不用花钱买Bloomberg或者Wind
- 简单——几行代码就能拿到完整的期权链
- 覆盖广——美股期权基本都有,包括SPY、AAPL这些主流标的
我在项目中遇到过一个问题:用付费数据源拿SPY的期权链,一天就要几百块。后来换成yfinance,虽然数据延迟15分钟,但做回测和策略研究完全够用。说白了,你又不是做高频交易,15分钟延迟影响不大。
2.2 安装与基础用法
先装库,这个不用多说:
pip install yfinance pandas numpy
然后拉一只股票的期权链。我拿SPY举例,这是流动性最好的ETF之一:
import yfinance as yf
import pandas as pd
# 创建ticker对象
spy = yf.Ticker("SPY")
# 获取所有到期日
expirations = spy.options
print(f"可用到期日: {expirations[:5]} ... 共{len(expirations)}个")
# 拉某个到期日的期权链
exp_date = expirations[0] # 最近到期日
opt_chain = spy.option_chain(exp_date)
# 看涨和看跌分开存放
calls = opt_chain.calls
puts = opt_chain.puts
print(f"看涨期权数量: {len(calls)}")
print(f"看跌期权数量: {len(puts)}")
嗯,这里要注意:option_chain() 返回的是一个 Options 对象,里面有两个DataFrame——calls 和 puts。别搞混了。
2.3 数据清洗与预处理
拿到的数据长什么样?我们看看列名:
print(calls.columns.tolist())
# ['contractSymbol', 'lastTradeDate', 'strike', 'lastPrice', 'bid', 'ask',
# 'change', 'percentChange', 'volume', 'openInterest', 'impliedVolatility',
# 'inTheMoney', 'contractSize', 'currency']
这里面有几个坑,我一个个说。
2.3.1 缺失值处理
你想想看,有些深度虚值的期权,可能一天都没人交易。这时候 volume 和 openInterest 会是0,bid 和 ask 可能是NaN。我的处理方式是:
# 把bid/ask的NaN填充为0
calls['bid'] = calls['bid'].fillna(0)
calls['ask'] = calls['ask'].fillna(0)
# 计算中间价,如果bid和ask都是0,就用lastPrice
calls['mid_price'] = (calls['bid'] + calls['ask']) / 2
calls.loc[calls['mid_price'] == 0, 'mid_price'] = calls['lastPrice']
# 剔除volume和openInterest都为0的期权(流动性太差)
calls = calls[(calls['volume'] > 0) | (calls['openInterest'] > 0)]
我曾经吃过一次亏:没处理NaN就直接算隐含波动率,结果一堆inf。后来学乖了,清洗这步不能省。
2.3.2 隐含波动率的异常值
impliedVolatility 这列有时候会出现离谱的值。比如深度实值期权,IV可能变成负数或者超过200%。这通常是因为定价模型在边界条件下不稳定。
# 过滤合理的IV范围(0.05 ~ 2.0,即5% ~ 200%)
calls = calls[(calls['impliedVolatility'] > 0.05) &
(calls['impliedVolatility'] < 2.0)]
# 也可以按分位数截断
q_low = calls['impliedVolatility'].quantile(0.01)
q_high = calls['impliedVolatility'].quantile(0.99)
calls = calls[(calls['impliedVolatility'] >= q_low) &
(calls['impliedVolatility'] <= q_high)]
2.3.3 时间格式与到期时间
yfinance返回的 lastTradeDate 是时间戳,我们需要把它转成可读格式,同时计算到期剩余时间(用于后续的theta计算和曲面插值):
# 转换时间格式
calls['lastTradeDate'] = pd.to_datetime(calls['lastTradeDate'])
# 计算到期剩余天数
from datetime import datetime
exp_date_dt = datetime.strptime(exp_date, '%Y-%m-%d')
calls['days_to_expiry'] = (exp_date_dt - datetime.now()).days
# 转换成年化时间(用于BS公式)
calls['T'] = calls['days_to_expiry'] / 365.0
2.4 数据存储策略
数据拿下来,清洗好了,怎么存?我试过几种方案,说说我的经验。
2.4.1 CSV存储(适合小规模、快速验证)
# 按到期日分别存储
calls.to_csv(f'spy_calls_{exp_date}.csv', index=False)
puts.to_csv(f'spy_puts_{exp_date}.csv', index=False)
优点:简单,Excel能直接打开看。缺点:文件多了管理麻烦,而且没有增量更新。
2.4.2 Parquet存储(适合大规模、列式存储)
# 把所有到期日的数据合并成一个DataFrame
all_calls = pd.DataFrame()
for exp in expirations[:10]: # 只拉最近10个到期日
chain = spy.option_chain(exp)
df = chain.calls
df['expiration'] = exp
all_calls = pd.concat([all_calls, df], ignore_index=True)
# 存成parquet
all_calls.to_parquet('spy_calls_all.parquet')
Parquet的好处是压缩率高,读取快。我一般用这个做历史数据归档。
2.4.3 SQLite数据库(适合需要查询的场景)
import sqlite3
conn = sqlite3.connect('options_data.db')
all_calls.to_sql('spy_calls', conn, if_exists='replace', index=False)
# 之后可以按条件查询
query = "SELECT * FROM spy_calls WHERE strike BETWEEN 400 AND 450 AND expiration='2024-12-20'"
df = pd.read_sql(query, conn)
我个人习惯用SQLite做日常数据管理。为什么?因为你可以写SQL做复杂的筛选,比在DataFrame里用条件过滤快多了。
- 日常研究:SQLite + Parquet 混合使用
- 快速验证:CSV
- 生产环境:考虑用InfluxDB这类时序数据库
2.5 完整的数据获取流程
把上面所有步骤串起来,就是一个完整的数据流水线。我画了个图,方便你理解整体逻辑:
这个流程看起来简单,但每一步都有细节。比如清洗那步,我曾经因为没处理 bid 为0的情况,导致后面算出来的波动率曲面全是平的——因为中间价全是0。排查了半天才发现问题。
2.6 实战:拉取多到期日的完整曲面数据
最后,给你一个可以直接用的函数。我平时做波动率曲面分析,就用这个拉数据:
def fetch_option_surface(ticker, max_expirations=20):
"""
拉取期权波动率曲面所需数据
"""
stock = yf.Ticker(ticker)
expirations = stock.options[:max_expirations]
all_data = []
for exp in expirations:
try:
chain = stock.option_chain(exp)
for opt_type, df in [('call', chain.calls), ('put', chain.puts)]:
df['option_type'] = opt_type
df['expiration'] = exp
all_data.append(df)
except Exception as e:
print(f"到期日 {exp} 拉取失败: {e}")
continue
# 合并所有数据
surface = pd.concat(all_data, ignore_index=True)
# 清洗
surface['bid'] = surface['bid'].fillna(0)
surface['ask'] = surface['ask'].fillna(0)
surface['mid_price'] = (surface['bid'] + surface['ask']) / 2
surface.loc[surface['mid_price'] == 0, 'mid_price'] = surface['lastPrice']
# 过滤流动性差的
surface = surface[(surface['volume'] > 0) | (surface['openInterest'] > 0)]
# 计算到期时间
surface['expiration_date'] = pd.to_datetime(surface['expiration'])
surface['days_to_expiry'] = (surface['expiration_date'] - pd.Timestamp.now()).dt.days
surface['T'] = surface['days_to_expiry'] / 365.0
return surface
# 使用示例
spy_surface = fetch_option_surface("SPY", max_expirations=10)
print(f"共获取 {len(spy_surface)} 条期权数据")
print(f"覆盖 {spy_surface['expiration'].nunique()} 个到期日")
这个函数我用了大半年,基本没出过问题。唯一要注意的是,如果某天雅虎的数据源挂了,option_chain() 会抛异常,所以我在循环里加了 try-except。
- yfinance免费但数据延迟15分钟,适合研究和回测
- 清洗时重点关注bid/ask的NaN和IV的异常值
- 存储方案按场景选:CSV快速、Parquet高效、SQLite灵活
- 写一个通用函数,把获取-清洗-预处理封装起来,以后直接用
数据拿到手了,下一步就是画波动率微笑和曲面。不过那是后面章节的事。今天先把数据基础打牢,后面分析起来才顺手。
公众号:蓝海资料掘金营,微信deep3321