Why surface-level CAGR hides the true risk of momentum rotation

Cross-sectional momentum rotation is one of the most widely published quantitative strategies in retail and institutional finance. The premise appears mathematically elegant: at regular intervals (such as monthly or bi-weekly), rank an investable universe of exchange-traded funds (ETFs)—such as US equity sectors (XLF, XLK, XLE), international equities (EEM, VGK), or multi-asset proxies (SPY, TLT, GLD)—by a relative momentum metric, allocate capital into the top K performers, and liquidate the laggards.

In naive backtests, these models routinely output stellar top-line numbers: 18% to 24% Compound Annual Growth Rate (CAGR), smooth equity curves during long bull runs, and Sharpe ratios exceeding 1.20.

Yet when deployed in live accounts or subjected to rigorous institutional audits, many momentum rotation models experience severe performance degradation. The strategy churns accounts, incurs unexpected capital gains liabilities, suffers prolonged multi-year underwater grinds, and suffers devastating drawdowns during sudden market regime reversals (such as the 2020 pandemic crash or the 2022 inflationary rate-hike shock).

Code
          Naive In-Sample Backtest vs. Real-World Rotation Dynamics
┌────────────────────────────────────────────────────────────────────────┐
│ Naive Simulation: Zero Friction, Monthly Close Fills, Top-1 Sizing    │
│ Equity: $100k ──────────────────────────────────────────────> $480k   │
│ (CAGR: 21.4%, Sharpe: 1.35, "Free Lunch" Momentum Illusion)           │
└───────────────────────────────────┬────────────────────────────────────┘
                                    │ Real-World Friction & Decay
                                    ▼
┌────────────────────────────────────────────────────────────────────────┐
│ Reality: Turnover Drag + Spread Crossing + Whipsaw + Underwater Grind │
│ Equity: $100k ───────────────────────/\──────/\─────────────> $195k   │
│ (Net CAGR: 8.2%, Sharpe: 0.58, Underwater Duration: 580 Days)          │
└────────────────────────────────────────────────────────────────────────┘

The fundamental failure lies in evaluating momentum rotation through surface-level CAGR alone. To understand whether an ETF rotation strategy possesses an authentic structural edge or is merely harvesting random factor noise, quants must track the key metrics for backtesting momentum etf rotation strategy portfolios.

[!WARNING] High CAGR in an ETF rotation model is often an artifact of unpenalized turnover and extreme concentration. A backtest that achieves 22% CAGR while turning over 800% of its portfolio annually and holding a single sector ETF will likely collapse under real-world spread crossing and sudden factor rotations.


The Core Scorecard: 7 Critical Metrics for Rotation Strategies

A complete rotation strategy audit requires a balanced multi-dimensional scorecard spanning benchmark relativity, capital velocity, execution friction, diversification stability, and out-of-sample decay.

Code
       ┌─────────────────────────────────────────────────────────┐
       │     ETF Momentum Rotation Quantitative Scorecard        │
       └────────────────────────────┬────────────────────────────┘
           │                        │                        │
  [Return & Risk]          [Execution Cost]         [Regime Stability]
   • Benchmark Alpha        • Annual Turnover        • Mean HHI (Concentration)
   • Underwater Duration    • Friction Drag          • Avg Holding Period (Whipsaw)
                            • Rebalance Timing Luck  • Out-of-Sample Decay Ratio

1. CAGR vs. Equal-Weight Buy-and-Hold Benchmark

A momentum strategy should not be judged in a vacuum. If an ETF universe consists of 10 US sectors and achieves a 12% CAGR, that return is unimpressive if an equal-weight (1/N) passive buy-and-hold portfolio of the exact same 10 ETFs generated 11.8% CAGR with near-zero turnover.

Relative excess CAGR (\alpha_{\text{rot}}) defines the true value added by dynamic switching:

Code
\text{CAGR} = \left(\frac{E_T}{E_0}\right)^{\frac{252}{T}} - 1
Code
\alpha_{\text{rot}} = \text{CAGR}_{\text{strategy}} - \text{CAGR}_{1/N \text{ Benchmark}}

If \alpha_{\text{rot}} \le 0 after transaction friction, the active momentum ranking algorithm is destroying economic value relative to a passive basket of the same underlying assets.

2. Maximum Drawdown and Underwater Duration (Peak-to-Recovery Grind)

Standard single-asset backtests focus on Maximum Drawdown (MDD) magnitude:

Code
\text{MDD} = \max_{t \in [0, T]} \left( \frac{\max_{\tau \le t} E_\tau - E_t}{\max_{\tau \le t} E_\tau} \right)

However, for momentum rotation strategies, Underwater Duration (time spent below the previous high-water mark) is often the lethal flaw. Because momentum buys assets that have already appreciated, it is structurally prone to buying near cyclical tops:

Code
\tau_{\text{underwater}} = t_{\text{recovery}} - t_{\text{peak}}

A strategy with a -16% drawdown that recovers in 45 days is manageable. A strategy with a -16% drawdown that grinds sideways for 650 trading days (over 2.5 calendar years) causes severe capital paralysis and investor capitulation. Measuring the longest underwater duration and median recovery time is critical.

3. Annualized Portfolio Turnover: Measuring Trading Velocity

Portfolio turnover quantifies the volume of capital reshuffled at each rebalancing interval. For an investable universe of N ETFs with target weights w_{i, t} at rebalance time t, and pre-rebalance drifted weights w_{i, t^-}:

Code
\text{Turnover}_t = \frac{1}{2} \sum_{i=1}^{N} \left| w_{i, t} - w_{i, t^-} \right|

Annualized turnover aggregates these rebalance shifts over an annual cycle:

Code
\text{Turnover}_{\text{ann}} = \frac{252}{T} \sum_{t=1}^{M} \text{Turnover}_t

Where M is the total count of rebalance events across T trading days. An aggressive top-1 sector rotation model often generates 600% to 1,200% annualized turnover, meaning the entire account equity is completely replaced 6 to 12 times per year.

4. Transaction Friction Drag

High turnover directly converts theoretical alpha into broker commissions, bid-ask spread payments, and market impact. Transaction friction drag measures the spread between gross and net performance:

Code
\text{Friction Drag} = \text{CAGR}_{\text{gross}} - \text{CAGR}_{\text{net}}
Code
\text{Friction Ratio} (\mathcal{F}) = \frac{\sum_{t=1}^{T} \text{Total Transaction Frictions}_t}{\text{Gross Realized Profits}}

On liquid mega-cap ETFs like SPY or QQQ, round-trip friction might be as low as 2 to 4 basis points (bps). But for specialized thematic, international, or high-beta sector ETFs (XBI, KWEB, SOXX), effective bid-ask crossing and rebalance slippage frequently range from 10 to 30 bps per trade. If your strategy executes 800% turnover, a 20 bps round-trip cost extracts 160 bps (1.6%) directly from your bottom-line return every single year.

5. Concentration Risk: Herfindahl-Hirschman Index (HHI)

Momentum models frequently suffer from hidden concentration risk. If a strategy selects the top 2 ETFs out of a 10-ETF universe, but both selected ETFs belong to tech and semiconductor sectors (XLK and SMH), the portfolio holds near-total idiosyncratic exposure to a single factor.

The Herfindahl-Hirschman Index (HHI) measures concentration across active portfolio weights w_i:

Code
\text{HHI}_t = \sum_{i=1}^{N} w_{i, t}^2
  • For an equal-weight portfolio of N assets, \text{HHI} = \frac{1}{N}. (For N=10, \text{HHI} = 0.10).
  • For a completely concentrated portfolio holding a single asset, \text{HHI} = 1.0.

Tracking the rolling mean and maximum HHI prevents researchers from mistaking high-variance levered sector bets for diversified factor momentum.

6. Average Holding Period (AHP) and Whipsaw Ratio

Whipsaw occurs when an ETF enters the top momentum rank, triggers a buy order, immediately rolls over, and is ejected at the subsequent rebalance date at a loss.

The Average Holding Period (AHP) tracks the average duration (in trading days) an ETF remains continuously allocated in the portfolio:

Code
\text{AHP} = \frac{\sum_{k=1}^{K} \text{Holding Days}_k}{K}

Where K is the total number of distinct asset positions entered over the backtest. If a monthly rebalancing strategy has an AHP of 22 to 25 trading days, the strategy is churning positions on nearly every cycle. Robust momentum models display an AHP of 60 to 120 trading days, capturing extended multi-month trends rather than high-frequency noise.

7. Out-of-Sample Decay Ratio (OODR)

Quant researchers routinely optimize lookback windows (e.g., testing 1-month, 3-month, 6-month, and 12-month ROC or moving average filters). This parameter tuning creates severe lookahead and selection bias.

The Out-of-Sample Decay Ratio compares the risk-adjusted performance of the in-sample (IS) training window against the unseen out-of-sample (OOS) validation window:

Code
\mathcal{D}_{\text{Sharpe}} = \frac{\text{Sharpe}_{\text{OOS}}}{\text{Sharpe}_{\text{IS}}}, \quad \mathcal{D}_{\text{Sortino}} = \frac{\text{Sortino}_{\text{OOS}}}{\text{Sortino}_{\text{IS}}}
Code
                Out-of-Sample Decay Spectrum
┌───────────────────────┬───────────────────────┬───────────────────────┐
│     Severe Overfit    │   Acceptable Decay    │   Robust Persistence  │
│      OODR < 0.50      │   0.50 <= OODR < 0.80 │     OODR >= 0.80      │
├───────────────────────┼───────────────────────┼───────────────────────┤
│ Spurious parameter    │ Expected real-world   │ Genuine persistent    │
│ fit; noise harvesting │ regime divergence     │ structural factor edge│
└───────────────────────┴───────────────────────┴───────────────────────┘

An OODR below 0.50 signifies that more than half of the strategy's risk-adjusted returns evaporate outside the optimization window.


Visualizing the trade-offs: The Rotation Evaluation Matrix

To establish clear operational thresholds before deploying an ETF momentum rotation model, quantitative teams utilize a standardized evaluation matrix:

MetricTarget (Robust Strategy)Warning ThresholdDanger Zone (Uninvestable)Quantitative Interpretation
Excess CAGR (\alpha_&#123;\text&#123;rot&#125;&#125;)> +3.0\% vs. 1/N benchmark0.0\% to +2.0\%&lt; 0.0\%Negative alpha indicates rebalancing costs and whipsaws destroy baseline asset returns.
Underwater Duration&lt; 180 trading days180 to 365 trading days> 450 trading daysProtracted drawdowns lead to execution abandonment and systematic underperformance.
Annualized Turnover&lt; 250\%250\% to 450\%> 500\%High turnover accelerates transaction drag and amplifies execution sensitivity.
Friction Drag Ratio (\mathcal&#123;F&#125;)&lt; 10\% of gross returns10\% to 25\% of gross> 30\% of grossWhen fees exceed 30% of gross profits, the broker extracts the economic rent.
Mean HHI Concentration0.15 to 0.350.35 to 0.60> 0.65Excessive concentration converts rotation into an unhedged single-sector lottery.
Average Holding Period> 60 trading days30 to 60 trading days&lt; 25 trading daysIndicates rampant whipsaw; the model buys at local peaks and sells at troughs.
OOS Decay Ratio (\mathcal&#123;D&#125;)\ge 0.750.50 to 0.74&lt; 0.50Severe drop indicates parameter over-optimization and curve-fitting.

Python snippet: Computing the rotation metrics scorecard

Below is a self-contained, production-grade Python function using NumPy and pandas to compute the full rotation scorecard. It accepts a historical daily returns matrix of constituent ETFs, a corresponding matrix of portfolio weights, and realistic execution parameters.

calculate_rotation_scorecard.py
import numpy as np
import pandas as pd
from typing import Dict, Any

def compute_rotation_scorecard(
    returns_df: pd.DataFrame,
    weights_df: pd.DataFrame,
    friction_bps: float = 5.0,
    split_date: str = "2024-01-01"
) -> Dict[str, Any]:
    """
    Computes a comprehensive quantitative scorecard for an ETF momentum rotation strategy.
    
    Parameters:
    -----------
    returns_df : pd.DataFrame
        Daily returns for each ETF in the investable universe (DatetimeIndex, Tickers as columns).
    weights_df : pd.DataFrame
        Daily or rebalance weights for each ETF (aligned with returns_df columns).
    friction_bps : float
        One-way transaction cost per 100% turnover in basis points (e.g., 5.0 bps = 0.0005).
    split_date : str
        Date separating In-Sample (IS) from Out-of-Sample (OOS) data for decay calculation.
        
    Returns:
    --------
    Dict[str, Any]
        Dictionary containing CAGR, Turnover, Drag, HHI, Holding Period, and OOS Decay.
    """
    # 1. Align data indices and handle weight forward-filling between rebalance bars
    common_idx = returns_df.index.intersection(weights_df.index)
    returns = returns_df.loc[common_idx].copy()
    weights = weights_df.loc[common_idx].copy().fillna(0.0)
    
    # 2. Compute turnover: 0.5 * sum(|w_t - w_{t-1}|)
    weight_diffs = weights.diff().abs()
    daily_turnover = 0.5 * weight_diffs.sum(axis=1).fillna(0.0)
    
    trading_days = len(returns)
    years = trading_days / 252.0
    total_turnover = daily_turnover.sum()
    annualized_turnover = total_turnover / years if years > 0 else 0.0
    
    # 3. Compute Gross & Net Returns
    # Strategy gross return at t is the dot product of weights at t-1 and returns at t
    lagged_weights = weights.shift(1).fillna(0.0)
    gross_daily_returns = (lagged_weights * returns).sum(axis=1)
    
    # Friction cost in decimal (e.g., 5 bps = 0.0005) applied to turnover
    cost_factor = friction_bps / 10000.0
    daily_frictions = daily_turnover * cost_factor
    net_daily_returns = gross_daily_returns - daily_frictions
    
    # 4. Benchmark: Equal-weight 1/N buy-and-hold across all available ETFs
    num_assets = returns.shape[1]
    equal_weights = np.ones(num_assets) / float(num_assets)
    benchmark_daily_returns = returns.dot(equal_weights)
    
    # 5. Compound Annual Growth Rate (CAGR)
    gross_equity = (1.0 + gross_daily_returns).cumprod()
    net_equity = (1.0 + net_daily_returns).cumprod()
    bench_equity = (1.0 + benchmark_daily_returns).cumprod()
    
    gross_cagr = (gross_equity.iloc[-1] ** (1.0 / years)) - 1.0 if years > 0 else 0.0
    net_cagr = (net_equity.iloc[-1] ** (1.0 / years)) - 1.0 if years > 0 else 0.0
    bench_cagr = (bench_equity.iloc[-1] ** (1.0 / years)) - 1.0 if years > 0 else 0.0
    relative_alpha = net_cagr - bench_cagr
    friction_drag_bps = (gross_cagr - net_cagr) * 10000.0
    
    # 6. Maximum Drawdown and Max Underwater Duration
    running_max = net_equity.cummax()
    drawdown_series = (net_equity - running_max) / running_max
    max_drawdown = float(drawdown_series.min())
    
    # Compute longest underwater duration in trading days
    is_underwater = drawdown_series < 0.0
    underwater_groups = (~is_underwater).cumsum()
    underwater_durations = is_underwater.groupby(underwater_groups).sum()
    max_underwater_days = int(underwater_durations.max()) if len(underwater_durations) > 0 else 0
    
    # 7. Portfolio Concentration (Herfindahl-Hirschman Index)
    # HHI = sum(w_i^2) across active assets
    daily_hhi = (weights ** 2).sum(axis=1)
    # Filter only rebalance or active days where weights sum > 0
    active_hhi = daily_hhi[weights.sum(axis=1) > 0.0]
    mean_hhi = float(active_hhi.mean()) if len(active_hhi) > 0 else 0.0
    
    # 8. Average Holding Period (AHP)
    # Binary active indicator (holding asset i if weight > 0.001)
    is_holding = (weights > 0.001).astype(int)
    # Entry occurs when is_holding changes from 0 to 1
    entries = (is_holding.diff() == 1).sum().sum()
    total_holding_days = is_holding.sum().sum()
    avg_holding_period = float(total_holding_days / entries) if entries > 0 else 0.0
    
    # 9. Out-of-Sample Decay Ratio
    # Split into IS and OOS
    is_mask = net_daily_returns.index < pd.Timestamp(split_date)
    oos_mask = net_daily_returns.index >= pd.Timestamp(split_date)
    
    def calculate_sharpe(ret_series: pd.Series) -> float:
        if len(ret_series) < 10 or ret_series.std() == 0:
            return 0.0
        return float(np.sqrt(252) * (ret_series.mean() / ret_series.std()))

    sharpe_is = calculate_sharpe(net_daily_returns[is_mask])
    sharpe_oos = calculate_sharpe(net_daily_returns[oos_mask])
    sharpe_decay_ratio = float(sharpe_oos / sharpe_is) if sharpe_is > 0 else 0.0

    return {
        "Net CAGR (%)": round(net_cagr * 100, 2),
        "Benchmark CAGR (%)": round(bench_cagr * 100, 2),
        "Relative Alpha (%)": round(relative_alpha * 100, 2),
        "Annualized Turnover (%)": round(annualized_turnover * 100, 1),
        "Friction Drag (bps/yr)": round(friction_drag_bps, 1),
        "Max Drawdown (%)": round(max_drawdown * 100, 2),
        "Max Underwater Days": max_underwater_days,
        "Mean Concentration (HHI)": round(mean_hhi, 3),
        "Avg Holding Period (Days)": round(avg_holding_period, 1),
        "Sharpe IS": round(sharpe_is, 2),
        "Sharpe OOS": round(sharpe_oos, 2),
        "OOS Sharpe Decay Ratio": round(sharpe_decay_ratio, 2),
    }

if __name__ == "__main__":
    # Example validation with dummy momentum data
    dates = pd.date_range("2021-01-01", "2026-06-30", freq="B")
    tickers = ["XLK", "XLF", "XLE", "XLV", "XLI"]
    
    np.random.seed(42)
    sample_returns = pd.DataFrame(
        np.random.normal(0.0004, 0.012, size=(len(dates), len(tickers))),
        index=dates,
        columns=tickers
    )
    
    # Simulate a monthly top-2 momentum rotation allocation
    sample_weights = pd.DataFrame(0.0, index=dates, columns=tickers)
    for i, date in enumerate(dates):
        if i % 21 == 0: # Rebalance every ~21 trading days
            # Rank based on trailing 63-day cumulative return
            if i >= 63:
                cum_perf = (1 + sample_returns.iloc[i-63:i]).prod()
                top_2 = cum_perf.nlargest(2).index
                sample_weights.loc[date, top_2] = 0.5
            else:
                sample_weights.loc[date, :] = 0.2
        else:
            sample_weights.loc[date] = sample_weights.iloc[i-1]
            
    scorecard = compute_rotation_scorecard(
        returns_df=sample_returns,
        weights_df=sample_weights,
        friction_bps=4.0,
        split_date="2024-01-01"
    )
    
    for metric, value in scorecard.items():
        print(f"{metric:<26}: {value}")

Execution realities: Slippage and rebalance timing

Even after implementing robust turnover and holding period metrics, momentum rotation models remain exposed to subtle microstructure realities: rebalance timing luck and execution slippage anomalies.

The Rebalance Timing Luck Phenomenon

In quantitative literature, backtests almost universally assume rebalancing occurs exactly on the final trading day of each month at the closing price (MOC - Market On Close).

However, rotating on the 1st trading day of the month versus the 15th or 28th can produce radical performance divergence—frequently shifting annualized CAGR by 200 to 500 basis points over a multi-year backtest. This variance is known as Rebalance Timing Luck.

Code
┌────────────────────────────────────────────────────────────────────────┐
│                   Rebalance Timing Luck Dispersion                     │
├────────────────────────────────────────────────────────────────────────┤
│ Strategy Variant               │ 5-Year CAGR │ Max Drawdown │ Sharpe   │
├────────────────────────────────┼─────────────┼──────────────┼──────────┤
│ Month-End Close (Trading Day -1)│ 16.4%       │ -19.2%       │ 1.05     │
│ Month-Start Open (Trading Day 1)│ 13.8%       │ -23.1%       │ 0.88     │
│ Mid-Month (Trading Day 15)     │ 15.1%       │ -20.5%       │ 0.96     │
└────────────────────────────────────────────────────────────────────────┘

Why does this divergence occur?

  1. Institutional Rebalancing Waves: Trillions of dollars in mutual funds, pension funds, and passive target-date funds execute programmatic rebalancing at month-end. This creates temporary liquidity distortions and price spikes in popular momentum leaders.
  2. End-of-Month Momentum Reversion: Heavily crowded momentum leaders frequently suffer short-term mean reversion immediately after the calendar turn as institutional buying pressure dissipates.

Execution Slippage on Gap Openings

Assuming that rotation orders fill smoothly at the closing price of the signal bar introduces lookahead and execution leakage. In practice:

  • If a signal is generated at Friday's close, the order is typically executed at Monday's market open (MOO).
  • Weekend macro news or geopolitical developments can cause sector ETFs to gap up or down by 1% to 3%, triggering immediate execution adverse selection.

To ensure realistic modeling, quant researchers decouple portfolio-level weight allocation from instrument-level microstructure execution. Frameworks like OpenBacktesting specialize in single-asset historical bar-by-bar execution modeling—allowing you to inspect how market, limit, stop, and trailing-stop orders fill against realistic slippage models, spread crosses, and bar volume before feeding those realized execution series into higher-level portfolio allocation scorecards.


Conclusion and audit checklist

ETF momentum rotation is not an automated path to market outperformance; it is an active risk-taking strategy that swaps beta volatility for turnover velocity, whipsaw exposure, and factor concentration. Evaluating models using CAGR alone hides the operational and psychological costs that cause live rotation systems to fail.

Before putting capital behind any ETF momentum rotation backtest, run through this comprehensive quantitative audit checklist:

  • Benchmark Relative Performance: Confirm that net CAGR surpasses an equal-weighted (1/N) passive buy-and-hold portfolio of the same ETF universe by at least 250 to 300 basis points.
  • Quantify Turnover Velocity: Ensure annualized portfolio turnover is documented and realistically sustainable (ideally &lt; 300\%).
  • Penalize Realistic Transaction Frictions: Deduct at least 3 to 6 bps per trade for mega-cap index ETFs and 10 to 25 bps for sector/thematic ETFs to account for bid-ask spread crossing and broker execution drag.
  • Inspect Max Underwater Duration: Verify that the strategy does not leave the portfolio underwater for longer than your mandate's risk tolerance (typically &lt; 250 trading days).
  • Audit Concentration Limits (HHI): Ensure the strategy does not concentrate 100% of capital into 1 or 2 correlated sector proxies, creating hidden single-factor failure modes.
  • Check Average Holding Period: Confirm the average holding period exceeds 45 to 60 trading days to ensure the model is capturing trends rather than whipsawing on short-term noise.
  • Test Rebalance Timing Sensitivity: Run the model across multiple rebalance dates (e.g., 1st, 10th, 20th trading day of the month) to verify that performance is not an artifact of rebalance timing luck.
  • Measure Out-of-Sample Decay: Calculate the OOS Decay Ratio (\mathcal&#123;D&#125;_&#123;\text&#123;Sharpe&#125;&#125;); reject any model where out-of-sample risk-adjusted return decays by more than 50% (\mathcal&#123;D&#125; &lt; 0.50).
  • Validate Single-Instrument Execution Realities: Cross-reference execution assumptions and order mechanics using ETF Backtesting in Python and our architectural guide on How to Backtest a Portfolio.

By applying these 7 critical metrics, you transform ETF momentum rotation from a fragile, curve-fitted backtest into an institutional-grade, risk-managed systematic portfolio strategy.