Earnings Disagreement Put Spread Strategy on S&P 100 Options
Log in to collectAcademic paper
Option-Implied Correlations and the Price of Correlation Risk
Joost Driessen; Pascal J. Maenhout; Grigory Vilkov
- NLTilburg University
- ?Tilburg University - Center for Economic Research (CentER)
- ?Tilburg University - Tilburg University School of Economics and Management
- INSEAD
- ?INSEAD - Finance
- DEFrankfurt School of Finance & Management
Strategy in a nutshell
The investment universe consists of stocks from the S&P 100 index. Trading vehicles are options on stocks from this index and also options on the index itself. The investor uses analyst forecasts of earnings per share from the Institutional Brokers Estimate System (I/B/E/S) database and computes for each firm the mean absolute difference scaled by an indicator of earnings uncertainty (see page 24 in the source academic paper for detailed methodology). Each month, investor sorts stocks into quintiles based on the size of belief disagreement. He buys puts of stocks with the highest belief disagreement and sells the index puts with Black-Scholes deltas ranging from -0.8 to -0.2.
Economic rationale
The academic paper shows that dispersion in analysts’ forecasts is strongly related to the implied volatility of index and single-name options. Research shows that option excess returns reflect the different exposure to disagreement risk. Investors who buy options of firms that are more prone to heterogeneity in beliefs are compensated in equilibrium for holding this risk. Volatility risk premia of individual and index options represent compensation for the priced disagreement risk. Hence, in the cross-section of options, the volatility risk premium depends on the size of the belief heterogeneity of this particular firm and the business cycle indicator. As the risk-neutral skewness, the volatility risk premium for index options can be larger or smaller depending on the size of disagreement and of the firm’s share.
Backtest performance
Full Python code
from AlgoLib import *
from numpy import floor
#endregion
class DispersionTrading(XXX):
def Initialize(self):
self.SetStartDate(2010, 1, 1)
self.SetCash(1000000)
self.min_expiry:int = 20
self.max_expiry:int = 60
self.leverage:int = 5
self.min_share_price:int = 5
self.buying_power_model:int = 2
self.index_symbol:Symbol = self.AddIndex('SPX').Symbol
self.percentage_traded:float = 1.0
self.selected_symbols:List[Symbol] = []
self.subscribed_contracts = {}
self.fundamental_sorting_key = lambda x: x.DollarVolume
self.fundamental_count:int = 100
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
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]:
# rebalance on SPX contract expiration (should be on monthly basis)
if len(self.selected_symbols) != 0:
return Universe.Unchanged
# select top n stocks by dollar volume
selected:List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.AdjustedPrice > 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.selected_symbols = [x.Symbol for x in selected]
return self.selected_symbols
def OnData(self, data: Slice) -> None:
# liquidate portfolio, when SPX contract is about to expire in 2 days
if self.index_symbol in self.subscribed_contracts and self.subscribed_contracts[self.index_symbol].ID.Date.date() - timedelta(2) <= self.Time.date():
self.subscribed_contracts.clear() # perform new subscribtion
self.selected_symbols.clear() # perform new selection
self.Liquidate()
if len(self.subscribed_contracts) == 0:
if self.Portfolio.Invested:
self.Liquidate()
# NOTE order is important, index should come first
for symbol in [self.index_symbol] + self.selected_symbols:
# subscribe to contract
contracts:List[Symbol] = self.OptionChainProvider.GetOptionContractList(symbol, self.Time)
# get current price for stock
underlying_price:float = self.Securities[symbol].Price
# get strikes from stock contracts
strikes:List[float] = [i.ID.StrikePrice for i in contracts]
# check if there is at least one strike
if len(strikes) <= 0:
continue
# at the money
atm_strike:float = min(strikes, key=lambda x: abs(x-underlying_price))
# filtred contracts based on option rights and strikes
atm_puts:List[Symbol] = [i for i in contracts if i.ID.OptionRight == OptionRight.Put and
i.ID.StrikePrice == atm_strike and
self.min_expiry <= (i.ID.Date - self.Time).days <= self.max_expiry]
# index contract is found
if symbol == self.index_symbol and len(atm_puts) == 0:
# cancel whole selection since index contract was not found
return
# make sure there are enough contracts
if len(atm_puts) > 0:
# sort by expiry
atm_put:List[Symbol] = sorted(atm_puts, key = lambda item: item.ID.Date, reverse=True)[0]
# add contract
option = self.AddOptionContract(atm_put, Resolution.Minute)
option.PriceModel = OptionPriceModels.CrankNicolsonFD()
option.SetDataNormalizationMode(DataNormalizationMode.Raw)
# store subscribed atm put contract
self.subscribed_contracts[symbol] = atm_put
# perform trade, when spx and stocks contracts are selected
if not self.Portfolio.Invested and len(self.subscribed_contracts) != 0 and self.index_symbol in self.subscribed_contracts:
index_option_contract = self.subscribed_contracts[self.index_symbol]
# make sure subscribed SPX contract has data
if self.Securities.ContainsKey(index_option_contract):
if self.Securities[index_option_contract].Price != 0 and self.Securities[index_option_contract].IsTradable:
# sell SPX ATM put contract
self.Securities[index_option_contract].MarginModel = BuyingPowerModel(self.buying_power_model)
price:float = self.Securities[self.index_symbol].Price
if price != 0:
q:int = floor((self.Portfolio.TotalPortfolioValue * self.percentage_traded) / (price*100))
self.Sell(index_option_contract, q)
# buy stock's ATM put contracts
long_count:int = len(self.subscribed_contracts) - 1 # minus index symbol
for stock_symbol, stock_option_contract in self.subscribed_contracts.items():
if stock_symbol == self.index_symbol:
continue
if stock_option_contract in data and data[stock_option_contract]:
if self.Securities[stock_option_contract].Price != 0 and self.Securities[stock_option_contract].IsTradable:
# buy contract
self.Securities[stock_option_contract].MarginModel = BuyingPowerModel(self.buying_power_model)
if self.Securities.ContainsKey(stock_option_contract):
price:float = self.Securities[stock_symbol].Price
if price != 0:
q:int = floor(((self.Portfolio.TotalPortfolioValue / long_count) * self.percentage_traded) / (price*100))
self.Buy(stock_option_contract, q)
# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))