
“该策略涉及具有流动性期权的股票,使用隐含波动率凸度将股票分为五等分。投资者做多凸度最低的五分之一,做空凸度最高的五分之一,每月重新平衡。”
资产类别: 股票 | 地区: 美国 | 周期: 每月 | 市场: 股票 | 关键词: 期权凸度
I. 策略概要
该策略专注于在纽约证券交易所、美国证券交易所和纳斯达克上市的具有流动性期权且可从OptionMetrics获得隐含波动率(IV)数据的股票。投资者使用以下公式计算IV凸度:IV凸度 = IVput(Δ = -0.2) + IVput(Δ = -0.8) − 2 x IVcall(Δ = 0.5)。在每个月末,股票根据其IV凸度被分为五个五等分。投资者做多IV凸度最低的五分之一(Q1),做空IV凸度最高的五分之一(Q5)。投资组合每月重新平衡并持有一个月,股票按价值加权。
II. 策略合理性
由于交易成本较低、杠杆较高且没有卖空限制等优势,知情交易者在股票期权市场比在股票市场更能有效地利用其信息优势。因此,期权价格通常比股票价格更早反映信息。预测股票尾部风险过高的投资者会导致回报的峰态隐含分布,这表明未来股票表现较差。这产生了一种负相关关系,即较高的超额峰度(通过隐含波动率(IV)凸度衡量)预测较低的未来股票回报。
III. 来源论文
股票期权市场中的微笑熊与股票回报的横截面 [点击查看论文]
- 朴海轩、金培浩、沈亨燮。西南财经大学(SWUFE)。高丽大学商学院(KUBS)。嘉泉大学。
<摘要>
我们提出了期权隐含波动率曲线凸度的一种度量方法,即IV凸度,作为对感知到的标的股票回报方差的超额尾部风险贡献的前瞻性度量。使用2000-2013年美国上市个股的股票期权数据,我们发现最低和最高IV凸度五等分投资组合之间的平均回报差异每月超过1%,这在风险调整后的基础上具有经济和统计学意义。我们的实证结果表明,知情期权交易在股票市场实现尾部风险规避方面的价格发现贡献。


IV. 回测表现
| 年化回报 | 14.44% |
| 波动率 | 9.99% |
| β值 | -0.025 |
| 夏普比率 | 1.05 |
| 索提诺比率 | -0.079 |
| 最大回撤 | N/A |
| 胜率 | 52% |
V. 完整的 Python 代码
import numpy as np
from AlgorithmImports import *
from typing import Dict, List, Tuple
class OptionsConvexityPredictsConsecutiveStockReturns(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2015, 1, 1)
self.SetCash(100000)
self.min_expiry: int = 25
self.max_expiry: int = 35
self.quantile: int = 5
self.leverage: int = 5
self.min_share_price: int = 5
self.contracts_count: int = 3
self.thresholds: List[int] = [0.95, 1.05]
self.next_expiry: Union[None, datetime.date] = None
self.fundamental_count: int = 100
self.fundamental_sorting_key = lambda x: x.DollarVolume
self.selection_flag: bool = False
self.stock_universe: List[Symbol] = []
self.option_universe: Dict[Symbol, List[Symbol]] = {}
self.contracts_expiry: Dict[Symbol, datetime.date] = {} # storing contracts expiry date under symbols
self.settings.daily_precise_end_time = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.UniverseSettings.Resolution = Resolution.Minute
self.AddUniverse(self.FundamentalSelectionFunction)
self.SetSecurityInitializer(lambda x: x.SetDataNormalizationMode(DataNormalizationMode.Raw))
self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.Raw
self.current_day: int = -1
symbol: Symbol = self.AddEquity("SPY", Resolution.Daily).Symbol
self.Schedule.On(self.DateRules.MonthStart(symbol), self.TimeRules.AfterMarketOpen(symbol), self.Selection)
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
security.SetFeeModel(CustomFeeModel())
security.SetLeverage(self.leverage)
# remove old option contracts
for security in changes.RemovedSecurities:
symbol = security.Symbol
if symbol in self.option_universe:
for option in self.option_universe[symbol]:
self.RemoveSecurity(option)
del self.option_universe[symbol]
self.Liquidate(symbol)
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
# new symbol selection once a quarter
if not self.selection_flag:
return Universe.Unchanged
selected: List[Fundamental] = [
x for x in fundamental
if x.HasFundamentalData
and x.Market == 'usa'
and x.Price > self.min_share_price
]
if len(selected) > self.fundamental_count:
selected = [
x for x in sorted(
selected,
key=self.fundamental_sorting_key,
reverse=True)[:self.fundamental_count]
]
self.stock_universe = [x.Symbol for x in selected]
return self.stock_universe
def Selection(self) -> None:
if self.Time.month % 3 == 0:
self.selection_flag = True
self.Liquidate()
def OnData(self, data: Slice) -> None:
# rebalance daily
if self.current_day == self.Time.day:
return
self.current_day = self.Time.day
if self.next_expiry and self.Time.date() >= self.next_expiry.date():
for symbol in self.option_universe:
for option in self.option_universe[symbol]:
self.RemoveSecurity(option)
self.Liquidate()
# for symbol in self.option_universe:
# # subscribe to new contracts, because current ones has expiried
# if symbol not in self.contracts_expiry or self.contracts_expiry[symbol] <= self.Time.date():
if not self.Portfolio.Invested:
for symbol in self.stock_universe:
contracts: List[Symbol] = self.OptionChainProvider.GetOptionContractList(symbol, self.Time)
underlying_price: float = self.Securities[symbol].Price
if self.Securities[symbol].IsDelisted:
continue
strikes: List[float] = [i.ID.StrikePrice for i in contracts]
if len(strikes) > 0:
atm_strike: float = min(strikes, key=lambda x: abs(x-underlying_price))
itm_strike: float = min(strikes, key=lambda x: abs(x-(underlying_price*min(self.thresholds))))
otm_strike: float = min(strikes, key=lambda x: abs(x-(underlying_price*max(self.thresholds))))
atm_calls: List[Symbol] = [i for i in contracts if i.ID.OptionRight == OptionRight.Call and
i.ID.StrikePrice == atm_strike and
self.min_expiry < (i.ID.Date - self.Time).days < self.max_expiry]
itm_puts: List[Symbol] = [i for i in contracts if i.ID.OptionRight == OptionRight.Put and
i.ID.StrikePrice == itm_strike and
self.min_expiry < (i.ID.Date - self.Time).days < self.max_expiry]
otm_puts: List[Symbol] = [i for i in contracts if i.ID.OptionRight == OptionRight.Put and
i.ID.StrikePrice == otm_strike and
self.min_expiry < (i.ID.Date - self.Time).days < self.max_expiry]
if len(atm_calls) > 0 and len(itm_puts) > 0 and len(otm_puts) > 0:
# sort by expiry
atm_call: List[Symbol] = sorted(atm_calls, key = lambda x: x.ID.Date)[0]
itm_put: List[Symbol] = sorted(itm_puts, key = lambda x: x.ID.Date)[0]
otm_put: List[Symbol] = sorted(otm_puts, key = lambda x: x.ID.Date)[0]
# store expiry date
# self.contracts_expiry[symbol] = itm_put.ID.Date.date()
self.next_expiry = atm_call.ID.Date
# add contracts
option: Option = self.AddOptionContract(atm_call, Resolution.Minute)
option.PriceModel = OptionPriceModels.CrankNicolsonFD()
option: Option = self.AddOptionContract(itm_put, Resolution.Minute)
option.PriceModel = OptionPriceModels.CrankNicolsonFD()
option: Option = self.AddOptionContract(otm_put, Resolution.Minute)
option.PriceModel = OptionPriceModels.CrankNicolsonFD()
options: List[Symbol] = [atm_call, itm_put, otm_put]
self.option_universe[symbol] = options
iv_convexity: Dict[Symbol, float] = {}
if data.OptionChains.Count != 0:
for kvp in data.OptionChains:
chain: OptionChain = kvp.Value
contracts: List[Symbol] = [x for x in chain]
if len(contracts) == self.contracts_count:
atm_call_iv: Union[None, float] = None
itm_put_iv: Union[None, float] = None
otm_put_iv: Union[None, float] = None
symbol: Symbol = chain.Underlying.Symbol
for c in contracts:
if c.Right == OptionRight.Call:
# found atm call
atm_call_iv = c.ImpliedVolatility
else:
# found put option
underlying_price:float = self.Securities[c.UnderlyingSymbol].Price
if c.Strike < underlying_price:
# found itm put
itm_put_iv = c.ImpliedVolatility
else:
# found otm put
otm_put_iv = c.ImpliedVolatility
if atm_call_iv and itm_put_iv and otm_put_iv:
iv_convexity[symbol] = itm_put_iv + otm_put_iv - (2*atm_call_iv)
long: List[Symbol] = []
short: List[Symbol] = []
# convexity sorting
if len(iv_convexity) >= self.quantile:
sorted_by_convexity: List[Tuple[Symbol, float]] = sorted(iv_convexity.items(), key = lambda x: x[1], reverse = True)
quantile: int = int(len(sorted_by_convexity) / self.quantile)
long = [x[0] for x in sorted_by_convexity[-quantile:]]
short = [x[0] for x in sorted_by_convexity[:quantile]]
# trade execution
targets: List[PortfolioTarget] = []
for i, portfolio in enumerate([long, short]):
for symbol in portfolio:
if symbol in data and data[symbol]:
targets.append(PortfolioTarget(symbol, ((-1) ** i) / len(portfolio)))
self.SetHoldings(targets, True)
# 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"))