Quant BuffetRelax, Not Over Thinking

Momentum and Asset Growth Long-Short Strategy with January Exclusion

Log in to collect

Strategy 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

Annualised return16.77%
Volatility13.84%
Beta-0.146
Sharpe ratio-0.237
Sortino ratio-0.231
Maximum drawdown81.2%
Win rate50%

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"))