The strategy selects high-growth S&P 500 stocks based on growth and value metrics, weighted by adjusted growth scores, and rebalances annually to construct a pure growth portfolio.

I. STRATEGY IN A NUTSHELL

The strategy targets S&P 500 stocks with strong growth characteristics, measured via 3-year EPS change/price, 3-year sales growth, and 12-month momentum, while controlling for value using book-to-price, earnings-to-price, and sales-to-price ratios. Stocks are ranked for growth and value, and a pure growth portfolio is formed with those exceeding the index growth average by 0.25. Weights are based on adjusted growth scores, capped at 2, with annual rebalancing.

II. ECONOMIC RATIONALE

The strategy aims to capture above-average capital appreciation by focusing on high-growth stocks while avoiding overlap with value stocks. Diversification across multiple growth opportunities reduces idiosyncratic risk, ensuring the portfolio benefits from the growth factor while mitigating exposure to unrealized growth disappointments.

III. SOURCE PAPER

S&P U.S. Style Indices Methodology [Click to Open PDF]

S&P Dow Jones Indices

IV. BACKTEST PERFORMANCE

Annualised Return11.01%
Volatility17.78%
Beta-0.057
Sharpe Ratio0.62
Sortino Ratio-0.027
Maximum Drawdown-49.81%
Win Rate53%

V. FULL PYTHON CODE

import numpy as np
from AlgorithmImports import *
import data_tools
from pandas.core.frame import dataframe
from numpy import isnan
from functools import reduce
class PureGrowthStrategy(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        
        self.data = {} # storing data for factors in SymbolData object
        self.prices = {} # storing daily prices for stocks in StockPrices object
        
        self.long:List[Symbol] = []
        self.short:List[Symbol] = []
        self.last_selection:List[Symbol] = [] # list of stock symbols, which were selected in previous selection
        self.financial_statement_names:List[str] = [
            'FinancialStatements.IncomeStatement.TotalRevenue.TwelveMonths',
            'EarningReports.NormalizedBasicEPS.TwelveMonths',
            'EarningReports.BasicEPS.TwelveMonths',
            'ValuationRatios.SalesPerShare',
            'ValuationRatios.PBRatio',
        ]
        self.period:int = 12 * 21 # storing n of daily prices
        self.factor_period:int = 3 # storing n of needed data for each factor
        self.quantile:int = 10
        self.leverage:int = 5
        self.min_share_price:float = 5.
        
        market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        self.fundamental_count:int = 500
        self.fundamental_sorting_key = lambda x: x.DollarVolume
        self.months_counter:int = 0
        self.selection_flag:bool = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.Schedule.On(self.DateRules.MonthStart(market), self.TimeRules.BeforeMarketClose(market, 0), self.Selection)
        self.settings.daily_precise_end_time = False
    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]:
        # updating prices on daily basis
        for stock in fundamental:
            symbol:Symbol = stock.Symbol
            
            if symbol in self.prices:
                self.prices[symbol].update(stock.AdjustedPrice)
        
        # annual rebalance
        if not self.selection_flag:
            return Universe.Unchanged
        
        # selecting top n liquid stocks
        selected:List[Fundamental] = [
            x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.Price >= self.min_share_price and \
            all((not isnan(self.rgetattr(x, statement_name)) \
            and self.rgetattr(x, statement_name) != 0) for statement_name in self.financial_statement_names)
        ]
        
        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
        # dictionaries for factors
        ear_per_share_to_price = {}
        sales_per_share = {}
        book_value_to_price = {}
        earnings_to_price = {}
        sales_to_price = {}
        momentum = {}
        # warm up stock prices
        for stock in selected:
            symbol:Symbol = stock.Symbol
            
            if symbol not in self.prices:
                self.prices[symbol] = data_tools.StockPrices(self.period)
                history:dataframe = self.History(symbol, self.period, Resolution.Daily)
                if history.empty:
                    continue
                closes:pd.Series = history.loc[symbol].close
                for time, close in closes.items():
                    self.prices[symbol].update(close)
            
            if self.prices[symbol].is_ready():
                # check if data are consecutive
                if symbol not in self.last_selection:
                    self.data[symbol] = data_tools.SymbolData(self.factor_period)
                
                if symbol not in self.data:
                    continue
                
                # update data for each factor
                last_price = self.prices[symbol].last_price
                self.data[symbol].update(stock, last_price)
                
                # check if data for each factor are ready    
                if not self.data[symbol].is_ready():
                    continue
                
                # get symbol data object
                symbol_obj = self.data[symbol]
                
                # calculate and store each factor for current stock
                ear_per_share_to_price[symbol] = symbol_obj.calc_ear_per_share_to_price()
                sales_per_share[symbol] = symbol_obj.calc_sales_per_share()
                book_value_to_price[symbol] = symbol_obj.calc_book_value_to_price()
                earnings_to_price[symbol] = symbol_obj.calc_earnings_to_price()
                sales_to_price[symbol] = symbol_obj.calc_sales_to_price()
                momentum[symbol] = self.prices[symbol].momentum()
            
        # make sure data are consecutive    
        self.last_selection = [x.Symbol for x in selected]
        
        # can't perform decile selection after winsorization 
        if len(ear_per_share_to_price) < self.quantile:
            return Universe.Unchanged
        
        # perform winsonrization and standardization on each factor
        alt_ear_per_share_to_price = self.WinsorizeAndStandardize(ear_per_share_to_price)
        alt_sales_per_share = self.WinsorizeAndStandardize(sales_per_share)
        alt_book_value_to_price = self.WinsorizeAndStandardize(book_value_to_price)
        alt_earnings_to_price = self.WinsorizeAndStandardize(earnings_to_price)
        alt_sales_to_price = self.WinsorizeAndStandardize(sales_to_price)
        alt_momentum = self.WinsorizeAndStandardize(momentum)
        
        # create dictionary of stock symbols with their growth score based on their factor values
        growth_score = self.Score(alt_ear_per_share_to_price, alt_sales_per_share, alt_momentum) 
        
        # # create dictionary of stock symbols with thier value score based on their factor values
        # value_score = self.Score(alt_book_value_to_price, alt_earnings_to_price, alt_sales_to_price)
        
        # sort and perform decile selection on both dictionaries
        quantile:int = int(len(growth_score) / self.quantile)
        sorted_growth = [x[0] for x in sorted(growth_score.items(), key=lambda item: item[1])]
        # sorted_value = [x[0] for x in sorted(value_score.items(), key=lambda item: item[1])]
        
        # long stocks, which are in top growth decile
        self.long = [x for x in sorted_growth[-quantile:]]
        # short stocks, which are in bottom growth decile
        self.short = [x for x in sorted_growth[:quantile]]
                                    
        return self.long + self.short
        
    def OnData(self, data: Slice) -> None:
        if not self.selection_flag:
            return
        self.selection_flag = False
        
        # order execution
        targets:List[PortfolioTarget] = []
        for i, portfolio in enumerate([self.long, self.short]):
            for symbol in portfolio:
                if symbol in data and data[symbol]:
                    targets.append(PortfolioTarget(symbol, ((-1) ** i) / len(portfolio)))
        self.SetHoldings(targets, True)
        self.long.clear()
        self.short.clear()
        
    def WinsorizeAndStandardize(self, stock_dict):
        # check if dictionary can be divided into 5% segments
        if len(stock_dict) < 20:
            return stock_dict.copy()
            
        # copy of stock dictionary
        altered_dict = stock_dict.copy()
        # sort dictionary by value
        sorted_stocks = [x[0] for x in sorted(stock_dict.items(), key=lambda item: item[1])]
        # get index of first top 5% and last bottom 5% element
        index = int(len(sorted_stocks) / 20)
        # get top 5%
        top_five_percent = sorted_stocks[-index:]
        # get bottom %5
        bottom_five_percent = sorted_stocks[:index]
        
        # perform winsonrization on top 5%
        for symbol in top_five_percent:
            # change value of current symbol to value of first element before top 5% elements
            altered_dict[symbol] = altered_dict[sorted_stocks[-index]]
        
        # perform winsonrization on bottom 5%
        for symbol in bottom_five_percent:
            # change value of current symbol to value of fir element after bottom 5% elements
            altered_dict[symbol] = altered_dict[sorted_stocks[index]]
        
        # get mean and standard deviation of dictionary
        altered_dict_values = [x[1] for x in altered_dict.items()]
        mean = np.mean(altered_dict_values)
        std = np.std(altered_dict_values)
        
        # perform standardization
        for symbol, value in altered_dict.items():
            # standardize value
            new_value = (value - mean) / std
            # store standardized value
            altered_dict[symbol] = new_value
        
        # reutrn dictionary of stocks with their winsonrized and standardized values
        return altered_dict
        
    def Score(self, dict_1, dict_2, dict_3) -> float:
        score = {}
        
        # calculate score for each stock as mean of values in dictionaries
        for symbol in dict_1:
            # calculate score value
            score_value = np.mean([dict_1[symbol], dict_2[symbol], dict_3[symbol]])
            # store score value in score dictionary under stock symbol
            score[symbol] = score_value
            
        # return dictionary with score values and stock keys
        return score
        
    def Selection(self) -> None:
        # yearly selection
        if self.months_counter % 12 == 0:
            self.selection_flag = True
        self.months_counter += 1
    # https://gist.github.com/wonderbeyond/d293e7a2af1de4873f2d757edd580288
    def rgetattr(self, obj, attr, *args):
        def _getattr(obj, attr):
            return getattr(obj, attr, *args)
        return reduce(_getattr, [obj] + attr.split('.'))

Leave a Reply

Discover more from Quant Buffet

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

Continue reading