
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.
ASSET CLASS: stocks | REGION: United States | FREQUENCY:
Daily | MARKET: equities | KEYWORD: Earnings, Elasticity
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 Return | 8.5% |
| Volatility | 17.15% |
| Beta | -0.573 |
| Sharpe Ratio | 0.26 |
| Sortino Ratio | -0.458 |
| Maximum Drawdown | N/A |
| Win Rate | 44% |
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]