第三章 市场数据获取与清洗

做期权做市,数据就是你的弹药。弹药有问题,枪法再准也白搭。

我刚开始做这行的时候,犯过一个低级错误——用了某天下午3点15分的快照数据,没注意交易所的撮合规则。结果波动率曲面画出来全是锯齿,回测曲线倒是漂亮,实盘一跑就亏钱。后来排查了三天,才发现是数据时间戳没对齐。

所以这一章,咱们就聊聊怎么把交易所的原始数据,变成能用的干净数据。

3.1 从交易所获取期权链数据

国内期权市场,目前主要就是上证50ETF期权、沪深300ETF期权、中证500ETF期权,还有中金所的股指期权。每家交易所的数据格式略有不同,但核心字段差不多。

我个人习惯用CTP接口或者万得、聚宽的API来拉数据。但不管用哪种方式,你拿到的原始数据大概长这样:

# 伪代码示例:获取期权链数据
import pandas as pd
from datetime import datetime

# 假设从交易所API获取
raw_data = {
    'ticker': ['510050C2309M02700', '510050P2309M02700'],
    'type': ['call', 'put'],
    'strike': [2.700, 2.700],
    'expiry': ['2023-09-27', '2023-09-27'],
    'last_price': [0.0450, 0.0320],
    'bid': [0.0448, 0.0318],
    'ask': [0.0452, 0.0322],
    'volume': [12500, 9800],
    'open_interest': [45200, 38900],
    'timestamp': ['2023-09-15 14:30:00', '2023-09-15 14:30:00']
}
df = pd.DataFrame(raw_data)

这里有个坑——合约代码的解析。你看那个ticker字段,里面包含了标的、到期日、行权价等信息。我建议你写个解析函数,一次性拆开:

def parse_option_ticker(ticker):
    """解析期权合约代码"""
    # 示例:510050C2309M02700
    # 510050 = 标的代码, C = call, 2309 = 2023年9月, M = 月合约, 02700 = 2.700
    symbol = ticker[:6]
    option_type = 'call' if ticker[6] == 'C' else 'put'
    # 继续解析...
    return symbol, option_type, strike, expiry
我的小技巧: 把解析逻辑单独封装成一个模块。因为不同交易所的编码规则不一样,中金所和上交所的格式就不同。你想想看,如果哪天要加新品种,改一个地方就行。

3.2 处理缺失值

数据缺失是家常便饭。比如某个深度虚值期权,全天都没成交,bid/ask都是空的。或者某只合约因为流动性差,某分钟的快照里只有ask没有bid。

我处理缺失值的策略分三步:

  1. 识别缺失模式:是随机缺失,还是系统性缺失?
  2. 填充或删除:根据业务场景决定
  3. 标记:填充过的数据一定要打标签

举个例子,对于bid/ask缺失的情况:

# 处理缺失的买卖价
def fill_missing_bid_ask(df):
    # 如果bid缺失但ask存在,用ask - 最小价差估算
    df['bid'] = df['bid'].fillna(df['ask'] - 0.0001)
    # 如果ask缺失但bid存在,用bid + 最小价差估算
    df['ask'] = df['ask'].fillna(df['bid'] + 0.0001)
    # 如果都缺失,直接删除该行
    df = df.dropna(subset=['bid', 'ask'], how='all')
    return df
注意: 千万别用均值填充期权数据!不同行权价、不同到期日的期权,价格差异巨大。我曾经见过有人用全市场均值填充,结果波动率曲面直接变形。

还有一种情况——时间序列上的缺失。比如某只合约在14:30到14:35之间没有数据。这时候我一般用前向填充,但会加一个时间阈值。超过5分钟没数据,我就认为这个合约暂时失效了。

3.3 异常值检测

异常值比缺失值更危险。因为它看起来像正常数据,但实际上是错的。

我常用的检测方法有几种:

  • 价格突变检测:相邻两个tick的价格变化超过N个标准差
  • 买卖价差异常:bid-ask spread突然放大到正常值的10倍以上
  • 隐含波动率跳跃:同一行权价、相邻到期日的IV出现不合理差异

这里给个简单的异常检测代码:

def detect_price_spike(df, threshold=5):
    """检测价格突变"""
    df['price_change'] = df['last_price'].pct_change()
    mean = df['price_change'].mean()
    std = df['price_change'].std()
    df['is_spike'] = abs(df['price_change'] - mean) > threshold * std
    return df[df['is_spike'] == False]

嗯,这里要注意——阈值设多少合适?我个人习惯用5倍标准差。但如果是高频数据,可能要用3倍。因为高频数据噪声更大,阈值设太高反而漏掉异常。

避坑指南: 我曾经遇到过一种情况——某只深度实值期权,因为一笔大单成交,价格瞬间跳了20%。用统计方法检测,它确实是异常值。但实际这笔交易是真实的,只是因为流动性太差。所以,异常值不一定是错误数据。我现在的做法是:先标记,再人工复核,或者用成交量辅助判断。

3.4 调整分红与拆股

分红和拆股,是期权数据清洗里最容易被忽视的环节。

为什么?因为期权合约的条款会随着标的资产的变化而调整。比如50ETF分红,期权合约的行权价、合约单位都会变。如果你不调整,波动率曲面就会出现断层。

调整的逻辑其实不复杂:

事件类型 调整方式 示例
现金分红 行权价 = 原行权价 - 分红金额 原行权价2.700,分红0.05,调整后2.650
股票分红 合约单位 = 原合约单位 × (1 + 送股比例) 原合约10000股,10送1,调整后11000股
拆股 行权价按比例调整,合约单位按比例调整 1拆2,行权价减半,合约单位翻倍

具体实现时,我建议维护一个调整因子表

# 调整因子示例
adjustment_factors = {
    '2023-06-15': {'dividend': 0.05, 'split_ratio': 1.0},
    '2023-12-20': {'dividend': 0.08, 'split_ratio': 1.0},
}

def adjust_option_data(df, adjustment_factors):
    """根据分红拆股调整期权数据"""
    for date, factors in adjustment_factors.items():
        mask = df['expiry'] > date
        if factors['dividend'] > 0:
            df.loc[mask, 'strike'] -= factors['dividend']
        if factors['split_ratio'] != 1.0:
            df.loc[mask, 'strike'] /= factors['split_ratio']
            df.loc[mask, 'contract_size'] *= factors['split_ratio']
    return df
我的经验: 分红调整后,记得把历史波动率也重新算一遍。因为调整后的价格序列变了,波动率也会变。我吃过这个亏——调整了期权数据,但没调整标的的历史价格,结果IV和HV对不上。

3.5 数据清洗的整体流程

说了这么多,咱们把整个流程串起来。我画了一张图,你看看:

期权数据清洗流程 1. 数据获取 交易所API/数据商 2. 合约解析 拆分ticker字段 3. 缺失值处理 填充/删除/标记 4. 异常检测 价格突变/价差异常 5. 分红/拆股调整 调整行权价/合约单位 6. 输出干净数据 用于波动率曲面构建 反馈循环:发现异常数据,回溯到步骤3重新处理 每个步骤都需要记录日志,方便后续审计和回溯

这个流程看起来简单,但实际跑起来,你会发现很多细节。比如数据获取这一步,不同交易所的接口频率限制不一样。上交所的行情推送是500ms一次,中金所是100ms一次。你想想看,如果混在一起用,时间戳对齐就是个麻烦事。

我个人习惯的做法是:先统一时间基准。把所有数据都对齐到同一个时间戳上,比如都用毫秒级时间戳。然后再按上面的流程一步步走。

最后说一句: 数据清洗没有一劳永逸的方案。市场在变,交易所的规则在变,你的清洗逻辑也得跟着变。我每个月都会review一次清洗流程,看看有没有新的异常模式出现。做量化,说白了就是跟数据打交道。数据干净了,后面的模型才能靠谱。


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