Quant BuffetRelax, Not Over Thinking

Post-Earnings Announcement Drift for Friday Evening Announcers

Log in to collect

Academic paper

When is the Best Time to Hide Earnings News?

AuthorsRoni Michaely; Amir Rubin; Alexander Vedrashko

Institute
  • BEEuropean Corporate Governance Institute
  • HKUniversity of Hong Kong
  • ?ECGI
  • ?The University of Hong Kong
  • ILReichman University
  • CASimon Fraser University
  • ?Interdisciplinary Center (IDC) Herzliyah
  • ?Simon Fraser University (SFU) - Beedie School of Business
  • ?Simon Fraser University - Beedie School of Business

Strategy in a nutshell

This strategy trades U.S. stocks with Friday earnings announcements, forming long/short portfolios based on standardized unexpected earnings (SUE). Stocks above the positive median go long, below the negative median go short, held 12 months with monthly rebalancing.

Economic rationale

Managers may time earnings announcements strategically, delaying full market reactions. This allows opportunistic trading before information is fully reflected in prices, exploiting market inefficiencies for potential gains.

Backtest performance

Annualised return20.84%
Beta0
Sortino ratio-0.473
Win rate44%

Full Python code

import numpy as np
from AlgorithmImports import *
from collections import deque
from dateutil.relativedelta import relativedelta
from typing import List, Dict, Tuple
from numpy import isnan
class PostEarningsAnnouncementDriftFridayEveningAnnouncers(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
self.period:int = 13
self.leverage:int = 5
self.min_share_price:int = 2

# EPS quarterly data.
self.eps_data:Dict[Symbol, List[Tuple[float]]] = {}

market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
# Surprise data count needed to count standard deviation.
self.surprise_period:int = 4
self.earnings_surprise:Dict[Symbol, List[float]] = {}

# SUE history for previous year used for statistics.
self.sue_previous_year:List[float] = []
self.sue_actual_year:List[float] = []

# Trenching.
self.holding_period:int = 12
self.managed_queue:List[RebalanceQueueItem] = []
self.market_cap_threshold: float = 100_000_000

# Last fundamental stock price
self.last_price:Dict[Symbol, float] = {}

self.month:int = 12
self.fundamental_sorting_key = lambda x: x.DollarVolume
self.fundamental_count:int = 1000
self.selection_flag:bool = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.Schedule.On(self.DateRules.MonthEnd(market), self.TimeRules.AfterMarketOpen(market), self.Selection)
self.settings.daily_precise_end_time = False
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] = [
    x for x in fundamental if x.HasFundamentalData and x.Price > self.min_share_price and x.Market == 'usa' and not\
    isnan(x.EarningReports.BasicEPS.ThreeMonths) and (x.EarningReports.BasicEPS.ThreeMonths != 0) and x.MarketCap > self.market_cap_threshold
]
if len(selected) > self.fundamental_count:
    selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]

for stock in selected:
    self.last_price[stock.Symbol] = stock.AdjustedPrice

# Stocks with last month's earnings.
last_month_date:datetime = self.Time - timedelta(self.Time.day)
filered_fundamental:List[Symbol] = [x for x in selected if (x.EarningReports.FileDate.Value.year == last_month_date.year and x.EarningReports.FileDate.Value.month == last_month_date.month)]    

sue_data:Dict[Symbol, float] = {}

for stock in filered_fundamental:
    symbol:Symbol = stock.Symbol
    
    # Store eps data.
    if symbol not in self.eps_data:
        self.eps_data[symbol] = deque(maxlen = self.period)
    data:Tuple[float] = (stock.EarningReports.FileDate.Value.date(), stock.EarningReports.BasicEPS.ThreeMonths)
    # NOTE: Handles duplicate values. QC fundamental contains duplicated stocks in some cases.
    if data not in self.eps_data[symbol]:
        self.eps_data[symbol].append(data)
        
    if len(self.eps_data[symbol]) == self.eps_data[symbol].maxlen:
        recent_eps_data:Tuple[float] = self.eps_data[symbol][-1]
        
        year_range:range = range(self.Time.year - 3, self.Time.year)
        
        last_month_date:datetime = recent_eps_data[0] + relativedelta(months = -1)
        next_month_date:datetime = 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[Tuple[float]] = [x for x in self.eps_data[symbol] if x[0].month in month_range and x[0].year in year_range]
        if len(seasonal_eps_data) != 3: continue
        
        # Make sure we have a consecutive seasonal data. Same months with one year difference.
        year_diff:float = 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:List[float] = np.diff(seasonal_eps)
            drift:float = np.average(diff_values)
            
            # SUE calculation.
            last_earnings:Tuple[float] = seasonal_eps[-1]
            expected_earnings:Tuple[float] = last_earnings + drift
            actual_earnings:Tuple[float] = recent_eps_data[1]
            # Store sue value with earnigns date.
            earnings_surprise:Tuple[float] = actual_earnings - expected_earnings
            if symbol not in self.earnings_surprise:
                self.earnings_surprise[symbol] = deque()
            else:
                # Surprise data is ready.
                if 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
                    
                    # Store sue in this years's history of friday's earnings.
                    self.sue_actual_year.append(sue)
            
                    # Only stocks with last month's earnings on friday.
                    if stock.EarningReports.FileDate.Value.weekday() == 4:
                        sue_data[symbol] = sue
            
            self.earnings_surprise[symbol].append(earnings_surprise)

long:List[Symbol] = []
short:List[Symbol] = []

# Wait until we have history data for previous year.
if len(sue_data) != 0 and len(self.sue_previous_year) != 0: 
    positive_sue_values:List[float] = [x for x in self.sue_previous_year if x > 0]
    positive_sue_median:float = np.median(positive_sue_values)
    
    negative_sue_values:List[float] = [x for x in self.sue_previous_year if x <= 0]
    negative_sue_median:float = np.median(negative_sue_values)
    
    long = [x[0] for x in sue_data.items() if x[1] > 0 and x[1] >= positive_sue_median]
    short = [x[0] for x in sue_data.items() if x[1] <= 0 and x[1] <= negative_sue_median]
    
    long_symbol_q:List[Tuple[Symbol, float]] = []
    short_symbol_q:List[Tuple[Symbol, float]] = []
    if len(long) != 0:
        long_w:float = self.Portfolio.TotalPortfolioValue / self.holding_period / len(long)
        long_symbol_q = [(x, np.floor(long_w / self.last_price[x])) for x in long if self.last_price[x] != 0]
    
    if len(short) != 0:
        short_w:float = self.Portfolio.TotalPortfolioValue / self.holding_period / len(short)
        short_symbol_q = [(x, -np.floor(short_w / self.last_price[x])) for x in short if self.last_price[x] != 0]
    
    self.managed_queue.append(RebalanceQueueItem(long_symbol_q, short_symbol_q))                
    
return long + short

def OnData(self, data: Slice) -> None:
if not self.selection_flag:
    return
self.selection_flag = False
# Trade execution
remove_item:Union[None, RebalanceQueueItem] = None

# Rebalance portfolio
for item in self.managed_queue:
    if item.holding_period == self.holding_period:
        for symbol, quantity in item.long_symbol_q + item.short_symbol_q:
            if symbol in data and data[symbol]:
                if self.Securities[symbol].Price != 0 and self.Securities[symbol].IsTradable:
                    self.MarketOrder(symbol, -quantity)
                    
        remove_item = item
        
    elif item.holding_period == 0:
        open_long_symbol_q:List[Tuple[Symbol, float]] = []
        open_short_symbol_q:List[Tuple[Symbol, float]] = []
        
        for symbol, quantity in item.long_symbol_q + item.short_symbol_q:
            if symbol in data and data[symbol]:
                if self.Securities[symbol].Price != 0 and self.Securities[symbol].IsTradable:
                    self.MarketOrder(symbol, quantity)
                    open_long_symbol_q.append((symbol, quantity))
                    
        # Only opened orders will be closed        
        item.long_symbol_q = open_long_symbol_q
        item.short_symbol_q = open_short_symbol_q
        
    item.holding_period += 1
    
# We need to 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:
self.selection_flag = True

# Save yearly history.
if self.month == 12:
    self.sue_previous_year = list(self.sue_actual_year)
    self.sue_actual_year.clear()
self.month += 1
if self.month > 12:
    self.month = 1
class RebalanceQueueItem():
def __init__(self, long_symbol_q:List[Tuple[Symbol, float]], short_symbol_q:List[Tuple[Symbol, float]]):
# symbol/quantity collections
self.long_symbol_q:List[Tuple[Symbol, float]] = long_symbol_q  
self.short_symbol_q:List[Tuple[Symbol, float]] = short_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"))