Quant BuffetRelax, Not Over Thinking

Option Gamma Predicts Stock Returns

Log in to collect

Academic paper

Option Gamma and Stock Returns

AuthorsAmar Soebhag

Institute
  • NLErasmus University Rotterdam
  • ?Erasmus University Rotterdam (EUR) - Department of Business Economics
  • ?Robeco Asset Management

Strategy in a nutshell

The strategy sorts U.S. stocks (NYSE, AMEX, NASDAQ) into deciles by their net gamma exposure (Γ), measured from option open interest with a one-day lag. At each month’s end, a self-financing long–short portfolio is formed by going long the top Γ stocks and short the bottom Γ stocks, rebalanced monthly and value-weighted.

Economic rationale

Net gamma exposure captures option market makers’ hedging pressure, which influences stock volatility. Positive Γ dampens volatility, while negative Γ amplifies it. This hedging dynamic, not private information, explains why Γ predicts cross-sectional returns.

Backtest performance

Annualised return8.99%
Volatility11.23%
Beta-0.011
Sharpe ratio0.8
Sortino ratio-0.22
Win rate48%

Full Python code

from AlgorithmImports import *
from typing import List, Dict
# endregion

class OptionGammaPredictsStockReturns(QCAlgorithm):

def Initialize(self):
self.SetStartDate(2015, 1, 1)
self.SetCash(100_000)

self.leverage: int = 5
self.quantile: int = 10

self.min_contracts: int = 3
self.one_stock_contracts: int = 6 * self.min_contracts

self.min_expiry: int = 35
self.max_expiry: int = 360

self.exchanges: List[str] = ['NYS', 'NAS', 'ASE']

self.market_cap: Dict[Symbol, float] = {}
self.subscribed_options: List[OptionContract] = []
self.symbols_by_tickers: Dict[str, Symbol] = {}

self.market_symbol: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol

self.fundamental_count: int = 300
self.fundamental_sorting_key = lambda x: x.MarketCap

self.selection_flag: bool = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.Raw

self.Schedule.On(self.DateRules.MonthStart(self.market_symbol), self.TimeRules.BeforeMarketClose(self.market_symbol, 0), self.Selection)

def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
    security.SetFeeModel(CustomFeeModel())
    security.SetLeverage(self.leverage)

def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
if not self.selection_flag:
    return Universe.Unchanged

selected: List[Fundamental] = [
    f for f in fundamental if f.HasFundamentalData and \
    f.MarketCap != 0 and \
    f.SecurityReference.ExchangeId in self.exchanges
]

if len(selected) > self.fundamental_count:
    selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]

self.subscribed_options.clear()
self.market_cap = { stock.Symbol: stock.MarketCap for stock in selected }

return list(self.market_cap.keys()) 

def OnData(self, data: Slice) -> None:
if self.selection_flag:
    self.selection_flag = False
    self.Liquidate()

    for symbol in self.market_cap:
        # subscribe to contract
        contracts: List[Symbol] = self.OptionChainProvider.GetOptionContractList(symbol, self.Time)
        underlying_price: float = self.Securities[symbol].Price
        
        strikes: List[float] = [i.ID.StrikePrice for i in contracts]
        
        if len(strikes) <= 0:
            continue
        
        atm_strike: float = min(strikes, key=lambda x: abs(x-underlying_price))
        itm_strike: float = min(strikes, key=lambda x: abs(x-(underlying_price*0.95)))
        otm_strike: float = min(strikes, key=lambda x: abs(x-(underlying_price*1.05)))

        atm_calls: List[Symbol] = self.SelectContracts(contracts, OptionRight.Call, atm_strike)
        atm_puts: List[Symbol] = self.SelectContracts(contracts, OptionRight.Put, atm_strike)

        itm_calls: List[Symbol] = self.SelectContracts(contracts, OptionRight.Call, itm_strike)
        itm_puts: List[Symbol] = self.SelectContracts(contracts, OptionRight.Put, itm_strike)

        otm_calls: List[Symbol] = self.SelectContracts(contracts, OptionRight.Call, otm_strike)
        otm_puts: List[Symbol] = self.SelectContracts(contracts, OptionRight.Put, otm_strike)
            
        # make sure there are enough contracts
        if not all(len(x) >= self.min_contracts for x in [atm_calls, atm_puts, itm_calls, itm_puts, otm_calls, otm_puts]):
            continue
        
        # store stock's symbol under it's ticker, because it is the only possibility how to access it from option contract
        self.symbols_by_tickers[symbol.Value] = symbol

        # sort by expiry and subscribe n nearest contracts
        for selected_contracts in [atm_calls, atm_puts, itm_calls, itm_puts, otm_calls, otm_puts]:
            nearest_contracts: List[Symbol] = sorted(selected_contracts, key=lambda item: item.ID.Date)[:self.min_contracts]

            for contract in nearest_contracts:
                option: OptionContract = self.AddOptionContract(contract, Resolution.Daily)
                option.PriceModel = OptionPriceModels.CrankNicolsonFD()

                # after trade execution subscribed options will be unsubscribed,
                # to stop receiving unncessary data, which slows down strategy
                self.subscribed_options.append(option)

if len(self.symbols_by_tickers) != 0 and data.OptionChains.Count != 0:
    gamma_weighted_sum: Dict[Symbol, float] = {}
    
    for kvp in data.OptionChains:
        chain: OptionChain = kvp.Value
        ticker: str = chain.Underlying.Symbol.Value
        if ticker not in self.symbols_by_tickers:
            continue

        symbol: Symbol = self.symbols_by_tickers[ticker]
        contracts: List[OptionContract] = [x for x in chain]
        
        # check if there are enough contracts for option
        if len(contracts) < self.one_stock_contracts:
            continue
        
        gamma_weighted_sum_value: float = sum([(c.Greeks.Gamma * c.OpenInterest) for c in contracts])
        gamma_weighted_sum[symbol] = gamma_weighted_sum_value

    self.symbols_by_tickers.clear() # clear list to prevent next gamma weight sum calculation
    # unsubscribe option contracts, to stop receiving data
    for option in self.subscribed_options:
        self.RemoveOptionContract(option.Symbol)

    # make sure there are enough stocks with gamma value for selection
    if len(gamma_weighted_sum) < self.quantile:
        return

    quantile: int = int(len(gamma_weighted_sum) / self.quantile)
    sorted_by_gamma_weighted_sum: List[Symbol] = [x[0] for x in sorted(gamma_weighted_sum.items(), key=lambda item: item[1])]
    long_leg: List[Symbol] = sorted_by_gamma_weighted_sum[-quantile:]
    short_leg: List[Symbol] = sorted_by_gamma_weighted_sum[:quantile]

    # trade execution
    total_long_cap: float = sum(list(map(lambda symbol: self.market_cap[symbol], long_leg)))
    for symbol in long_leg:
        self.SetHoldings(symbol, self.market_cap[symbol] / total_long_cap)

    total_short_cap: float = sum(list(map(lambda symbol: self.market_cap[symbol], short_leg)))
    for symbol in short_leg:
        self.SetHoldings(symbol, -self.market_cap[symbol] / total_short_cap)

def SelectContracts(self, contracts: List[Symbol], option_right: int, strike: float) -> List[Symbol]:
return [i for i in contracts if i.ID.OptionRight == option_right \
    and i.ID.StrikePrice == strike and self.min_expiry < (i.ID.Date - self.Time).days < self.max_expiry]

def Selection(self) -> None:
self.selection_flag = True

# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))