Quant BuffetRelax, Not Over Thinking

Quarterly Investment Spikes Predict Stock Returns

Log in to collect

Academic paper

Quarterly Investment Spikes, Stock Returns and the Investment Factor

AuthorsMichela Altieri; Jan Schnitzler

Institute
  • ITLibera Università Internazionale degli Studi Sociali Guido Carli
  • ?Luiss Guido Carli
  • Grenoble Ecole de Management

Strategy in a nutshell

Universe: US common stocks (CRSP, share codes 10 & 11). Exclude small or irregular reporters. Compute Q4-spike (qspike) = Q4 capex ÷ average of prior 3 quarters. Each month, sort firms by qspike (from 6 months prior) into Low, Mid, High groups. Long Low, short High (value-weighted, monthly rebalanced).

Economic rationale

High Q4 investment spikes signal agency-driven overinvestment, as managers rush to exhaust budgets, leading to future underperformance. Low qspike firms avoid such inefficiencies, producing superior returns. The pattern reflects agency conflict and capital misallocation effects.

Backtest performance

Annualised return3.91%
Volatility7.91%
Beta-0.186
Sharpe ratio0.49
Sortino ratio0.161
Win rate42%

Full Python code

from AlgorithmImports import *

class QuarterlyInvestmentSpikesPredictStockReturns(QCAlgorithm):

def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)

self.period:int = 4             # need n quarterly capex growths
self.leverage:int = 10
self.quantile:int = 3
self.selection_months = [4, 7, 10, 1]   # MonthStart
self.total_asset_threshold:int = 1e7
self.capex_threshold:int = 1e5

self.weight:Dict[Symbol, float] = {}
self.capex:Dict[Symbol, RollingWindow] = {}
self.last_fine:List[Symbol] = []

market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol

self.rebalance_flag:bool = False
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)

def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
    security.SetFeeModel(CustomFeeModel())
    security.SetLeverage(self.leverage)

def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
if not self.selection_flag:
    return Universe.Unchanged
self.selection_flag = False

# filter stock symbols
selected:List[Fundamental] = [x for x in fundamental if x.MarketCap != 0 and \
    x.FinancialStatements.CashFlowStatement.CapExReported.ThreeMonths >= self.capex_threshold and \
    x.FinancialStatements.BalanceSheet.TotalAssets.ThreeMonths >= self.total_asset_threshold
    ]

qspikes:Dict[Fundamental, float] = {} # storing stocks qspike keyed by stocks objects
    
for stock in selected:
    symbol:Symbol = stock.Symbol
    capex:float = stock.FinancialStatements.CashFlowStatement.CapExReported.ThreeMonths
    
    # make sure data are consecutive
    if symbol not in self.last_fine:
        self.capex[symbol] = RollingWindow[float](self.period)
    self.capex[symbol].Add(capex)
    
    # calculate qspike only on rebalance and when data are ready
    if self.rebalance_flag and self.capex[symbol].IsReady:
        capexes:List[float] = list(self.capex[symbol])
        
        # get last quarter of capex growth
        last_capex:float = capexes[0]
        # calculate average from first three quarters of capex growth
        avg_from_first_three:float = np.average(capexes[1:4])
        
        # calculate and store stock's qspike
        qspikes[stock] = last_capex / avg_from_first_three

# change last fine universe based on current fine
self.last_fine = list(map(lambda x: x.Symbol, selected))
        
# not enough stocks for selection
if len(qspikes) < self.quantile:
    return Universe.Unchanged

# perform selection
# select stocks based on 30th percentile and 70th percentile 
quantile:int = int(len(qspikes) / self.quantile)
sorted_by_qspike = [x[0] for x in sorted(qspikes.items(), key=lambda item: item[1])]

# long low portfolio
long = sorted_by_qspike[:quantile]
# short high portfolio
short = sorted_by_qspike[-quantile:]

# calculate weights
for i, portfolio in enumerate([long, short]):
    mc_sum:float = sum(map(lambda x: x.MarketCap, portfolio))
    for stock in portfolio:
        self.weight[stock.Symbol] = ((-1) ** i) * (stock.MarketCap / mc_sum)

return list(self.weight.keys())

def OnData(self, data: Slice) -> None:
# rebalance yearly
if not self.rebalance_flag:
    return
self.rebalance_flag = False

# trade execution
portfolio:List[PortfolioTarget] = [PortfolioTarget(symbol, w) for symbol, w in self.weight.items() if symbol in data and data[symbol]]
self.SetHoldings(portfolio, True)

self.weight.clear()

def Selection(self) -> None:
if self.Time.month in self.selection_months:
    self.selection_flag = True

# rebalance yearly on last selection month
if self.Time.month == self.selection_months[-1]:
    self.rebalance_flag = True

# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))