Option Gamma Predicts Stock Returns
Log in to collectAcademic paper
Option Gamma and Stock Returns
Amar Soebhag
- 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"))