第二章:金融数据获取——用yfinance搞定期权链数据

做波动率曲面分析,第一步就是拿数据。这步要是没做好,后面再漂亮的模型也是白搭。我个人习惯用 yfinance 这个库,免费、轻量、够用。今天我们就聊聊怎么用它拉期权链数据,怎么清洗,怎么存。

2.1 为什么选yfinance?

市面上能拿期权数据的渠道不少,但 yfinance 有几个好处:

  • 免费——不用花钱买Bloomberg或者Wind
  • 简单——几行代码就能拿到完整的期权链
  • 覆盖广——美股期权基本都有,包括SPY、AAPL这些主流标的

我在项目中遇到过一个问题:用付费数据源拿SPY的期权链,一天就要几百块。后来换成yfinance,虽然数据延迟15分钟,但做回测和策略研究完全够用。说白了,你又不是做高频交易,15分钟延迟影响不大。

小提示:yfinance拿的是雅虎财经的数据,延迟15分钟。如果你需要实时数据,可以考虑IBKR的API或者Polygon.io。但做课程和回测,yfinance足够了。

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——callsputs。别搞混了。

2.3 数据清洗与预处理

拿到的数据长什么样?我们看看列名:

print(calls.columns.tolist())
# ['contractSymbol', 'lastTradeDate', 'strike', 'lastPrice', 'bid', 'ask', 
#  'change', 'percentChange', 'volume', 'openInterest', 'impliedVolatility', 
#  'inTheMoney', 'contractSize', 'currency']

这里面有几个坑,我一个个说。

2.3.1 缺失值处理

你想想看,有些深度虚值的期权,可能一天都没人交易。这时候 volumeopenInterest 会是0,bidask 可能是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)]
避坑指南:我曾经直接拿原始IV画波动率微笑,结果曲面图上有几个尖峰,查了半天发现是某只深度虚值期权的IV算错了。从那以后,我每次都会先画个箱线图看看IV分布。

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 完整的数据获取流程

把上面所有步骤串起来,就是一个完整的数据流水线。我画了个图,方便你理解整体逻辑:

期权数据获取与处理流程 1. 获取期权链 yfinance.option_chain() 2. 数据清洗 缺失值/异常值处理 3. 特征工程 到期时间/中间价 4. 数据存储 CSV / Parquet / SQLite 每个步骤都有对应的清洗规则和存储策略 建议:日常研究用SQLite,归档用Parquet

这个流程看起来简单,但每一步都有细节。比如清洗那步,我曾经因为没处理 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