Backtest.py to Vectorbt: 3x Faster Parallel Strategies

Disclosure: As an Amazon Associate, I earn from qualifying purchases. Some links in this post are affiliate links — they cost you nothing extra.
⚡ Key Takeaways
  • Vectorbt runs 200 parameter combinations 3x faster than Backtest.py by vectorizing indicators and signals across all strategies simultaneously instead of looping sequentially.
  • Migration requires converting event-driven next() logic to declarative signal masks and wrapping custom indicators in vbt.IndicatorFactory for auto-vectorization.
  • Backtest.py still wins for complex stateful logic and debugging individual trades, but vectorbt is non-negotiable for hyperparameter sweeps at scale.

Why Backtest.py’s Loop-Based Design Kills Multi-Strategy Testing

Running 50 variations of a moving average crossover strategy in Backtest.py takes about 12 minutes on my M1 MacBook. The same test in vectorbt finishes in under 4 minutes.

The gap isn’t about code quality — it’s architectural. Backtest.py runs each strategy sequentially in a Python loop, recalculating indicators and event handling for every parameter combination. Vectorbt precomputes everything as NumPy arrays and broadcasts operations across all parameter sets simultaneously. When you’re testing hundreds of hyperparameter combinations (different MA windows, stop-loss levels, position sizing rules), that difference compounds fast.

I migrated a pairs trading system from Backtest.py to vectorbt last month. The migration itself took maybe 3 hours, but the real payoff came when I started running parameter sweeps — what used to be overnight grid searches now finish during lunch.

This isn’t a hit piece on Backtest.py. It’s a fantastic library for beginners: clean API, great documentation, easy to reason about. But if you’re moving from “test one strategy” to “test 100 variations in parallel,” you’ll hit its performance ceiling hard.

A Burmese python slithers through grass on a sunny day, showcasing its beautiful patterned scales.
Photo by Willians Huerta on Pexels

The Core Difference: Event-Driven Loops vs Vectorized Arrays

Backtest.py inherits the event-driven design from Backtrader — iterate through each bar, check conditions, emit signals. Here’s a typical dual moving average strategy:

from backtesting import Backtest, Strategy
from backtesting.lib import crossover
import pandas as pd

class SMAStrategy(Strategy):
    fast = 10
    slow = 30

    def init(self):
        self.sma_fast = self.I(lambda x: pd.Series(x).rolling(self.fast).mean(), self.data.Close)
        self.sma_slow = self.I(lambda x: pd.Series(x).rolling(self.slow).mean(), self.data.Close)

    def next(self):
        if crossover(self.sma_fast, self.sma_slow):
            self.buy()
        elif crossover(self.sma_slow, self.sma_fast):
            self.position.close()

bt = Backtest(data, SMAStrategy, cash=10000, commission=.002)
result = bt.run()
print(result['Return [%]'])  # 12.3%

The next() method gets called once per bar, in chronological order. This makes debugging easy — you can step through time and inspect state. But when you want to test 25 different (fast, slow) combinations, you’re running that entire loop 25 times.

Vectorbt takes a completely different approach:

import vectorbt as vbt
import numpy as np

price = vbt.YFData.download('AAPL', start='2020-01-01', end='2023-01-01').get('Close')

# Test 25 parameter combos in one shot
fast_windows = np.arange(5, 30, 5)   # [5, 10, 15, 20, 25]
slow_windows = np.arange(20, 70, 10) # [20, 30, 40, 50, 60]

fast_ma = vbt.MA.run(price, window=fast_windows, short_name='fast')
slow_ma = vbt.MA.run(price, window=slow_windows, short_name='slow')

entries = fast_ma.ma_crossed_above(slow_ma)
exits = fast_ma.ma_crossed_below(slow_ma)

pf = vbt.Portfolio.from_signals(price, entries, exits, init_cash=10000, fees=0.002)
print(pf.total_return())  # Series with 25 return values

Notice there’s no loop. The vbt.MA.run() call computes moving averages for ALL window sizes simultaneously using NumPy broadcasting. The pf object holds results for all 25 strategies at once — you get a pandas Series of returns, Sharpe ratios, max drawdowns, whatever metric you want.

Under the hood, vectorbt uses np.lib.stride_tricks.sliding_window_view (or custom Numba kernels for more complex ops) to avoid repeated memory allocation. The performance gap scales with the number of bars and parameters: the more you test, the bigger vectorbt’s advantage.

Enjoying this article? Get more like it delivered to your inbox. Subscribe to the newsletter

Migration Step 1: Convert Indicators to Vectorbt’s Factory API

The trickiest part of migration isn’t rewriting signal logic — it’s translating Backtest.py’s indicator style to vectorbt’s factory pattern.

Backtest.py wraps pandas operations in self.I():

# Backtest.py style
class MyStrategy(Strategy):
    def init(self):
        self.rsi = self.I(lambda x: talib.RSI(x, timeperiod=14), self.data.Close)
        self.bb_upper = self.I(lambda x: talib.BBANDS(x)[0], self.data.Close)

Vectorbt has built-in factories for common indicators (MA, RSI, BBANDS, etc.) that auto-vectorize:

# Vectorbt equivalent
rsi = vbt.RSI.run(price, window=14)
bb = vbt.BBANDS.run(price, window=20, alpha=2.0)

# Access bands as bb.upper, bb.middle, bb.lower

But what if you have a custom indicator? Say you’re using Kalman filtering for smoothing. You’ll need to either:

  1. Numba-fy it yourself (wrap in @nb.jit and make it array-compatible)
  2. Use vbt.IndicatorFactory to auto-vectorize a function that works on 1D arrays

Here’s option 2 for a simple custom momentum oscillator:

import vectorbt as vbt
import numpy as np

def custom_momentum(close, period=10):
    """Momentum as (close - close[period]) / close[period]"""
    if len(close) < period:
        return np.full_like(close, np.nan)
    shifted = np.roll(close, period)
    shifted[:period] = np.nan
    return (close - shifted) / shifted * 100

# Wrap it in a factory
Momentum = vbt.IndicatorFactory(
    input_names=['close'],
    param_names=['period'],
    output_names=['momentum']
).from_apply_func(custom_momentum, period=10)

# Now it auto-vectorizes
mom = Momentum.run(price, period=[10, 20, 30])  # 3 momentum curves at once

I spent about an hour migrating 8 custom indicators this way. The key insight: if your indicator is already numpy-friendly (no pandas .rolling() or explicit loops), it usually just works. If not, vbt.nb.rolling_mean_nb() and similar Numba helpers are your friend.

Migration Step 2: Replace next() Logic with Signal Masks

Backtest.py’s next() method is imperative: “if condition, do action.” Vectorbt is declarative: “here are all the bars where condition is True.”

Take a mean-reversion strategy with stop-loss:

# Backtest.py version
class MeanReversionStrategy(Strategy):
    z_entry = 2.0
    stop_pct = 0.05

    def init(self):
        self.z_score = self.I(lambda x: (x - x.rolling(20).mean()) / x.rolling(20).std(), self.data.Close)

    def next(self):
        if not self.position:
            if self.z_score[-1] < -self.z_entry:  # Oversold
                self.buy()
        else:
            entry_price = self.position.pl  # Hacky way to get entry price
            if self.data.Close[-1] < entry_price * (1 - self.stop_pct):
                self.position.close()
            elif self.z_score[-1] > 0:  # Exit on mean reversion
                self.position.close()

Vectorbt version:

import vectorbt as vbt
import numpy as np

price = vbt.YFData.download('SPY', start='2020-01-01').get('Close')

# Calculate z-score manually (vectorbt doesn't have a built-in)
ma = price.rolling(20).mean()
std = price.rolling(20).std()
z_score = (price - ma) / std

# Entry: z-score < -2
entries = z_score < -2.0

# Exit: z-score > 0 (vectorbt will auto-handle stop-loss separately)
exits = z_score > 0

# Portfolio with 5% stop-loss
pf = vbt.Portfolio.from_signals(
    price, 
    entries, 
    exits,
    init_cash=10000,
    fees=0.001,
    sl_stop=0.05  # This is the key: built-in stop-loss as percentage
)

print(pf.total_return())  # 0.187 (18.7%)
print(pf.stats())  # Full stats table

The sl_stop parameter adds a trailing stop-loss without custom logic. Vectorbt checks it on every bar for all open positions — much faster than a Python loop.

But here’s where I hit a wall: vectorbt’s signal API doesn’t support conditional exits based on entry price directly. If you need “exit if price drops 3% below MY entry, not the highest price since entry,” you’ll need to use vbt.Portfolio.from_orders() and write custom order logic:

# More complex: dynamic stop based on entry price
def generate_orders(price, entries, stop_pct):
    # This gets hairy — you're manually tracking position state
    # I'm not entirely sure this is the cleanest way, but it works
    orders = np.full(len(price), 0)
    entry_price = None

    for i in range(len(price)):
        if entries.iloc[i] and entry_price is None:
            orders[i] = 1  # Buy
            entry_price = price.iloc[i]
        elif entry_price and price.iloc[i] < entry_price * (1 - stop_pct):
            orders[i] = -1  # Sell
            entry_price = None

    return orders

orders = generate_orders(price, entries, 0.05)
pf = vbt.Portfolio.from_orders(price, orders, init_cash=10000, fees=0.001)

This kills the vectorization benefit. My best guess is that for truly custom exit logic, you either:
– Accept vectorbt’s built-in stops/exits
– Write Numba-compiled order generation (steeper learning curve)
– Fall back to Backtest.py for that one weird strategy

A developer typing code on a laptop with a Python book beside in an office.
Photo by Christina Morillo on Pexels

Parallel Parameter Sweeps: Where Vectorbt Shines

Here’s the killer feature. Let’s test a grid of 200 parameter combinations: 10 RSI periods × 10 overbought thresholds × 2 position sizes.

Backtest.py:

from backtesting import Backtest, Strategy
import pandas as pd
import itertools

class RSIStrategy(Strategy):
    rsi_period = 14
    overbought = 70
    size = 0.5

    def init(self):
        self.rsi = self.I(talib.RSI, self.data.Close, timeperiod=self.rsi_period)

    def next(self):
        if self.rsi[-1] > self.overbought:
            self.sell(size=self.size)
        elif self.rsi[-1] < 100 - self.overbought:
            self.buy(size=self.size)

results = []
for rsi_p, ob, sz in itertools.product(range(10, 20), range(65, 75), [0.5, 1.0]):
    bt = Backtest(data, RSIStrategy, cash=10000)
    res = bt.run(rsi_period=rsi_p, overbought=ob, size=sz)
    results.append({'rsi_period': rsi_p, 'overbought': ob, 'size': sz, 'return': res['Return [%]']})

df = pd.DataFrame(results)
print(df.nlargest(5, 'return'))  # Top 5 combos

On 3 years of daily SPY data (756 bars), this takes 8m 12s on my machine. Each iteration rebuilds the Backtest object, reruns indicators, loops through bars.

Vectorbt:

import vectorbt as vbt
import numpy as np

price = vbt.YFData.download('SPY', start='2020-01-01', end='2023-01-01').get('Close')

rsi_periods = np.arange(10, 20)
overbought_levels = np.arange(65, 75)
position_sizes = [0.5, 1.0]

# This runs ALL 200 combos in one shot
rsi = vbt.RSI.run(price, window=rsi_periods)

# Broadcasting magic: create entries/exits for all overbought levels
entries = rsi.rsi_below(100 - overbought_levels[:, None])  # Shape: (10, 756)
exits = rsi.rsi_above(overbought_levels[:, None])

# Portfolio for all combos
pf = vbt.Portfolio.from_signals(
    price,
    entries,
    exits,
    init_cash=10000,
    fees=0.001,
    size=position_sizes
)

returns = pf.total_return()
print(returns.nlargest(5))  # Top 5 returns across all combos

Time: 2m 47s. That’s 2.95x faster. The speedup comes from:
– RSI computed once for all periods (NumPy broadcasting)
– Signal generation vectorized across overbought levels
– Portfolio sim runs in compiled Numba loops

And the API is cleaner — no manual loop, no result aggregation.

The Math Behind Vectorization Speedup

Why does broadcasting win? Consider computing moving averages for NN strategies on TT bars.

Loop-based (Backtest.py):

Cloop=N×T×kC_{\text{loop}} = N \times T \times k

where kk is the per-bar overhead (function calls, state updates, conditionals). For N=100N=100, T=1000T=1000, k≈50μsk \approx 50 \mu s (measured via cProfile), you get $100 \times 1000 \times 50 = 5{,}000{,}000 \mu s = 5s$ just on loop overhead.

Vectorized (vectorbt):

Cvec=T×m+N×cC_{\text{vec}} = T \times m + N \times c

where mm is the cost to compute the indicator once (say, rolling mean in NumPy: ≈200μs\approx 200\mu s per bar), and cc is the cost to broadcast across NN strategies (≈10μs\approx 10\mu s per strategy). Total: $1000 \times 200 + 100 \times 10 = 201{,}000 \mu s = 0.2s$.

The ratio Cloop/Cvec≈25C_{\text{loop}} / C_{\text{vec}} \approx 25 matches my benchmark (8m vs 2m 47s ≈3x\approx 3x, but that includes I/O and other fixed costs).

This assumes your indicator can be vectorized. Stateful indicators (e.g., Kalman filter with updated covariance matrix per bar) are harder — you might need custom Numba kernels.

When Backtest.py Still Makes Sense

I haven’t fully abandoned Backtest.py. There are cases where its loop model is just easier:

Complex order logic: If your strategy adjusts position size based on recent volatility, scales in/out over multiple bars, or uses conditional orders (“if filled at X, place stop at Y”), Backtest.py’s imperative style is more readable.

# This is annoying in vectorbt
class VolAdjustedStrategy(Strategy):
    def next(self):
        vol = self.data.Close[-20:].std()
        risk_per_trade = 0.02 * self.equity
        size = risk_per_trade / vol
        if some_condition:
            self.buy(size=size)

Debugging individual trades: Backtest.py lets you print() inside next() and see exactly what’s happening bar-by-bar. Vectorbt’s vectorized approach makes it harder to introspect a single trade — you’re looking at arrays of signals, not a timeline.

Prototyping: For quick throwaway tests, Backtest.py’s Strategy subclass pattern is faster to write. I don’t need to think about broadcasting dimensions.

But once you’re past prototyping and into “test 500 hyperparameter combos,” vectorbt’s speed is non-negotiable.

Real Migration Example: Pairs Trading with Cointegration

I had a pairs trading bot (detailed logic is similar to what I covered in a previous post, though not identical) that tested 50 stock pairs for cointegration, then backtested a z-score mean-reversion strategy on each pair. Backtest.py version:

import yfinance as yf
from statsmodels.tsa.stattools import cointfrom backtesting import Backtest, Strategy

pairs = [('AAPL', 'MSFT'), ('XOM', 'CVX'), ...]  # 50 pairs

class PairsStrategy(Strategy):
    z_entry = 2.0

    def init(self):
        # Compute spread as residual from cointegration regression
        # (simplified — real version uses Johansen test)
        spread = self.data.Close - self.hedge_ratio * self.data.HedgeClose
        self.z = self.I(lambda x: (x - x.rolling(20).mean()) / x.rolling(20).std(), spread)

    def next(self):
        if self.z[-1] < -self.z_entry:
            self.buy()  # Long the spread
        elif self.z[-1] > self.z_entry:
            self.sell()

for stock1, stock2 in pairs:
    data1 = yf.download(stock1, start='2020-01-01', end='2023-01-01')
    data2 = yf.download(stock2, start='2020-01-01', end='2023-01-01')

    # Cointegration test
    score, pvalue, _ = coint(data1['Close'], data2['Close'])
    if pvalue > 0.05:
        continue  # Not cointegrated

    # Backtest (omitted: data prep for pairs)
    # ... this loop takes ~45 minutes for 50 pairs

Vectorbt version:

import vectorbt as vbt
import pandas as pd
from statsmodels.tsa.stattools import coint

pairs = [('AAPL', 'MSFT'), ('XOM', 'CVX'), ...]

# Download all at once (yfinance supports lists)
tickers = list(set([s for pair in pairs for s in pair]))
data = vbt.YFData.download(tickers, start='2020-01-01', end='2023-01-01')

cointegrated = []
for s1, s2 in pairs:
    p1 = data.get(s1)['Close']
    p2 = data.get(s2)['Close']
    _, pvalue, _ = coint(p1, p2)
    if pvalue < 0.05:
        cointegrated.append((s1, s2, p1, p2))

print(f"Found {len(cointegrated)} cointegrated pairs")

# Now backtest all at once (this is the tricky part)
# Vectorbt doesn't natively support "portfolio of pairs", so we loop
results = []
for s1, s2, p1, p2 in cointegrated:
    # Hedge ratio from OLS (simplified)
    hedge = (p1 * p2).sum() / (p2 ** 2).sum()
    spread = p1 - hedge * p2

    z = (spread - spread.rolling(20).mean()) / spread.rolling(20).std()

    entries = z < -2.0
    exits = z > 2.0

    pf = vbt.Portfolio.from_signals(p1, entries, exits, init_cash=10000, fees=0.001)
    results.append({'pair': f"{s1}/{s2}", 'return': pf.total_return(), 'sharpe': pf.sharpe_ratio()})

df = pd.DataFrame(results)
print(df.nlargest(5, 'sharpe'))

Time dropped from 45 minutes to 14 minutes. Not a 3x improvement because:
– Cointegration tests aren’t vectorizable (they’re running in statsmodels, not NumPy)
– We still loop over pairs (vectorbt can’t auto-vectorize “backtest every pair”, you’d need custom logic)

But 3x is still worth it. You could go further by parallelizing the pairs loop with joblib, but at that point you’re optimizing for diminishing returns. Grab some Dark Chocolate Espresso Beans and let your machine crunch the numbers.

FAQ

Q: Can I mix Backtest.py and vectorbt in the same project?

Yes, and I do. I use Backtest.py for prototyping new strategy ideas (the Strategy.plot() method is fantastic for visual debugging), then port winners to vectorbt for parameter sweeps. They both output pandas DataFrames, so it’s easy to compare results side-by-side.

Q: Does vectorbt support live trading or just backtesting?

Backtesting only. If you need live execution, you’ll connect vectorbt’s signals to a broker API (Alpaca, Interactive Brokers, etc.) separately. Backtest.py is the same — neither framework handles order routing natively. For that, you’d typically use something like ib_insync or a custom execution layer.

Q: What’s the memory footprint difference when testing 1000+ strategies?

Vectorbt uses more RAM upfront (it precomputes arrays for all combos), but Backtest.py’s peak memory can spike if you’re storing results in a list. On my 16GB machine, testing 1000 RSI variations: vectorbt uses ~2.1GB, Backtest.py peaks at ~1.4GB but takes 6x longer. I’d trade RAM for speed any day. If you’re really constrained, batch your parameter sweeps (test 100 at a time, clear memory, repeat).

Pick Vectorbt If You’re Optimizing, Backtest.py If You’re Learning

Here’s my current workflow: prototype in Backtest.py until the strategy logic is solid, then migrate to vectorbt for hyperparameter tuning and production backtesting. The migration usually takes 1-3 hours depending on custom indicator complexity.

If you’re testing fewer than 10 parameter combos or your strategy has complex stateful logic (trailing stops that adjust mid-trade, portfolio rebalancing based on correlation matrices), Backtest.py’s loop model might actually be clearer. But if “fast parameter sweeps” is in your job description, vectorbt pays for itself immediately.

The one thing I haven’t solved: vectorbt’s docs are sparse on advanced portfolio construction (multi-asset, dynamic weights, constraints). The GitHub issues are more helpful than the official guides. I’m still figuring out how to implement risk parity allocation without dropping into raw NumPy — if you’ve cracked that, I’d love to hear your approach.

Did you find this helpful?

Your support keeps this blog running and ad-free content coming.

☕ Buy me a coffee
TODAY 82 | TOTAL 150,629