The strategy ranks NYSE, AMEX, and NASDAQ stocks by SUE, going long on top-decile positive surprises, short on bottom-decile, with a flexible 60-180 day holding period.

I. STRATEGY IN A NUTSHELL

Universe: NYSE, AMEX, NASDAQ stocks ($1+). Compute Standardized Unexpected Earnings (SUE) over past 12 quarters. Long top decile, short bottom decile based on SUE. Holding period: 60–180 days.

II. ECONOMIC RATIONALE

Investors misprice earnings surprises due to behavioral biases, underestimating conditional probabilities of profit/loss. This creates post-earnings drift, with positive drift after profits and negative after losses, especially for extreme surprises.

III. SOURCE PAPER

Post Loss/Profit Announcement Drift [Click to Open PDF]

Karthik Balakrishnan, Eli Bartov and Lucile Faurel.Rice University – Jesse H. Jones Graduate School of Busines.NYU Stern School of Business.Arizona State University

<Abstract>

We document a market failure to fully respond to loss/profit quarterly announcements. The annualized post portfolio formation return spread between two portfolios formed on extreme losses and extreme profits is approximately 21 percent. This loss/profit anomaly is incremental to previously documented accounting-related anomalies, and is robust to alternative risk adjustments, distress risk, firm size, short sales constraints, transaction costs, and sample periods. In an effort to explain this finding, we show that this mispricing is related to differences between conditional and unconditional probabilities of losses/profits, as if stock prices do not fully reflect conditional probabilities in a timely fashion.

IV. BACKTEST PERFORMANCE

Annualised Return26%
Volatility5%
Beta0.005
Sharpe Ratio4.4
Sortino Ratio-0.041
Maximum DrawdownN/A
Win Rate53%

V. FULL PYTHON CODE

import numpy as np
from AlgorithmImports import *
import data_tools
from collections import deque
from pandas.tseries.offsets import BDay
from dateutil.relativedelta import relativedelta
from typing import Dict, List, Deque
from numpy import isnan
class SwitchingBetweenValueMomentum(QCAlgorithm):
    def Initialize(self) -> None:
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100_000)
        self.period: int = 13
        self.eps_data: Dict[Symbol, Deque[datetime.date]] = {} # EPS quarterly data
        
        self.coarse_count: int = 500
        self.leverage: int = 5
        self.min_share_price: int = 5
        self.symbol: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        
        self.long: List[Symbol] = []
        self.short: List[Symbol] = []
        
        self.last_selected: List[str] = []     # This year's selected stocks.
        
        # Surprise data count needed to count standard deviation.
        self.surprise_period: int = 4
        self.earnings_surprise: Dict[Symbol, float] = {}
        # SUE history for previous quarter used for statistics.
        self.sue_history_previous: Deque[float] = deque()
        self.sue_history_actual: Deque[float] = deque()
        
        # 50 equally weighted brackets for traded symbols.
        self.trade_manager: data_tools.TradeManager = data_tools.TradeManager(self, 15, 15, 60)
        
        self.month: int = 12
        self.selection_flag: bool = False
        self.rebalance_flag: bool = False
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.settings.daily_precise_end_time = False
        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(data_tools.CustomFeeModel())
            security.SetLeverage(self.leverage)
    def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
        # At the begining of the year pick whole new set of stocks.
        if self.selection_flag: 
            self.selection_flag = False
            selected: List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Price > self.min_share_price and x.Market == 'usa' and not isnan(x.EarningReports.BasicEPS.ThreeMonths) and (x.EarningReports.BasicEPS.ThreeMonths != 0)]
            if len(selected) > self.coarse_count:
                selected = sorted(selected, key=lambda x: x.DollarVolume, reverse=True)[:self.coarse_count]
            
            self.last_selected = [x.Symbol.Value for x in selected]
        # During the year, filter just already picked stocks.
        # Stock with yesterdays earnings.
        filtered_fundamental: List[Fundamental] = [x for x in fundamental if x.Symbol.Value in self.last_selected and x.EarningReports.FileDate.Value.year != 1  and (self.Time.date() == (x.EarningReports.FileDate.Value + BDay(1)).date())]      
        symbol_sue: Dict[Symbol, float] = {}     # SUE for this day.
        
        for stock in filtered_fundamental:
            symbol: Symbol = stock.Symbol
            
            # Store eps data.
            if symbol not in self.eps_data:
                self.eps_data[symbol] = deque(maxlen = self.period)
            data: Tuple[float] = (stock.EarningReports.FileDate.Value.date(), stock.EarningReports.BasicEPS.ThreeMonths)
            # NOTE: Handles duplicate values. QC fine contains duplicated stocks in some cases.
            if data not in self.eps_data[symbol]:
                self.eps_data[symbol].append(data)
                
            if len(self.eps_data[symbol]) == self.eps_data[symbol].maxlen:
                recent_eps_data = self.eps_data[symbol][-1]
                
                year_range: List[int] = range(self.Time.year - 3, self.Time.year)
                
                last_month_date: datetime.date = recent_eps_data[0] + relativedelta(months = -1)
                next_month_date: datetime.date = recent_eps_data[0] + relativedelta(months = 1)
                month_range: List[datetime.date] = [last_month_date.month, recent_eps_data[0].month, next_month_date.month]
                # Earnings with todays month number 4 years back.
                seasonal_eps_data: List[float] = [x for x in self.eps_data[symbol] if x[0].month in month_range and x[0].year in year_range]
                if len(seasonal_eps_data) != 3: continue
                
                # 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)
                    else:
                        # Surprise data is ready.
                        if 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
                            symbol_sue[symbol] = sue
                            
                            self.sue_history_actual.append(sue)
                    
                    self.earnings_surprise[symbol].append(earnings_surprise)
                    
        if len(symbol_sue) != 0:
            # Wait until we have history data for previous three months.
            if len(self.sue_history_previous) != 0:
                # Sort by SUE.
                sue_values: List[float] = [x for x in self.sue_history_previous]
                top_sue_decile: float  = np.percentile(sue_values, 90)
                bottom_sue_decile: float = np.percentile(sue_values, 10)
            
                self.long = [x[0] for x in symbol_sue.items() if x[1] >= top_sue_decile]
                self.short = [x[0] for x in symbol_sue.items() if x[1] <= bottom_sue_decile]
        self.rebalance_flag = True
        
        # Return symbols
        return self.long + self.short
        
    def Selection(self) -> None:
        self.selection_flag = True
        # Every three months.    
        if self.month % 3 == 0:
            # Save previous month history.
            self.sue_history_previous = [x for x in self.sue_history_actual]
            self.sue_history_actual.clear()
        self.month += 1
        if self.month > 12:
            self.month = 1
            
    def OnData(self, data: Slice) -> None:
        if not self.rebalance_flag:
            return
        self.rebalance_flag = False
        
        # Open new trades.
        for i, portfolio in enumerate([self.short, self.long]):
            for symbol in portfolio:
                if symbol in data and data[symbol]:
                    self.trade_manager.Add(symbol, bool(i))
                
        self.trade_manager.TryLiquidate()
        
        self.long.clear()
        self.short.clear()

Leave a Reply

Discover more from Quant Buffet

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

Continue reading