Quant BuffetRelax, Not Over Thinking

Dynamic Momentum Strategy

Log in to collect

Academic paper

Momentum Turning Points

AuthorsAshish Garg; Christian L. Goulding; Campbell R. Harvey; Michele Mazzoleni

Institute
  • ?Research Affiliates, LLC
  • National Bureau of Economic Research
  • Duke University
  • ?Duke University - Fuqua School of Business
  • ?National Bureau of Economic Research (NBER)
  • ?STRS Ohio

Strategy in a nutshell

The strategy trades U.S. excess value-weighted factors (Mkt-RF) from NYSE, AMEX, and NASDAQ stocks using fast (1-month) and slow (12-month) momentum signals. Signals are blended via a state-dependent alpha: 0.5 in Bull or Bear markets and optimized monthly in Correction or Rebound states to maximize Sharpe ratio. Historical estimates guide monthly rebalancing, with state-dependent alphas applied over a 15-year period for performance optimization.

Economic rationale

Markets cycle through Bull, Correction, Bear, and Rebound states based on momentum alignment. In Corrections and Rebounds, slow and fast momentum signals diverge, requiring dynamic blending to maximize Sharpe ratio. This approach enhances returns, reduces drawdowns, improves skewness, and strengthens predictability compared to static strategies. Dynamic, cycle-tailored momentum strategies outperform traditional static methods by adapting to changing macro conditions.

Backtest performance

Annualised return6.11%
Volatility10%
Beta0.55
Sharpe ratio0.61
Sortino ratio0.168
Win rate68%

Full Python code

from AlgorithmImports import *
class DynamicMomentumStrategy(QCAlgorithm):
def Initialize(self):
 self.SetStartDate(2010, 1, 1)
 self.SetCash(100000)
 
 self.slow_period = 12*21
 self.fast_period = 21
 
 # subscribe 
 data = self.AddData(QuantpediaFutures, 'CME_ES1', Resolution.Daily)     # E-mini S&P 500 Futures, Continuous Contract #1
 data.SetFeeModel(CustomFeeModel())
 data.SetLeverage(5)
 self.market = data.Symbol
 
 # daily price data
 self.price_data = RollingWindow[float](self.slow_period)
 self.recent_month:int = -1
def OnData(self, data):
 # check if data is still coming.
 if self.securities[self.market].get_last_data() and self.time.date() > QuantpediaFutures.get_last_update_date()[self.market]:
     self.liquidate()
     return
 # store daily market price
 if self.market in data and data[self.market]:
     self.price_data.Add(data[self.market].Value)
     if self.recent_month != self.Time.month:
         self.recent_month = self.Time.month
         
         if self.price_data.IsReady:
             slow_momentum = self.price_data[0] / self.price_data[self.price_data.Count-1] - 1
             fast_momentum = self.price_data[0] / self.price_data[21] - 1
             
             slow_signal = 1 if slow_momentum >= 0 else -1
             fast_signal = 1 if fast_momentum >= 0 else -1
             
             # market cycles
             # A month ending at date t is classified as Bull if both the trailing 12-month return (arithmetic average monthly return), rt−12,t, is nonnegative and
             # the trailing 1-month return, rt−1,t, is nonnegative. A month is classified as Correction if rt−12,t ≥ 0 but rt−1,t < 0; as Bear if rt−12,t < 0 and rt−1,t < 0; 
             # and as Rebound if rt−12,t < 0 but rt−1,t ≥ 0. 
             bull = slow_signal == 1 and fast_signal == 1
             bear = slow_signal == -1 and fast_signal == -1
             correction = slow_signal == 1 and fast_signal == -1
             rebound = slow_signal == -1 and fast_signal == 1
             
             alpha = 0
             
             # if the market`s state is bear or bull – the alpha is not important since the signals agree and it could be set at one half
             if bull or bear:
                 alpha = 0.5
             # source: https://papers.ssrn.com/sol3/papers.cfm?abstract_id=3489539
             # Table 6
             elif correction:
                 alpha = 0.16
             elif rebound:
                 alpha = 0.69
             
             # weight calculation
             w = ((1-alpha) * slow_signal) + (alpha*fast_signal)
             self.SetHoldings(self.market, w)
# Custom fee model.
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
 fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
 return OrderFee(CashAmount(fee, "USD"))
 
# Quantpedia data.
# NOTE: IMPORTANT: Data order must be ascending (datewise)
class QuantpediaFutures(PythonData):
_last_update_date:Dict[Symbol, datetime.date] = {}
@staticmethod
def get_last_update_date() -> Dict[Symbol, datetime.date]:
return QuantpediaFutures._last_update_date
def GetSource(self, config, date, isLiveMode):
 return SubscriptionDataSource("data.quantpedia.com/backtesting_data/futures/{0}.csv".format(config.Symbol.Value), SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
def Reader(self, config, line, date, isLiveMode):
 data = QuantpediaFutures()
 data.Symbol = config.Symbol
 
 if not line[0].isdigit(): return None
 split = line.split(';')
 
 data.Time = datetime.strptime(split[0], "%d.%m.%Y") + timedelta(days=1)
 data['back_adjusted'] = float(split[1])
 data['spliced'] = float(split[2])
 data.Value = float(split[1])
 if config.Symbol not in QuantpediaFutures._last_update_date:
     QuantpediaFutures._last_update_date[config.Symbol] = datetime(1,1,1).date()
 if data.Time.date() > QuantpediaFutures._last_update_date[config.Symbol]:
     QuantpediaFutures._last_update_date[config.Symbol] = data.Time.date()
 return data