Quant BuffetRelax, Not Over Thinking

Post-Earnings Announcement Drift Based on Price Signal Alone

Log in to collect

Academic paper

Post Earnings Announcement Drift, a Price Signal?

AuthorsJulien Messias

Institute
  • Université Paris Dauphine-PSL
  • Capital University
  • ?Quantology Capital Management
  • ?Université Paris Dauphine

Strategy in a nutshell

This strategy trades NASDAQ 100 stocks around earnings announcements. It goes long or short when a stock’s adjusted open-to-close move exceeds five times its 60-day historical volatility, holding positions for 40 days.

Economic rationale

Delayed reactions by hedge funds and equity analysts create inefficiencies, allowing the strategy to profit from slow adjustments in stock prices after significant earnings-related moves.

Backtest performance

Annualised return5.64%
Volatility4.03%
Beta-0.11
Sharpe ratio1.4
Sortino ratio-0.217
Maximum drawdown-7.79%
Win rate52%

Full Python code

from data_tools import CustomFeeModel, TradeManager, SymbolData
from AlgorithmImports import *
import numpy as np
from collections import deque
from typing import Dict, List, Set
from pandas.tseries.offsets import BDay
class PostEarningsAnnouncementDrift(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2010, 1, 1) # earnings data start in 2010
self.SetCash(100000)
self.period:int = 61
self.leverage:int = 5
self.long:List[Symbol] = []
self.short:List[Symbol] = []
self.long_count:int = 5
self.short_count:int = 5
self.holding_period:int = 40

# monthly selected universe
self.last_selection:List[Symbol] = []

self.data:Dict[Symbol, SymbolData] = {}
self.tickers:Set(str) = set()
# EPS quarterly data
self.eps:Dict[Symbol, deque] = {}
self.earnings_data:Dict[datetime.date, List[str]] = {} 
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] = []
    for stock_data in obj['stocks']:
        ticker:str = stock_data['ticker']
        self.earnings_data[date].append(ticker)
        self.tickers.add(ticker)

# equally weighted brackets for traded symbols
self.trade_manager:TradeManager = TradeManager(self, self.long_count, self.short_count, self.holding_period)

self.market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.fundamental_sorting_key = lambda x: x.DollarVolume
self.fundamental_count:int = 500
self.last_month:int = -1
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.settings.daily_precise_end_time = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.

def OnSecuritiesChanged(self, changes:SecurityChanges) -> None:
for security in changes.AddedSecurities:
    security.SetFeeModel(CustomFeeModel())
    security.SetLeverage(self.leverage)

for security in changes.RemovedSecurities:
    if security.Symbol in self.data:
        if security.Symbol != self.market:
            del self.data[security.Symbol]
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
if self.Time.month != self.last_month:
    self.last_month = self.Time.month

    # in fundamental always select whole universe (stocks, which are in QP earnings data),
    # because prices of each stock in this universe are updated in OnData (due to open price)
    selected:List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.Symbol.Value in self.tickers]
    if len(selected) > self.fundamental_count:
        selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
    self.last_selection = [x.Symbol for x in selected]
# warm up prices
for symbol in self.last_selection + [self.market]:
    if symbol in self.data:
        continue
    
    self.data[symbol] = SymbolData(self.period)
    history:DataFrame = self.History(symbol, self.period+1, Resolution.Daily)
    if history.empty:
        continue
    
    closes:Series = history.close
    opens:Series = history.open
    
    for (_, open_price), (_, close) in zip(opens.items(), closes.items()):
        self.data[symbol].update(close, open_price)

# market prices has to be ready
if self.market not in self.data or not self.data[self.market].is_ready(): 
    return self.last_selection
prev_business_day:datetime.date = (self.Time - BDay(1)).date()
if prev_business_day not in self.earnings_data:
    return self.last_selection
# filter stocks, which had earnings on prev business day
prev_bussiness_day_earnings:List[str] = self.earnings_data[prev_business_day]
selected_fundamental:List[Symbol] = [x for x in self.last_selection if x.Value in prev_bussiness_day_earnings]

market_intraday_returns:List[float] = [x if x != 0.0 else 1.0 for x in self.data[self.market].get_intraday_returns()]

for symbol in selected_fundamental:
    # symbol:Symbol = stock.Symbol
    if not self.data[symbol].is_ready():
        continue
    stock_intraday_returns:List[float] = self.data[symbol].get_intraday_returns()
    daily_moves:List[float] = [(stock_intrady_ret / market_intraday_ret) for stock_intrady_ret, market_intraday_ret \
        in zip(stock_intraday_returns, market_intraday_returns)]
        
    std:float = np.std(daily_moves)
    mean:float = np.mean(daily_moves)

    if daily_moves[0] > mean + 5 * std:
        self.long.append(symbol)
    elif daily_moves[0] < mean - 5 * std:
        self.short.append(symbol)

return self.last_selection
def OnData(self, data:Slice) -> None:
# update stock data for current universe
for symbol in self.last_selection + [self.market]:
    if symbol in self.data and symbol in data and data[symbol]:
        close:float = data[symbol].Close
        open:float = data[symbol].Open
        self.data[symbol].update(close, open)

self.trade_manager.TryLiquidate()
# open new trades
for symbol in self.long:
    if symbol in data and data[symbol]:
        self.trade_manager.Add(symbol, True)
for symbol in self.short:
    if symbol in data and data[symbol]:
        self.trade_manager.Add(symbol, False)
self.long.clear()
self.short.clear()