Combining Time-Series and Cross-Sectional Momentum
Log in to collectAcademic paper
The Enduring Effect of Time-Series Momentum on Stock Returns Over Nearly 100-Years
Ian D'Souza; Voraphat Srichanachaichok; George Jiaguo Wang; Chelsea Yaqiong Yao
- New York University
- ?New York University - Leonard N. Stern School of Business
- ?Bangkok Bank
- Lancaster University
- ?Lancaster University Management School
- ?New York University - Stern School of Business
- ?New York University (NYU) - Leonard N. Stern School of Business
- ?Lancaster University - Lancaster University Management School
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.
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.
Backtest performance
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"))