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.

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 Return23.58%
Volatility36.52%
Beta-0.226
Sharpe Ratio0.65
Sortino Ratio-0.122
Maximum DrawdownN/A
Win Rate52%

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

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading