
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.
ASSET CLASS: stocks | REGION: United States | FREQUENCY:
Yearly | MARKET: equities | KEYWORD: Growth
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 Return | 11.01% |
| Volatility | 17.78% |
| Beta | -0.057 |
| Sharpe Ratio | 0.62 |
| Sortino Ratio | -0.027 |
| Maximum Drawdown | -49.81% |
| Win Rate | 53% |
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('.'))