Lesson 2.4.3 · 60 min

vectorbt: massive parameter sweeps and portfolio simulation

vectorbt turns a sweep of thousands of parameter combinations into a few operations between matrices. This lesson explains how it does it and its status (license, PRO edition), programs the same sweep with NumPy on 24 futures and 400 moving-average pairs, and measures what almost nobody measures: how much the best combination is worth out of sample.

Fig. — Sweep of 325 moving-average pairs over 24 synthetic futures, 2001–2025: net-of-cost Sharpe of each pair, in four bands of about 80 pairs, and the best one
Level 2 lessons
  1. 2.1 · Statistics and econometrics for trading

    1. 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
    2. 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
    3. 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
    4. 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
    5. 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
    6. 2.1.6 Kalman filter for dynamic hedge ratios
    7. 2.1.7 Regime detection: hidden Markov models and structural breaks
    8. 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
  2. 2.2 · Anatomy of a trading system

    1. 2.2.1 Edge hypotheses: risk, behavior, structure and information
    2. 2.2.2 Universe, signal, filter, entry, exit, sizing and risk management
    3. 2.2.3 Frequencies: intraday, swing and position trading, and the capacity of a strategy
    4. 2.2.4 Research journal and experiment tracking against data snooping
  3. 2.3 · Strategy families

    1. 2.3.1 Trend following and momentum
    2. 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
    3. 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
    4. 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
    5. 2.3.5 Factors (value, quality, low volatility, size) and event strategies
    6. 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
    7. 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
  4. 2.4 · Backtesting: engines and paradigms

    1. 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
    2. 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
    3. 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
    4. 2.4.4 backtesting.py and backtrader: two teaching engines and their status
    5. 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
    6. 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
    7. 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
    8. 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
  5. 2.5 · Biases and validation

    1. 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
    2. 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
    3. 2.5.3 Combinatorial purged cross-validation for strategies
    4. 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
    5. 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
    6. 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
    7. 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
    8. 2.5.8 “Honest backtest” checklist before moving to paper trading
  6. 2.6 · Performance metrics

    1. 2.6.1 CAGR, Sharpe, Sortino, Calmar, Omega, drawdown and Ulcer Index
    2. 2.6.2 Win rate, payoff, expectancy, alpha, beta, information ratio, turnover and capacity
    3. 2.6.3 Reports with QuantStats or pyfolio-reloaded and custom tearsheets

What vectorbt is and what state it is in

vectorbt is a Python library for researching strategies at scale: indicators, signals, portfolio simulation and analysis of results on top of pandas and NumPy, with Numba (and an optional Rust engine) for the heavy lifting. Its specialty is what was expensive in lessons 2.4.1 and 2.4.2: testing thousands of combinations of parameters and assets at once. As of this lesson, its status according to its primary sources1Note 1 · PyPI (pypi.org/project/vectorbt, metadata of version 1.2.0), the GitHub repository (README.md and LICENSE.md) and the “VectorBT vs PRO” page of its documentation, consulted on October 21, 2026. PRO edition prices are not quoted: they change and are not the subject of the lesson.:

AspectFact
Version on PyPI1.2.0, released October 19, 2026; Python ≥ 3.11 and < 3.15
Repositorypolakowo/vectorbt on GitHub, with activity in October 2026
LicenseApache 2.0 with Commons Clause (LICENSE.md)
Commercial editionVectorBT PRO, paid by subscription; private repository, documentation and community
EngineFunctions compiled with Numba; optional Rust engine (vectorbt[rust])

The license needs reading. Apache 2.0 is a permissive license; the Commons Clause takes one right away from it: the right to “sell” the software, defined as offering third parties, for a fee or other consideration, a product or service whose value derives entirely or substantially from its functionality, including fees “for hosting or consulting/ support services related to the Software”. For a researcher or a trader who trades their own money nothing changes. For anyone who wants to build a paid backtesting service, a platform or a consultancy on top of vectorbt, it does: they need another license. For that reason, and because the course owner decided so, vectorbt is not executed in this lesson: its code appears as the library documents it, and the executable part reproduces its ideas with NumPy.

The PRO edition. The README presents vectorbt as the community, open-source edition of VectorBT PRO, which adds parallelization, more data integrations, portfolio optimization, limit orders, leverage and other features. Its comparison page describes access as a subscription: private repository, private documentation and Discord, and the right to keep using the installed version when the subscription ends. The features in this lesson belong to the open edition.

The idea: each parameter, one more column

Lesson 2.4.1 computed a portfolio with a date × market matrix. vectorbt adds dimensions: each value of each parameter is one more column, and the simulation treats all the columns at once. A moving-average crossover with 20 fast windows and 20 slow ones on 24 markets is 20×20×24=9,60020 \times 20 \times 24 = 9{,}600 columns. It is broadcasting (lesson 0.5.1): NumPy combines an array of shape (T,20,1)(T, 20, 1) with another of shape (T,1,20)(T, 1, 20) and gets (T,20,20)(T, 20, 20) without copying the input data.

Prices TAverages T×20Averages T×20Signals T×20×20PositionsNet returnsSharpe 20×20
Mermaid source
flowchart TB
  P[Prices T] --> R[Averages T×20]
  P --> L[Averages T×20]
  R --> S[Signals T×20×20]
  L --> S
  S --> W[Positions]
  W --> N[Net returns]
  N --> M[Sharpe 20×20]

The vectorbt code for a sweep like this, taken from its README and adapted to the course futures, is the following (it is not executed in the lesson):

IN [01]python
import numpy as np
import pandas as pd
import vectorbt as vbt

fut = pd.read_parquet("futuros2_continuos.parquet")
px = fut.pivot(index="fecha", columns="mercado",
               values="cierre_ajustado")

ventanas = np.arange(5, 401, 5)
rapida, lenta = vbt.MA.run_combs(px, window=ventanas, r=2,
                                 short_names=["rapida", "lenta"])
entradas = rapida.ma_crossed_above(lenta)
salidas = rapida.ma_crossed_below(lenta)

pf = vbt.Portfolio.from_signals(
    px, entradas, salidas,
    short_entries=salidas, short_exits=entradas,  # long and short
    fees=0.0005, slippage=0.0005, freq="1D")
sharpe = pf.sharpe_ratio()          # one figure per column
sharpe.groupby(["rapida_window", "lenta_window"]).mean()

MA.run_combs computes the moving averages for all the windows and forms the combinations of two (r=2), with the first window smaller than the second; the result has one column level per parameter (rapida_window, lenta_window) and another per market. Portfolio.from_signals simulates the portfolio of each column: it buys on the entries, sells on the exits, charges commissions (fees, as a fraction of the order value) and slippage (slippage, as a fraction of the price). By default the order executes at the closing price of the signal bar, the same assumption as the shift(1) of lesson 2.4.1.

The full path, from the data to the metrics:

Data: columnsMA.run_combsEntries, exitsfrom_signalsNumba loopOrders and tradesstats, heatmap
Mermaid source
flowchart TB
  D[Data: columns] --> I[MA.run_combs]
  I --> S[Entries, exits]
  S --> P[from_signals]
  P --> N[Numba loop]
  N --> R[Orders and trades]
  R --> M[stats, heatmap]

What you do not see: according to its documentation, the portfolio module uses Numba-compiled functions for almost all calculations. The simulation does walk through time, bar by bar, but in compiled code and over all the columns. That is why from_signals supports rules with memory that a purely matrix-based calculation cannot do:

IN [02]python
# 4% trailing stop with the bar's highs and lows
pf = vbt.Portfolio.from_signals(
    close, entradas, salidas,
    open=apertura, high=maximo, low=minimo,
    sl_stop=0.04, sl_trail=True)

pf.stats()                          # summary of one column
sharpe.vbt.heatmap(x_level="lenta_window", y_level="rapida_window",
                   slider_level="mercado")

sl_stop is the stop as a fraction of the entry price and sl_trail=True turns it into a trailing stop; open, high and low are only used to decide whether a stop has been touched within the bar. It is an event-driven engine in miniature (lesson 2.4.2), with its own rules for the order of things within a bar: before trusting it for an intraday strategy you have to read them.

The same sweep with NumPy

The executable part reproduces the sweep with what we already know. Data, volatility and costs are those of lesson 2.4.1: 24 synthetic futures with a planted 12-month momentum and carry, positions with a volatility target and costs per contract on turnover and rolls.

IN [03]python
import numpy as np
import pandas as pd

fut = pd.read_parquet("futuros2_continuos.parquet")
spec = pd.read_csv("futuros2_especificaciones.csv",
                   index_col="mercado")
tabla = {c: fut.pivot(index="fecha", columns="mercado", values=c)
         for c in ("cierre_ajustado", "cierre", "contrato")}
px, bruto, contrato = tabla.values()
r = px.pct_change(fill_method=None).fillna(0.0)
coste_usd = (spec["coste_tipico_ticks"] * spec["valor_tick"]
             + spec["comision_usd"])
coste_unit = (coste_usd / (bruto * spec["multiplicador"])).fillna(0.0)
rolls = (contrato != contrato.shift(1)) & contrato.shift(1).notna()
n = px.shape[1]
vol = r.ewm(span=60, min_periods=60).std() * np.sqrt(252)
k = (0.10 / np.sqrt(n) / vol).fillna(0.0)   # weight per unit of signal
print(px.shape, "| risk per market:", round(0.10 / np.sqrt(n), 4))
OUT [03]Data, volatility and costs
(6539, 24) | risk per market: 0.0204

The rule: be long if the fast moving average is above the slow one and short if it is below. The windows form a geometric grid of 20 values each: fast ones from 5 to 120 sessions and slow ones from 20 to 400. Pairs with the fast window equal to or greater than the slow one make no sense and are discarded.

IN [04]python
rapidas = np.unique(np.geomspace(5, 120, 20).round().astype(int))
lentas = np.unique(np.geomspace(20, 400, 20).round().astype(int))
valido = rapidas[:, None] < lentas[None, :]   # broadcasting 20 x 20
print("fast:", rapidas)
print("slow:", lentas)
print(f"combinations: {valido.size}, valid: {valido.sum()}")
OUT [04]The parameter grid
fast: [  5   6   7   8  10  12  14  16  19  23  27  31  37  44  52  61  73  86
 102 120]
slow: [ 20  23  27  32  38  44  52  60  71  83  97 113 133 155 182 213 249 292
 342 400]
combinations: 400, valid: 325

The moving averages for all the windows come from a single cumulative sum: the average over ww sessions ending at tt is (Ct−Ct−w)/w(C_t - C_{t-w}) / w, with CtC_t the sum of prices up to tt. Before using it, it is checked against rolling:

IN [05]python
def medias(p, ventanas):
    """Simple moving average of p for each window (one column per
    window), with a cumulative sum instead of a rolling."""
    cs = np.concatenate([[0.0], np.cumsum(p)])
    out = np.full((len(p), len(ventanas)), np.nan)
    for j, w in enumerate(ventanas):
        out[w - 1:, j] = (cs[w:] - cs[:-w]) / w
    return out

ref = px["IDA"].rolling(97).mean().to_numpy()
error = np.nanmax(np.abs(medias(px["IDA"].to_numpy(), [97])[:, 0]
                         - ref))
print(f"maximum error versus rolling: {error:.1e} points")
OUT [05]Moving averages with cumulative sums
maximum error versus rolling: 1.5e-10 points

The whole sweep is one function. The important line is the signal one: mf[:, :, None] has shape (T,20,1)(T, 20, 1) and ms[:, None, :], (T,1,20)(T, 1, 20); their difference is the (T,20,20)(T, 20, 20) tensor with all the pairs. The size of each market, k, has shape (T,)(T,) and is expanded with [:, None, None]. The loop is over the 24 markets, not over the 400 pairs or the 6,539 sessions.

IN [06]python
def barrido(px, r, k, coste_unit, rolls, rapidas, lentas,
            largo_corto=True):
    """Daily net return of the portfolio for each pair of
    windows: array of shape (dates, fast, slow)."""
    T, F, S = len(px), len(rapidas), len(lentas)
    neto = np.zeros((T, F, S))
    for m in px.columns:
        p = px[m].to_numpy()
        mf, ms = medias(p, rapidas), medias(p, lentas)
        dif = mf[:, :, None] - ms[:, None, :]           # T x F x S
        senal = np.sign(dif) if largo_corto else (dif > 0) * 1.0
        w = np.nan_to_num(senal) * k[m].to_numpy()[:, None, None]
        pos = np.concatenate([np.zeros((1, F, S)), w[:-1]])
        rot = (np.abs(w - pos)
               + 2 * np.abs(pos) * rolls[m].to_numpy()[:, None, None])
        neto += (pos * r[m].to_numpy()[:, None, None]
                 - rot * coste_unit[m].to_numpy()[:, None, None])
    return neto

neto = barrido(px, r, k, coste_unit, rolls, rapidas, lentas)
print(neto.shape, f"{neto.nbytes / 1e6:.0f} MB")
OUT [06]The sweep: a date x fast x slow tensor
(6539, 20, 20) 21 MB

An array of 6,539 × 20 × 20 numbers, 21 MB. vectorbt would do the same with more columns (the 24 markets separately as well) and would also store the orders and trades of each one. A sweep of 80 windows by 80 on 24 markets would already be 150,000 columns: memory, more than time, is the limit of this way of working.

A fast sweep that gives a wrong answer is useless, so one cell of the tensor is compared with a direct implementation, written as in lesson 2.4.1:

IN [07]python
def una_pareja(f, s):
    dif = px.rolling(f).mean() - px.rolling(s).mean()
    w = np.sign(dif).fillna(0.0) * k
    pos = w.shift(1, fill_value=0.0)
    rot = (w - pos).abs() + 2 * pos.abs().where(rolls, 0.0)
    return (pos * r).sum(axis=1) - (rot * coste_unit).sum(axis=1)

i, j = list(rapidas).index(31), list(lentas).index(97)
dif = np.abs(una_pareja(31, 97).to_numpy() - neto[:, i, j]).max()
print(f"pair (31, 97): maximum difference {dif:.1e}")
OUT [07]One pair, computed by hand
pair (31, 97): maximum difference 2.8e-17

The Sharpe map: plateau and peaks

IN [08]python
fechas = px.index
def sharpe(x):
    return np.sqrt(252) * x.mean(axis=0) / x.std(axis=0, ddof=1)

def mapa(neto, desde, hasta=None):
    filas = (fechas >= desde) & (fechas < (hasta or "2100"))
    sh = np.full(valido.shape, np.nan)
    sh[valido] = sharpe(neto[filas][:, valido])
    return sh

sh = mapa(neto, "2001")
i, j = np.unravel_index(np.nanargmax(sh), sh.shape)
print(f"best pair: ({rapidas[i]}, {lentas[j]}), "
      f"Sharpe {sh[i, j]:.2f}")
print(f"median of the {valido.sum()}: {np.nanmedian(sh):.2f}; "
      f"above 0.45: {(sh > 0.45).sum()}")
pd.DataFrame(sh, index=rapidas, columns=lentas).iloc[::3, ::3].round(2)
OUT [08]Net Sharpe of each pair, 2001-2025
best pair: (27, 342), Sharpe 1.00
median of the 325: 0.55; above 0.45: 202
      20    32    52    83    133   213   342
5    0.06  0.23  0.28  0.37  0.55  0.68  0.92
8    0.09  0.24  0.31  0.42  0.55  0.68  0.90
14   0.09  0.20  0.30  0.41  0.54  0.67  0.95
23    NaN  0.27  0.34  0.41  0.68  0.70  0.97
37    NaN   NaN  0.30  0.49  0.71  0.82  0.93
61    NaN   NaN   NaN  0.65  0.73  0.71  0.86
102   NaN   NaN   NaN   NaN  0.46  0.46  0.72

The header figure is the full map. Net Sharpe grows almost continuously with the slow moving average: in the table, with a slow window of 20 sessions it goes from 0.06 to 0.09; with one of 342, from 0.72 to 0.97. The good zone is not a point: it is a broad plateau of long slow averages, and 202 of the 325 pairs exceed 0.45 (the median is 0.55). It makes sense: the effect planted in these futures is the sign of the return over the previous 252 sessions, and any slow moving-average crossover approximates it. Short slow averages follow trends of weeks, which carry no premium in this data, turn over a lot and pay more costs. The best cell, (27, 342) with 1.00, sits on that plateau, surrounded by almost equally good neighbors (in the table, 0.97 with (23, 342) and 0.93 with (37, 342)), and that is what makes it credible; an isolated maximum, surrounded by mediocre cells, would be the opposite (lesson 2.5.6).

But the whole map is computed on the full sample. The honest question is the one that would be asked in real time: if I had chosen the best pair with the data from 2001 to 2012, what would I have obtained from 2013 to 2025?

IN [09]python
sh_in = mapa(neto, "2001", "2013")[valido]
sh_out = mapa(neto, "2013")[valido]
pares = np.argwhere(valido)            # same order as [valido]
b = sh_in.argmax()
f, s = rapidas[pares[b, 0]], lentas[pares[b, 1]]
print(f"best in sample: ({f}, {s}), Sharpe {sh_in[b]:.2f}")
print(f"the same one, out of sample: {sh_out[b]:.2f}")
print(f"out-of-sample median: {np.median(sh_out):.2f}")
rho = pd.Series(sh_in).rank().corr(pd.Series(sh_out).rank())
print(f"in/out rank correlation: {rho:.2f}")
quintil = pd.qcut(sh_in, 5, labels=["Q1", "Q2", "Q3", "Q4", "Q5"])
pd.Series(sh_out).groupby(quintil, observed=True).mean().round(2)
OUT [09]Choose with 2001-2012, check with 2013-2025
best in sample: (27, 155), Sharpe 0.79
the same one, out of sample: 0.70
out-of-sample median: 0.63
in/out rank correlation: 0.72
Q1    0.20
Q2    0.50
Q3    0.58
Q4    0.85
Q5    0.92
dtype: float64
Fig. — Sweep of 325 moving-average pairs over 24 synthetic futures: mean out-of-sample Sharpe (2013–2025) by quintile of in-sample Sharpe (2001–2012), and what the in-sample best pair delivers out of sample

Two results at once, and both matter. The first: the sweep has found something real. The order of the pairs is preserved from one period to the other (rank correlation of 0.72) and, grouped by quintile of their in-sample Sharpe, the pairs perform out of sample from worse to better, from 0.20 in the worst quintile to 0.92 in the best. It is what you expect when the map has a genuine structure: slow moving averages capture the planted momentum and pay fewer costs in any period.

The second: the best pair of 2001-2012, (27, 155), is not the best of 2013-2025. It had a Sharpe of 0.79, 0.31 above the median of its period (0.48, in the next cell); out of sample it gives 0.70, only 0.07 above the median (0.63) and below the mean of its own quintile. The whole plateau performs better in the second period, because the trends of those years were more profitable; what does not repeat is the distance from the maximum to its neighbors. That part of the Sharpe was noise from 2001-2012, and it is precisely the part you pay for when you choose the maximum. Sticking with the good zone (the whole top quintile gives 0.92 out of sample) would have given more than sticking with the best cell.

A sweep on data with no effect

The last check is the toughest: repeat the sweep on data in which there is nothing to find. If each market's daily returns are randomly permuted, their distribution (volatility, tails) is preserved and any trend is destroyed. The best result of 325 combinations on that data measures how much the search “finds” by pure luck.

IN [10]python
rng = np.random.default_rng(2028)
r_azar = r.apply(lambda c: pd.Series(rng.permutation(c.to_numpy()),
                                     c.index))
px_azar = (1 + r_azar).cumprod()
vol_azar = r_azar.ewm(span=60, min_periods=60).std() * np.sqrt(252)
k_azar = (0.10 / np.sqrt(n) / vol_azar).fillna(0.0)
neto_azar = barrido(px_azar, r_azar, k_azar, coste_unit, rolls,
                    rapidas, lentas)
for nombre, x in [("synthetic data", neto),
                  ("permuted", neto_azar)]:
    s_in = mapa(x, "2001", "2013")[valido]
    print(f"{nombre:16s} 2001-2012: median {np.median(s_in):5.2f}, "
          f"maximum {s_in.max():.2f}")
OUT [10]The same sweep with permuted returns
synthetic data   2001-2012: median  0.48, maximum 0.79
permuted         2001-2012: median -0.46, maximum 0.15
Fig. — Distribution of 2001–2012 net Sharpe for the 325 moving-average pairs: on permuted returns with no effect (top) and on the synthetic futures with the planted momentum (bottom)

On permuted data, the median Sharpe is −0.46 (with no trends, all that remains is costs) and the maximum of 325 combinations reaches 0.15 over twelve years with no effect at all. That is the reference against which a sweep has to be measured: the best result on the synthetic data, 0.79, is far above it, and its median (0.48) also beats the maximum from chance; in the figure, almost all the pairs land to its right. The planted effect is modest (a Sharpe of around 0.15 per market), but summed over 24 markets it stands clearly apart from chance. A real strategy whose best Sharpe in a sweep like this did not stand apart from the maximum of the permutations would have proven nothing. And this chance is benign: permuting the days also destroys volatility clustering and streaks, and real markets give noise more ways to look like a trend. Lesson 2.5.4 turns that intuition into a formal correction (the Deflated Sharpe Ratio, which vectorbt also computes with deflated_sharpe_ratio()), and lesson 2.5.5 generalizes permutations.

When to use vectorbt, and when not to

SituationvectorbtAlternative
Sweeping thousands of parameters over many assetsIts home ground: columns and NumbaYour own NumPy, as in this lesson
Exploring signals and indicators with chartsVery convenient (Plotly, heatmap, stats)pandas and matplotlib
Stops and trailing stops on barsYes, with its rules for order within the barYour own event-driven engine (2.4.2)
Limit orders, leverage, futures multipliersAccording to its README, in the PRO editionNautilusTrader (2.4.5)
Same code in backtest and liveNot its goalNautilusTrader, LEAN (2.4.5, 2.4.6)
Selling a service based on itNot without another license (Commons Clause)Libraries with a permissive license or your own

The practical decision: vectorbt is an excellent tool for the exploration phase, the same one your own vectorized backtest covered in lesson 2.4.1, with more speed and more analysis already built. The final validation of a strategy that survives the sweep belongs to an event-driven engine with the exact rules, and the decision on how many trials to run, to you.

Exercises

  1. Repeat the sweep with long positions only (largo_corto=False): long if the fast average is above, flat if not. Does the median of the map change? And the position of the best pair?
Solution
IN [11]python
neto_l = barrido(px, r, k, coste_unit, rolls, rapidas, lentas,
                 largo_corto=False)
sh_l = mapa(neto_l, "2001")
i, j = np.unravel_index(np.nanargmax(sh_l), sh_l.shape)
print(f"long only: median {np.nanmedian(sh_l):.2f}, best "
      f"({rapidas[i]}, {lentas[j]}) with {sh_l[i, j]:.2f}")
print(f"long and short: median {np.nanmedian(sh):.2f}")
OUT [11]Long only
long only: median 0.84, best (27, 342) with 1.11
long and short: median 0.55

With longs only the median rises from 0.55 to 0.84 and the best pair is still (27, 342), now with 1.11. The planted momentum is symmetric, but it is not the only thing in this data: the level 2 README adds the equity premium (the script's drift) to the index futures, and the short side of the crossover pays for it every time it is sold; besides, being flat instead of short reduces exposure, turnover and costs. It is a good example of why a sweep result is explained before it is used: the improvement does not come from the trend rule, but from a premium that the long version collects and the long-short one neutralizes. In another universe, the comparison would come out differently.

  1. Count how many pairs would have a tt statistic greater than 2 in 2001-2012 (with t≈SNt \approx S \sqrt{N}, SS the annual Sharpe and NN the years) in the synthetic data and in the permuted data. What does the permuted result say about a fixed threshold of t>2t > 2?
Solution
IN [12]python
anos = 12
for nombre, x in [("synthetic", neto), ("permuted", neto_azar)]:
    t = mapa(x, "2001", "2013")[valido] * np.sqrt(anos)
    print(f"{nombre:10s} t > 2: {(t > 2).sum():3d} of {t.size}")
OUT [12]“Significant” pairs
synthetic  t > 2:  99 of 325
permuted   t > 2:   0 of 325

In the synthetic data, 99 of 325 pairs exceed t>2t > 2 because the effect exists; in the permuted data, none, partly because costs sink the median. The 99 are not 99 discoveries: they are the same momentum seen through very similar windows, with highly correlated returns. And a fixed threshold is not a guarantee: with independent trials and no costs, t>2t > 2 would let through by chance around 2% of them (about 7 of 325), and in real data noise has more ways to look like a trend than in a permutation. The correct threshold depends on the number of trials and how correlated they are, and the count does not say which of the 99 to choose either. Lessons 2.1.8 and 2.5.4 cover the adjustment for multiple testing.

  1. By default vectorbt executes at the close of the signal bar and from_signals does not allow re-entering while a position is already open. What two differences in results would you expect between this lesson's skip vectorbt code and the NumPy sweep, even with the same data and windows?
Solution

The first is sizing: the NumPy sweep uses volatility-targeted weights that change every day, and the vectorbt example invests all the available cash on each entry (size infinite by default), without rebalancing between signals; neither the risk nor the turnover are comparable. The second is costs: there, a fixed percentage of the traded value; here, a cost per contract that depends on the contract size, plus the rolls, which vectorbt does not see in an adjusted price. For them to match you would have to pass it target weights (Portfolio.from_orders with a target-type size_type) and an equivalent cost: the same reconciliation as in lesson 2.4.2.

Footnotes

  1. PyPI (pypi.org/project/vectorbt, metadata of version 1.2.0), the GitHub repository (README.md and LICENSE.md) and the “VectorBT vs PRO” page of its documentation, consulted on October 21, 2026. PRO edition prices are not quoted: they change and are not the subject of the lesson. ↩

Each answer is folded under its question

Self-assessment

  1. What does the Commons Clause that accompanies vectorbt's Apache 2.0 license allow and not allow?

    • AIt allows using, modifying and redistributing the code, but not selling a product or service whose value derives entirely or substantially from vectorbt, including hosting or consulting on it
    • BIt prohibits using it for commercial purposes, including trading your own money
    • CIt requires publishing the code of your strategies
    • DIt is a public domain license
    Show answer

    Answer A. It allows using, modifying and redistributing the code, but not selling a product or service whose value derives entirely or substantially from vectorbt, including hosting or consulting on it

    The text of the condition, in the repository's LICENSE.md, removes from the Apache 2.0 grant the right to “sell” the software, and defines selling as offering third parties, for a fee or other consideration, a product or service whose value derives substantially from its functionality. Using it for research or trading is not selling it; building a paid backtesting service on top of it is.

  2. In the lesson's sweep, the combination with the best Sharpe from 2001 to 2012 (fast 27, slow 155) has 0.79 in sample, 0.31 above the median (0.48). What happens from 2013 to 2025?

    • AIt keeps its edge: the planted effect is real
    • BIts Sharpe turns negative
    • CIt cannot be known without vectorbt
    • DIt gives 0.70, only 0.07 above the out-of-sample median (0.63) and below the mean of its own quintile (0.92)
    Show answer

    Answer D. It gives 0.70, only 0.07 above the out-of-sample median (0.63) and below the mean of its own quintile (0.92)

    The sweep found something real: the order of the pairs is preserved out of sample (rank correlation of 0.72, increasing quintiles). But the part of the Sharpe that set the best one apart from the rest was mostly noise from that period: its edge over the median goes from 0.31 to 0.07. Choosing the maximum does not pay off; choosing the good zone does.

  3. Why is the same sweep on permuted returns (with no effect at all) a useful check?

    • ABecause that way the sweep takes less time
    • BBecause it gives the distribution of the best result when there is nothing to find: if the maximum on real data does not stand apart from it, the sweep has not found an edge
    • CBecause it removes costs
    • DBecause it improves the Sharpe
    Show answer

    Answer B. Because it gives the distribution of the best result when there is nothing to find: if the maximum on real data does not stand apart from it, the sweep has not found an edge

    Permuting each market's returns destroys any trend and preserves their distribution. The best of 325 results on that data measures how much the search can “find” by chance. It is the idea behind the permutation tests of lesson 2.5.5.

  4. Portfolio.from_signals accepts sl_stop with sl_trail=True. How can a “vectorized” backtester simulate a trailing stop?

    • AIt cannot: the parameter does not exist
    • BBecause it precomputes all the highs with rolling
    • CBecause its simulation is a loop over time compiled with Numba, which walks through each column bar by bar; what is vectorized is processing many columns at once
    • DBecause it uses the GPU
    Show answer

    Answer C. Because its simulation is a loop over time compiled with Numba, which walks through each column bar by bar; what is vectorized is processing many columns at once

    vectorbt's documentation says the portfolio module uses Numba-compiled functions for almost all calculations. The walk through time exists, but in compiled code and over thousands of columns at once: that is why it can carry state (position, high, stop) without the slowness of a Python loop.

Key points

  • vectorbt extends each parameter into one more column: 400 moving-average pairs on 24 markets are 9,600 columns simulated together.
  • Its license is Apache 2.0 with Commons Clause: it can be used and modified, but you cannot sell a product or service based on it. VectorBT PRO is a paid subscription edition, with a private repository and documentation.
  • The same sweep can be programmed with NumPy: moving averages with cumulative sums, signals with date × fast × slow broadcasting and costs on turnover; 325 valid combinations in seconds.
  • With a real effect, the order of the map is preserved out of sample (rank correlation 0.72), but the maximum shrinks: the best pair of 2001-2012 goes from 0.79 to 0.70, and its edge over the median, from 0.31 to 0.07.
  • In the synthetic data, the good zone of the map is a broad plateau of slow moving averages, consistent with the planted 12-month momentum; on permuted data, the maximum of 325 combinations stays at 0.15 in sample.
  • Many pairs passing a threshold (99 of 325 with t > 2) are not many discoveries: it is the same effect seen through highly correlated windows.

Resources

Data