DNS Research. Trading and Investing Blog. Free articles every day.

Algorithmic Momentum Trading: Using Python to Backtest Strategies

advertisement

Subscribe to never miss a post

Algorithmic Momentum Trading: Using Python to Backtest Strategies

Momentum trading, the practice of buying assets that have performed well and selling those that have performed poorly, is one of the most empirically validated anomalies in finance. Unlike mean-reversion strategies, which bet on a return to the average, momentum strategies bank on the persistence of trends. While the concept is simple, the execution is fraught with psychological pitfalls—fear, greed, and hindsight bias. This is where algorithmic trading and Python step in. By codifying your momentum rules and rigorously backtesting them, you remove emotion and quantify edge. This guide provides a deep, technical walkthrough of building, testing, and validating momentum strategies using Python.


1. The Core Mechanics of Momentum

Before writing a single line of code, you must understand the two primary flavors of momentum.

Absolute Momentum (Time-Series): This compares an asset’s current price to its own historical price over a lookback window (e.g., 12 months). If the return is positive, you go long; if negative, you go short or stay in cash. This is often called “trend following.”

Cross-Sectional Momentum (Relative): This ranks a universe of assets by their historical returns and buys the top quantile (winners) while shorting the bottom quantile (losers). This is the classic Jegadeesh and Titman (1993) strategy.

Your choice dictates the data structure and the backtesting logic. For this article, we will focus on a hybrid approach: a long-only, cross-sectional strategy on a basket of ETFs, rebalanced monthly.


2. Data Acquisition and Preparation

High-quality backtesting begins with clean, adjusted data. Survivorship bias—where delisted assets are removed from the dataset—is the silent killer of backtests. Use libraries like yfinance, pandas-datareader, or professional APIs (e.g., Alpha Vantage, Polygon.io) that account for splits and dividends via “adjusted close” prices.

Step-by-Step Implementation:

import pandas as pd
import numpy as np
import yfinance as yf
from datetime import datetime, timedelta

# Ticker universe: diversified ETFs
tickers = ['SPY', 'QQQ', 'IWM', 'EFA', 'EEM', 'TLT', 'GLD', 'DBC']
end_date = datetime.now()
start_date = end_date - timedelta(days=365*5)  # 5 years of data

# Download adjusted close prices
data = yf.download(tickers, start=start_date, end=end_date, auto_adjust=True)['Close']

# Drop any columns with excessive NaNs (e.g., ETFs that started mid-period)
data = data.dropna(axis=1, thresh=int(len(data) * 0.95))
data = data.fillna(method='ffill').dropna()

# Calculate historical returns for momentum score
lookback = 90  # 3-month lookback (approx 63 trading days)
momentum_scores = data.pct_change(lookback).iloc[lookback:]

Critical Note on Lookback: Academic research often uses a 12-month lookback skipping the most recent month (to avoid short-term reversal). Test multiple lookbacks (60, 90, 126, 252 days) to find robustness, not just a single optimized value.


3. Building the Signal Generation Logic

Your signal is a rule that converts raw price data into portfolio positions. For a cross-sectional strategy, we rank the assets daily or weekly and select the top N.

The Ranking Function:

def generate_signals(data, lookback, top_n=2):
    """
    Generate a DataFrame of long positions (1) or no position (0).
    """
    scores = data.pct_change(lookback)
    signals = pd.DataFrame(0, index=scores.index, columns=scores.columns)

    # Rank assets each row (date) and select top_n
    for date, row in scores.iterrows():
        valid = row.dropna()
        if len(valid) < top_n:
            continue
        # Rank descending, 1 = highest momentum
        ranks = valid.rank(ascending=False)
        top_picks = ranks[ranks <= top_n].index
        signals.loc[date, top_picks] = 1

    # Apply a rebalancing frequency: trade only on the first trading day of each month
    monthly = signals.resample('M').last()  # Last signal of month
    # Forward fill to hold positions throughout the month
    signals = monthly.reindex(data.index, method='ffill').fillna(0)

    return signals.shift(1)  # Shift to avoid lookahead bias (use next day's signal)

Why shift(1)? This is non-negotiable. It ensures you execute trades on the next trading day after the signal is generated, mimicking real-world latency. Without this, you are peeking into the future.

Transaction Costs: The above code ignores costs. Efficiently, you must subtract a cost per turnover. Let’s compute turnover and apply slippage & commissions:

def apply_costs(signals, data, cost_per_trade=0.001):
    """
    Approximate costs: 0.1% per trade (commission + slippage).
    """
    # Determine where positions change
    position_changes = signals.diff().abs().sum(axis=1)
    turnover = position_changes * cost_per_trade
    return turnover

4. Backtesting Engine: Vectorization vs. Event-Driven

You have two primary backtesting architectures. For most 80% of strategies, vectorized backtesting is sufficient, faster, and less error-prone. Event-driven backtesting is for complex execution logic (partial fills, limit orders) and is overkill for a daily momentum strategy.

Vectorized Portfolio Returns:

def backtest_strategy(signals, data, cost_per_trade=0.001):
    """
    Vectorized backtest logic.
    """
    # Align signals and data
    aligned_data = data.reindex(signals.index)

    # Strategy daily returns: position * asset daily return
    asset_returns = aligned_data.pct_change().fillna(0)
    strategy_returns = (signals.shift(1) * asset_returns).sum(axis=1)

    # Subtract transaction costs based on turnover
    turnover = signals.diff().abs().sum(axis=1).fillna(0)
    cost_penalty = turnover * cost_per_trade
    net_returns = strategy_returns - cost_penalty

    # Accumulate performance
    cumulative_returns = (1 + net_returns).cumprod()

    return net_returns, cumulative_returns

# Run backtest
signal_df = generate_signals(data, lookback=90, top_n=2)
net_rets, cum_ret = backtest_strategy(signal_df, data)
print(f"Total Return: {cum_ret[-1]:.2f}%")

Performance Metrics: A good backtest report must include more than total return. Calculate annualized Sharpe ratio, max drawdown, and win rate.

def calculate_metrics(net_returns, risk_free_rate=0.01):
    """
    Compute Sharpe, Sortino, Max Drawdown, etc.
    """
    # Annualized Sharpe (assuming 252 trading days)
    mu = net_returns.mean() * 252
    sigma = net_returns.std() * np.sqrt(252)
    sharpe = (mu - risk_free_rate) / sigma if sigma > 0 else 0

    # Max Drawdown from cumulative returns
    cum = (1 + net_returns).cumprod()
    running_max = cum.cummax()
    drawdown = (cum - running_max) / running_max
    max_dd = drawdown.min()

    # Profit factor
    gains = net_returns[net_returns > 0].sum()
    losses = -net_returns[net_returns < 0].sum()
    profit_factor = gains / losses if losses != 0 else np.inf

    return {'Sharpe': sharpe, 'MaxDrawdown': max_dd, 'ProfitFactor': profit_factor}

metrics = calculate_metrics(net_rets)
print(metrics)

5. Parameter Sensitivity Analysis and Overfitting

The most significant pitfall in algorithmic trading is overfitting to historical noise. A strategy that works with a 90-day lookback might fail with 95-days. You must conduct a sensitivity analysis across the parameter grid.

Grid Search Implementation:

import itertools

results = []
lookbacks = [60, 90, 126, 180, 252]
top_n_values = [1, 2, 3]

for lb, tn in itertools.product(lookbacks, top_n_values):
    sig_df = generate_signals(data, lookback=lb, top_n=tn)
    net_r, _ = backtest_strategy(sig_df, data)
    met = calculate_metrics(net_r)
    met['Lookback'] = lb
    met['TopN'] = tn
    results.append(met)

result_df = pd.DataFrame(results)
result_df = result_df.sort_values('Sharpe', ascending=False)

Interpreting the Grid: If the best Sharpe (e.g., 1.5) is surrounded by parameters yielding Sharpe ratios of 0.3, you have overfit. Look for plateaus—regions of parameter space where performance is consistently decent. The strategy should be robust, not a razor-sharp peak.


6. Walk-Forward Analysis (WFA)

Static backtesting tests parameters on the entire dataset after the data has occurred. Walk-forward analysis simulates reality: optimize on historical data, then test on out-of-sample data, rolling forward.

Implementation Sketch:

def walk_forward(data, initial_train_years=2, test_months=3, lookback_range=[60, 90, 126]):
    """
    Simplified walk-forward: retrain quarterly on a rolling window.
    """
    train_days = initial_train_years * 252
    test_days = test_months * 21
    all_net_returns = []

    start = train_days
    for i in range(start, len(data), test_days):
        train_data = data.iloc[i-train_days:i]
        test_data = data.iloc[i:i+test_days]

        # Find best lookback on train_data (short grid search)
        best_sharpe = -np.inf
        best_lb = lookback_range[0]
        for lb in lookback_range:
            sig = generate_signals(train_data, lookback=lb, top_n=2)
            net_r, _ = backtest_strategy(sig, train_data)
            met = calculate_metrics(net_r)
            if met['Sharpe'] > best_sharpe:
                best_sharpe = met['Sharpe']
                best_lb = lb

        # Apply best parameters to test period (no cost optimization here)
        sig_test = generate_signals(test_data, lookback=best_lb, top_n=2)
        net_r_test, _ = backtest_strategy(sig_test, test_data)
        all_net_returns.append(net_r_test)

    # Concatenate all out-of-sample segments
    final_returns = pd.concat(all_net_returns)
    final_cum = (1 + final_returns).cumprod()
    return final_cum, final_returns

Why WFA matters: It gives a realistic estimate of live performance. If your strategy survives walk-forward analysis with acceptable drawdown and positive returns, it has passed the first gate of validity.


7. Risk Management Integration

Momentum strategies often exhibit high volatility and prone to “whipsaw” losses in choppy markets. Your backtest must include risk overlays.

Volatility Scaling: Position sizes inversely proportional to volatility. This is a critical enhancement to raw momentum.

def add_volatility_targeting(data, signals, target_vol=0.15):
    """
    Scale gross exposure to target annualized volatility.
    """
    # Calculate 20-day rolling annualized volatility for each asset
    rolling_vol = data.pct_change().rolling(20).std() * np.sqrt(252)

    # Average volatility of held positions
    # If holding N assets, the portfolio vol is sqrt(N) * avg_vol (assuming uncorrelated)
    max_exposure = signals.sum(axis=1)
    avg_vol = (rolling_vol * signals).sum(axis=1) / max_exposure

    # Scaling factor: target_vol / (avg_vol * sqrt(N))
    scaling = np.where(avg_vol > 0, target_vol / (avg_vol * np.sqrt(max_exposure+1e-6)), 0)
    scaling = np.clip(scaling, 0, 1)  # Cap at 1x leverage

    scaled_signals = signals.multiply(scaling, axis=0)
    return scaled_signals.fillna(0)

Stop-Loss Logic: While momentum implies holding through drawdowns, a trailing stop (e.g., 15% below the peak) prevents catastrophic losses on sudden reversals. Backtest with and without stops to quantify the impact.

def apply_trailing_stop(cumulative_returns, stop_pct=0.20):
    """
    Reset positions to cash if equity drops more than stop_pct from peak.
    """
    running_max = cumulative_returns.cummax()
    drawdown = (cumulative_returns - running_max) / running_max
    stop_triggered = drawdown < -stop_pct
    return stop_triggered

8. Common Pitfalls in Momentum Backtesting

Lookahead Bias: Besides shift(1), you must also delay the momentum score. When you use pct_change(lookback), at the close of day T, you know the return from T-lookback to T. You can trade at the close of T or the open of T+1. If you trade at the close of T, that’s realistic. If you trade at the open of T, you must shift the score by 1 day.

Survivorship Bias: Including ETFs that are still active but excluding those that merged or closed (e.g., JETS was liquidated in 2024). Backtest on a static universe and you will overestimate returns. Use point-in-time data if possible.

Transaction Cost Understatement: In live trading, market impact and the bid-ask spread are larger than theoretical costs. Use a cost model that increases with volatility: cost = spread/2 + slippage * (volume_fraction). Start with 10-20 basis points per trade for liquid ETFs; for small caps, expect 50+ bps.

Rebalance Frequency: Monthly rebalancing is standard for momentum. But beware of calendar clustering—all momentum strategies rebalance at month-end, causing crowding. Test mid-month or bi-weekly rebalancing to reduce correlation with other quant funds.

Correlation with the Market: Momentum is strongly correlated with a long equity exposure. Check the beta of your strategy to SPY. If beta is high, you are essentially a leveraged S&P 500 index fund masquerading as an alpha strategy. Compute a rolling beta and consider market-neutral (long high, short low) construction.

# Calculate strategy vs SPY beta
import statsmodels.api as sm

spy_returns = data['SPY'].pct_change().dropna()
strat_returns = net_returns.reindex(spy_returns.index).dropna()

# Align lengths
min_len = min(len(spy_returns), len(strat_returns))
X = spy_returns[-min_len:].values
y = strat_returns[-min_len:].values
X = sm.add_constant(X)
model = sm.OLS(y, X).fit()
beta = model.params[1]
print(f"Strategy Beta to SPY: {beta:.2f}")

9. Code Optimization and Scaling

Your first backtest might run on 8 ETFs. But real-world strategies use 500+ stocks. Python’s performance can become a bottleneck. Use these techniques to scale:

Numpy Vectorization: Avoid for loops over rows. Use .apply on DataFrames or convert to NumPy arrays for heavy calculations. The ranking logic in generate_signals can be vectorized using rank(axis=1, ascending=False) directly on the scores DataFrame.

# Vectorized cross-sectional ranking
def vectorized_signals(scores, top_n):
    ranks = scores.rank(axis=1, ascending=False)
    signals = (ranks <= top_n).astype(int)
    return signals

Numba JIT Compilation: For complex loops (e.g., simulation of trade fills), use @njit from numba to compile Python to machine code. This can speed up loops by 100x.

Multiprocessing: For grid searches across thousands of parameter combinations, use concurrent.futures.ProcessPoolExecutor to parallelize across CPU cores. Each backtest is independent.

from concurrent.futures import ProcessPoolExecutor

def run_single_param(args):
    lb, tn = args
    sig = generate_signals(data, lookback=lb, top_n=tn)
    net_r, _ = backtest_strategy(sig, data)
    met = calculate_metrics(net_r)
    return (lb, tn, met['Sharpe'])

with ProcessPoolExecutor() as executor:
    results = list(executor.map(run_single_param, itertools.product(lookbacks, top_n_values)))

Data Storage: Use Parquet files instead of CSV for historical data. They are compressed and handle large datasets efficiently.


10. Advanced Momentum Enhancements to Test

Beyond simple price momentum, modern algorithms incorporate:

Volume-Adjusted Momentum: Multiply price change by volume. An uptrend on high volume is stronger than one on low volume.

Overnight vs. Intraday Momentum: Research shows that momentum profits are primarily earned overnight. Test a strategy that only holds from close to open.

Momentum Accrual: Instead of a binary selection (top 2), use a weighted portfolio where rank determines weight. Use a linear or exponential weighting scheme.

# Weighted momentum
def weighted_signals(scores, top_n=5):
    ranks = scores.rank(axis=1, ascending=True)  # 1 = lowest
    # Only keep top_n
    weights = (ranks <= top_n).astype(float)
    # Assign linearly decreasing weights: top pick gets N, next N-1...
    weights = weights * (top_n + 1 - ranks.where(weights==1, 0))
    # Normalize to sum to 1
    weights = weights.div(weights.sum(axis=1), axis=0)
    return weights.shift(1)

Sector Neutrality: If you are trading ETFs or stocks, classify them into sectors (Tech, Energy, etc.) and ensure you hold at least one from each sector. This avoids concentration risk in a single industry.


11. Reporting and Visualization

A backtest is meaningless without context. Generate charts that show:

  • Cumulative returns vs. buy-and-hold SPY
  • Drawdown timeline
  • Rolling 12-month excess returns
  • Scatter plot of monthly returns vs. SPY monthly returns (to show alpha/beta)
  • Heatmap of Sharpe ratios across the parameter grid

Python Visualization Stack:

import matplotlib.pyplot as plt
import seaborn as sns

plt.figure(figsize=(12, 6))
plt.plot(cum_ret.index, cum_ret, label='Momentum Strategy')
plt.plot(data['SPY'].reindex(cum_ret.index).pct_change().add(1).cumprod(), label='S&P 500 Buy & Hold')
plt.title('Cumulative Returns Comparison')
plt.ylabel('Growth of $1')
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()

# Drawdown chart
running_max = cum_ret.cummax()
dd = (cum_ret - running_max) / running_max
plt.figure(figsize=(12, 4))
plt.fill_between(dd.index, dd*100, 0, color='red', alpha=0.5)
plt.title('Strategy Drawdown (%)')
plt.ylabel('Drawdown %')
plt.show()

# Parameter heatmap
pivot = result_df.pivot(index='Lookback', columns='TopN', values='Sharpe')
sns.heatmap(pivot, annot=True, cmap='RdYlGn', fmt='.2f')
plt.title('Sharpe Ratio Heatmap')
plt.show()

12. Connecting to Live Trading

The final step is bridging the backtest to a paper trading environment. Use a broker API like Alpaca (Python SDK) or Interactive Brokers. The key translation:

1. Convert Signals to Orders: Your backtest generates a target portfolio weight per asset. The live system must calculate the number of shares to buy based on current account equity.

2. Execution Logic: Use market-on-open (MOO) orders for monthly rebalancing to minimize slippage, or schedule an algo to trade during the last hour of the day.

3. Monitoring: Build a dashboard using streamlit or plotly-dash that fetches the latest momentum scores and flags when the portfolio is due for rebalancing.

# Pseudocode for live order generation
def get_target_orders(current_prices, score_df, portfolio_value, lookback=90):
    latest_scores = score_df.iloc[-1]
    top_tickers = latest_scores.nlargest(2).index
    # Allocate equally 50% each
    allocation = portfolio_value / 2
    orders = []
    for t in top_tickers:
        qty = int(allocation / current_prices[t])
        orders.append((t, qty))
    return orders

Important Caveat: The code is a framework, not a finished product. You must handle corporate actions (splits dividends), short selling restrictions, and regulatory requirements (pattern day trader rules) before deploying capital.

advertisement

Latest Posts (multi-lingual)

Something went wrong. Please refresh the page and/or try again.

Discover more from DNS Research

Subscribe now to keep reading and get access to the full archive.

Continue reading