Reversal in Post-Earnings Announcement Drift
Log in to collectAcademic paper
Strategy in a nutshell
The investment universe consists of all stocks from NYSE, AMEX, and NASDAQ with active options market (so mostly large-cap stocks). Each day investor selects stocks which would have earnings announcement during the next working day. He then checks the abnormal performance of these stocks during the previous earnings announcement. Investor goes long decile of stocks with the lowest abnormal past earnings announcement performance and goes short stocks with the highest abnormal past performance. Stocks are held for two days, and the portfolio is weighted equally.
Economic rationale
The academic paper speculates that it seems that due to their well-documented history of apparently underreacting to earnings news, investors are now overreacting to earnings announcement news. However, classical PEAD (post-earnings announcement drift) literature examines mainly quarterly portfolio returns while this academic paper focuses on 2-days return; therefore, it is probable that PEAD still holds and both anomalies exist concurrently.
Backtest performance
Full Python code
from data_tools import SymbolData, CustomFeeModel, TradeManager
from AlgoLib import *
import numpy as np
from collections import deque
from typing import Dict, List
from pandas.core.frame import DataFrame
from pandas.tseries.offsets import BDay
from dateutil.relativedelta import relativedelta
class ReversalPostEarningsAnnouncementDrift(XXX):
def Initialize(self) -> None:
self.SetStartDate(2009, 1, 1) # earnings dates starts in 2010
self.SetCash(100_000)
self.long_symbols: int = 10
self.short_symbols: int = 10
self.holding_period: int = 2
self.lookback_period: int = 3
self.leverage: int = 5
self.ear_period: int = 30
self.prev_month_year: int = -1
self.prev_month: int = -1
self.percentiles: List[int] = [10, 90]
self.data: Dict[Symbol, SymbolData] = {}
# EAR last quarter data
self.ear_data: Dict[Symbol, List[datetime.date, float]] = {}
self.earnings_data: Dict[datetime.date, List[str]] = {}
self.eps_data: Dict[int, Dict[int, Dict[str, Dict[datetime.date, float]]]] = {}
self.first_date: Union[datetime.date, None] = None
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()
year: int = date.year
month: int = date.month
self.earnings_data[date] = []
if not self.first_date: self.first_date = date
for stock_data in obj['stocks']:
ticker: str = stock_data['ticker']
self.earnings_data[date].append(ticker)
if stock_data['eps'] == '':
continue
if year not in self.eps_data:
self.eps_data[year] = {}
if month not in self.eps_data[year]:
self.eps_data[year][month] = {}
if ticker not in self.eps_data[year][month]:
self.eps_data[year][month][ticker] = {}
self.eps_data[year][month][ticker][date] = float(stock_data['eps'])
# EAR quarters history
self.current_quarter_ears: List[float] = []
self.previous_quarter_ears: List[float] = []
# equally weighted brackets for traded symbols - 10 symbols long and short, 2 days of holding
self.trade_manager: TradeManager = TradeManager(self, self.long_symbols, self.short_symbols, self.holding_period)
self.symbol: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.selection_flag: bool = False
self.store_sales_data_flag: bool = True
self.sales_growth_sort_flag: bool = 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]:
# update daily prices
for stock in fundamental:
symbol: Symbol = stock.Symbol
if symbol in self.data:
self.data[symbol].update(self.Time, stock.AdjustedPrice)
# monthly selection
if not self.selection_flag:
return Universe.Unchanged
self.selection_flag = False
prev_month_date: datetime.date = (self.Time - relativedelta(months=1)).date()
self.prev_month_year: int = prev_month_date.year
self.prev_month: int = prev_month_date.month
if self.prev_month_year not in self.eps_data or self.prev_month not in self.eps_data[self.prev_month_year]:
return Universe.Unchanged
# select every stock, which had earnings in previous month
stocks_with_prev_month_eps: Dict[str, Dict[datetime.date, float]] = self.eps_data[self.prev_month_year][self.prev_month]
selected_symbols: List[Symbol] = [x.Symbol for x in fundamental if x.Symbol.Value in stocks_with_prev_month_eps]
for symbol in selected_symbols + [self.symbol]:
if symbol not in self.data:
# warm up stock prices
self.data[symbol] = SymbolData(self.ear_period)
history: DataFrame = self.History(symbol, self.ear_period, Resolution.Daily)
if history.empty:
continue
closes: Series = history.loc[symbol].close
for time, close in closes.iteritems():
self.data[symbol].update(self.Time, close)
for symbol in selected_symbols:
if not self.data[symbol].is_ready():
continue
ticker: str = symbol.Value
# get all stock's eps from previous month
stock_prev_month_eps: Dict[datetime.date, float] = self.eps_data[self.prev_month_year][self.prev_month][ticker]
# get the date of the latest eps in previous month
stock_latest_eps_date: datetime.date = list(stock_prev_month_eps.keys())[-1]
# get 4 days around earnings and calculate EAR
date_from: datetime = stock_latest_eps_date - BDay(2)
date_to: datetime = stock_latest_eps_date + BDay(1)
market_return: float = self.data[self.symbol].get_prices([date_from, date_to])
stock_return: float = self.data[symbol].get_prices([date_from, date_to])
# check if returns are ready
if market_return and stock_return:
ear: float = stock_return - market_return
ear_data: List[datetime.date] = (stock_latest_eps_date, ear)
self.ear_data[symbol] = ear_data
# store ear in this month's history
self.current_quarter_ears.append(ear)
# check if there are any symbols, which can be traded
if len(self.ear_data) == 0:
return Universe.Unchanged
# return symbols from self.ear_data, because they will be traded
return list(self.ear_data.keys())
def OnData(self, data: Slice) -> None:
# open trades on earnings day
date_to_lookup: datetime.date = self.Time.date()
# if there is no earnings data yet
if date_to_lookup < self.first_date:
return
# liquidate opened symbols after holding period
self.trade_manager.TryLiquidate()
# wait until we have history data for previous three months
if len(self.previous_quarter_ears) == 0:
return
ear_values: List[float] = [x for x in self.previous_quarter_ears]
top_ear_decile: float = np.percentile(ear_values, self.percentiles[1])
bottom_ear_decile: float = np.percentile(ear_values, self.percentiles[0])
# Open new trades.
if date_to_lookup in self.earnings_data:
symbols_to_trade: List[Symbol] = [symbol for symbol in self.ear_data if symbol.Value in self.earnings_data[date_to_lookup]]
symbols_to_delete: List[Symbol] = []
for symbol in symbols_to_trade:
# last earnings was less than three months ago
last_earnings_date: datetime.date = self.ear_data[symbol][0]
if last_earnings_date >= (self.Time - relativedelta(months=self.lookback_period)).date():
if symbol in data and data[symbol]:
if self.ear_data[symbol][1] >= top_ear_decile:
self.trade_manager.Add(symbol, True)
symbols_to_delete.append(symbol)
elif self.ear_data[symbol][1] <= bottom_ear_decile:
self.trade_manager.Add(symbol, False)
symbols_to_delete.append(symbol)
# delete already traded symbols from symbol to trade
for symbol in symbols_to_delete:
del self.ear_data[symbol]
def Selection(self) -> None:
self.selection_flag = True
if self.Time.month % 3 == 0:
# store previous quarter's history
self.previous_quarter_ears = [x for x in self.current_quarter_ears]
self.current_quarter_ears.clear()