Quant BuffetRelax, Not Over Thinking

Using Straddles to Trade on Earnings Announcements

Log in to collect

Academic paper

Strategy in a nutshell

The strategy trades optionable NYSE, Amex, and Nasdaq stocks above $5, focusing on earnings near option expirations. It buys delta-neutral straddles three days before announcements, allocating 10% of assets to manage volatility risk, and holds until expiration to capture earnings-driven price swings.

Economic rationale

Research shows at-the-money straddles earn positive returns during earnings because investors underreact to rising uncertainty. Driven by conservatism bias, this delayed adjustment creates predictable volatility and profitable option mispricing.

Backtest performance

Annualised return40.75%
Volatility64.67%
Beta-0.002
Sharpe ratio0.57
Sortino ratio-1.119
Win rate36%

Full Python code

from AlgorithmImports import *
from pandas.tseries.offsets import BDay
from typing import Dict, List, Tuple
#endregion
class UsingStraddlesToTradeOnEarningsAnnouncements(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2018, 1, 1)
self.SetCash(100_000) 

self.earnings: Dict[datetime.date, str] = {}
self.selected_for_trade: List[Tuple[Symbol, float]] = []

# dj30 universe
self.tickers: List[str] = [
    'UNH','GS','HD','AMGN','MSFT','CAT','MCD','V','BA','HON','CRM','MMM','DIS','JNJ','JPM','TRV','AXP','IBM','WMT','PG','NKE','AAPL','CVX','MRK','DOW','VZ','INTC','KO','WBA','CSCO'
]

min_strike: int = -5
max_strike: int = 5
min_expiry: int = 4
max_expiry: int = 30
self.leverage: int = 10
self.day_threshold: int = 5

self.percentage_traded: float = 0.1  # only 10% of portfolio assets are used to perform this strategy

for ticker in self.tickers:
    # add equity
    data: Security = self.AddEquity(ticker, Resolution.Minute)
    data.SetFeeModel(CustomFeeModel())
    data.SetLeverage(self.leverage)
    
    # add options
    option: Option = self.AddOption(ticker, Resolution.Minute)
    option.SetFilter(min_strike, max_strike, min_expiry, max_expiry)

# source: 'https://www.nasdaq.com/market-activity/earnings
earnings_data: str = self.Download('data.quantpedia.com/backtesting_data/economic/earnings_dates_eps.json')
earnings_data_json: List[dict] = json.loads(earnings_data)

for obj in earnings_data_json:
    date: datetime.date = datetime.strptime(obj['date'], "%Y-%m-%d").date()
    self.earnings[date] = []
    for stock_data in obj['stocks']:
        ticker: str = stock_data['ticker']
        self.earnings[date].append(ticker)
# sort earnings by date    
self.earnings: Dict[datetime, str] = {date: tickers for date, tickers in sorted(self.earnings.items(), key=lambda x: x[0])}
            
self.earnings_date: Union[None, datetime.date] = None
            
self.last_day: int = -1
self.opened_equity_count: int = 0
def OnData(self, slice: Slice) -> None:
# check once a day
if self.Time.day == self.last_day:
    return
self.last_day = self.Time.day

invested: List[str] = [x.Key.Value for x in self.Portfolio if x.Value.Invested]
# only equities are opened, options expired
if self.opened_equity_count != 0 and len(invested) == self.opened_equity_count:
    self.opened_equity_count = 0
    # liquidate hedge
    self.Liquidate()

if not self.Portfolio.Invested and len(self.selected_for_trade) == 0:
    current_date: datetime.date = self.Time.date()
    for date in self.earnings:
        # get closest earnings annoucement day, which is older than 3 days from current
        # if date > current_date and abs((date - current_date).days) > 3 and self.earnings_date != date:
        if date > (current_date + BDay(3)).date() and self.earnings_date != date:
            self.earnings_date = date
            break
    
    # return from function, if earnings_date wasn't selected
    if self.earnings_date is None:
        return
    
    for i in slice.OptionChains:
        chains: OptionChains = i.Value
        # get ticker for current chain
        ticker:str = chains.get_Symbol().get_Value()[1:]
        
        # check if current ticker is stored in self.earnings dictionary under self.earnings_date
        if ticker not in self.earnings[self.earnings_date]:
            continue
        
        calls: List[OptionChains] = list(filter(lambda x: x.Right == OptionRight.Call, chains))
        puts: List[OptionChains] = list(filter(lambda x: x.Right == OptionRight.Put, chains))
    
        if not calls or not puts:
            continue
        
        underlying_price: float = chains.Underlying.Price
        expiries: List[datetime.date] = [i.Expiry for i in puts]
        
        expiry_date: datetime.date = None
        for expiry in expiries:
            date: datetime.date = expiry.date()
            
            # pick expiry_date, if difference between expiration date and earnings annoucement date is less than 10 days
            if abs((date - self.earnings_date).days) < 10:
                expiry_date = date
                break
                
        # don't continue in current iteration, if expiry_date wasn't picked
        if expiry_date is None:
            continue
        
        strikes: List[float] = [i.Strike for i in puts]
    
        # determine at-the-money strike
        strike: float = min(strikes, key=lambda x: abs(x-underlying_price))
        atm_call: List[OptionChains] = [i for i in calls if i.Expiry.date() == expiry_date and i.Strike == strike]
        atm_put: List[OptionChains] = [i for i in puts if i.Expiry.date() == expiry_date and i.Strike == strike]
        if atm_call and atm_put:
            self.selected_for_trade.append((atm_call[0], atm_put[0], underlying_price))

if (self.Time + BDay(3)).date() > self.earnings_date and len(self.selected_for_trade) != 0:
    self.selected_for_trade.clear()

# need to trade 3 days before earnings annoucement            
if (self.Time + BDay(3)).date() == self.earnings_date and len(self.selected_for_trade) != 0:
    length: int = len(self.selected_for_trade)
    
    for atm_call, atm_put, underlying_price in self.selected_for_trade:
        if (atm_call.Symbol in slice and slice[atm_call.Symbol] and atm_put.Symbol in slice and slice[atm_put.Symbol] and atm_put.UnderlyingSymbol in slice and slice[atm_put.UnderlyingSymbol]):
            options_q: float = int(((self.Portfolio.TotalPortfolioValue*self.percentage_traded) / length) / (underlying_price * 100))
            
            # buy at-the-money straddle
            self.Buy(atm_call.Symbol, options_q)
            self.Buy(atm_put.Symbol, options_q)
            
            self.SetHoldings(atm_put.UnderlyingSymbol, -self.percentage_traded / length)
        
            # increase number of opened equities
            self.opened_equity_count += 1
    
    # clear list after trade
    self.selected_for_trade.clear()
   
# 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"))