How to Backtest a Momentum Strategy Using vectorbt: A Step-by-Step Guide

You can backtest a momentum strategy using vectorbt by calculating 12-month returns (skipping the most recent month), ranking assets to generate long/short signals, and passing these signals to vbt.Portfolio.from_signals() for vectorized backtesting.

The repository paperswithbacktest/awesome-systematic-trading contains canonical momentum implementations originally written for QuantConnect that translate directly into vectorbt workflows. By leveraging vectorbt's Numba-accelerated engine, you can replicate institutional-grade momentum factor backtesting on plain pandas DataFrames without complex event-driven architecture.

Understanding the Momentum Logic

The reference implementation in static/strategies/momentum-factor-effect-in-stocks.py defines momentum as the past 12-month return excluding the most recent month—a standard academic definition used to avoid short-term reversal effects. The script's CoarseSelectionFunction and FineSelectionFunction methods filter and rank securities by this metric, then allocate equal weights to top and bottom performers.

This same logic maps cleanly to vectorbt's vectorized paradigm. Instead of iterating through bars, you operate on entire price matrices at once, computing signals for all assets simultaneously.

Step-by-Step Implementation

Load Price Data

vectorbt accepts any pandas DataFrame with shape (dates × assets). Use yfinance or your preferred data provider to fetch adjusted close prices.

import yfinance as yf
import vectorbt as vbt

# Fetch S&P 500 constituents or your universe

tickers = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'META', 'JPM', 'BAC', 'XOM']
prices = yf.download(tickers, start='2000-01-01', end='2024-01-01')['Adj Close']

Calculate Momentum Signals

Compute the 12-month (252 trading days) return and skip the most recent month (21 days) to match the logic in the QuantConnect algorithm.


# 12-month momentum, skipping the most recent month

momentum = prices.pct_change(252).shift(21)

This mirrors the comment in momentum-factor-effect-in-stocks.py lines 3-5, which explicitly excludes the most recent month's returns to avoid reversal bias.

Generate Long/Short Signals

Rank assets monthly and select extremes. The QuantConnect version uses sorted_by_perf to identify winners and losers, assigning equal weights in lines 79-94.


# Define portfolio size (e.g., top/bottom 10%)

n_assets = prices.shape[1]
n_long = n_short = int(0.10 * n_assets)

# Rank assets by momentum (1 = highest)

rank = momentum.rank(axis=1, ascending=False, method='first')

# Generate signals: 1 for long, -1 for short, 0 otherwise

long_signal = (rank <= n_long).astype(int)
short_signal = (rank > n_assets - n_short).astype(int) * -1
signals = long_signal + short_signal

Build the Portfolio with vectorbt

Pass the boolean signals to Portfolio.from_signals(), specifying bidirectional trading to allow both long and short positions.


# Create boolean masks for entries and exits

entries = signals > 0
exits = signals < 0

pf = vbt.Portfolio.from_signals(
    prices,
    entries=entries,
    exits=exits,
    direction='both',      # Enable long and short positions

    freq='1M'              # Monthly rebalancing frequency

)

Analyze Performance Metrics

vectorbt replaces the ad-hoc logging in QuantConnect's OnData method with comprehensive built-in analytics.


# Key performance statistics

print(pf.stats())

# Specific metrics

print(f"Total Return: {pf.total_return():.2%}")
print(f"Sharpe Ratio: {pf.sharpe():.2f}")
print(f"Max Drawdown: {pf.max_drawdown():.2%}")

Complete Working Example

The following script consolidates the entire workflow, replicating the momentum factor effect from the repository's QuantConnect implementation using pure vectorbt:

import yfinance as yf
import pandas as pd
import vectorbt as vbt

# 1. Load data

tickers = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'META', 
           'JPM', 'BAC', 'XOM', 'JNJ', 'WMT']
prices = yf.download(tickers, start='2015-01-01', end='2024-01-01')['Adj Close']

# 2. Calculate momentum (12-month, skip 1 month)

lookback = 252  # trading days

skip = 21       # 1 month

momentum = prices.pct_change(lookback).shift(skip)

# 3. Generate signals monthly

# Resample to month-end to match QuantConnect's monthly rebalancing

month_end_prices = prices.resample('M').last()
month_end_momentum = momentum.resample('M').last()

n_long = n_short = 2  # Top/bottom 2 assets

# Rank and select

rank = month_end_momentum.rank(axis=1, ascending=False)
long_entries = rank <= n_long
short_entries = rank > (prices.shape[1] - n_short)

# Align signals with daily prices for vectorbt

long_entries = long_entries.reindex(prices.index, method='ffill')
short_entries = short_entries.reindex(prices.index, method='ffill')

# 4. Build portfolio

pf = vbt.Portfolio.from_signals(
    prices,
    entries=long_entries,
    exits=short_entries,
    direction='both',
    freq='1D'
)

# 5. Results

print(pf.stats())
pf.total_return().vbt.plot()

Key Reference Files in the Repository

Summary

  • vectorbt enables vectorized momentum backtesting directly on pandas DataFrames, eliminating the need for event-driven loops.
  • Calculate momentum using pct_change(252).shift(21) to match the academic definition found in the repository's QuantConnect scripts.
  • Use rank(axis=1) to replicate the sorting logic in momentum-factor-effect-in-stocks.py lines 79-94.
  • Feed boolean signals to vbt.Portfolio.from_signals() with direction='both' to implement long/short equity strategies.
  • Access institutional-grade analytics via pf.stats(), including Sharpe ratio, drawdown, and return attribution.

Frequently Asked Questions

What is the optimal lookback period for momentum strategies?

Most implementations in paperswithbacktest/awesome-systematic-trading use a 12-month lookback period (252 trading days) excluding the most recent month. This 12-1 month formation period is the academic standard found in the momentum-factor-effect-in-stocks.py file, designed to capture persistent trends while avoiding short-term reversal effects.

How does vectorbt handle transaction costs and slippage?

When calling vbt.Portfolio.from_signals(), you can specify fees (as a percentage of traded value), slippage (as a percentage of price), and fixed_fees (per-trade costs). For realistic momentum strategy backtesting, set fees=0.001 for 10 basis points commission and slippage=0.0005 to simulate market impact during monthly rebalancing.

Can I implement time-series momentum instead of cross-sectional momentum?

Yes. While the repository's time-series-momentum-effect.py demonstrates cross-sectional ranking (comparing assets to each other), you can adapt the code to time-series momentum by comparing each asset's return to a threshold (e.g., risk-free rate or zero): long_signal = momentum > 0. This creates individual trend-following signals without ranking against other assets.

How do I optimize the number of portfolio positions (N) in vectorbt?

Use vbt.Portfolio.from_signals() inside an optimization loop or with vbt.Param objects. Create a parameter grid for n_long and n_short, then run pf.from_signals() for each combination. Compare results using pf.sharpe() or pf.calmar_ratio() to identify the optimal portfolio concentration that maximizes risk-adjusted returns for your specific universe.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →