The study constructs 65 U.S. stock factor portfolios, applying dynamic time-series momentum scaling. Positive past-month returns trigger buys; negative trigger sells. A unit-leverage portfolio integrates all factors, rebalanced monthly.

I. STRATEGY IN A NUTSHELL

Trades 65 U.S. stock factor portfolios using time-series factor momentum (TSFM). Factors are grouped by size and characteristic levels, forming long-short portfolios. TSFM dynamically scales factors’ one-month returns via z-scores: positive returns trigger buys, negative returns trigger sells. Portfolios are rebalanced monthly, integrating multiple factor momentum signals into a single unit-leverage portfolio.

II. ECONOMIC RATIONALE

Individual factors exhibit robust time-series momentum: past returns predict future returns. TSFM outperforms cross-sectional factor momentum and traditional stock momentum, maintaining positive alpha across look-back windows of one month to five years. Its dynamic, time-series approach provides a purer signal of expected factor returns and delivers stable, high Sharpe performance across different periods.

III. SOURCE PAPER

Factor Momentum Everywhere [Click to Open PDF]

Tarun Gupta, INVESCO Global Asset Management, N.A., Yale SOM; Bryan Kelly, AQR Capital Management, LLC; National Bureau of Economic Research (NBER)

<Abstract>

In this article, the authors document robust momentum behavior in a large collection of 65 widely studied characteristic-based equity factors around the globe. They show that, in general, individual factors can be reliably timed based on their own recent performance. A time series “factor momentum” portfolio that combines timing strategies of all factors earns an annual Sharpe ratio of 0.84. Factor momentum adds significant incremental performance to investment strategies that employ traditional momentum, industry momentum, value, and other commonly studied factors. Their results demonstrate that the momentum phenomenon is driven in large part by persistence in common return factors and not solely by persistence in idiosyncratic stock performance.

IV. BACKTEST PERFORMANCE

Annualised Return12%
Volatility14.29%
Beta0.02
Sharpe Ratio0.84
Sortino Ratio-0.447
Maximum DrawdownN/A
Win Rate72%

V. FULL PYTHON CODE

from AlgorithmImports import *
import numpy as np
from typing import List, Dict
#endregion
class TimeSeriesFactorMomentum(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2005, 1, 1)
        self.SetCash(100000)
        
        # daily price data
        self.data:Dict[str, SymbolData] = {}
        self.period:int = 12 * 21 * 3 # Three years daily closes
        self.leverage:int = 10
        self.SetWarmUp(self.period, Resolution.Daily)
        
        csv_string_file:str = self.Download('data.quantpedia.com/backtesting_data/equity/quantpedia_strategies/backtest_end_year.csv')
        lines:str = csv_string_file.split('\r\n')
        last_id:None|str = None
        for line in lines[1:]:
            split:str = line.split(';')
            id:str = str(split[0])
            
            data:QuantpediaEquitz = self.AddData(QuantpediaEquity, id, Resolution.Daily)
            data.SetLeverage(self.leverage)
            data.SetFeeModel(CustomFeeModel())
            self.data[id] = SymbolData(self.period)
            
            if not last_id:
                last_id = id
                
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.recent_month:int = -1
    
    def OnData(self, data):
        if self.IsWarmingUp:
            return
        
        # Update equity close each day.
        for symbol in self.data:
            if symbol in data and data[symbol]:
                self.data[symbol].update(data[symbol].Value)
        
        if self.Time.month == self.recent_month:
            return
        self.recent_month = self.Time.month
        
        long:List[str] = []
        short:List[str] = []
        _last_update_date:Dict[str, datetime.date] = QuantpediaEquity.get_last_update_date()
        for symbol in self.data:
            if not self.data[symbol].is_ready():
                continue
            
            if symbol in data and data[symbol]:
                if _last_update_date[symbol] > self.Time.date():
                    # Calculate factore score for each equity
                    equity_score:float = self.data[symbol].calculate_z_score()
                    
                    # Go long if equity_score is positive
                    # and short if equity_score is negative
                    if equity_score >= 0:
                        long.append(symbol)
                    else:
                        short.append(symbol)
                
        # Trade execution.
        invested:List[str] = [x.Key.Value for x in self.Portfolio if x.Value.Invested]
        for symbol in invested:
            if symbol not in long + short:
                self.Liquidate(symbol)
                
        long_length:int = len(long)
        short_length:int = len(short)
        
        # Equally weighted long and short portfolio
        for symbol in long:
            self.SetHoldings(symbol, 1 / long_length)
            
        for symbol in short:
            self.SetHoldings(symbol, -1 / short_length)
class SymbolData():
    def __init__(self, period:int):
        self.closes:RollingWindow[float] = RollingWindow[float](period)
        
    def update(self, close:float):
        self.closes.Add(close)
        
    def is_ready(self) -> bool:
        return self.closes.IsReady
        
    def calculate_z_score(self) -> float:
        closes:List[float] = [x for x in self.closes]
        
        values:np.ndarray = np.array(closes) # Full period daily closes
        daily_returns:np.ndarray = (values[:-1] - values[1:]) / values[1:] # Daily returns for full period
        full_period_volatility:float = np.std(daily_returns) # Full period volatility
        
        monthly_returns:List[float] = [(values[i] - values[i + 20]) / values[i + 20] for i in range(0, len(values), 21)]
        
        # This equation is first in paper on page 10.
        return min(max( sum(monthly_returns) / full_period_volatility, -2), 2)
# Quantpedia strategy equity curve data.
# NOTE: IMPORTANT: Data order must be ascending (datewise)
class QuantpediaEquity(PythonData):
    def GetSource(self, config, date, isLiveMode):
        return SubscriptionDataSource("data.quantpedia.com/backtesting_data/equity/quantpedia_strategies/{0}.csv".format(config.Symbol.Value), SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
    _last_update_date:Dict[str, datetime.date] = {}
    @staticmethod
    def get_last_update_date() -> Dict[str, datetime.date]:
       return QuantpediaEquity._last_update_date
    def Reader(self, config, line, date, isLiveMode):
        data = QuantpediaEquity()
        data.Symbol = config.Symbol
        
        if not line[0].isdigit(): return None
        split = line.split(';')
        
        data.Time = datetime.strptime(split[0], "%Y-%m-%d") + timedelta(days=1)
        data['close'] = float(split[1])
        data.Value = float(split[1])
        
        # store last update date
        if config.Symbol.Value not in QuantpediaEquity._last_update_date:
            QuantpediaEquity._last_update_date[config.Symbol.Value] = datetime(1,1,1).date()
        if data.Time.date() > QuantpediaEquity._last_update_date[config.Symbol.Value]:
            QuantpediaEquity._last_update_date[config.Symbol.Value] = data.Time.date()
        
        return data
# 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