Quant Memo

Build It Yourself

← All guides

Build a Dual-Momentum Strategy in Python

Implement Gary Antonacci's dual momentum end-to-end, relative ranking plus an absolute-momentum cash filter, on a cross-section of ETFs, with monthly rebalancing, turnover costs, and honest metrics.

Momentum is the most robust anomaly in finance: past winners keep winning over horizons of 3–12 months, across nearly every asset class and market ever studied (see Momentum). Dual momentum, popularized by Gary Antonacci, combines two flavors of it:

  • Relative momentum, hold the strongest assets in your cross-section (a cross-sectional signal).
  • Absolute momentum, but only if the asset's own trailing return is positive; otherwise sit in cash (a time-series signal).

The absolute-momentum gate is what makes it interesting: it's a built-in risk switch that historically sidestepped the worst of 2008 by rotating to bills. This guide implements the whole thing on a small ETF universe, reusing the backtest skeleton from backtest from scratch. Every line runs.

The universe and data

Antonacci's canonical version rotates between US equities, international equities, and bonds. We'll use a representative ETF set. Pull month-end closes (swap the synthetic block for yf.download(tickers)["Close"] on real data):

import numpy as np
import pandas as pd

tickers = ["SPY", "EFA", "AGG", "TLT", "GLD"]   # US eq, intl eq, agg bonds, long bonds, gold
# --- real data: prices = yf.download(tickers)["Close"] ---

# month-end panel of prices, indexed by date, one column per ticker
monthly = prices.resample("ME").last()

Working on monthly bars is deliberate: momentum is a slow signal, and monthly rebalancing keeps turnover, and therefore costs, low.

The signal: 12-month momentum

Relative momentum is just the trailing 12-month return of each asset. Because we're already on monthly data, that's a 12-period percentage change:

lookback = 12
mom = monthly.pct_change(lookback)      # each row: trailing 12m return per asset

A subtlety practitioners care about: many use a 12-month return skipping the most recent month (t-12 to t-1) to avoid the short-term reversal that contaminates the last month. For clarity we use the plain 12-month return here; skipping a month is a one-line change (monthly.shift(1).pct_change(lookback)) worth testing.

The rule: rank, then gate on cash

Each month, rank assets by momentum, take the top N, but drop any whose own momentum is negative, that's the absolute-momentum filter. If everything is falling, you hold nothing (cash):

top_n = 2

def build_weights(row):
    elig = row.dropna()
    elig = elig[elig > 0]                        # absolute-momentum gate: positive only
    winners = elig.sort_values(ascending=False).head(top_n)
    w = pd.Series(0.0, index=row.index)
    if len(winners) > 0:
        w[winners.index] = 1.0 / len(winners)    # equal-weight the survivors
    return w

target = mom.apply(build_weights, axis=1)        # target weights, one row per month

Each row of target now sums to 1.0 when at least one asset qualifies, or 0.0 (fully in cash) when none do. That cash state is the whole point, it's a systematic de-risking rule, not a discretionary call.

No lookahead: decide this month, hold next month

target at month t was computed from prices through month t. So you can only hold those weights starting month t+1. Same shift(1) discipline as every honest backtest:

weights = target.shift(1).fillna(0.0)

Skip this and you'd be rebalancing into winners using the very month-end prices that defined them, instant, invisible lookahead.

Returns, costs, equity

monthly_ret = monthly.pct_change().fillna(0.0)
port_gross = (weights * monthly_ret).sum(axis=1)         # portfolio return

# turnover = total absolute change in weights across all assets
turn = weights.diff().abs().sum(axis=1).fillna(0.0)
port_net = port_gross - turn * 0.0010                    # 10 bps per unit traded

equity = (1 + port_net).cumprod()

Because we rebalance monthly and hold only two names, turnover is modest, in a representative run, average monthly turnover is about 0.16, i.e. you replace ~16% of the book a month. That's cheap enough that costs shave only a little off returns, which is exactly why slow momentum survives real trading while fast technical rules don't.

Evaluate honestly

def sharpe(r, ppy=12):                                    # monthly data -> annualize by sqrt(12)
    return (r.mean() / r.std()) * np.sqrt(ppy) if r.std() > 0 else np.nan

def max_drawdown(equity):
    return (equity / equity.cummax() - 1).min()

print(f"Sharpe {sharpe(port_net):.2f} | MaxDD {max_drawdown(equity):.1%} "
      f"| Final {equity.iloc[-1]:.2f}")

Note the annualization uses 12\sqrt{12}, not 252\sqrt{252}, the Sharpe Ratio scales with the square root of periods-per-year, and we're on monthly bars. Getting that constant wrong is a classic interview trip-up.

Where this breaks, and what to test next

A backtest that impresses you should immediately make you suspicious. Pressure-test it:

  • Rebalance-timing luck. Rebalancing on the last day of the month is an arbitrary choice; results can swing on it. Re-run rebalancing mid-month, if the edge evaporates, it was noise.
  • Lookback sensitivity. You picked 12 months. Sweep 3–12 and look at the shape of the response, not the peak. A single sharp optimum is overfitting; a broad plateau is a real effect. Beware that sweeping many settings and reporting the best inflates your Sharpe, deflate it (see The Deflated Sharpe Ratio).
  • Whole-universe survivorship. Using ETFs that exist today is fine; using stocks that exist today is Survivorship Bias.
  • Costs at scale. 10 bps is fine for a retail account; a larger book pays nonlinear impact.

Dual momentum is genuinely one of the more robust retail-tractable strategies, but the value here is the machinery: a monthly cross-sectional signal, an absolute-momentum overlay, shift-clean weights, turnover-based costs, and skeptical evaluation. That skeleton generalizes to almost any systematic strategy. Compare your implementation against the full writeup in the Dual Momentum Equity database entry.

The theory behind it

Related strategies

Further reading

  • Gary Antonacci, Dual Momentum Investing (2014)
  • Antonacci, Risk Premia Harvesting Through Dual Momentum (2013)
  • Moskowitz, Ooi & Pedersen, Time Series Momentum (2012)

Code is for education, not investment advice. Test everything on paper before risking capital.