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.

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 Return37.18%
VolatilityN/A
Beta-0.01
Sharpe RatioN/A
Sortino Ratio-0.576
Maximum DrawdownN/A
Win Rate48%

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"))

Leave a Reply

Discover more from Quant Buffet

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

Continue reading