ney-torres_1773283746.jpg

This is the third strategy in my series testing whether backtested strategies hold up over extended periods. This one comes from Zura Kakushadze's "151 Trading Strategies" book - specifically Strategy #1: Standardized Unexpected Earnings (SUE).

What This Strategy Does:

The SUE strategy exploits post-earnings announcement drift (PEAD). It calculates the change in quarterly EPS from its value four quarters ago, computes the standard deviation of this change over the prior eight quarters, and derives a SUE score by dividing the change by the standard deviation. Each month, it sorts the universe by SUE and goes long on the top 5% with the highest positive earnings surprises. Monthly rebalancing captures persistence in earnings momentum.

Based on the foundational research by Foster, Olsen and Shevlin (1984) and replicated by Hou, Xue and Zhang (2018).

5-Year Backtest (2021-2026) - "Focused Fluorescent Pink Falcon":
- Sharpe Ratio: 0.635
- PSR: 23.050%
- Sortino: 0.752
- CAGR: 23.576%
- Net Profit: 173.363%
- Max Drawdown: 28.9%
- Win Rate: 67%
- Total Orders: 225
- End Equity: $2,733,631 (from $1M)

18-Year Backtest (2007-2026) - "Maximum Fundamental Data Coverage":
- Sharpe Ratio: 0.442
- PSR: 0.495%
- Sortino: 0.485
- CAGR: 13.619%
- Net Profit: 1,009.312%
- Max Drawdown: 63.9%
- Win Rate: 65%
- Total Orders: 976
- End Equity: $11,093,124 (from $1M)

Key Observations:

1. Like the Drawdown Regime Gold Hedge, this strategy survives the extended test with meaningful returns. $1M to $11M over 18 years at 13.6% CAGR is solid.

2. The Sharpe dropped from 0.635 to 0.442 - less degradation than many strategies I have tested, suggesting some genuine alpha from PEAD.

3. However, the max drawdown jumped from 28.9% to a brutal 63.9%. The 2008-2009 crisis decimated the strategy - if you were running this live, you would have lost nearly two-thirds of your capital.

4. PSR dropped from 23.05% to 0.495%, again raising questions about statistical significance over the longer window.

5. QC flags this as "Likely Overfitting" with 23 parameters detected.

Lessons Learned:

- Academic anomalies like PEAD can generate real alpha, but surviving a financial crisis matters enormously
- A 63.9% drawdown would be psychologically devastating in live trading, even if the strategy eventually recovers
- The strategy needs risk management overlays (stop losses, position sizing limits) before it could be traded live
- Comparing 5-year vs 18-year results tells a much more honest story about what to expect

Full code below. Would love to hear ideas on reducing the drawdown while preserving the earnings momentum alpha.

# 151-1 Standardized Unexpected Earnings
# From 151 Trading Strategies book by Zura Kakushadze
# 
# Strategy: Uses standardized unexpected earnings (SUE) to identify stocks with significant earnings surprises.
# Calculates quarterly earnings changes, computes standard deviations, and derives SUE scores.
# Ranks stocks by SUE and takes long positions in the top 5% with highest positive surprises.
# Monthly rebalancing captures persistence in earnings momentum.
# Based on research showing post-earnings announcement drift.

#region imports
from AlgorithmImports import *

from universe import TopStandardizedUnexpectedEarningsUniverseSelectionModel
from alpha import MonthlyLongAlphaModel
#endregion


class StandardizedUnexpectedEarningsAlgorithm(QCAlgorithm):
    '''Step 1. Calculate the change in quarterly EPS from its value four quarters ago
       Step 2. Calculate the st dev of this change over the prior eight quarters
       Step 3. Get standardized unexpected earnings (SUE) from dividing results of step 1 by step 2
       Step 4. Each month, sort universe by SUE and long the top quantile
       
       Reference:
       [1] Foster, Olsen and Shevlin, 1984, Earnings Releases, Anomalies, and the Behavior of Security Returns,
           The Accounting Review. URL: https://www.jstor.org/stable/pdf/247321.pdf?casa_token=KHX3qwnGgTMAAAAA:
           ycgY-PzPfQ9uiYzVYeOF6yRDaNcRkL6IhLmRJuFpI6iWxsXJgB2BcM6ylmjy-g6xv-PYbXySJ_VxDpFETxw1PtKGUi1d91ce-h-V6CaL_SAAB56GZRQ
       [2] Hou, Xue and Zhang, 2018, Replicating Anomalies, Review of Financial Studies,
           URL: http://theinvestmentcapm.com/HouXueZhang2019RFS.pdf
    '''

    _undesired_symbols_from_previous_deployment = []
    _checked_symbols_from_previous_deployment = False

    def initialize(self):
        months_count = self.get_parameter("months_count", 36) # Number of months of rolling window object

        # Set backtest start date and warm-up period
        WARM_UP_FOR_LIVE_MODE = self.get_parameter("warm_up_for_live_mode", 1)
        MORNING_STAR_LIVE_MODE_HISTORY = timedelta(30) # US Fundamental Data by Morningstar is limited to the last 30 days
        if self.live_mode:
            self.set_warm_up(MORNING_STAR_LIVE_MODE_HISTORY)

        else: # Backtest mode
            now = datetime.now()
            self.set_end_date(now)
            if WARM_UP_FOR_LIVE_MODE: # Need to run a backtest before you can deploy this algorithm live
                # The universe selection model will quit 30-days before the current day
                self.set_start_date(now - MORNING_STAR_LIVE_MODE_HISTORY)
            else: # Regular backtest - using maximum available fundamental data
                self.set_start_date(2007, 1, 1)  # Extended to earliest available Morningstar fundamental data
            self.set_warm_up(timedelta(31 * (months_count+1)))
        self.set_cash(1_000_000)

        self.set_brokerage_model(BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.MARGIN)
        self.set_security_initializer(BrokerageModelSecurityInitializer(self.brokerage_model, FuncSecuritySeeder(self.get_last_known_prices)))
        self.settings.minimum_order_margin_portfolio_percentage = 0

        self.universe_settings.data_normalization_mode = DataNormalizationMode.RAW
        self.universe_settings.schedule.on(self.date_rules.month_start())
        self.add_universe_selection(TopStandardizedUnexpectedEarningsUniverseSelectionModel(
            self,
            self.universe_settings,
            self.get_parameter("coarse_size", 50),        # Number of stocks to return from Coarse universe selection
            self.get_parameter("top_percent", 0.05),      # Percentage of symbols selected based on SUE sorting
            self.get_parameter("months_eps_change", 12),  # Number of months of lag to calculate eps change
            months_count,
            WARM_UP_FOR_LIVE_MODE
        ))

        self.add_alpha(MonthlyLongAlphaModel())

        self.settings.rebalance_portfolio_on_security_changes = False
        self.settings.rebalance_portfolio_on_insight_changes = False
        self._month = -1
        self.set_portfolio_construction(EqualWeightingPortfolioConstructionModel(self._rebalance_func))

        self.add_risk_management(NullRiskManagementModel())

        self.set_execution(ImmediateExecutionModel())        

    def _rebalance_func(self, time):
        if self._month != self.time.month and not self.is_warming_up and self.current_slice.quote_bars.count > 0:
            self._month = self.time.month
            return time
        return None

    def on_data(self, data):
        # Exit positions that aren't backed by existing insights.
        # If you don't want this behavior, delete this method definition.
        if not self.is_warming_up and not self._checked_symbols_from_previous_deployment:
            for security_holding in self.portfolio.values():
                if not security_holding.invested:
                    continue
                symbol = security_holding.symbol
                if not self.insights.has_active_insights(symbol, self.utc_time):
                    self._undesired_symbols_from_previous_deployment.append(symbol)
            self._checked_symbols_from_previous_deployment = True
        
        for symbol in self._undesired_symbols_from_previous_deployment[:]:
            if self.is_market_open(symbol):
                self.liquidate(symbol, tag="Holding from previous deployment that's no longer desired")
                self._undesired_symbols_from_previous_deployment.remove(symbol)