Enduring Momentum in Stocks
Log in to collectAcademic paper
Hui Zeng; Ben R. Marshall; Nhut H. Nguyen; Nuttawat Visaltanachoti
- NZMassey University
- ?Massey University - Department of Economics and Finance
- ?Massey University - School of Economics and Finance
- NZAuckland University of Technology
Strategy in a nutshell
The strategy focuses on U.S. stocks listed on NYSE, AMEX, and NASDAQ, excluding stocks priced below $1. At the end of each month, stocks are ranked into ten deciles based on their past six-month returns. Winners (top decile) and losers (bottom decile) are further evaluated using a Cox Proportional Hazard model applied to 37 firm characteristics to estimate the “enduring” probability—how likely a stock is to remain a winner or loser over the next six months. The strategy goes long the winners and short the losers with the highest enduring probabilities, holding these positions for six months.
Economic rationale
While traditional momentum strategies rely solely on past price trends, they are limited by behavioral biases like herding or confirmation bias. This enhanced momentum approach integrates firm-specific characteristics to estimate enduring momentum probability, identifying winners and losers whose trends are more likely to persist. By combining price momentum with this probabilistic assessment, the strategy captures stronger and more reliable return opportunities, outperforming simple price-based momentum strategies.
Backtest performance
Full Python code
from AlgorithmImports import *
from collections import deque
# endregion
class EnduringMomentuminStocks(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
self.market_symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.leverage:int = 5
self.fundamental_count:int = 500
self.fundamental_sorting_key = lambda x: x.DollarVolume
self.min_share_price:float = 5.
self.data:Dict[Symbol, RollingWindow] = {}
self.momentum_period:int = 21
self.quantile:int = 10
self.monthly_period:int = 60 # “enduring” probability period
self.value_weighted:bool = False # True - value weighted; False - equally weighted
self.consecutive_occurance_period:int = 6
self.traded_symbol_count:int = 10
self.skip_monthly_period:int = 6
# trenching
self.managed_queue:List[RebalanceQueueItem] = []
self.holding_period:int = 6 # months
self.monthly_winners:deque[List[Symbol]] = deque(maxlen=self.monthly_period)
self.monthly_losers:deque[List[Symbol]] = deque(maxlen=self.monthly_period)
self.required_exchanges:List[str] = ['NYS', 'NAS', 'ASE']
self.selection_flag:bool = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.Schedule.On(self.DateRules.MonthStart(self.market_symbol), self.TimeRules.BeforeMarketClose(self.market_symbol), 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]:
# update the rolling window every day
for stock in fundamental:
symbol = stock.Symbol
# store daily price
if symbol in self.data:
self.data[symbol].Add(stock.AdjustedPrice)
if not self.selection_flag:
return Universe.Unchanged
selected:List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and \
x.AdjustedPrice >= self.min_share_price and x.SecurityReference.ExchangeId in self.required_exchanges]
if len(selected) > self.fundamental_count:
selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
# warmup price rolling windows
for stock in selected:
symbol:Symbol = stock.Symbol
if symbol in self.data:
continue
self.data[symbol] = RollingWindow[float](self.momentum_period)
history = self.History(symbol, self.momentum_period, Resolution.Daily)
if history.empty:
self.Log(f"Not enough data for {symbol} yet.")
continue
closes = history.loc[symbol].close
for time, close in closes.items():
self.data[symbol].Add(close)
monthly_perf:Dict[Fundamental, float] = {
stock: self.data[stock.Symbol][0] / self.data[stock.Symbol][self.momentum_period-1] - 1 for stock in selected if self.data[stock.Symbol].IsReady
}
losers:List[Symbol] = []
winners:List[Symbol] = []
market_cap:Dict[Symbol, float] = {}
# sort by monthly performance
if len(monthly_perf) >= self.quantile:
sorted_by_perf:List = sorted(monthly_perf.items(), key=lambda x: x[1], reverse=True)
quantile:int = int(len(monthly_perf) / self.quantile)
winners = [x[0].Symbol for x in sorted_by_perf[:quantile]]
losers = [x[0].Symbol for x in sorted_by_perf[-quantile:]]
market_cap = { stock.Symbol : stock.MarketCap for stock in monthly_perf }
# append this month's winners and losers
self.monthly_winners.append(winners)
self.monthly_losers.append(losers)
long:List[Symbol] = []
short:List[Symbol] = []
if len(self.monthly_winners) == self.monthly_period and len(self.monthly_losers) == self.monthly_period:
# get relevant winners and losers groups over the time from previous sixty to six months.
relevant_monthly_winners_group:List[Symbol] = list(self.monthly_winners)[:(len(self.monthly_winners) - self.skip_monthly_period)]
relevant_monthly_losers_group:List[Symbol] = list(self.monthly_losers)[:(len(self.monthly_losers) - self.skip_monthly_period)]
# calculate the “enduring” probability
winners_enduring_prob:Dict[Symbol, float] = {}
losers_enduring_prob:Dict[Symbol, float] = {}
for symbol in winners:
n_of_occurance:float = 0.
endurance_count:float = 0.
for i, symbol_list in enumerate(relevant_monthly_winners_group):
if symbol in symbol_list:
n_of_occurance += 1.
# look at forward groups
lookahead_period_index:int = i + self.consecutive_occurance_period + 1 if i + self.consecutive_occurance_period + 1 < len(relevant_monthly_winners_group) else -1
if lookahead_period_index != -1: # is valid index
if all((symbol in group) for group in relevant_monthly_winners_group[i+1 : lookahead_period_index]):
endurance_count += 1.
if n_of_occurance != 0.:
winners_enduring_prob[symbol] = endurance_count / n_of_occurance
for symbol in losers:
n_of_occurance:float = 0.
endurance_count:float = 0.
for i, symbol_list in enumerate(relevant_monthly_losers_group):
if symbol in symbol_list:
n_of_occurance += 1.
# look at forward groups
lookahead_period_index:int = i + self.consecutive_occurance_period + 1 if i + self.consecutive_occurance_period + 1 < len(relevant_monthly_losers_group) else -1
if lookahead_period_index != -1: # is valid index
if all((symbol in group) for group in relevant_monthly_losers_group[i+1 : lookahead_period_index]):
endurance_count += 1.
if n_of_occurance != 0.:
losers_enduring_prob[symbol] = endurance_count / n_of_occurance
# go long the top ten past winners and go short the top ten past losers with the highest estimated enduring probability and hold this position for six months
if len(winners_enduring_prob) >= self.traded_symbol_count:
winners_sorted_by_prob:List = sorted(winners_enduring_prob.items(), key=lambda x: x[1], reverse=True)
long = [x[0] for x in winners_sorted_by_prob[:self.traded_symbol_count]]
if len(losers_enduring_prob) >= self.traded_symbol_count:
losers_sorted_by_prob:List = sorted(losers_enduring_prob.items(), key=lambda x: x[1], reverse=True)
short = [x[0] for x in losers_sorted_by_prob[:self.traded_symbol_count]]
if long and short:
# calculate quantities for long and short trenche
if self.value_weighted:
total_market_cap_long:float = sum([market_cap[x] for x in long])
total_market_cap_short:float = sum([market_cap[x] for x in short])
long_w:float = self.Portfolio.TotalPortfolioValue / self.holding_period
short_w:float = self.Portfolio.TotalPortfolioValue / self.holding_period
long_symbol_q:List[Tuple[Symbol, float]] = [(x, np.floor(long_w * (market_cap[x] / total_market_cap_long) / self.data[x][0])) for x in long]
short_symbol_q:List[Tuple[Symbol, float]] = [(x, -np.floor(short_w * (market_cap[x] / total_market_cap_short) / self.data[x][0])) for x in short]
self.managed_queue.append(RebalanceQueueItem(long_symbol_q + short_symbol_q))
else:
long_w:float = self.Portfolio.TotalPortfolioValue / self.holding_period / len(long)
short_w:float = self.Portfolio.TotalPortfolioValue / self.holding_period / len(short)
long_symbol_q:List[Tuple[Symbol, float]] = [(x, np.floor(long_w / self.data[x][0])) for x in long]
short_symbol_q:List[Tuple[Symbol, float]] = [(x, -np.floor(short_w / self.data[x][0])) for x in short]
self.managed_queue.append(RebalanceQueueItem(long_symbol_q + short_symbol_q))
return long + short
def OnData(self, data: Slice) -> None:
if not self.selection_flag:
return
self.selection_flag = False
# trade execution - rebalance portfolio
remove_item:Union[RebalanceQueueItem, None] = None
for item in self.managed_queue:
# liquidate
if item.holding_period == self.holding_period: # all portfolio parts are held for n months
for symbol, quantity in item.opened_symbol_q:
self.MarketOrder(symbol, -quantity)
remove_item = item
# trade execution
if item.holding_period == 0: # all portfolio parts are held for n months
opened_symbol_q:List[Tuple[Symbol, float]] = []
for symbol, quantity in item.opened_symbol_q:
if symbol in data and data[symbol] and self.Securities[symbol].IsTradable:
self.MarketOrder(symbol, quantity)
opened_symbol_q.append((symbol, quantity))
# only opened orders will be closed
item.opened_symbol_q = opened_symbol_q
item.holding_period += 1
# need to remove closed part of portfolio after loop. Otherwise it will miss one item in self.managed_queue
if remove_item:
self.managed_queue.remove(remove_item)
def Selection(self) -> None:
self.selection_flag = True
class RebalanceQueueItem():
def __init__(self, symbol_q:List):
# symbol/quantity collections
self.opened_symbol_q:List[Tuple[Symbol, float]] = symbol_q
self.holding_period:int = 0
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))