Features

Parameter optimization

Sweep millions of parameter combinations with grids, conditions, and random search

Which parameters work, and how do you test millions of combinations without running out of time or memory? VBT turns any function into a parameter sweep with one decorator, builds grids with conditions, and can run compiled functions in chunks across all cores. Start with your existing Python strategy, then scale from a few parameter values to millions.

Backtest 1,000,000 parameter combinations and keep only the Sharpe ratio
close = vbt.YFData.pull("BTC-USD", start="2020-01-01", end="2025-01-01").close

@vbt.parameterized(  
    merge_func="concat",
    engine="threadpool",
    chunk_len="auto",
    warmup=True,
)
@njit(nogil=True)  
def sharpe_nb(close, fast_window, slow_window, sl_stops):
    fast = vbt.nb.rolling_mean_1d_nb(close, fast_window)
    slow = vbt.nb.rolling_mean_1d_nb(close, slow_window)
    entries = vbt.nb.crossed_above_1d_nb(fast, slow)
    exits = vbt.nb.crossed_above_1d_nb(slow, fast)
    n = len(sl_stops)
    sim_out = vbt.pf_nb.from_signals_nb(  
        target_shape=(len(close), n),
        group_lens=np.full(n, 1),
        close=close,
        long_entries=entries,
        long_exits=exits,
        sl_stop=sl_stops.reshape(1, -1),
        fees=0.001,
        save_returns=True,
    )
    return vbt.ret_nb.sharpe_ratio_nb(sim_out.in_outputs.returns, 365.0)

sl_stops = np.linspace(0.01, 0.30, 100)
sharpe = sharpe_nb(
    close.values,
    vbt.Param(np.arange(2, 102), name="fast_window"),
    vbt.Param(np.arange(105, 305, 2), name="slow_window"),
    sl_stops,
)
len(sharpe)  
1000000
sharpe.index = sharpe.index.set_levels(sl_stops.round(4), level=2)
sharpe.index = sharpe.index.set_names("sl_stop", level=2)
sharpe.nlargest(5).round(3)
fast_window  slow_window  sl_stop
2            107          0.0129     1.608
                          0.0159     1.607
             119          0.0246     1.603
             117          0.0510     1.594
                          0.0539     1.594
dtype: float64
best_per_pair = sharpe.groupby(["fast_window", "slow_window"]).max()
best_per_pair.vbt.heatmap(x_level="fast_window", y_level="slow_window").show()
Heatmap of the best Sharpe ratio across 100 fast and 100 slow moving average windows for Bitcoin from 2020 to 2024 Figure data (JSON)

Only one number per backtest is kept, so a million backtests fit in a few megabytes. The heatmap shows the best Sharpe ratio of each window pair across all stop levels. In this bull market, 95% of the combinations had a positive Sharpe ratio, which is why a single best value says little on its own.

Parameterize any function

@vbt.parameterized wraps any function, from a few lines of pandas to a full backtest. Arguments wrapped in vbt.Param become parameters, every combination is passed to the function, and the results are merged into one labeled object, with the parameter values as index levels. Merging can concatenate, stack columns, or return the raw list, and parameters can be nested, shared across calls, or functions themselves. The Parameterized decorator highlight below shows the pattern on a full strategy.

You can keep your existing pandas calculations and Python strategy logic. Parameter values can also be functions, so the same pipeline can compare different entry rules as well as different lookback windows. Give each rule a label to keep the results easy to identify.

Reuse calculations across combinations

A moving average used in 100 window pairs only needs to be calculated once. Precompute the indicators, then pass their outputs into the search. The Paramables example below runs all moving averages first and combines the resulting indicator objects into fast/slow pairs. You can also search in stages: find useful entry settings, reuse those signals, then explore stops and position sizes.

Cartesian, zipped, and lazy grids

Parameters combine as a Cartesian product by default, or element by element when they share a level. Conditions remove combinations that make no sense, such as a fast window longer than the slow one, before anything runs, as in the Conditional parameters highlight. Large grids are generated lazily, so a grid of billions of potential combinations does not need to fit in memory before a random subset is drawn from it, as Lazy parameter grids shows.

Search strategies

Exhaustive search is only one option. random_subset tests a random sample of the grid, which often finds the same good region at a fraction of the cost, as the Random search highlight shows. Staged searches narrow a coarse grid into a finer one around the best area. Set a seed when sampling so you can repeat the same selection of parameter combinations.

Optimize a VBT backtest with Optuna or Hyperopt

For Bayesian or evolutionary search, use your VBT backtest as the objective function for Optuna or Hyperopt. The optimizer proposes a parameter combination, your function calculates signals and runs the backtest, then returns the metric you want to optimize. You can search lookback windows, signal thresholds, stop levels, or model settings without calculating the entire grid first.

The objective is yours to define. For example, maximize a return metric after fees, or combine return and drawdown into a score. You can use the same backtest function for an initial grid search and a later optimizer-driven search. VBT handles the simulation and metrics, while the external optimizer chooses which parameters to try next.

Big sweeps

Three settings keep large sweeps fast:

  • Chunks. chunk_len splits the combinations into groups that run in parallel threads or processes. For a Numba function, warmup=True runs the first task before distributing the rest so compilation happens up front.
  • Mono-chunks. Combine many parameter values into one call so the function processes them as columns, as the example above does with stop levels and the Mono-chunks highlight does with two parameters.
  • Metrics only. Return a single metric per combination instead of a portfolio, and save the returns during the simulation so the metric does not have to reconstruct them.

For long runs, chunk caching can save completed results to disk and reuse them when you resume the same search. See Parallel execution and caching. A pipeline can also return vbt.NoResult to leave an unwanted combination out of the merged results, such as a strategy that produces too few trades to evaluate.

Choosing among results

The highest Sharpe ratio in a large grid is usually an outlier. Rank on more than one number: results come back as pandas objects indexed by parameters, so several metrics, such as Sharpe ratio, maximum drawdown, and trade count, can be normalized and averaged into one score in a few lines. Prefer regions where neighboring parameters also do well, and when several strategies are needed, choose combinations that differ from each other rather than the top few, which tend to be near-duplicates. Compare trade duration and exposure to see whether those variants actually trade differently. Walk-forward testing on the Walk-forward and cross-validation page checks whether a choice holds outside the data it was picked on. For tests of parameter sensitivity and selection bias, see Robustness and overfitting.

Compare several metrics in one table

Keep the numbers you need for selection and leave the full portfolio inside the function. This small example uses made-up prices and three moving average windows. Signals are shifted by one bar, and each backtest includes fees.

Compare return, drawdown, and trade count
close = pd.Series(
    [100, 102, 105, 103, 99, 101, 106, 110, 107, 102, 104, 109, 114, 111, 106, 108, 115, 118, 113, 110],
    index=pd.date_range("2025-01-01", periods=20),
)

@vbt.parameterized(merge_func="column_stack")
def compare_windows(close, window):
    average = close.rolling(window).mean()
    entries = (close > average).shift(1, fill_value=False)
    exits = (close < average).shift(1, fill_value=False)
    pf = vbt.PF.from_signals(close, entries, exits, fees=0.001, freq="1D")
    return pd.Series({
        "Return [%]": pf.total_return * 100,
        "Max drawdown [%]": pf.max_drawdown * 100,
        "Closed trades": pf.trades.status_closed.count(),
    })

results = compare_windows(close, vbt.Param([2, 3, 5])).T
results.round(2)
        Return [%]  Max drawdown [%]  Closed trades
window
2           -16.28            -16.28            4.0
3           -23.36            -23.36            4.0
5           -16.92            -16.92            2.0
results.index[results["Closed trades"] >= 3].tolist()
[2, 3]

The last line selects windows with at least three closed trades. Change the filter to your own criteria, or add performance metrics to the returned Series. One result table gives you returns, drawdowns, and trade counts for every setting, ready for your own selection rule.

Paramables

✅ Each analyzable VBT object (such as data, indicator, or portfolio) can now be split into items, which are multiple objects of the same type, each containing only one column or group. This makes it possible to use VBT objects as standalone parameters and process only a subset of information at a time, such as a symbol in a data instance or a parameter combination in an indicator.

Combine outputs of a SMA indicator combinatorially
@vbt.parameterized(merge_func="column_stack")
def get_signals(fast_sma, slow_sma):  
    entries = fast_sma.crossed_above(slow_sma)
    exits = fast_sma.crossed_below(slow_sma)
    return entries, exits

data = vbt.YFData.pull(["BTC-USD", "ETH-USD"])
sma = data.run("talib:sma", timeperiod=range(20, 50, 2))  
fast_sma = sma.rename_levels({"sma_timeperiod": "fast"})  
slow_sma = sma.rename_levels({"sma_timeperiod": "slow"})
entries, exits = get_signals(
    vbt.Param(fast_sma, condition="__fast__ < __slow__"),  
    vbt.Param(slow_sma)
)
entries.columns
MultiIndex([(20, 22, 'BTC-USD'),
            (20, 22, 'ETH-USD'),
            (20, 24, 'BTC-USD'),
            (20, 24, 'ETH-USD'),
            (20, 26, 'BTC-USD'),
            (20, 26, 'ETH-USD'),
            ...
            (44, 46, 'BTC-USD'),
            (44, 46, 'ETH-USD'),
            (44, 48, 'BTC-USD'),
            (44, 48, 'ETH-USD'),
            (46, 48, 'BTC-USD'),
            (46, 48, 'ETH-USD')],
           names=['fast', 'slow', 'symbol'], length=210)

Lazy parameter grids

✅ The parameterized decorator no longer needs to materialize parameter grids if you are only interested in a subset of all parameter combinations. This change enables the generation of random parameter combinations almost instantly, no matter how large the total number of possible combinations is.

Test a random subset of a huge number of parameter combinations
@vbt.parameterized(merge_func="concat")
def test_combination(data, n, sl_stop, tsl_stop, tp_stop):
    return data.run(
        "from_random_signals",
        n=n,
        sl_stop=sl_stop,
        tsl_stop=tsl_stop,
        tp_stop=tp_stop,
        seed=42,
    ).total_return

n = np.arange(10, 100)
sl_stop = np.arange(1, 1000) / 1000
tsl_stop = np.arange(1, 1000) / 1000
tp_stop = np.arange(1, 1000) / 1000
len(n) * len(sl_stop) * len(tsl_stop) * len(tp_stop)
89730269910
test_combination(
    vbt.YFData.pull("BTC-USD"),
    n=vbt.Param(n),
    sl_stop=vbt.Param(sl_stop),
    tsl_stop=vbt.Param(tsl_stop),
    tp_stop=vbt.Param(tp_stop),
    _random_subset=10,
    _seed=42
)
n   sl_stop  tsl_stop  tp_stop
18  0.476    0.485     0.862       2.233997
21  0.530    0.697     0.017       0.091041
49  0.499    0.560     0.954      15.987541
50  0.535    0.200     0.477       2.182477
72  0.763    0.360     0.129      -0.040304
78  0.503    0.071     0.533      10.851910
79  0.656    0.388     0.930      11.854461
80  0.746    0.042     0.644       0.702603
87  0.274    0.539     0.434       2.072847
97  0.806    0.206     0.426       7.136395
dtype: float64

Mono-chunks

✅ The parameterized decorator now supports splitting parameter combinations into "mono-chunks," merging the parameter values within each chunk into a single value, and running the entire chunk with a single function call. This means you are no longer limited to processing only one parameter combination at a time 🌪️ Keep in mind that your function must be adapted to handle multiple parameter values, and you should modify the merging function as needed.

Test 100 combinations of SL and TP values per thread
@vbt.parameterized(
    merge_func="concat",
    mono_chunk_len=100,  
    chunk_len="auto",  
    engine="threadpool",  
    warmup=True  
)
@njit(nogil=True)
def test_stops_nb(close, entries, exits, sl_stop, tp_stop):
    sim_out = vbt.pf_nb.from_signals_nb(
        target_shape=(close.shape[0], sl_stop.shape[1]),
        group_lens=np.full(sl_stop.shape[1], 1),
        close=close,
        long_entries=entries,
        short_entries=exits,
        sl_stop=sl_stop,
        tp_stop=tp_stop,
        save_returns=True
    )
    return vbt.ret_nb.total_return_nb(sim_out.in_outputs.returns)

data = vbt.YFData.pull("BTC-USD", start="2020")  
entries, exits = data.run("randnx", n=10, seed=42, hide_params=True, unpack=True)  
sharpe_ratios = test_stops_nb(
    vbt.to_2d_array(data.close),
    vbt.to_2d_array(entries),
    vbt.to_2d_array(exits),
    sl_stop=vbt.Param(np.arange(0.01, 1.0, 0.01), mono_merge_func=np.column_stack),  
    tp_stop=vbt.Param(np.arange(0.01, 1.0, 0.01), mono_merge_func=np.column_stack)
)
sharpe_ratios.vbt.heatmap().show()
Heatmap of BTC-USD total returns across stop-loss and take-profit combinations Figure data (JSON)

✅ Parameters can depend on each other. For example, when testing a crossover of moving averages, it makes no sense to test a fast window that is longer than the slow window. By filtering out such cases, you only need to evaluate about half as many parameter combinations.

Test slow windows being longer than fast windows by at least 5
@vbt.parameterized(merge_func="column_stack")
def ma_crossover_signals(data, fast_window, slow_window):
    fast_sma = data.run("sma", fast_window, short_name="fast_sma")
    slow_sma = data.run("sma", slow_window, short_name="slow_sma")
    entries = fast_sma.real_crossed_above(slow_sma.real)
    exits = fast_sma.real_crossed_below(slow_sma.real)
    return entries, exits

entries, exits = ma_crossover_signals(
    vbt.YFData.pull("BTC-USD", start="one year ago UTC"),
    vbt.Param(np.arange(5, 50), condition="slow_window - fast_window >= 5"),
    vbt.Param(np.arange(5, 50))
)
entries.columns
MultiIndex([( 5, 10),
            ( 5, 11),
            ( 5, 12),
            ( 5, 13),
            ( 5, 14),
            ...
            (42, 48),
            (42, 49),
            (43, 48),
            (43, 49),
            (44, 49)],
           names=['fast_window', 'slow_window'], length=820)

✅ While grid search tests every possible combination of hyperparameters, random search selects and tests random combinations of hyperparameters. This is especially useful when there is a huge number of parameter combinations. Random search has also been shown to find equal or better values than grid search with fewer function evaluations. The indicator factory, parameterized decorator, and any method that performs broadcasting now support random search out of the box.

Test a random subset of SL, TSL, and TP combinations
data = vbt.YFData.pull("BTC-USD", start="2020")
stop_values = np.arange(1, 100) / 100  
pf = vbt.PF.from_random_signals(
    data,
    n=100,
    seed=42,
    sl_stop=vbt.Param(stop_values),
    tsl_stop=vbt.Param(stop_values),
    tp_stop=vbt.Param(stop_values),
    broadcast_kwargs=dict(random_subset=1000, seed=42)  
)
pf.total_return.sort_values(ascending=False)
sl_stop  tsl_stop  tp_stop
0.02     0.22      0.55       1.093685
0.93     0.60      0.99       1.091450
0.88     0.68      0.99       1.091450
0.79     0.69      0.99       1.091450
         0.62      0.99       1.091450
                                   ...
0.08     0.84      0.14      -0.466277
0.14     0.09      0.12      -0.502329
0.80     0.11      0.11      -0.509395
0.19     0.11      0.09      -0.529528
0.29     0.11      0.06      -0.563992
Name: total_return, Length: 1000, dtype: float64

✅ There is a special decorator that allows any Python function to accept multiple parameter combinations, even if the function itself supports only one. The decorator wraps the function, gains access to its arguments, identifies all arguments acting as parameters, builds a grid from them, and calls the underlying function on each parameter combination from that grid. The execution can be easily parallelized. Once all outputs are ready, it merges them into a single object. Use cases are endless: from running indicators that cannot be wrapped with the indicator factory, to parameterizing entire pipelines! 🪄

Parameterize a basic SMA indicator without Indicator Factory
@vbt.parameterized(merge_func="column_stack")  
def sma(close, window):
    return close.rolling(window).mean()

data = vbt.YFData.pull("BTC-USD")
sma(data.close, vbt.Param(range(20, 50)))
Combination 30/30
window                               20            21            22  \
Date
2014-09-17 00:00:00+00:00           NaN           NaN           NaN
2014-09-18 00:00:00+00:00           NaN           NaN           NaN
2014-09-19 00:00:00+00:00           NaN           NaN           NaN
...                                 ...           ...           ...
2024-03-07 00:00:00+00:00  57657.135156  57395.376488  57147.339134
2024-03-08 00:00:00+00:00  58488.990039  58163.942708  57891.045455
2024-03-09 00:00:00+00:00  59297.836523  58956.156064  58624.648793

...

window                               48            49
Date
2014-09-17 00:00:00+00:00           NaN           NaN
2014-09-18 00:00:00+00:00           NaN           NaN
2014-09-19 00:00:00+00:00           NaN           NaN
...                                 ...           ...
2024-03-07 00:00:00+00:00  49928.186686  49758.599330
2024-03-08 00:00:00+00:00  50483.072266  50303.123565
2024-03-09 00:00:00+00:00  51040.440837  50846.672353

[3462 rows x 30 columns]

✅ The broadcasting mechanism has been completely refactored and now supports parameters. Many parameters in VBT, such as SL and TP, are array-like and can be provided per row, per column, or even per element. Internally, even a scalar is treated as a regular time series and is broadcast along with other proper time series. Previously, to test multiple parameter combinations, you had to tile other time series so that all shapes matched perfectly. With this feature, the tiling procedure is performed automatically!

Write a steep slope indicator without indicator factory
def steep_slope(close, up_th):
    r = vbt.broadcast(dict(close=close, up_th=up_th))
    return r["close"].pct_change() >= r["up_th"]

data = vbt.YFData.pull("BTC-USD", start="2020", end="2022")
fig = data.plot(plot_volume=False)
sma = vbt.talib("SMA").run(data.close, timeperiod=50).real
sma.rename("SMA").vbt.plot(fig=fig)
mask = steep_slope(sma, vbt.Param([0.005, 0.01, 0.015]))  

def plot_mask_ranges(column, color):
    mask.vbt.ranges.plot_shapes(
        column=column,
        plot_close=False,
        add_shape_kwargs=dict(fillcolor=color),
        fig=fig
    )
plot_mask_ranges(0.005, "orangered")
plot_mask_ranges(0.010, "orange")
plot_mask_ranges(0.015, "yellow")
fig.update_xaxes(showgrid=False)
fig.update_yaxes(showgrid=False)
fig.show()
BTC-USD OHLC and 50-day SMA with steep-slope ranges at three thresholds from 2020 through 2021 Figure data (JSON)

Parameters

✅ There is a new module for working with parameters.

Generate 10,000 random parameter combinations for MACD
from itertools import combinations

window_space = np.arange(100)
fastk_windows, slowk_windows = list(zip(*combinations(window_space, 2)))  
window_type_space = list(vbt.enums.WType)
param_product = vbt.combine_params(
    dict(
        fast_window=vbt.Param(fastk_windows, level=0),  
        slow_window=vbt.Param(slowk_windows, level=0),
        signal_window=vbt.Param(window_space, level=1),
        macd_wtype=vbt.Param(window_type_space, level=2),  
        signal_wtype=vbt.Param(window_type_space, level=2),
    ),
    random_subset=10_000,
    seed=42,
    build_index=False
)
pd.DataFrame(param_product)
      fast_window  slow_window  signal_window  macd_wtype  signal_wtype
0               0            1              0           2             2
1               0            3              5           1             1
2               0            3             55           4             4
3               0            3             80           2             2
4               0            4             17           2             2
...           ...          ...            ...         ...           ...
9995           97           99             14           0             0
9996           97           99             35           2             2
9997           98           99              6           2             2
9998           98           99             44           4             4
9999           98           99             78           4             4

[10000 rows x 5 columns]

Copyright © 2021–2026 Oleg Polakow. All rights reserved.

Site content and documentation are provided for using and evaluating VectorBT PRO and for educational purposes. Any other use, including building or supporting competing products or services, requires prior written consent.