The strategy involves calculating earnings response elasticity (ERE) for NYSE stocks, sorting by ERE quintiles, taking long positions in low ERE stocks with positive earnings surprises, and shorting high ERE stocks with negative surprises.

I. STRATEGY IN A NUTSHELL

The investment universe consists of NYSE stocks. Calculate the earnings response elasticity (ERE) for each stock, based on abnormal returns around earnings announcements divided by the earnings surprise. Stocks are sorted into quintiles by ERE. Long positions are taken in the bottom quintile when earnings surprises and abnormal returns are positive, and short positions are taken in the top quintile when both are negative. The stock is held until the next quarter, and the strategy is rebalanced daily to adjust for earnings announcements.

II. ECONOMIC RATIONALE

Exploits delayed market reactions to earnings: low-ERE stocks, often small and lightly covered by analysts, underreact to earnings news, generating abnormal returns as information is gradually incorporated.

III. SOURCE PAPER

Earnings Response Elasticity and Post-Earnings-Announcement Drift [Click to Open PDF]

Yan, Zhipeng; Zhao, Yan; Xu, Wei; Cheng, Lee-Young — Shanghai Jiao Tong University (SJTU) – Shanghai Advanced Institute of Finance (SAIF); City College – City University of New York; New Jersey Institute of Technology; National Chung Cheng University.

<Abstract>

This article studies the relationship between initial market response to earnings surprise and subsequent stock price movement. We first develop a new measure – the earnings response elasticity (ERE) – to capture initial market response. It is defined as the absolute value of earnings announcement abnormal returns (EAARs) divided by the earnings surprise. The ERE is then examined under various categories contingent on the signs of earnings surprises (+/-/0) and EAARs (+/-). We find that a weaker initial market reaction to earnings surprises, or lower ERE, leads to a larger post-announcement drift. A trading strategy of taking a long position in stocks in the lowest ERE quintile when both earnings surprises and EAARs are positive and a short position when both are negative can generate an average abnormal return of 5.11 percent per quarter.

IV. BACKTEST PERFORMANCE

Annualised Return8.5%
Volatility17.15%
Beta-0.573
Sharpe Ratio0.26
Sortino Ratio-0.458
Maximum DrawdownN/A
Win Rate44%

V. FULL PYTHON CODE

from AlgorithmImports import *
import numpy as np
from collections import deque
from pandas.tseries.offsets import BDay
from trade_manager import TradeManager
from typing import List, Dict, Deque
from pandas.core.frame import dataframe
from pandas.core.series import Series
#endregion
class EarningsResponseElasticity(QCAlgorithm):
    def Initialize(self) -> None:
        self.SetStartDate(2010, 1, 1)
        self.SetCash(100000)
        self.period: int = 13
        self.ear_period: int = 4
        self.surprise_period: int = 4
        self.holding_period: int = 60
        self.long_size: int = 50
        self.short_size: int = 50
        self.leverage: int = 5
        self.threshold: int = 3
        self.percentile: int = 20
        
        # market daily price data
        self.market_prices: Deque = deque(maxlen = self.ear_period)
        
        self.earnings_surprise: Dict[Symbol, float] = {}
        self.long: List[Symbol] = []
        self.short: List[Symbol] = []
        self.ere_history_previous: List[float] = []
        self.ere_history_actual: List[float] = []
        self.eps: Dict[Symbol, deque] = {} 
        self.earnings_data: Dict[datetime.date, Dict[str, float]] = {}
        
        # 50 equally weighted brackets for traded symbols
        self.trade_manager: trade_manager.TradeManager = TradeManager(self, self.long_size, self.short_size, self.holding_period)
        self.symbol: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        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.month: int = 12
        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.MonthEnd(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]:
        # 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
        selected: List[Fundamental] = [x for x in fundamental if x.Symbol.Value in tickers_with_yesterday_earnings]
        # ERE data
        ere_data: Dict[Symbol, List[float, float, float]] = {}
        
        for stock in selected:
            symbol: Symbol = stock.Symbol
            ticker: str = symbol.Value
            
            # Store eps data.
            if symbol not in self.eps:
                self.eps[symbol] = deque(maxlen = self.period)
            self.eps[symbol].append([yesterday, self.earnings_data[yesterday][ticker]])
            if len(self.eps[symbol]) == self.eps[symbol].maxlen:
                year_range: range = range(self.Time.year - 3, self.Time.year)
                month_range: List[int] = [self.Time.month-1, self.Time.month, self.Time.month+1]
                
                # earnings 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.threshold: continue
                recent_eps_data: List[datetime.date, float] = self.eps[symbol][-1]
                
                # 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)
                    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
                
                        # EAR calc
                        if len(self.market_prices) == self.market_prices.maxlen:
                            # abnormal return calc
                            history: dataframe = self.History(symbol, self.ear_period, Resolution.Daily)
                            if len(history) == self.ear_period and 'close' in history:
                                stock_closes: Series = history['close']
                                ear: float = Return(stock_closes) - Return(self.market_prices)
                                ere: float = abs(ear) / sue
                                ere_data[symbol] = [ere, ear, sue]
                                
                                # store ere data in this month's history
                                self.ere_history_actual.append(ere)
                    self.earnings_surprise[symbol].append(earnings_surprise)
                
        if len(ere_data) != 0 and len(self.ere_history_previous) != 0:
            # sort by ERE
            bottom_ere_quintile:float = np.percentile(self.ere_history_previous, self.percentile)
            self.long = [x[0] for x in ere_data.items() if x[1][0] <= bottom_ere_quintile and x[1][1] > 0 and x[1][2] > 0]
            self.short = [x[0] for x in ere_data.items() if x[1][0] <= bottom_ere_quintile and x[1][1] < 0 and x[1][2] < 0]
            
        return self.long + self.short
    def OnData(self, data: Slice) -> None:
        if self.symbol in data and data[self.symbol]:
            self.market_prices.append(data[self.symbol].Value)
        # open new trades
        for symbol in self.long:
            self.trade_manager.Add(symbol, True)
        for symbol in self.short:
            self.trade_manager.Add(symbol, False)
        
        self.trade_manager.TryLiquidate()
        
        self.long.clear()
        self.short.clear()
        
    def Selection(self) -> None:
        # every three months  
        if self.month % 3 == 0:
            # save previous history
            self.ere_history_previous = [x for x in self.ere_history_actual]
            self.ere_history_actual.clear()
        self.month += 1
        if self.month > 12:
            self.month = 1
# Custom fee model.
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
        fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))
def Return(values: np.ndarray) -> float:
    return (values[-1] - values[0]) / values[0]

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading