CH
CalcHub
Back to Guides
Advanced

Momentum Strategies

Momentum is the opposite of mean reversion: things that have been going up tend to keep going up (for a while). It is one of the most robust and well-documented anomalies in finance, and it works across virtually every market and time period studied.

Momentum in One Sentence

Buy recent winners, sell recent losers. Academic research has shown this works across stocks, bonds, currencies, and commodities going back over 200 years. The hard part is surviving the crashes.

Cross-Sectional Momentum

Rank all stocks by their past returns. Buy the top performers (winners) and sell or avoid the bottom performers (losers). This is relative momentum โ€” you care about which stocks are doing BETTER than others, not whether they are going up in absolute terms.

import yfinance as yf import pandas as pd import numpy as np import matplotlib.pyplot as plt # Universe of stocks tickers = ["AAPL", "MSFT", "GOOGL", "AMZN", "META", "NVDA", "JPM", "JNJ", "V", "PG", "XOM", "UNH"] # Download data data = yf.download(tickers, start="2021-01-01", end="2024-01-01")["Close"] # Calculate 12-month (252-day) returns, excluding the most recent month (21 days) # This is the classic "12-1" momentum: 12-month return minus the last month lookback = 252 skip = 21 momentum_scores = data.pct_change(lookback - skip).shift(skip) # Monthly rebalance: on the first trading day of each month monthly = momentum_scores.resample("MS").first() # Each month: rank stocks, go long the top third, short the bottom third def momentum_portfolio(row, n_long=4, n_short=4): """Select top N and bottom N stocks by momentum score.""" ranked = row.dropna().sort_values() if len(ranked) < n_long + n_short: return pd.Series(0, index=row.index) weights = pd.Series(0.0, index=row.index) # Long the top performers for ticker in ranked.index[-n_long:]: weights[ticker] = 1.0 / n_long # Short the bottom performers for ticker in ranked.index[:n_short]: weights[ticker] = -1.0 / n_short return weights # Calculate monthly returns monthly_returns = data.pct_change().resample("MS").apply(lambda x: (1 + x).prod() - 1) # Build portfolio portfolio_returns = [] for date in monthly.index[1:]: if date in monthly_returns.index: weights = momentum_portfolio(monthly.loc[date]) port_ret = (weights * monthly_returns.loc[date]).sum() portfolio_returns.append((date, port_ret)) results = pd.DataFrame(portfolio_returns, columns=["Date", "Return"]).set_index("Date") results["Cumulative"] = (1 + results["Return"]).cumprod() plt.figure(figsize=(12, 5)) plt.plot(results.index, results["Cumulative"], color="teal", linewidth=1.5) plt.title("Cross-Sectional Momentum Strategy (Long Winners, Short Losers)") plt.xlabel("Date") plt.ylabel("Growth of $1") plt.grid(True, alpha=0.3) plt.tight_layout() plt.show() total = results["Cumulative"].iloc[-1] - 1 annual = (1 + total) ** (12 / len(results)) - 1 print(f"Total return: {{total:.2%}}") print(f"Annualised return: {{annual:.2%}}")

Time-Series Momentum (Trend Following)

Time-series momentum looks at each asset individually: has it been going up or down over the lookback period? If up, go long. If down, go short (or stay flat). This is essentially trend following.

# Time-series momentum for a single stock ticker = "AAPL" df = yf.download(ticker, start="2020-01-01", end="2024-01-01") # Multiple lookback periods for lookback_days in [21, 63, 126, 252]: # Signal: +1 if past return is positive, -1 if negative past_return = df["Close"].pct_change(lookback_days) signal = np.sign(past_return) # Strategy return daily_return = df["Close"].pct_change() strategy_return = daily_return * signal.shift(1) cumulative = (1 + strategy_return).cumprod() months = lookback_days // 21 total = cumulative.iloc[-1] - 1 print(f"{{months}}-month lookback: {{total:.2%}} total return")

The Danger: Momentum Crashes

Momentum Can Crash Violently

Momentum strategies can suffer sudden, catastrophic losses when trends reverse sharply. In 2009, momentum crashed as beaten-down stocks (banks, autos) suddenly rallied while winners fell. These crashes tend to happen during market stress โ€” exactly when you can least afford to lose money.

Lookback Periods

The choice of lookback period dramatically affects results. Academic research suggests:

  • 1 month โ€” Short-term reversal (not momentum โ€” actually mean reversion at this frequency)
  • 3-6 months โ€” Medium-term momentum (moderate signal, moderate turnover)
  • 12 months minus 1 month โ€” The classic โ€œ12-1โ€ signal. Strongest academic evidence. Skip the most recent month because of short-term reversal effects.
  • 2+ years โ€” Long-term reversal (not momentum โ€” this is mean reversion again)

Combining Momentum with Mean Reversion

The most sophisticated quantitative strategies combine both:

# Combined signal: momentum at longer timeframe, mean reversion at shorter df = yf.download("AAPL", start="2021-01-01", end="2024-01-01") # Long-term momentum: 6-month return (trend direction) df["Momentum"] = np.sign(df["Close"].pct_change(126)) # Short-term mean reversion: 10-day Z-score (entry timing) ma_10 = df["Close"].rolling(10).mean() std_10 = df["Close"].rolling(10).std() df["Z_Score"] = (df["Close"] - ma_10) / std_10 # Combined signal: # If momentum is positive AND Z-score is below -1: Strong buy #   (stock is in an uptrend but temporarily pulled back โ€” buy the dip) # If momentum is negative AND Z-score is above 1: Strong sell #   (stock is in a downtrend but temporarily bounced โ€” sell the rally) df["Combined_Signal"] = 0 df.loc[(df["Momentum"] == 1) & (df["Z_Score"] < -1), "Combined_Signal"] = 1 df.loc[(df["Momentum"] == -1) & (df["Z_Score"] > 1), "Combined_Signal"] = -1 # Forward fill positions df["Position"] = df["Combined_Signal"].replace(0, np.nan).ffill().fillna(0) # Calculate returns df["Strategy_Return"] = df["Close"].pct_change() * df["Position"].shift(1) df["Cumulative"] = (1 + df["Strategy_Return"]).cumprod() df["Buy_Hold"] = (1 + df["Close"].pct_change()).cumprod() plt.figure(figsize=(12, 5)) plt.plot(df.index, df["Cumulative"], label="Combined Strategy", linewidth=1.5) plt.plot(df.index, df["Buy_Hold"], label="Buy & Hold", alpha=0.7) plt.title("Momentum + Mean Reversion Combined Strategy") plt.ylabel("Growth of $1") plt.legend() plt.grid(True, alpha=0.3) plt.tight_layout() plt.show() strategy_total = df["Cumulative"].iloc[-1] - 1 bh_total = df["Buy_Hold"].iloc[-1] - 1 print(f"Combined strategy: {{strategy_total:.2%}}") print(f"Buy & hold:        {{bh_total:.2%}}")

Different Timeframes, Different Effects

At very short timeframes (days), prices tend to revert (mean reversion). At medium timeframes (months), prices tend to continue (momentum). At very long timeframes (years), prices tend to revert again. The best strategies exploit the right effect at the right timeframe.