Quant BuffetRelax, Not Over Thinking

Post-Loss/Profit Announcement Drift in Stocks

Log in to collect

Academic paper

Post Loss/Profit Announcement Drift

AuthorsKarthik Balakrishnan; Eli Bartov; Lucile Faurel

Institute
  • Jones College
  • Rice University
  • ?Rice University - Jesse H. Jones Graduate School of Business
  • ?NYU Stern School of Business
  • Arizona State University

Strategy in a nutshell

Universe: NYSE, AMEX, NASDAQ stocks ($1+). Compute Standardized Unexpected Earnings (SUE) over past 12 quarters. Long top decile, short bottom decile based on SUE. Holding period: 60–180 days.

Economic rationale

Investors misprice earnings surprises due to behavioral biases, underestimating conditional probabilities of profit/loss. This creates post-earnings drift, with positive drift after profits and negative after losses, especially for extreme surprises.

Backtest performance

Annualised return26%
Volatility5%
Beta0.005
Sharpe ratio4.4
Sortino ratio-0.041
Win rate53%

Full Python code

import numpy as np
from AlgorithmImports import *
import data_tools
from collections import deque
from pandas.tseries.offsets import BDay
from dateutil.relativedelta import relativedelta
from typing import Dict, List, Deque
from numpy import isnan
class SwitchingBetweenValueMomentum(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2000, 1, 1)
self.SetCash(100_000)
self.period: int = 13
self.eps_data: Dict[Symbol, Deque[datetime.date]] = {} # EPS quarterly data

self.coarse_count: int = 500
self.leverage: int = 5
self.min_share_price: int = 5
self.symbol: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol

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

self.last_selected: List[str] = []     # This year's selected stocks.

# Surprise data count needed to count standard deviation.
self.surprise_period: int = 4
self.earnings_surprise: Dict[Symbol, float] = {}
# SUE history for previous quarter used for statistics.
self.sue_history_previous: Deque[float] = deque()
self.sue_history_actual: Deque[float] = deque()

# 50 equally weighted brackets for traded symbols.
self.trade_manager: data_tools.TradeManager = data_tools.TradeManager(self, 15, 15, 60)

self.month: int = 12
self.selection_flag: bool = False
self.rebalance_flag: bool = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.settings.daily_precise_end_time = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)

self.Schedule.On(self.DateRules.MonthEnd(self.symbol), self.TimeRules.AfterMarketOpen(self.symbol), self.Selection)
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
    security.SetFeeModel(data_tools.CustomFeeModel())
    security.SetLeverage(self.leverage)
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
# At the begining of the year pick whole new set of stocks.
if self.selection_flag: 
    self.selection_flag = False
    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)]
    if len(selected) > self.coarse_count:
        selected = sorted(selected, key=lambda x: x.DollarVolume, reverse=True)[:self.coarse_count]
    
    self.last_selected = [x.Symbol.Value for x in selected]
# During the year, filter just already picked stocks.
# Stock with yesterdays earnings.
filtered_fundamental: List[Fundamental] = [x for x in fundamental if x.Symbol.Value in self.last_selected and x.EarningReports.FileDate.Value.year != 1  and (self.Time.date() == (x.EarningReports.FileDate.Value + BDay(1)).date())]      
symbol_sue: Dict[Symbol, float] = {}     # SUE for this day.

for stock in filtered_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 fine 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 = self.eps_data[symbol][-1]
        
        year_range: List[int] = 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[datetime.date] = [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[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:np.ndarray = 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.ndarray = 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)
            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
                    symbol_sue[symbol] = sue
                    
                    self.sue_history_actual.append(sue)
            
            self.earnings_surprise[symbol].append(earnings_surprise)
            
if len(symbol_sue) != 0:
    # Wait until we have history data for previous three months.
    if len(self.sue_history_previous) != 0:
        # Sort by SUE.
        sue_values: List[float] = [x for x in self.sue_history_previous]
        top_sue_decile: float  = np.percentile(sue_values, 90)
        bottom_sue_decile: float = np.percentile(sue_values, 10)
    
        self.long = [x[0] for x in symbol_sue.items() if x[1] >= top_sue_decile]
        self.short = [x[0] for x in symbol_sue.items() if x[1] <= bottom_sue_decile]
self.rebalance_flag = True

# Return symbols
return self.long + self.short

def Selection(self) -> None:
self.selection_flag = True
# Every three months.    
if self.month % 3 == 0:
    # Save previous month history.
    self.sue_history_previous = [x for x in self.sue_history_actual]
    self.sue_history_actual.clear()
self.month += 1
if self.month > 12:
    self.month = 1
    
def OnData(self, data: Slice) -> None:
if not self.rebalance_flag:
    return
self.rebalance_flag = False

# Open new trades.
for i, portfolio in enumerate([self.short, self.long]):
    for symbol in portfolio:
        if symbol in data and data[symbol]:
            self.trade_manager.Add(symbol, bool(i))
        
self.trade_manager.TryLiquidate()

self.long.clear()
self.short.clear()