Quant BuffetRelax, Not Over Thinking

Earnings Disagreement Put Spread Strategy on S&P 100 Options

Log in to collect

Academic paper

Option-Implied Correlations and the Price of Correlation Risk

AuthorsJoost Driessen; Pascal J. Maenhout; Grigory Vilkov

Institute
  • 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

Annualised return15.39%
Volatility13.86%
Beta0.491
Sharpe ratio0.238
Sortino ratio0.177
Maximum drawdown31.7%
Win rate55%

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