Momentum and Asset Growth Long-Short Strategy with January Exclusion
Log in to collectStrategy in a nutshell
The investment universe consists of NYSE, AMEX, and NASDAQ stocks (data for the backtest in the
Economic rationale
Academic research shows that the existing literature does not offer clear answers as to why firm investment, measured by asset growth, should be connected to return continuation. However, results from this academic study are highly statistically significant and based on a long data sample. The interaction between asset growth and momentum survives the inclusion of the number of control variables. Confidence in this combined strategy, therefore, could be high.
III. SOURCE PAPER
Firm Expansion and Stock Price Momentum [Click to Open PDF]
Peter M. Nyberg, Aalto University ; Salla Pöyry, Hanken School of Economics
We document a significant and robust connection between firm-level asset changes and return momentum. Momentum profits are large and significant for firms that have experienced large asset expansions or contractions, whereas they otherwise are small and often insignificant. The interaction pattern is not subsumed by previously documented drivers of momentum and shows up in market states where prior literature has documented an absence of momentum profits. Furthermore, we find a positive time-series relationship between aggregate asset growth and return momentum, and the effect of aggregate asset growth is stronger than that of variables related to business cycles and investor sentiment. While most existing models of firm investment and momentum cannot explain our results, recent real options models appear to hold the most promise.
Backtest performance
Full Python code
from AlgoLib import *
import numpy as np
from pandas.core.frame import DataFrame
from pandas.core.series import Series
class MomentumFactorAssetGrowthEffect(XXX):
def Initialize(self) -> None:
self.SetStartDate(2000, 1, 1)
self.SetCash(100_000)
self.UniverseSettings.Leverage = 5
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.0
self.exchange_codes: list[str] = ['NYS', 'NAS', 'ASE']
self.fundamental_count: int = 1_000
self.fundamental_sorting_key = lambda x: x.MarketCap
self.months_in_year: int = 12
self.days_in_month: int = 21
self.total_assets_history_period: int = 2
self.decile: int = 10
self.quintile: int = 5
self.excluded_month: int = 1
# Monthly close prices and total assets
self.symbol_data: dict[Symbol, SymbolData] = {}
self.long_symbols: dict[Symbol] = []
self.short_symbols: dict[Symbol] = []
self.selection_flag: bool = False
market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.Schedule.On(self.DateRules.MonthStart(market),
self.TimeRules.AfterMarketOpen(market),
self.Selection)
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
if not self.selection_flag:
return Universe.Unchanged
# Update the rolling window every month.
for security in fundamental:
if security.Symbol in self.symbol_data:
self.symbol_data[security.Symbol].update_price(security.AdjustedPrice)
filtered: list[Fundamental] = [f for f in fundamental if f.HasFundamentalData
and f.SecurityReference.ExchangeId in self.exchange_codes
and not np.isnan(f.FinancialStatements.BalanceSheet.TotalAssets.TwelveMonths)
and f.FinancialStatements.BalanceSheet.TotalAssets.TwelveMonths > 0]
sorted_filter: List[Fundamental] = sorted(filtered,
key=self.fundamental_sorting_key,
reverse=True)[:self.fundamental_count]
# Warmup price rolling windows.
for f in sorted_filter:
if f.Symbol in self.symbol_data:
continue
self.symbol_data[f.Symbol] = SymbolData(f.Symbol, self.months_in_year, self.total_assets_history_period)
history: DataFrame = self.History(f.Symbol, self.months_in_year * self.days_in_month, Resolution.Daily)
if history.empty:
self.Log(f"Not enough data for {f.Symbol} yet.")
continue
closes: Series = history.loc[f.Symbol].close
# Find monthly closes.
for index, time_close in enumerate(closes.iteritems()):
# index out of bounds check.
if index + 1 < len(closes.keys()):
date_month = time_close[0].date().month
next_date_month = closes.keys()[index + 1].month
# Find last day of month.
if date_month != next_date_month:
self.symbol_data[f.Symbol].update_price(time_close[1])
ready_securities: list[Fundamental] = [x for x in sorted_filter if self.symbol_data[x.Symbol].price_is_ready()]
# Asset growth calc.
asset_growth: dict[Symbol, float] = {}
for security in ready_securities:
if self.symbol_data[security.Symbol].asset_data_is_ready():
asset_growth[security.Symbol] = self.symbol_data[security.Symbol].asset_growth()
self.symbol_data[security.Symbol].update_assets(security.FinancialStatements.BalanceSheet.TotalAssets.TwelveMonths)
sorted_by_growth: list[tuple[Symbol, float]] = sorted(asset_growth.items(), key=lambda x: x[1], reverse=True)
decile: int = int(len(sorted_by_growth) / self.decile)
top_by_growth: list[Symbol] = [x[0] for x in sorted_by_growth][:decile]
performance: dict[Symbol, float] = {x: self.symbol_data[x].performance() for x in top_by_growth}
sorted_by_performance: list[tuple[Symbol, float]] = sorted(performance.items(), key=lambda x: x[1], reverse=True)
quintile = int(len(sorted_by_performance) / self.quintile)
self.long_symbols = [x[0] for x in sorted_by_performance][:quintile]
self.short_symbols = [x[0] for x in sorted_by_performance][-quintile:]
return self.long_symbols + self.short_symbols
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
security.SetFeeModel(CustomFeeModel())
def OnData(self, slice: Slice) -> None:
if not self.selection_flag:
return
self.selection_flag = False
# Trade execution.
targets: List[PortfolioTarget] = []
for i, portfolio in enumerate([self.long_symbols, self.short_symbols]):
for symbol in portfolio:
if slice.ContainsKey(symbol) and slice[symbol] is not None:
targets.append(PortfolioTarget(symbol, ((-1) ** i) / len(portfolio)))
self.SetHoldings(targets, True)
self.long_symbols.clear()
self.short_symbols.clear()
def Selection(self) -> None:
# Exclude January trading.
if self.Time.month != self.excluded_month:
self.selection_flag = True
else:
self.Liquidate()
class SymbolData():
def __init__(self, symbol: Symbol, period: int, total_assets_history_period: int) -> None:
self.Symbol: Symbol = symbol
self.Price: RollingWindow = RollingWindow[float](period)
self.TotalAssets: RollingWindow = RollingWindow[float](total_assets_history_period)
def update_price(self, value) -> None:
self.Price.Add(value)
def update_assets(self, assets_value) -> None:
self.TotalAssets.Add(assets_value)
def asset_data_is_ready(self) -> bool:
return self.TotalAssets.IsReady
def asset_growth(self) -> float:
asset_values: list[float] = [x for x in self.TotalAssets]
return (asset_values[0] - asset_values[1]) / asset_values[1]
def price_is_ready(self) -> bool:
return self.Price.IsReady
# Performance, one month skipped.
def performance(self, values_to_skip: int = 1) -> float:
closes: list[float] = [x for x in self.Price][values_to_skip:]
return (closes[0] / closes[-1] - 1)
# 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"))