第二十六讲:回测框架搭建——基于历史曲面数据的策略回测
做期权量化,最怕什么?
怕策略在实盘里翻车。
我见过太多人,拿着回测曲线漂亮得不像话的策略,一上实盘就亏得亲妈都不认识。为什么?因为回测框架本身就有问题。今天我们就来聊聊,怎么搭建一个靠谱的、基于历史波动率曲面数据的回测框架。
回测框架的核心逻辑
说白了,回测就是模拟历史。你把策略放到过去的数据里跑一遍,看看它能不能赚钱。但期权回测比股票回测复杂得多——你不仅要看价格,还要看波动率曲面的变化。
我个人习惯把回测框架拆成三层:
- 数据层:历史曲面数据、标的价格、无风险利率
- 策略层:开仓信号、组合构建、调仓逻辑
- 评估层:盈亏计算、风险指标、绩效归因
这三层缺一不可。数据层是地基,策略层是房子,评估层是验收报告。地基没打好,后面全是白搭。
核心原则:回测框架必须能处理曲面数据的动态变化,而不是简单用历史波动率均值。
数据层:历史曲面数据的处理
我在项目中遇到过最坑的事,就是历史曲面数据不完整。有些交易日,某些行权价的期权根本没有成交,曲面数据是插值出来的。如果你直接拿这些数据回测,结果会严重失真。
怎么处理?我建议这样做:
- 数据清洗:剔除成交量过小的期权合约(比如日成交量低于100张)
- 曲面重构:用SVI或SSVI模型对缺失点进行插值
- 时间对齐:确保曲面数据与标的价格的时间戳一致
小技巧:回测时尽量用收盘价数据。如果用盘中数据,要考虑滑点和流动性问题。我曾经因为用了盘中tick数据,回测年化收益高了20%,实盘直接打回原形。
策略层:如何模拟真实交易
回测不是纸上谈兵。你得尽可能模拟真实交易环境。
举个例子,假设你的策略是「当隐含波动率高于历史波动率80分位数时,卖出跨式期权」。在回测中,你需要:
- 检查当天收盘时的曲面数据
- 计算各期限的隐含波动率
- 与历史波动率分位数对比
- 如果触发条件,构建卖出跨式组合
- 记录开仓时的曲面状态
这里有个坑:你开仓时的曲面数据,必须是当时能拿到的数据。不能拿未来数据去回测。我见过有人用当天收盘后的曲面数据去判断开盘时的信号,这属于典型的未来函数。
警告:回测中一定要加入滑点和交易成本。期权交易成本包括:手续费、买卖价差、冲击成本。我一般按每张合约2-5元计算手续费,买卖价差按0.5-1个tick估算。
评估层:不只是看收益率
很多人回测只看年化收益率和最大回撤。但期权策略的评估维度更多。
我个人习惯看这几个指标:
| 指标 | 说明 | 我的经验阈值 |
|---|---|---|
| 夏普比率 | 风险调整后收益 | 大于1.5算合格 |
| Calmar比率 | 收益与最大回撤之比 | 大于2.0算优秀 |
| 胜率 | 盈利交易占比 | 期权卖方策略通常高于70% |
| 盈亏比 | 平均盈利/平均亏损 | 大于1.5比较健康 |
| 最大连续亏损 | 连续亏损次数 | 不超过5次 |
嗯,这里要注意:期权策略的胜率往往很高,但盈亏比可能很低。比如卖出跨式,胜率可能80%以上,但一次亏损就能吃掉之前所有的利润。所以回测时一定要看最大回撤和尾部风险。
代码实现:一个简单的回测框架
下面是一个简化版的回测框架代码。它基于历史曲面数据,对卖出跨式策略进行回测。
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
class OptionBacktest:
def __init__(self, surface_data, spot_data, rf_rate=0.03):
"""
surface_data: DataFrame, 包含日期、期限、行权价、隐含波动率
spot_data: DataFrame, 包含日期、标的价格
rf_rate: 无风险利率
"""
self.surface = surface_data
self.spot = spot_data
self.rf = rf_rate
self.trades = []
self.pnl = []
def generate_signals(self, lookback=60, threshold=0.8):
"""
生成交易信号
lookback: 历史波动率计算窗口
threshold: 分位数阈值
"""
signals = []
# 计算历史波动率
self.spot['returns'] = self.spot['close'].pct_change()
self.spot['hist_vol'] = self.spot['returns'].rolling(lookback).std() * np.sqrt(252)
for date in self.surface['date'].unique():
# 获取当天的曲面数据
day_surface = self.surface[self.surface['date'] == date]
# 获取当天的历史波动率
hist_vol = self.spot[self.spot['date'] == date]['hist_vol'].values[0]
# 计算隐含波动率的分位数
iv_values = day_surface['implied_vol'].values
iv_percentile = np.percentile(iv_values, threshold * 100)
# 如果隐含波动率高于阈值,生成卖出信号
if np.mean(iv_values) > iv_percentile:
signals.append({
'date': date,
'signal': 'sell_straddle',
'iv_mean': np.mean(iv_values),
'hist_vol': hist_vol
})
return signals
def run_backtest(self, signals):
"""
执行回测
"""
for signal in signals:
# 模拟开仓
entry_date = signal['date']
entry_spot = self.spot[self.spot['date'] == entry_date]['close'].values[0]
# 假设卖出平值跨式,每张合约赚取2%的权利金
premium = entry_spot * 0.02
# 持有到下一个交易日平仓
next_date = entry_date + timedelta(days=1)
if next_date in self.spot['date'].values:
exit_spot = self.spot[self.spot['date'] == next_date]['close'].values[0]
# 计算盈亏(简化模型)
pnl = premium - abs(exit_spot - entry_spot) / entry_spot * 100
self.trades.append({
'entry_date': entry_date,
'exit_date': next_date,
'entry_spot': entry_spot,
'exit_spot': exit_spot,
'pnl': pnl
})
# 计算累计收益
self.pnl = pd.DataFrame(self.trades)
return self.pnl
# 使用示例
bt = OptionBacktest(surface_data, spot_data)
signals = bt.generate_signals(lookback=60, threshold=0.8)
results = bt.run_backtest(signals)
print(f"总交易次数: {len(results)}")
print(f"胜率: {len(results[results['pnl'] > 0]) / len(results):.2%}")
print(f"平均盈亏: {results['pnl'].mean():.2f}%")
避坑指南:上面的代码是简化版,实际回测要考虑更多细节。比如:
- 期权到期日处理(临近到期时波动率会异常)
- 保证金计算(卖出期权需要保证金)
- 展期操作(当月合约到期后如何切换到下月)
我曾经因为没处理好展期,回测收益虚高了30%。
回测框架的架构图
下面这张图展示了回测框架的整体流程。你可以看到数据从输入到输出的完整路径。
回测中常见的坑
做期权回测这几年,我踩过的坑能写一本书。这里挑几个最要命的:
- 幸存者偏差:只回测了存续期较长的合约,忽略了那些提前退市的。结果就是回测收益偏高。
- 前视偏差:用到了未来数据。比如用当天的收盘价去判断开盘时的信号。
- 流动性陷阱:回测时假设所有合约都能以中间价成交。实盘里,深度虚值合约可能根本卖不出去。
- 波动率跳跃:黑天鹅事件发生时,波动率曲面会瞬间变形。回测数据里可能没有包含这种极端情况。
我曾经犯过一个低级错误:回测时用了复权后的标的价格,但期权数据没有复权。结果就是标的价格和期权价格对不上,回测结果完全失真。所以,数据对齐是回测的第一要务。
总结
回测框架搭建,说白了就是三个字:真、准、稳。
- 数据要真——别用未来数据
- 计算要准——滑点、成本、保证金一个不能少
- 结果要稳——多跑几遍,看看是不是每次都一样
记住,回测只是工具,不是圣杯。它能帮你排除明显不靠谱的策略,但不能保证实盘一定赚钱。真正的高手,都是在回测和实盘之间反复迭代,不断优化。