Dual Momentum Heuristic
Antonacci-style single-asset proxy using T-bill and SPY filters rather than a full asset-allocation implementation.
This keeps the dual-momentum intuition visible, but it is not a canonical Antonacci switcher across offensive and defensive asset sleeves.
Implementation Scope
This keeps the dual-momentum intuition visible, but it is not a canonical Antonacci switcher across offensive and defensive asset sleeves.
The Intuition
Dual Momentum, popularised by Gary Antonacci (2014), combines two distinct momentum filters to create a more robust trend-following system. Absolute momentum asks: is this asset beating the risk-free rate (T-bills)? Relative momentum asks: is this asset beating a relevant benchmark (SPY)? Only when both filters are positive do you hold the asset long; if absolute momentum fails, you exit to safety.
The two-filter design solves a major problem with pure relative momentum (cross-sectional): even the "best" asset can be in a bear market. In 2008, U.S. equities dominated international equities on relative terms — but both were down 40–60%. Absolute momentum would have exited both before the crash, because even the "best" asset was below its T-bill return. This is why Dual Momentum avoided the 2008 crisis much more effectively than cross-sectional momentum alone.
Antonacci's backtests across equities, bonds, and real estate from 1974–2013 showed annualised returns of ~17% vs. ~10% for the S&P 500, with a maximum drawdown of ~18% vs. ~51% for buy-and-hold. This remarkable risk-adjusted performance made Dual Momentum one of the most cited systematic trading frameworks among retail systematic investors in the 2010s.
Key assumptions: (1) The 6-12 month lookback captures intermediate-term momentum that persists (Jegadeesh & Titman 1993). (2) T-bills serve as a meaningful risk-free rate proxy — in near-zero rate environments (2010–2021) this filter was frequently crossed, generating excessive trades. (3) SPY is an appropriate relative benchmark for the asset being traded. The strategy may need to adjust the benchmark to the asset class.
Known failure modes: momentum crashes (sudden sharp reversals in momentum leaders) and low-yield environments where the absolute momentum filter fires too frequently. Post-2008, the extremely low T-bill yield made the absolute momentum filter relatively easy to beat, reducing its defensive power. The strategy is most powerful when rates are meaningfully positive — its original design was calibrated to 1970s–1990s rate regimes.
The Math
Read this as a compact model summary: what the signal sees, what it ignores, and where fragility can creep in.
r_ticker(t) = Close(t) / Close(t - h) - 1
r_tbill(t) = T-bill yield proxy return over h days
r_spy(t) = SPY return over h days
abs_mom = r_ticker > r_tbill [beats risk-free rate?]
rel_mom = r_ticker > r_spy [beats market?]
Signal(t) = +1 if abs_mom AND rel_mom
= -1 if NOT abs_mom
= 0 otherwise
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| lookback | int | 126 | Momentum lookback period in trading days (~6 months) |
Source Code
def run(ticker: str, start: str, end: str, **params) -> dict:
lookback = int(params.get("lookback", 126))
df = fetch_ohlcv(ticker, start, end)
# Absolute momentum: compare to T-bill proxy (^IRX)
try:
df_irx = fetch_ohlcv("^IRX", start, end)
irx_common = df_irx.index.intersection(df.index)
irx_return = df_irx.loc[irx_common, "Close"].pct_change(lookback)
except Exception:
irx_return = pd.Series(0.0, index=df.index)
# Relative momentum: compare to SPY
try:
df_spy = fetch_ohlcv("SPY", start, end)
spy_common = df_spy.index.intersection(df.index)
spy_return = df_spy.loc[spy_common, "Close"].pct_change(lookback)
except Exception:
spy_return = pd.Series(float("nan"), index=df.index)
ticker_return = df["Close"].pct_change(lookback)
pos = pd.Series(0.0, index=df.index)
for i, date in enumerate(df.index):
if i < lookback:
continue
tr = ticker_return.iloc[i]
# Absolute: beat T-bills
try:
irx_val = irx_return.get(date, 0.0)
except Exception:
irx_val = 0.0
abs_mom = tr > (irx_val if not pd.isna(irx_val) else 0.0)
# Relative: beat SPY
try:
spy_val = spy_return.get(date, float("nan"))
except Exception:
spy_val = float("nan")
rel_mom = (not pd.isna(spy_val)) and (tr > spy_val)
if abs_mom and rel_mom:
pos.iloc[i] = 1.0
elif not abs_mom:
pos.iloc[i] = -1.0
return run_backtest(df, pos, ticker=ticker, start=start, end=end,
strategy=METADATA["slug"], params={"lookback": lookback})
Further Reading
- Antonacci, G. (2014). Dual Momentum Investing. McGraw-Hill.
- Antonacci, G. (2012). Risk Premia Harvesting Through Dual Momentum. SSRN Working Paper.
- Asness, C., Moskowitz, T. & Pedersen, L. (2013). Value and Momentum Everywhere. Journal of Finance, 68(3), 929–985.
When It Works / When It Fails
- Strong bull markets with clear leader/laggard dynamics
- Long-only diversified portfolios needing a regime filter
- When SPY clearly beats T-bills on absolute momentum
- Sideways or choppy markets — absolute momentum near zero
- Rapid reversals in relative ranking between assets
- Bear markets: goes flat (no short), caps drawdown but earns nothing