
The strategy involves double-sorting stocks by past returns, going long on the best-performing winners and short on the worst-performing losers. The portfolio is rebalanced monthly and value-weighted.
ASSET CLASS: stocks | REGION: United States | FREQUENCY:
Monthly | MARKET: equities | KEYWORD: Combining Time-Series, Cross-Sectional, Momentum
I. STRATEGY IN A NUTSHELL
The investment universe includes stocks from NYSE, AMEX, and NASDAQ with at least 11 months of past returns. The investor double-sorts stocks by the sign of cumulative returns and the quintiles of returns. Stocks are sorted into two groups: winners (positive returns) and losers (negative returns). Within these groups, stocks are further sorted into quintiles based on performance. The investor goes long on the best-performing winner quintile and shorts the worst-performing loser quintile. The portfolio is rebalanced monthly and value-weighted. This strategy is a zero-investment long-short approach.
II. ECONOMIC RATIONALE
The momentum effect in stocks arises from investors’ irrationality and behavioral biases, causing them to underreact to news. This leads to a delay in the full incorporation of news into stock prices. For time-series momentum, the authors identify two potential explanations for investors’ underreaction: the gradual information diffusion hypothesis, proposed by Hong and Stein (1999), and the frog-in-the-pan hypothesis, discussed by Da, Gurun, and Warachka (2014). These hypotheses suggest that information is slowly absorbed or ignored, contributing to delayed price adjustments and the persistence of momentum effects.
III. SOURCE PAPER
The Enduring Effect of Time-Series Momentum on Stock Returns Over Nearly 100-Years [Click to Open PDF]
Ian D’Souza, Voraphat Srichanachaichok, George Wang and Yaqiong Yao.New York University – Leonard N. Stern School of Business.Bangkok Bank.Lancaster University Management School; New York University – Stern School of Business.Lancaster University – Lancaster University Management School
<Abstract>
This study documents the significant profitability of “time-series momentum” strategies in individual stocks in the US markets from 1927 to 2014 and in international markets since 1975. Unlike cross-sectional momentum, time-series stock momentum performs well following both up- and down-market states, and it does not suffer from January losses and market crashes. An easily formed dual-momentum strategy, combining time-series and cross-sectional momentum, generates striking returns of 1.88% per month. We test both risk based and behavioral models for the existence and durability of time-series momentum and suggest the latter offers unique insights into its continuing factor dominance.


IV. BACKTEST PERFORMANCE
| Annualised Return | 23.58% |
| Volatility | 36.52% |
| Beta | -0.226 |
| Sharpe Ratio | 0.65 |
| Sortino Ratio | -0.122 |
| Maximum Drawdown | N/A |
| Win Rate | 52% |
V. FULL PYTHON CODE
from AlgorithmImports import *
from pandas.core.frame import dataframe
class TimeSeriesCrossSectionalMomentum(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.fundamental_count:int = 1000
self.fundamental_sorting_key = lambda x: x.DollarVolume
self.period:int = 13
self.quantile:int = 5
self.leverage:int = 5
self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']
# Monthly close data.
self.data:Dict[Symbol, SymbolData] = {}
self.weight:Dict[Symbol, float] = {}
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.AfterMarketOpen(market), self.Selection)
self.settings.daily_precise_end_time = False
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
# Update the rolling window every month.
for stock in fundamental:
symbol:Symbol = stock.Symbol
# Store monthly price.
if symbol in self.data:
self.data[symbol].update(stock.AdjustedPrice)
selected:List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and \
x.SecurityReference.ExchangeId in self.exchange_codes and x.MarketCap != 0]
if len(selected) > self.fundamental_count:
selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
performance:Dict[Fundamental, float] = {}
# Warmup price rolling windows.
for stock in selected:
symbol:Symbol = stock.Symbol
if symbol not in self.data:
self.data[symbol] = SymbolData(self.period)
history:dataframe = self.History(symbol, self.period*30, Resolution.Daily)
if history.empty:
self.Log(f"Not enough data for {symbol} yet.")
continue
closes:pd.Series = history.loc[symbol].close
closes_len:int = len(closes.keys())
# Find monthly closes.
for index, time_close in enumerate(closes.items()):
# index out of bounds check.
if index + 1 < closes_len:
date_month:int = time_close[0].date().month
next_date_month:int = closes.keys()[index + 1].month
# Found last day of month.
if date_month != next_date_month:
self.data[symbol].update(time_close[1])
if self.data[symbol].is_ready():
performance[stock] = self.data[symbol].performance()
if len(performance) >= self.quantile:
winner_group:List[Fundamental] = [x[0] for x in performance.items() if x[1] > 0]
loser_group:List[Fundamental] = [x[0] for x in performance.items() if x[1] < 0]
sorted_by_perf:List = sorted(performance.items(), key = lambda x: x[1], reverse = True)
quantile:int = int(len(sorted_by_perf) / self.quantile)
top_perf:List[Fundamental] = [x[0] for x in sorted_by_perf[:quantile]]
low_perf:List[Fundamental] = [x[0] for x in sorted_by_perf[-quantile:]]
long:List[Fundamental] = [x for x in winner_group if x in top_perf]
short:List[Fundamental] = [x for x in loser_group if x in low_perf]
# Market cap weighting.
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:
if not self.selection_flag:
return
self.selection_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:
self.selection_flag = True
class SymbolData():
def __init__(self, period: int):
self._price:RollingWindow = RollingWindow[float](period)
def update(self, close: float) -> None:
self._price.Add(close)
def is_ready(self) -> bool:
return self._price.IsReady
# Yearly performance, one month skipped.
def performance(self) -> float:
return self._price[1] / self._price[self._price.Count - 1] - 1
# Custom fee model.
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))