Quant BuffetRelax, Not Over Thinking

Options Convexity Predicts Consecutive Stock Returns

Log in to collect

Academic paper

A Smiling Bear in the Equity Options Market and the Cross-Section of Stock Returns

AuthorsHye-hyun Park; Baeho Kim; Hyeongsop Shim

Institute
  • Southwestern University of Finance and Economics
  • ?Southwestern University of Finance and Economics (SWUFE)
  • KRKorea University
  • ?Korea University Business School (KUBS)
  • KRGachon University

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.

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.

Backtest performance

Annualised return14.44%
Volatility9.99%
Beta-0.025
Sharpe ratio1.05
Sortino ratio-0.079
Win rate52%

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"))