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.
- Module
- 2.4 · Backtesting: engines and paradigms
- Duration
- 60 min
- Prerequisites
- 2.4.1 · Vectorized backtest from scratch: matrices, lag, costs and limits, 0.5.1 · NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators
- Author
- Jesús Cuesta
- Updated
Level 2 lessons
2.1 · Statistics and econometrics for trading
- 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
- 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
- 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
- 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
- 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
- 2.1.6 Kalman filter for dynamic hedge ratios
- 2.1.7 Regime detection: hidden Markov models and structural breaks
- 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
2.2 · Anatomy of a trading system
2.3 · Strategy families
- 2.3.1 Trend following and momentum
- 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
- 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
- 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
- 2.3.5 Factors (value, quality, low volatility, size) and event strategies
- 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
- 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
2.4 · Backtesting: engines and paradigms
- 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
- 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
- 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
- 2.4.4 backtesting.py and backtrader: two teaching engines and their status
- 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
- 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
- 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
- 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
2.5 · Biases and validation
- 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
- 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
- 2.5.3 Combinatorial purged cross-validation for strategies
- 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
- 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
- 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
- 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
- 2.5.8 “Honest backtest” checklist before moving to paper trading
2.6 · Performance metrics
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.:
| Aspect | Fact |
|---|---|
| Version on PyPI | 1.2.0, released October 19, 2026; Python ≥ 3.11 and < 3.15 |
| Repository | polakowo/vectorbt on GitHub, with activity in October 2026 |
| License | Apache 2.0 with Commons Clause (LICENSE.md) |
| Commercial edition | VectorBT PRO, paid by subscription; private repository, documentation and community |
| Engine | Functions 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 columns. It is broadcasting (lesson 0.5.1):
NumPy combines an array of shape with another of shape and gets without copying
the input data.
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):
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:
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:
# 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.
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))(6539, 24) | risk per market: 0.0204The 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.
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()}")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: 325The moving averages for all the windows come from a single cumulative sum: the average over sessions ending at
is , with the sum of prices up to . Before using it, it is checked against rolling:
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")maximum error versus rolling: 1.5e-10 pointsThe whole sweep is one function. The important line is the signal one: mf[:, :, None] has shape and
ms[:, None, :], ; their difference is the tensor with all the pairs. The size of each
market, k, has shape and is expanded with [:, None, None]. The loop is over the 24 markets, not over the 400
pairs or the 6,539 sessions.
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")(6539, 20, 20) 21 MBAn 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:
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}")pair (31, 97): maximum difference 2.8e-17The Sharpe map: plateau and peaks
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)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.72The 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?
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)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: float64Two 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.
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}")synthetic data 2001-2012: median 0.48, maximum 0.79
permuted 2001-2012: median -0.46, maximum 0.15On 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
| Situation | vectorbt | Alternative |
|---|---|---|
| Sweeping thousands of parameters over many assets | Its home ground: columns and Numba | Your own NumPy, as in this lesson |
| Exploring signals and indicators with charts | Very convenient (Plotly, heatmap, stats) | pandas and matplotlib |
| Stops and trailing stops on bars | Yes, with its rules for order within the bar | Your own event-driven engine (2.4.2) |
| Limit orders, leverage, futures multipliers | According to its README, in the PRO edition | NautilusTrader (2.4.5) |
| Same code in backtest and live | Not its goal | NautilusTrader, LEAN (2.4.5, 2.4.6) |
| Selling a service based on it | Not 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
- 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
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}")long only: median 0.84, best (27, 342) with 1.11
long and short: median 0.55With 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.
- Count how many pairs would have a statistic greater than 2 in 2001-2012 (with , the annual Sharpe and the years) in the synthetic data and in the permuted data. What does the permuted result say about a fixed threshold of ?
Solution
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}")synthetic t > 2: 99 of 325
permuted t > 2: 0 of 325In the synthetic data, 99 of 325 pairs exceed 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, 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.
- By default vectorbt executes at the close of the signal bar and
from_signalsdoes not allow re-entering while a position is already open. What two differences in results would you expect between this lesson'sskipvectorbt 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
-
PyPI (
pypi.org/project/vectorbt, metadata of version 1.2.0), the GitHub repository (README.mdandLICENSE.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
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.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.
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.
Portfolio.from_signalsacceptssl_stopwithsl_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 × slowbroadcasting 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.