波动率期限结构预测期权收益策略
登录后收藏学术论文
Volatility Term Structure and the Cross-Section of Option Returns
作者:Vasquez; 机构:墨西哥自治技术学院(ITAM) - 商业管理系
隐含波动率期限结构的斜率与未来期权收益正相关。我们根据波动率期限结构的斜率对公司进行排名,并分析跨式期权和个股期权的收益表现。研究结果表明,波动率期限结构斜率不仅能够预测期权的定价偏差,还能够解释期权横截面收益的显著异象。这表明斜率是一个强有力的期权市场定价指标,可用于构建基于波动率信号的交易策略。
策略概要
该策略针对具有流动性期权的美国股票,每月计算波动率期限结构的斜率,作为长期与短期平值期权(ATM)隐含波动率的差值。短期波动率取一个月到期的ATM认沽和认购期权的平均值,长期波动率取最长到期(50-360天)的ATM期权的平均值。根据斜率对股票进行排序,形成十分位投资组合,使用delta对冲的一个月期认购期权进行交易。投资者对斜率最高的十分位组合做多,对斜率最低的十分位组合做空,投资组合按等权重配置,并每月重新平衡。期权行权价选择为股票在期权到期后第二个交易日的收盘价最接近的价格。
策略合理性
学术研究表明,波动率期限结构的斜率可以预测未来的现货波动率。斜率上升表示未来波动率可能增加,斜率下降表示未来波动率可能减少。如果这一假设成立,则斜率的幅度与短期期权的收益之间应具有正相关性,因为波动率的变化会直接影响期权定价。通过利用这一规律,该策略试图捕捉由波动率变化驱动的期权市场定价误差,从而获得超额收益。
回测表现
年化收益19.56%
波动率21.13%
贝塔0.004
夏普比率0.74
索提诺比率-0.233
胜率57%
完整 Python 代码
from AlgorithmImports import *
from typing import List, Dict, Tuple
from dataclasses import dataclass
#endregion
class VolatilityTermStructurePredictsOptionReturns(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2015, 1, 1)
self.SetCash(1000000)
self.symbol_price_manager = {}
self.tickers_to_ignore: List[str] = ['DASH', 'GOOG', 'GME']
self.short_term_min_expiry: int = 25
self.short_term_max_expiry: int = 35
self.long_term_min_expiry: int = 50
self.long_term_max_expiry: int = 360
self.threshold: int = 4
self.leverage: int = 5
self.quantile: int = 10
self.min_share_price: int = 5
self.fundamental_count = 100
self.fundamental_sorting_key = lambda x: x.DollarVolume
self.short_term_contracts: Dict[str, Contracts] = {}
self.long_term_contracts: Dict[str, Contracts] = {}
self.symbols_by_ticker: Dict[str, Symbol] = {} # stock symbols indexed by ticker
self.updated_date: Dict[Symbol, datetime.date] = {} # last IV update date indexed by stock symbol
self.opened_positions: List[Symbol] = [] # opened stock symbols
symbol: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.some_trade_was_opened_day_before: bool = False
self.day: int = -1
self.traded_flag: bool = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.settings.daily_precise_end_time = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.SetSecurityInitializer(lambda x: x.SetDataNormalizationMode(DataNormalizationMode.Raw))
self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.Raw
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
security.SetFeeModel(CustomFeeModel())
security.SetLeverage(self.leverage)
stock_symbol: Symbol = security.Symbol
self.updated_date[stock_symbol] = None
for security in changes.RemovedSecurities:
stock_symbol = security.Symbol
if stock_symbol in self.updated_date:
del self.updated_date[stock_symbol]
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
# rebalance on contract expirations
if len(self.symbols_by_ticker) != 0:
return Universe.Unchanged
# select top n stocks by dollar volume with price higher than 5
selected: List[Fundamental] = [
x for x in fundamental
if x.HasFundamentalData
and x.Market == 'usa' and
x.Price > self.min_share_price
and x.Symbol.Value not in self.tickers_to_ignore
]
if len(selected) > self.fundamental_count:
selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
# create pair stock's ticker and stock's symbol
self.symbols_by_ticker = { x.Symbol.Value : x.Symbol for x in selected }
# return symbols of selected stocks
return list(self.symbols_by_ticker.values())
def OnData(self, data: Slice) -> None:
# prevent data error
# calculate slope every day if trades are not opened
if len(self.opened_positions) == 0:
slope: Dict[Symbol, float] = {} # stored slope of stock
stock_prices: Dict[Symbol, float] = {} # stored price of stock
for kvp in data.OptionChains:
chain: OptionChain = kvp.Value
# option symbol
symbol: Symbol = chain.Underlying.Symbol
# option ticker
ticker: str = symbol.Value
if ticker not in self.symbols_by_ticker:
continue
# based on option ticker get stock symbol
stock_symbol: Symbol = self.symbols_by_ticker[ticker]
# make sure, spread is updated once in a day
if stock_symbol in self.updated_date:
if self.updated_date[stock_symbol] is not None:
if self.updated_date[stock_symbol] == self.Time.date():
continue
contracts: List[OptionContract] = [x for x in chain]
# check if there are enough contracts
if len(contracts) < self.threshold:
continue
short_atm_call_iv: float = .0
short_atm_put_iv: float = .0
long_atm_call_iv: float = .0
long_atm_put_iv: float = .0
for c in contracts:
if c.Right == OptionRight.Call:
if ticker in self.short_term_contracts and c.Symbol in self.short_term_contracts[ticker].contracts:
# found short term atm call
short_atm_call_iv = c.ImpliedVolatility
elif ticker in self.long_term_contracts and c.Symbol in self.long_term_contracts[ticker].contracts:
# found long term atm call
long_atm_call_iv = c.ImpliedVolatility
else:
if ticker in self.short_term_contracts and c.Symbol in self.short_term_contracts[ticker].contracts:
# found short term atm put
short_atm_put_iv = c.ImpliedVolatility
elif ticker in self.long_term_contracts and c.Symbol in self.long_term_contracts[ticker].contracts:
# found long term atm put
long_atm_put_iv = c.ImpliedVolatility
if short_atm_call_iv and short_atm_put_iv and long_atm_call_iv and long_atm_put_iv:
# calculate mean of short term and long term implied volatilities of subscribed contracts
short_term_iv_mean = (short_atm_call_iv + short_atm_put_iv) / 2
long_term_iv_mean = (long_atm_call_iv + long_atm_put_iv) / 2
# update last updated time
self.updated_date[stock_symbol] = self.Time.date()
# make sure, there are DataBars for stock
if stock_symbol in data and data[stock_symbol]:
underlying_price = data[stock_symbol].Value
# store stock underlying price keyed by ticker
stock_prices[stock_symbol] = underlying_price
# calculate slope as a difference between long term iv mean and short term iv mean
slope[stock_symbol] = long_term_iv_mean - short_term_iv_mean
if len(slope) >= self.quantile:
quantile: int = int(len(slope) / self.quantile)
sorted_by_slope: List[Tuple[Symbol, float]] = sorted(slope.items(), key = lambda x: x[1], reverse=True)
# long highest slope and short lowest slope
long: List[Tuple[Symbol, float]] = sorted_by_slope[:quantile]
short: List[Tuple[Symbol, float]] = sorted_by_slope[-quantile:]
long_count: int = len(long)
short_count: int = len(short)
long_w: float = 1 / long_count
short_w: float = 1 / short_count
for stock_symbol, _ in long:
# retrieve short atm call contract symbol based on ticker
contract_symbol: Symbol = self.short_term_contracts[stock_symbol.Value].contracts[0]
price: float = stock_prices[stock_symbol]
equity: float = self.Portfolio.TotalPortfolioValue * long_w
options_q: int = int(equity / (price * 100))
# buy contract
if stock_symbol in data and data[stock_symbol] and contract_symbol in data and data[contract_symbol]:
self.Buy(contract_symbol, options_q)
self.Sell(stock_symbol, options_q * 50) # initial delta hedge
# store opened position
self.opened_positions.append(stock_symbol)
for stock_symbol, _ in short:
if self.Securities[stock_symbol].IsDelisted:
continue
# retrieve short atm call contract symbol based on ticker
contract_symbol: Symbol = self.short_term_contracts[stock_symbol.Value].contracts[0]
price: float = stock_prices[stock_symbol]
equity: float = self.Portfolio.TotalPortfolioValue * short_w
options_q: int = int(equity / (price * 100))
# sell contract
if stock_symbol in data and data[stock_symbol] and contract_symbol in data and data[contract_symbol]:
self.Sell(contract_symbol, options_q)
self.Buy(stock_symbol, options_q * 50) # initial delta hedge
# store opened position
self.opened_positions.append(stock_symbol)
# check if contracts expiries once in a day
if self.day == self.Time.day:
return
self.day = self.Time.day
for ticker, stock_symbol in self.symbols_by_ticker.items():
# subscribe to new short term contracts, when last one expiries
if ticker in self.short_term_contracts and ticker in self.long_term_contracts:
short_contracts_obj = self.short_term_contracts[ticker]
# check if contract is about to expire
if short_contracts_obj.expiry.date() - timedelta(days=2) <= self.Time.date():
for contract in short_contracts_obj.contracts:
# liquidate trade first - call contract and stock symbol
if stock_symbol in self.opened_positions:
self.Liquidate(contract) # liquidate call contract
self.Liquidate(stock_symbol) # liquidate hedge
self.opened_positions.remove(stock_symbol)
# remove long term contract also
long_contracts_obj: Contracts = self.long_term_contracts[ticker]
del self.short_term_contracts[ticker]
del self.long_term_contracts[ticker]
# subscribe to new contracts
self.SubscribeContracts(ticker, stock_symbol, True)
self.SubscribeContracts(ticker, stock_symbol, False)
else:
# have to subscribe new contracts
self.SubscribeContracts(ticker, stock_symbol, True)
self.SubscribeContracts(ticker, stock_symbol, False)
if len(self.opened_positions) != 0:
self.some_trade_was_opened_day_before = True
return
else:
# every position was close - expired
if self.some_trade_was_opened_day_before:
if self.Portfolio.Invested:
# handle positions left opened
opened: List[str] = [x.Key.Value for x in self.Portfolio if x.Value.Invested]
self.Liquidate()
# perform next selection of stock
self.symbols_by_ticker.clear()
self.short_term_contracts.clear()
self.long_term_contracts.clear()
self.some_trade_was_opened_day_before = False
def SubscribeContracts(self,
ticker: str,
symbol: Symbol,
short_term_flag: bool) -> None:
contracts: List[Symbol] = self.OptionChainProvider.GetOptionContractList(symbol, self.Time)
# get current price for stock
underlying_price: float = self.Securities[symbol].Price
# get strikes from stock contracts
strikes: List[float] = [i.ID.StrikePrice for i in contracts]
# check if there is at least one strike
if len(strikes) <= 0:
return
# at the money
atm_strike: float = min(strikes, key=lambda x: abs(x-underlying_price))
# select min and max expiry based on value of short term flag
min_expiry: int = self.short_term_min_expiry if short_term_flag else self.long_term_min_expiry
max_expiry: int = self.short_term_max_expiry if short_term_flag else self.long_term_max_expiry
# filtred contracts based on option rights and strikes
atm_calls: List[Symbol] = [i for i in contracts if i.ID.OptionRight == OptionRight.Call and i.ID.StrikePrice == atm_strike and
min_expiry <= (i.ID.Date - self.Time).days <= max_expiry]
atm_puts: List[Symbol] = [i for i in contracts if i.ID.OptionRight == OptionRight.Put and i.ID.StrikePrice == atm_strike and
min_expiry <= (i.ID.Date - self.Time).days <= max_expiry]
# make sure there are enough contracts
if len(atm_calls) > 0 and len(atm_puts) > 0:
# sort by expiry
atm_call: Symbol = sorted(atm_calls, key = lambda item: item.ID.Date, reverse=True)[0]
atm_put: Symbol = sorted(atm_puts, key = lambda item: item.ID.Date, reverse=True)[0]
subscriptions = self.SubscriptionManager.SubscriptionDataConfigService.GetSubscriptionDataConfigs(atm_call.Underlying)
if subscriptions:
# add contracts
for contract in [atm_call, atm_put]:
if self.Securities[contract.Underlying].IsDelisted:
continue
self.AddContract(contract)
# based on value of short_term_flag store subscribed contracts in specific dictionary
if short_term_flag:
self.short_term_contracts[ticker] = Contracts(atm_call.ID.Date, [atm_call, atm_put])
else:
self.long_term_contracts[ticker] = Contracts(atm_call.ID.Date, [atm_call, atm_put])
def AddContract(self, contract: Symbol) -> None:
''' subcribe to contract, set price model and normalization mode '''
option: Option = self.AddOptionContract(contract, Resolution.Daily)
option.SetLeverage(self.leverage)
option.PriceModel = OptionPriceModels.CrankNicolsonFD()
class SymbolData():
def __init__(self) -> None:
self.slope: Union[None, float] = None
self.updated_date: Union[None, datetime.date] = None
def update_slope(self, slope: float, updated_date: datetime.date) -> None:
self.slope = slope
self.updated_date = updated_date
@dataclass
class Contracts():
expiry: datetime.date
contracts: List[Symbol]
# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))