Dynamic Momentum Strategy
Log in to collectAcademic paper
Ashish Garg; Christian L. Goulding; Campbell R. Harvey; Michele Mazzoleni
- ?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
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