---
title: "2.4.3 vectorbt: parameter sweeps and portfolios"
description: "vectorbt inside: broadcasting, Portfolio.from_signals and massive sweeps; the same sweep of 400 moving-average pairs with NumPy, and its overfitting."
url: "https://pythonparatrading.com/en/python-trading-course/level-2/vectorbt-parameter-sweeps/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-2/vectorbt-parameter-sweeps.png"
---

# 2.4.3 · 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.

- Course: [Level 2: Systematic strategies and rigorous backtesting](https://pythonparatrading.com/en/python-trading-course/level-2/)
- Module: 2.4 · Backtesting: engines and paradigms
- Duration: 60 min
- Prerequisites: [2.4.1 · Vectorized backtest from scratch: matrices, lag, costs and limits](https://pythonparatrading.com/en/python-trading-course/level-2/vectorized-backtest-from-scratch/), [0.5.1 · NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators](https://pythonparatrading.com/en/python-trading-course/level-0/numpy-arrays-and-vectorization/)

## By the end of this lesson

- Explain how vectorbt vectorizes a parameter sweep (column broadcasting, simulation compiled with Numba) and what `MA.run_combs` and `Portfolio.from_signals` do.
- Know vectorbt's license (Apache 2.0 with Commons Clause), what it prohibits and what the PRO edition offers, with sources.
- Program with NumPy a sweep of 20 × 20 moving-average crossover windows on a futures portfolio, with costs, and validate it against a direct implementation.
- Measure the overfitting of choosing the best combination: Sharpe in and out of sample, rank correlation and the same sweep on data with no effect.

## 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 sources[^1]:

| 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 $20 \times 20 \times 24 = 9{,}600$ columns. It is **broadcasting** (lesson 0.5.1):
NumPy combines an array of shape $(T, 20, 1)$ with another of shape $(T, 1, 20)$ and gets $(T, 20, 20)$ without copying
the input data.

```mermaid
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):

```python skip
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
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:

```python skip
# 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.

```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))
```
```output
(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.

```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()}")
```
```output
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 $w$ sessions ending at $t$
is $(C_t - C_{t-w}) / w$, with $C_t$ the sum of prices up to $t$. Before using it, it is checked against `rolling`:

```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")
```
```output
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)$ and
`ms[:, None, :]`, $(T, 1, 20)$; their difference is the $(T, 20, 20)$ tensor with all the pairs. The size of each
market, `k`, has shape $(T,)$ and is expanded with `[:, None, None]`. The loop is over the 24 markets, not over the 400
pairs or the 6,539 sessions.

```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")
```
```output
(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:

```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}")
```
```output
pair (31, 97): maximum difference 2.8e-17
```

## The Sharpe map: plateau and peaks

```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)
```
```output
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?

```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)
```
```output
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.* Five bars that rise with the quintile: mean out-of-sample Sharpe of each quintile of pairs ranked by in-sample Sharpe, 0.20, 0.50, 0.58, 0.85 and 0.92 from worst to best quintile. A highlighted line at 0.70 marks what the in-sample best pair, which had a Sharpe of 0.79, delivers out of sample: below the mean of its own quintile.

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.

```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}")
```
```output
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).* Two histograms sharing an axis. Top, permuted data: most pairs between −0.8 and 0, median −0.46, and a highlighted line at the maximum, 0.15. Bottom, synthetic data: most pairs between 0.2 and 0.8, median 0.48, and a tail reaching 0.79; 312 of the 325 pairs lie to the right of the luck maximum.

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.

<aside class="callout" data-kind="riesgo">

A sweep of thousands of combinations is a machine for producing false positives. Record how many trials you have run
(lesson 2.2.4), choose by plateaus and not by maxima (lesson 2.5.6), leave out-of-sample data untouched (lesson 2.5.2)
and correct the Sharpe for the number of trials (lesson 2.5.4). And remember that this data is synthetic with planted
momentum: the plateau exists because we put it there.

</aside>

## 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.

[^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.

## 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.**

```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}")
```
```output
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.

2. Count how many pairs would have a $t$ statistic greater than 2 in 2001-2012 (with $t \approx S \sqrt{N}$, $S$
   the annual Sharpe and $N$ the years) in the synthetic data and in the permuted data. What does the permuted result
   say about a fixed threshold of $t > 2$?

**Solution.**

```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}")
```
```output
synthetic  t > 2:  99 of 325
permuted   t > 2:   0 of 325
```

In the synthetic data, 99 of 325 pairs exceed $t > 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 > 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.

3. 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.

## Self-assessment

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

   - 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
   - B. It prohibits using it for commercial purposes, including trading your own money
   - C. It requires publishing the code of your strategies
   - D. It is a public domain license

   *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?

   - A. It keeps its edge: the planted effect is real
   - B. Its Sharpe turns negative
   - C. It cannot be known without vectorbt
   - 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)

   *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?

   - A. Because that way the sweep takes less time
   - 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
   - C. Because it removes costs
   - D. Because it improves the Sharpe

   *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?

   - A. It cannot: the parameter does not exist
   - B. Because it precomputes all the highs with `rolling`
   - 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
   - D. Because it uses the GPU

   *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

### Research

- [Intraday Seasonality IV - Field Search](https://pythonparatrading.com/en/seasonality-intraday-optimization/) · Research-0027 · 7 min

### Documentation

- [vectorbt on GitHub (README and LICENSE.md with the Commons Clause)](https://github.com/polakowo/vectorbt) · github.com
- [vectorbt on PyPI (version 1.2.0, October 19, 2026)](https://pypi.org/project/vectorbt/) · pypi.org
- [vectorbt: Portfolio documentation (from_signals, broadcasting, Numba)](https://vectorbt.dev/api/portfolio/base/) · vectorbt.dev
- [VectorBT vs PRO: differences and access](https://vectorbt.dev/getting-started/upgrade/) · vectorbt.dev
- [NumPy: broadcasting](https://numpy.org/doc/stable/user/basics.broadcasting.html) · numpy.org

### Data

- [Level 2 data: synthetic continuous futures (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/futuros2_continuos.parquet) · Download · futuros2_continuos.parquet

Previous lesson: [2.4.2 · Event-driven backtest from scratch: loop, portfolio, orders and fills](https://pythonparatrading.com/en/python-trading-course/level-2/event-driven-backtest-from-scratch/)

Next lesson: [2.4.4 · backtesting.py and backtrader: two teaching engines and their status](https://pythonparatrading.com/en/python-trading-course/level-2/backtesting-py-and-backtrader/)
