
The strategy invests in stocks based on predicted seasonality, using past earnings data to rank firms. The investor goes long on top-ranked stocks and short on low-ranked ones, rebalancing daily.
ASSET CLASS: stocks | REGION: United States | FREQUENCY:
Daily | MARKET: equities | KEYWORD: Earnings Announcement, Seasonality Effect, Equities
I. STRATEGY IN A NUTSHELL
This strategy trades common NYSE, AMEX, and NASDAQ stocks (excluding those under $5 or missing data) around earnings announcements. Firms are ranked based on earnings seasonality using five years of prior-quarter data. The investor goes long on the top 5% earners and short on the bottom 5%, forming portfolios only when each leg has at least ten firms. Positions are opened the day before the earnings announcement and closed the day after, with equal weighting and daily rebalancing. The strategy exploits predictable seasonal patterns in earnings performance.
II. ECONOMIC RATIONALE
The approach leverages the recency effect, where investors overweight recent earnings relative to past data. This causes market participants to underestimate firms with strong historical earnings, creating an increased likelihood of positive surprises. The strategy profits from these predictable overreactions around earnings announcements.
III. SOURCE PAPER
Being Surprised by the Unsurprising: Earnings Seasonality and Stock Returns[Click to Open PDF]
Tom Y. Chang, University of Southern California – Marshall School of Business – Finance and Business Economics Department; Samuel M. Hartzmark, Boston College – Carroll School of Management; David H. Solomon, Boston College – Carroll School of Management; Eugene F. Soltes, Harvard University – Business School (HBS)
<Abstract>
We present evidence consistent with markets failing to properly price information in seasonal earnings patterns. Firms with historically larger earnings in one quarter of the year (“positive seasonality quarters”) have higher returns when those earnings are usually announced. Analysts have more positive forecast errors in positive seasonality quarters, consistent with the returns being driven by mistaken earnings estimates. We show that investors appear to overweight recent lower earnings following positive seasonality quarters, leading to pessimistic forecasts in the subsequent positive seasonality quarter. The returns are not explained by risk-based explanations, firm-specific information, increased volume, or idiosyncratic volatility.


IV. BACKTEST PERFORMANCE
| Annualised Return | 37.18% |
| Volatility | N/A |
| Beta | -0.01 |
| Sharpe Ratio | N/A |
| Sortino Ratio | -0.576 |
| Maximum Drawdown | N/A |
| Win Rate | 48% |
V. FULL PYTHON CODE
from AlgorithmImports import *
from data_tools import TradeManager
import numpy as np
from collections import deque
from pandas.tseries.offsets import BDay
from dateutil.relativedelta import relativedelta
from typing import List, Dict
#endregion
class EarningsAnnouncementSeasonalityEffectinEquities(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2010, 1, 1)
self.SetCash(100_000)
self.period: int = 20
self.long_symbols: int = 10
self.short_symbols: int = 10
self.holding_period: int = 4
self.leverage: int = 5
self.quantile: int = 20
self.prev_month: int = -1
self.prev_month_year: int = -1
symbol: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.long: List[Symbol] = []
self.short: List[Symbol] = []
self.eps: Dict[Symbol, deque] = {}
self.earnings: Dict[datetime.date, List[str]] = {}
self.eps_data: Dict[int, Dict[int, Dict[str, Dict[datetime.date, float]]]] = {}
# parse earnings dataset - Source: https://www.nasdaq.com/market-activity/earnings
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[date] = []
for stock_data in obj['stocks']:
ticker: str = stock_data['ticker']
self.earnings[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'])
# equally weighted brackets for traded symbols
# hodling period 3 days + 1 day due to QC midnight offset (00:00 trading)
self.trade_manager: TradeManager = TradeManager(self, self.long_symbols, self.short_symbols, self.holding_period)
self.selection_flag: bool = False
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.MonthStart(symbol), self.TimeRules.AfterMarketOpen(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]:
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 stocks, which has earning 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: List[Fundamental] = [x for x in fundamental if x.Symbol.Value in stocks_with_prev_month_eps]
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)
# 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]
# store stock's latest eps date in previous month and it's eps value
data_eps: List[datetime.date, float] = [stock_latest_eps_date, stock_prev_month_eps[stock_latest_eps_date]]
self.eps[symbol].append(data_eps)
# Earn rank calc
earn_rank: Dict[Symbol, float] = {}
for symbol in self.eps:
if len(self.eps[symbol]) != self.eps[symbol].maxlen:
continue
relevant_earnings_dates:List[datetime.date] = [self.eps[symbol][-4][0], self.eps[symbol][-8][0], \
self.eps[symbol][-12][0] ,self.eps[symbol][-16][0], self.eps[symbol][-20][0]]
# stock with potencial upcoming month earnings
eps_months: List[int] = [x.month for x in relevant_earnings_dates]
if self.Time.month in eps_months:
# rank the 20 quarters of earnings data from largest to smallest
ranked_earnings: List[Tuple[datetime.date, float]] = [i for i in sorted(list(self.eps[symbol]), key=lambda x: x[1], reverse=True)]
ranks: List[int] = [ranked_earnings.index(earnings_data)+1 for earnings_data in ranked_earnings if earnings_data[0] in relevant_earnings_dates]
earn_rank[symbol] = np.mean(ranks)
if len(earn_rank) < self.quantile:
return Universe.Unchanged
sorted_by_earn_rank: List[Tuple[Symbol, float]] = sorted(earn_rank.items(), key=lambda x: x[1], reverse=True)
quantile: int = int(len(sorted_by_earn_rank) / self.quantile)
# symbols to trade this month
self.long = [x[0] for x in sorted_by_earn_rank[:quantile]]
self.short = [x[0] for x in sorted_by_earn_rank[-quantile:]]
return self.long + self.short
def OnData(self, data: Slice) -> None:
earnings_date: datetime.date = (self.Time + BDay(1)).date()
if earnings_date in self.earnings:
for symbol in self.long + self.short:
if symbol not in data or not data[symbol]:
continue
ticker:str = symbol.Value
# symbol has earnings in 1 day
if ticker not in self.earnings[earnings_date]:
continue
if symbol in self.long:
self.trade_manager.Add(symbol, True)
else:
self.trade_manager.Add(symbol, False)
self.trade_manager.TryLiquidate()
def Selection(self) -> None:
self.long.clear()
self.short.clear()
self.selection_flag = True
# 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"))