Quant BuffetRelax, Not Over Thinking

Contrast Effect During the Earnings Announcements

Log in to collect

Academic paper

A Tough Act to Follow: Contrast Effects in Financial Markets

AuthorsSamuel M. Hartzmark; Kelly Shue

Institute
  • University of Chicago
  • Boston College
  • ?Boston College - Carroll School of Management
  • ?University of Chicago - Booth School of Business
  • Yale University
  • National Bureau of Economic Research
  • ?National Bureau of Economic Research (NBER)
  • ?Yale School of Management

Strategy in a nutshell

This strategy focuses on large-cap NYSE stocks, forming daily long-short positions based on earnings surprises. On low-surprise days, the investor goes long on the announcing firms and short the market, while on high-surprise days, the positions are reversed. Portfolios are value-weighted using market capitalization from three days prior and held for two days, capturing the short-term impact of prior earnings surprises on stock performance.

Economic rationale

The approach exploits the contrast effect, a behavioral bias where investors’ reactions are amplified by recent information. Returns are negatively affected by prior-day earnings surprises, with minimal influence from earlier or subsequent surprises. This short-term overreaction creates predictable price distortions that the strategy systematically leverages..

Backtest performance

Annualised return15%
Beta-0.041
Sortino ratio-0.167
Win rate50%

Full Python code

from AlgorithmImports import *
import numpy as np
from collections import deque
from pandas.tseries.offsets import BDay
from dateutil.relativedelta import relativedelta
from typing import Dict, List
#endregion
class ContrastEffectDuringtheEarningsAnnouncements(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2010, 1, 1)   # scheduled earnings data starts in 2010
self.SetCash(100000)
self.leverage:int = 5
self.seasonal_eps_count:int = 3
self.holding_period:int = 2
self.surprise_period:int = 4
self.period:int = 13
# trenching
self.managed_queue:List[RebalanceQueueItem] = []

# surprise data count needed to count standard deviation
self.earnings_surprise:Dict[Symbol, deque] = {}
   
self.last_price:Dict[Symbol, float] = {}

# SUE and EAR history for previous quarter used for statistics
self.surprise_history_previous:deque = deque()
self.surprise_history_actual:deque = deque()
self.eps:Dict[Symbol, deque] = {}
data = self.AddEquity('SPY', Resolution.Daily)
data.SetFeeModel(CustomFeeModel())
data.SetLeverage(self.leverage)
self.symbol:Symbol = data.Symbol
# Earning data parsing.
self.earnings_data:Dict[datetime.date, Dict[str, float]] = {}
self.tickers:Set(str) = set() 
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_data[date] = {}
    
    for stock_data in obj['stocks']:
        ticker:str = stock_data['ticker']
        if stock_data['eps'] != '':
            self.earnings_data[date][ticker] = float(stock_data['eps'])
            
        self.tickers.add(ticker)

self.month:int = 0
self.settings.daily_precise_end_time = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Schedule.On(self.DateRules.MonthStart(self.symbol), self.TimeRules.AfterMarketOpen(self.symbol), 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]:
self.last_price.clear()
for equity in fundamental:
    symbol:Symbol = equity.Symbol
    ticker:str = symbol.Value
    if ticker in self.tickers or symbol == self.symbol:
        self.last_price[symbol] = equity.AdjustedPrice
selected:List[FineFundamental] = [x for x in fundamental if x.MarketCap != 0]
# make sure there are some stocks with yesterday's earnings
yesterday:datetime.date = (self.Time - BDay(1)).date()
if yesterday not in self.earnings_data:
    return Universe.Unchanged
tickers_with_yesterday_earnings:List[str] = list(self.earnings_data[yesterday].keys())
# stocks with yesterday's earnings
filtered_fine:List[Fundamental] = [x for x in selected if x.Symbol.Value in tickers_with_yesterday_earnings] 
# SUE data
sue_data:Dict[Symbol, float] = {}

for stock in filtered_fine:
    symbol:Symbol = stock.Symbol
    ticker:str = symbol.Value
    # store eps data
    if symbol not in self.eps:
        self.eps[symbol] = deque(maxlen = self.period)
    data:List[datetime.date, float] = [yesterday, self.earnings_data[yesterday][ticker]]
    self.eps[symbol].append(data)
    
    # consecutive EPS data
    if len(self.eps[symbol]) == self.eps[symbol].maxlen:
        recent_eps_data:float = self.eps[symbol][-1]
        
        year_range:range = range(self.Time.year - 3, self.Time.year)
        
        last_month_date:datetime.date = recent_eps_data[0] - relativedelta(months=1)
        next_month_date:datetime.date = recent_eps_data[0] + relativedelta(months=1)
        month_range:List[int] = [last_month_date.month, recent_eps_data[0].month, next_month_date.month]
        # earnings with todays month number 4 years back
        seasonal_eps_data:List[List[datetime.date, float]] = [x for x in self.eps[symbol] \
            if x[0].month in month_range and x[0].year in year_range]
        if len(seasonal_eps_data) != self.seasonal_eps_count: continue
        
        # Make sure we have a consecutive seasonal data. Same months with one year difference.
        year_diff:np.array = np.diff([x[0].year for x in seasonal_eps_data])
        if all(x == 1 for x in year_diff):
            seasonal_eps:List[float] = [x[1] for x in seasonal_eps_data]
            diff_values:np.array = np.diff(seasonal_eps)
            drift:float = np.average(diff_values)
            
            # SUE calculation
            last_earnings:float = seasonal_eps[-1]
            expected_earnings:float = last_earnings + drift
            actual_earnings:float = recent_eps_data[1]
            # store sue value with earnigns date
            earnings_surprise:float = actual_earnings - expected_earnings

            if symbol not in self.earnings_surprise:
                self.earnings_surprise[symbol] = deque(maxlen=self.surprise_period)
            elif len(self.earnings_surprise[symbol]) >= self.surprise_period:
                earnings_surprise_std:float = np.std(self.earnings_surprise[symbol])
                sue:float = earnings_surprise / earnings_surprise_std
                
                sue_data[symbol] = sue
            self.earnings_surprise[symbol].append(earnings_surprise)

if len(sue_data) == 0:
    return Universe.Unchanged

long_symbol_q:List[Symbol, float] = []
short_symbol_q:List[Symbol, float] = []

# store total yesterday's surprise in this month's history
yesterdays_surprises:float = sum([x[1] for x in sue_data.items()])
# wait until there is surprise history data for previous three months
if len(self.surprise_history_previous) != 0:
    # find symbols with next day scheduled earnings
    earnings_date = (self.Time + BDay(1)).date()
    if earnings_date in self.earnings_data:
        surprise_values:List = [x for x in self.surprise_history_previous]
    
        top_surprise_percentile:float  = np.percentile(surprise_values, 75)
        bottom_surprise_percentile:float = np.percentile(surprise_values, 25)
        
        traded_symbols:List[List[Symbol, float]] = []
        for stock in selected:
            symbol:Symbol = stock.Symbol
            ticker:str = symbol.Value
            # stock has earnings in 1 day
            if ticker in self.earnings_data[earnings_date]:
                traded_symbols.append([symbol, stock.MarketCap])
        
        if len(traded_symbols) != 0:
            if self.symbol in self.last_price:
                total_market_cap:float = sum([x[1] for x in traded_symbols])
                
                stocks_w:float = self.Portfolio.TotalPortfolioValue / self.holding_period
                spy_quantity:float = self.Portfolio.TotalPortfolioValue / self.holding_period / self.last_price[self.symbol]
                
                if yesterdays_surprises > top_surprise_percentile:
                    long_symbol_q = [(x[0], np.floor(stocks_w * (x[1] / total_market_cap) / self.last_price[x[0]])) for x in traded_symbols]
                    
                    # Quantity instead of weight is used in case of SPY.
                    short_symbol_q = [(self.symbol, -spy_quantity)]
                    
                elif yesterdays_surprises < bottom_surprise_percentile:
                    # Quantity instead of weight is used in case of SPY.
                    long_symbol_q = [(self.symbol, spy_quantity)]
                    
                    short_symbol_q = [(x[0], -np.floor(stocks_w * (x[1] / total_market_cap) / self.last_price[x[0]])) for x in traded_symbols]  
    
self.surprise_history_actual.append(yesterdays_surprises)
    
self.managed_queue.append(RebalanceQueueItem(long_symbol_q + short_symbol_q))
    
return [x[0] for x in long_symbol_q + short_symbol_q] if len(long_symbol_q + short_symbol_q) != 0 else Universe.Unchanged
def OnData(self, data: Slice) -> None:
# trade execution
remove_item:Union[RebalanceQueueItem, None] = None

# rebalance portfolio
for item in self.managed_queue:
    if item.holding_period == self.holding_period:
        for symbol, quantity in item.symbol_q:
            self.MarketOrder(symbol, -quantity)
                    
        remove_item = item
        
    elif item.holding_period == 0:
        open_symbol_q:List[List[Symbol, float]] = []
        
        for symbol, quantity in item.symbol_q:
            if symbol in data and data[symbol]:
                self.MarketOrder(symbol, quantity)
                open_symbol_q.append((symbol, quantity))
                    
        # Only opened orders will be closed        
        item.symbol_q = open_symbol_q
        
    item.holding_period += 1
    
# remove closed part of portfolio after loop
# otherwise it will miss one item in self.managed_queue
if remove_item:
    self.managed_queue.remove(remove_item)
    
def Selection(self) -> None:        
# every three months
if self.month % 3 == 0:
    # save history
    self.surprise_history_previous = [x for x in self.surprise_history_actual]
    self.surprise_history_actual.clear()

self.month += 1
    
class RebalanceQueueItem:
def __init__(self, symbol_q):
# symbol/quantity collections
self.symbol_q:List[List[Symbol, float]] = symbol_q  
self.holding_period:int = 0

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