100% FreeNo Signup Required

Vectorized vs. Loop-Based Backtesting: NumPy, Numba, and the Real Speed Tradeoffs

Three ways to backtest a moving-average crossover in Python, benchmarked against each other: a pure Python loop, a Numba JIT-compiled loop, and vectorized pandas. Measured, not estimated.

Q

QuantEngines

April 24, 2026

|7 min read

Vectorized vs. Loop-Based Backtesting: NumPy, Numba, and the Real Speed Tradeoffs

Every backtesting engine makes a choice between two implementation styles. An event-driven loop walks through the price series bar by bar, tracking cash and position state exactly the way a live trading system would -- this is how Backtrader and most production systems work, and it's what makes complex order types (stops, trailing stops, partial fills) straightforward to express. A vectorized engine computes the entire signal and position series at once with array operations -- this is VectorBT's core design, and it's dramatically faster for simple long/flat or long/short strategies at the cost of making certain order types awkward to express.

This guide benchmarks three implementations of the same strategy -- a 10/50-day moving-average crossover -- against each other on identical data, so the speed difference isn't a claim, it's a measurement you can reproduce.

The Three Implementations

All three track the same logic: go long when the fast SMA crosses above the slow SMA, go flat when it crosses below, starting from $100,000 cash.

1. Pure Python Loop

python
import numpy as np
import pandas as pd

def backtest_loop(close, sma_fast, sma_slow, init_cash=100_000.0):
cash = init_cash
shares = 0.0
equity = np.empty(len(close))
prev_signal = 0
c, f, s = close.values, sma_fast.values, sma_slow.values
for i in range(len(c)):
if np.isnan(f[i]) or np.isnan(s[i]):
equity[i] = cash + shares * c[i]
continue
signal = 1 if f[i] > s[i] else -1
if signal == 1 and prev_signal != 1 and shares == 0:
shares, cash = cash / c[i], 0.0
elif signal == -1 and shares > 0:
cash, shares = shares * c[i], 0.0
prev_signal = signal
equity[i] = cash + shares * c[i]
return equity

This is the most flexible form -- adding a stop-loss, a trailing stop, or position-sized entries is a few lines inside the loop -- and it's the slowest, because every bar pays the interpreter overhead of a Python-level branch and attribute lookup.

2. The Same Loop, Numba-Compiled

python
from numba import njit

@njit(cache=True)
def _backtest_loop_numba(c, f, s, init_cash):
cash, shares = init_cash, 0.0
n = len(c)
equity = np.empty(n)
prev_signal = 0
for i in range(n):
if np.isnan(f[i]) or np.isnan(s[i]):
equity[i] = cash + shares * c[i]
continue
signal = 1 if f[i] > s[i] else -1
if signal == 1 and prev_signal != 1 and shares == 0.0:
shares, cash = cash / c[i], 0.0
elif signal == -1 and shares > 0.0:
cash, shares = shares * c[i], 0.0
prev_signal = signal
equity[i] = cash + shares * c[i]
return equity

def backtest_numba(close, sma_fast, sma_slow, init_cash=100_000.0):
return _backtest_loop_numba(close.values, sma_fast.values, sma_slow.values, init_cash)

Identical logic, but @njit compiles the function to machine code the first time it's called (with a one-time compilation cost), after which every subsequent call runs at native speed with no Python interpreter in the loop.

3. Vectorized (No Explicit Loop)

python
def backtest_vectorized(close, sma_fast, sma_slow, init_cash=100_000.0):
    signal = np.where(sma_fast > sma_slow, 1, -1)
    signal = pd.Series(signal, index=close.index)
    signal[sma_fast.isna() | sma_slow.isna()] = 0
    position = signal.replace(0, np.nan).ffill().fillna(0).clip(lower=0)
    daily_ret = close.pct_change().fillna(0)
    strategy_ret = position.shift(1).fillna(0) * daily_ret
    equity = init_cash * (1 + strategy_ret).cumprod()
    return equity.values

No explicit Python-level iteration over bars: the signal, position, and equity curve are each computed as one array operation across the whole series. This is fast because NumPy pushes the loop into compiled C code under the hood, but it only works cleanly because the strategy has no state beyond "long or flat" -- there's no order queue, no partial fills, nothing that depends on more than the previous bar's position.

Measured Results

Run on 50,000 bars of synthetic daily data (about 200 years -- large enough to make the differences unambiguous), all three produce the identical final equity value, confirming they implement the same strategy correctly:

ImplementationTime per runRelative to loop
Pure Python loop125.7 ms1x (baseline)
Vectorized pandas4.2 ms~30x faster
Numba JIT (warm)0.10 ms~1,300x faster

("Warm" means after the first call, which pays a one-time compilation cost of roughly 200-500 ms depending on function complexity -- irrelevant if you call the function thousands of times during optimization, and worth knowing about if you're timing a single call in a notebook and wondering why it looks slow.)

The gap between vectorized pandas and Numba is larger than most people expect. Pandas' vectorization still carries per-operation overhead (each .rolling(), .shift(), or comparison allocates a new Series), while a compiled loop with primitive float and int operations has none of that -- it's the difference between "no Python loop" and "no interpreter at all."

Why This Matters for Parameter Optimization

The speed difference compounds directly with how many times you run the backtest. A single 50,000-bar backtest at 125ms is fine to run once. A walk-forward optimization grid-searching 5 parameters across 10 values each, over 20 walk-forward windows, is 10^5 x 20 = 2,000,000 backtest calls. At 125ms each, that's roughly 70 hours. At 4.2ms (vectorized), it's about 2.3 hours. At 0.1ms (Numba, warm), it's about 3.3 minutes. The implementation choice isn't a micro-optimization at that scale -- it's the difference between an optimization run that finishes overnight and one that doesn't finish at all.

When to Reach for Which

  • Pure Python loop: Prototyping, strategies with complex order logic (multiple pending orders, partial fills, custom slippage models), or when you're validating correctness before optimizing for speed. Get it right first.
  • Vectorized (pandas/NumPy): Simple long/flat/short strategies with no path-dependent order management, especially when you want to lean on an existing vectorized engine like VectorBT rather than hand-rolling the equity accounting yourself. See the VectorBT mechanics guide for the pitfalls specific to that library.
  • Numba: Once you have a correct loop-based implementation and need to run it thousands of times (parameter grids, walk-forward windows, Monte Carlo permutation tests). Numba compiles a large subset of Python and NumPy, but not arbitrary objects -- expect to rewrite pandas-heavy logic in terms of raw NumPy arrays first, which is exactly what both loop examples above do by pulling .values out before the hot loop.
You can sanity-check a crossover signal's entries and exits interactively in our Charts tool before committing to any of the three implementations, and run the full strategy without writing this scaffolding yourself in the Strategy Builder.

Key Takeaways

  • All three backtest styles -- pure Python loop, Numba-compiled loop, and vectorized pandas -- can implement the same strategy and produce identical results; the difference is purely execution speed and expressiveness.
  • Measured on a 50,000-bar series: vectorized pandas ran roughly 30x faster than a pure Python loop, and a warm Numba-compiled version of the same loop ran roughly 1,300x faster than the loop and ~40x faster than vectorized pandas.
  • Numba pays a one-time JIT compilation cost on first call; it only pays off when the function runs many times, which is exactly the parameter-optimization and walk-forward use case.
  • Vectorization is easiest for simple long/flat/short logic with no order-level state; complex order management (stops, partial fills, multiple pending orders) is usually easier to express correctly in a loop first.
  • The speed difference compounds with the number of backtest runs -- it's most consequential for parameter grids and walk-forward optimization, not for running a single backtest once.

Frequently Asked Questions

Should I always use Numba for backtesting?

No. Numba adds a real constraint: your hot-path code has to be expressible in the subset of Python and NumPy that Numba's nopython mode supports, which means no pandas operations, no arbitrary Python objects, and careful handling of NaN values (as in the examples above). For a backtest you'll run once or a handful of times, the engineering time to convert loop logic to Numba-compatible code usually isn't worth it. It pays off specifically when you're running the same backtest thousands of times, as in walk-forward optimization or large parameter grids.

Why not just use vectorized pandas everywhere and skip loops entirely?

Vectorization works cleanly for strategies where the position at each bar depends only on the signal and the previous bar's position -- no order queue, no multiple simultaneous positions, no complex fill logic. Once you need stop-losses that trigger intrabar, trailing stops, partial position scaling, or multiple pending orders, expressing that correctly as array operations gets awkward fast, and a loop (ideally Numba-compiled) becomes easier to write correctly than to debug a vectorized version that silently mishandles an edge case.

Does the vectorized version give exactly the same result as the loop version?

In the benchmark above, yes -- both implementations assume fractional shares and no transaction costs, so they agree on the final equity value exactly. In practice, small differences creep in once you add integer share constraints, commissions, or slippage, because the loop version can apply these exactly at the moment of a trade while the vectorized version applies them as a return-based approximation across the whole series. Always verify a vectorized implementation against a loop-based reference on a small sample before trusting it at scale.

Related Articles