So far in our series, we’ve optimized a portfolio based on historical risk and return (MPT) and then deconstructed its performance using the Fama-French factors. Today, we go from analyzing past returns to using them as a predictive signal for a new, active trading strategy.
Welcome to the fifth installment of our hands-on guide. We are about to build and backtest one of the most persistent and well-documented anomalies in financial markets: the Momentum factor. The core idea is very simple. Stocks that have out-performed in the recent past tend to continue performing well in the near future, and vice versa.
We will build a complete “cross-sectional momentum” strategy from the ground up. This involves ranking a set of stocks based on their past performance. We will buy the “winners” and hold them for a set period. This is a step towards building a dynamic, rules-based strategy that hedge funds and asset managers use every day.
The Theory: What is Momentum?
Momentum is a behavioral anomaly. It stands in contrast to the “efficient market hypothesis” which suggests that all available information is already reflected in a stock’s price. The persistence of momentum is often attributed to investor ‘under-reaction’ to good news. It is also linked to herd behavior. Another reason is the disposition effect (selling winners too early and holding losers too long).
Our strategy will be based on the classic academic framework for momentum:
- Define a Universe: We need a broad set of stocks to rank against each other.
- Ranking Period (Lookback): We’ll measure each stock’s performance over a specific historical window, typically 12 months. Academic research suggests skipping the most recent month’s return to avoid the “short-term reversal” effect, where stocks that shot up in the last few weeks tend to pull back. So, we’ll measure performance from 12 months ago to 1 month ago.
- Portfolio Formation: At the end of each month, we will rank all stocks in our universe by their lookback performance. We then divide them into quantiles.
- Holding Period: We will “buy” an equal-weighted portfolio of the top-performing quantile (the “winners”) and hold it for a specific period, typically one month.
- Rebalancing: At the end of the holding period (the next month), we repeat the entire process: re-rank all stocks and form a new portfolio of winners. This makes it a dynamic strategy with monthly turnover.
Our goal is to backtest a long-only version of this strategy and compare its performance to a standard market benchmark.
Let’s Get Coding: Building the Momentum Engine
This will be our most sophisticated backtest yet, requiring careful data handling and logical implementation.
Prerequisites
You will need the standard libraries. Ensure you have them installed: pip install numpy pandas pandas-datareader matplotlib yfinance
Note: We’ll use the yfinance library as it can be more reliable for fetching data for a larger number of tickers.
Step 1: Defining Our Universe and Fetching Data
A momentum strategy works best with a broad universe. Let’s use a list of stocks from the S&P 100 as a proxy.
import pandas as pd
import numpy as np
import yfinance as yf
import matplotlib.pyplot as plt
# Define our universe of stocks (a selection from the S&P 100 for diversity)
tickers = [
'AAPL', 'MSFT', 'AMZN', 'GOOGL', 'NVDA', 'JPM', 'JNJ', 'V', 'PG', 'UNH',
'HD', 'MA', 'BAC', 'DIS', 'PFE', 'ADBE', 'CRM', 'NFLX', 'KO', 'PEP',
'XOM', 'CVX', 'LLY', 'AVGO', 'COST', 'MCD', 'WMT', 'CSCO', 'ACN', 'TMO'
]
# Define the backtest period
start_date = '2013-01-01'
end_date = '2023-12-31'
# Fetch monthly closing prices. We use monthly data for this strategy.
monthly_prices = yf.download(tickers, start=start_date, end=end_date, interval='1mo')['Close']
monthly_prices = monthly_prices.dropna(axis=1) # Drop stocks with missing data
# Calculate monthly returns
monthly_returns = monthly_prices.pct_change().dropna()
print("Shape of monthly returns data:", monthly_returns.shape)
print(monthly_returns.head())
Step 2: Implementing the Backtest Loop
This is the core of the strategy. We will loop through each month in our dataset, calculate momentum, form our portfolio, and record the returns for the following month.
# Strategy Parameters
lookback_period = 12
skip_months = 1 # Skip the most recent month
top_n_pct = 0.20 # We'll go long the top 20% of stocks
# Store portfolio returns
portfolio_returns = []
# Store dates for the returns series
portfolio_dates = []
# The main backtest loop starts after our initial lookback period
for i in range(lookback_period, len(monthly_returns)):
# 1. Define the momentum calculation window
# We look from i-12 months to i-1 months ago
momentum_window = monthly_returns.iloc[i - lookback_period : i - skip_months]
# 2. Calculate cumulative returns over the window (our momentum score)
momentum_score = (1 + momentum_window).prod() - 1
# 3. Rank the stocks
momentum_score = momentum_score.sort_values(ascending=False)
# 4. Form the portfolio for the NEXT month
num_stocks = len(momentum_score)
top_n = int(num_stocks * top_n_pct)
# Select the top N stocks (our "winners")
long_portfolio = momentum_score.head(top_n)
winner_tickers = long_portfolio.index
# 5. Calculate the portfolio's return for the holding period (month i)
# This is the crucial "walk-forward" step. We use the returns from the month
# immediately following our calculation period.
holding_month_returns = monthly_returns.iloc[i][winner_tickers]
# Assume an equal-weighted portfolio of the winners
period_return = holding_month_returns.mean()
# 6. Store the results
portfolio_returns.append(period_return)
portfolio_dates.append(monthly_returns.index[i])
# Create a pandas Series from the results
momentum_strategy_returns = pd.Series(portfolio_returns, index=portfolio_dates)
momentum_strategy_returns.name = "Momentum Strategy"
print("\nMomentum Strategy Monthly Returns (first 5 months):")
print(momentum_strategy_returns.head())
Step 3: Creating a Benchmark
To know if our strategy is any good, we must compare it to a simple benchmark. The S&P 500 ETF (SPY) is the standard choice.
# Download SPY data for the same period
spy_data = yf.download('SPY', start=start_date, end=end_date, interval='1mo')['Close']
spy_returns = spy_data.pct_change().dropna()
spy_returns.name = "S&P 500 Benchmark"
# Align the benchmark returns with our strategy's dates
spy_returns = spy_returns.loc[momentum_strategy_returns.index]
Step 4: Visualizing and Analyzing Performance
Now for the payoff. Let’s plot the cumulative growth of $1 invested in our strategy versus the benchmark and calculate our standard performance metrics.
# Calculate cumulative returns
cumulative_momentum = (1 + momentum_strategy_returns).cumprod()
cumulative_spy = (1 + spy_returns).cumprod()
# Plot the equity curves
plt.figure(figsize=(14, 8))
cumulative_momentum.plot(label=momentum_strategy_returns.name, legend=True)
# Extract the 'SPY' column as a Series to access its name
cumulative_spy['SPY'].plot(label=spy_returns['SPY'].name, legend=True)
plt.title('Momentum Strategy vs. S&P 500 Benchmark')
plt.xlabel('Date')
plt.ylabel('Cumulative Growth of $1')
plt.grid(True, linestyle='--', alpha=0.6)
plt.show()
# --- Performance Metrics Calculation ---
def calculate_performance_metrics(returns):
total_return = (1 + returns).prod() - 1
num_years = len(returns) / 12
annualized_return = (1 + total_return) ** (1/num_years) - 1
annualized_volatility = returns.std() * np.sqrt(12)
sharpe_ratio = (annualized_return - 0.02) / annualized_volatility # Assuming 2% risk-free rate
cumulative_returns = (1 + returns).cumprod()
peak = cumulative_returns.expanding(min_periods=1).max()
drawdown = (cumulative_returns/peak) - 1
max_drawdown = drawdown.min()
metrics = {
'Cumulative Return': f"{total_return:.2%}",
'Annualized Return (CAGR)': f"{annualized_return:.2%}",
'Annualized Volatility': f"{annualized_volatility:.2%}",
'Sharpe Ratio': f"{sharpe_ratio:.2f}",
'Maximum Drawdown': f"{max_drawdown:.2%}"
}
return pd.Series(metrics)
# Calculate metrics for the momentum strategy
momentum_metrics = calculate_performance_metrics(momentum_strategy_returns)
# Calculate metrics for the benchmark (using the 'SPY' column as a Series)
benchmark_metrics = calculate_performance_metrics(spy_returns['SPY'])
results_df = pd.DataFrame({
'Momentum Strategy': momentum_metrics,
'S&P 500 Benchmark': benchmark_metrics
})
print("\n--- Backtest Performance Metrics ---")
display(results_df)


Performance Analysis: Momentum Strategy vs. S&P 500
Based on the results from the backtest, the Momentum Strategy significantly outperformed the S&P 500 benchmark across nearly every key metric during the tested period. Here is a detailed breakdown:
- Returns (Absolute Performance):
- Cumulative Return: The Momentum Strategy’s return of 969.40% is extraordinary compared to the benchmark’s 218.32%.
- Annualized Return (CAGR): At 26.99%, the strategy’s annualized return is more than double that of the S&P 500’s 12.39%. This demonstrates not just a one-off success but consistent and powerful outperformance year after year.
- Risk (Volatility and Drawdown):
- Annualized Volatility: The strategy’s outperformance came with higher risk. Its volatility was 20.02% compared to the market’s 15.49%. This indicates that the price swings and fluctuations of the Momentum portfolio were more pronounced than those of the benchmark.
- Maximum Drawdown: This is a very compelling result. Despite being more volatile overall, the Momentum Strategy’s worst peak-to-trough loss was -20.66%, which is actually better than the S&P 500’s maximum loss of -23.97%. This suggests that while the strategy had bigger up-and-down swings, it showed greater resilience. It offered better downside protection during the most severe market downturn of the period.
- Risk-Adjusted Return (Efficiency):
- Sharpe Ratio: This is arguably the most important metric in the table. The Momentum Strategy achieved a Sharpe Ratio of 1.25, which is nearly double the S&P 500’s 0.67. This tells us that the strategy was far more efficient at generating returns for each unit of risk.
Conclusion
The backtest demonstrates that, for this specific universe of stocks and time period, the Momentum Strategy was a resounding success. It generated substantial alpha (excess return over the benchmark) and did so with remarkable efficiency, as evidenced by its high Sharpe Ratio.
The most surprising and attractive feature is its lower maximum drawdown. An investor would have achieved far superior returns while also sleeping slightly better during a market crash.
The Power and Peril of Momentum
Momentum strategies show impressive returns. However, they can also come with higher volatility. They are susceptible to sharp, sudden crashes when market regimes change.
Key Takeaways and Next Steps:
- Turnover and Costs: This is an active strategy that rebalances monthly. In the real world, transaction costs would eat into the returns. A full backtest would need to model these costs.
- Factor Crashes: Momentum can experience periods of severe underperformance, often during sharp market reversals. The maximum drawdown metric is critical to understanding this risk.
- Long-Short Version: A more classic implementation of this strategy would be to go long the top quantile and short-sell the bottom quantile. This aims to create a market-neutral portfolio that profits purely from the momentum effect.
You’ve added a powerful new tool to your quantitative arsenal. You can now move beyond static portfolio analysis and begin to explore the vast world of dynamic, rules-based trading strategies. You can experiment by changing the lookback period, the holding period, or the size of the portfolio to see how it impacts performance.
This is the heart of what quants do: hypothesize, build, test, and refine. Happy modeling!

