
The strategy involves stocks with liquid options, using implied volatility convexity to sort stocks into quintiles. The investor goes long on the lowest convexity quintile and short on the highest, rebalancing monthly.
ASSET CLASS: stocks | REGION: United States | FREQUENCY:
Monthly | MARKET: equities | KEYWORD: Options Convexity, Predicts Consecutive, Stock Returns
I. STRATEGY IN A NUTSHELL
This strategy trades NYSE, AMEX, and NASDAQ stocks with liquid options using implied volatility (IV) convexity. Stocks are sorted monthly into quintiles by IV convexity, going long on the lowest (Q1) and short on the highest (Q5). Positions are value-weighted and held for one month.
II. ECONOMIC RATIONALE
Options markets allow informed traders to act on information faster than stocks, reflecting excess tail risk through IV convexity. Higher IV convexity signals lower expected stock returns, creating a negative predictive relationship between IV convexity and future performance.
III. SOURCE PAPER
A Smiling Bear in the Equity Options Market and the Cross-section of Stock Returns [Click to Open PDF]
Haehean Park, Baeho Kim and Hyeongsop Shim.Southwestern University of Finance and Economics (SWUFE).Korea University Business School (KUBS).Gachon University.
<Abstract>
We propose a measure for the convexity of an option-implied volatility curve, IV convexity, as a forward-looking measure of excess tail-risk contribution to the perceived variance of underlying equity returns. Using equity options data for individual U.S.-listed stocks during 2000-2013, we find that the average return differential between the lowest and highest IV convexity quintile portfolios exceeds 1% per month, which is both economically and statistically significant on a risk-adjusted basis. Our empirical findings indicate the contribution of informed options trading to price discovery in terms of the realization of tail-risk aversion in the stock market.


IV. BACKTEST PERFORMANCE
| Annualised Return | 14.44% |
| Volatility | 9.99% |
| Beta | -0.025 |
| Sharpe Ratio | 1.05 |
| Sortino Ratio | -0.079 |
| Maximum Drawdown | N/A |
| Win Rate | 52% |
V. FULL PYTHON CODE
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"))