Features
Optimization
Explore parameter spaces with cross-validation, conditional grids, and portfolio optimizers
✅ Added support for walk-forward cross-validation (CV) with purging, as well as combinatorial CV with purging and embargoing, based on Marcos Lopez de Prado's Advances in Financial Machine Learning.
splitter = vbt.Splitter.from_purged_kfold(
vbt.date_range("2024", "2025"),
n_folds=10,
n_test_folds=2,
purge_td="3 days",
embargo_td="3 days"
)
splitter.plots().show()✅ 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.
@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.columnsMultiIndex([(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)✅ 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.
@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)89730269910test_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✅ 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.
@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()✅ Most cross-validation tasks involve testing a grid of parameter combinations on the training data, selecting the best parameter combination, and validating it on the test data. This process must be repeated for each split. The cross-validation decorator combines the parameterized and split decorators to automate this task.
@vbt.cv_split(
splitter="from_rolling",
splitter_kwargs=dict(length=365, split=0.5, set_labels=["train", "test"]),
takeable_args=["data"],
parameterized_kwargs=dict(random_subset=100, seed=42),
merge_func="concat"
)
def sma_crossover_cv(data, fast_period, slow_period, metric):
fast_sma = data.run("sma", fast_period, hide_params=True)
slow_sma = data.run("sma", slow_period, hide_params=True)
entries = fast_sma.real_crossed_above(slow_sma)
exits = fast_sma.real_crossed_below(slow_sma)
pf = vbt.PF.from_signals(data, entries, exits, direction="both")
return pf.deep_getattr(metric)
sma_crossover_cv(
vbt.YFData.pull("BTC-USD", start="4 years ago"),
vbt.Param(np.arange(20, 50), condition="x < slow_period"),
vbt.Param(np.arange(20, 50)),
"trades.expectancy"
)split set fast_period slow_period
0 train 20 25 8.015725
test 20 23 0.573465
1 train 40 48 -4.356317
test 39 40 5.666271
2 train 24 45 18.253340
test 22 36 111.202831
3 train 20 31 54.626024
test 20 25 -1.596945
4 train 25 48 41.328588
test 25 30 6.620254
5 train 26 32 7.178085
test 24 29 4.087456
6 train 22 23 -0.581255
test 22 31 -2.494519
dtype: float64Tutorial
Learn more in the Cross-validation tutorial.
✅ Normally, to run a function on each split, you need to build a splitter specifically targeted at the input data provided to the function. This means that each time the input data changes, you must recreate the splitter. The split decorator automates this process by wrapping the function, giving it access to all arguments so it can make splitting decisions as needed. Essentially, it can "infect" any Python function with splitting functionality 🦠
@vbt.split(
splitter="from_grouper",
splitter_kwargs=dict(by="Q"),
takeable_args=["data"],
merge_func="concat"
)
def get_quarter_return(data):
return data.returns.vbt.returns.total()
data = vbt.YFData.pull("BTC-USD")
get_quarter_return(data.loc["2021"])Date
2021Q1 1.005805
2021Q2 -0.407050
2021Q3 0.304383
2021Q4 -0.037627
Freq: Q-DEC, dtype: float64get_quarter_return(data.loc["2022"])Date
2022Q1 -0.045047
2022Q2 -0.572515
2022Q3 0.008429
2022Q4 -0.143154
Freq: Q-DEC, dtype: float64Tutorial
Learn more in the Cross-validation tutorial.
✅ 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.
@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.columnsMultiIndex([( 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)✅ Splitters in scikit-learn are not ideal for validating ML-based
and rule-based trading strategies. VBT provides a juggernaut class that supports many splitting
schemes that are safe for backtesting, including rolling windows, expanding windows, time-anchored windows,
random windows for block bootstraps, and even Pandas-native groupby and resample instructions such as
"M" for monthly frequency. As a bonus, the produced splits can be easily analyzed and
visualized! For example, you can detect any split or set overlaps, convert all splits into a single
boolean mask for custom analysis, group splits and sets, and analyze their distribution relative to each other.
This class contains more lines of code than the entire backtesting.py
package, so do not underestimate the new king in town! 🦏
data = vbt.YFData.pull("BTC-USD", start="4 years ago")
splitter = vbt.Splitter.from_rolling(
data.index,
length="360 days",
split=0.5,
set_labels=["train", "test"],
freq="daily"
)
splitter.plots().show()Tutorial
Learn more in the Cross-validation tutorial.
✅ 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.
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! 🪄
@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)))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]✅ Riskfolio-Lib is another increasingly popular library for portfolio optimization that has been integrated into VBT. Integration was done by automating typical workflows inside Riskfolio-Lib and putting them into a single function, so many portfolio optimization problems can be expressed using a single set of keyword arguments and easily parameterized.
data = vbt.YFData.pull(
["SPY", "TLT", "XLF", "XLE", "XLU", "XLK", "XLB", "XLP", "XLY", "XLI", "XLV"],
start="2020",
end="2023",
missing_index="drop"
)
pfo = vbt.PFO.from_riskfolio(
returns=data.close.vbt.to_returns(),
port_cls="hc",
every="M"
)
pfo.plot().show()Tutorial
Learn more in the Portfolio optimization tutorial.
✅ 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!
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()✅ There is a new module for working with parameters.
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]✅ Portfolio optimization is the process of creating a portfolio of assets that aims to maximize return and minimize risk. Usually, this process is performed periodically and involves generating new weights to rebalance an existing portfolio. As with most things in VBT, the weight generation step is implemented as a callback by the user, while the optimizer calls that callback periodically. The final result is a collection of returned weight allocations that can be analyzed, visualized, and used in actual simulations 🥧
def regime_change_optimize_func(data):
returns = data.returns
total_return = returns.vbt.returns.total()
weights = data.symbol_wrapper.fill_reduced(0)
pos_mask = total_return > 0
if pos_mask.any():
weights[pos_mask] = total_return[pos_mask] / total_return.abs().sum()
neg_mask = total_return < 0
if neg_mask.any():
weights[neg_mask] = total_return[neg_mask] / total_return.abs().sum()
return -1 * weights
data = vbt.YFData.pull(
["SPY", "TLT", "XLF", "XLE", "XLU", "XLK", "XLB", "XLP", "XLY", "XLI", "XLV"],
start="2020",
end="2023",
missing_index="drop"
)
pfo = vbt.PFO.from_optimize_func(
data.symbol_wrapper,
regime_change_optimize_func,
vbt.RepEval("data[index_slice]", context=dict(data=data)),
every="M"
)
pfo.plot().show()Tutorial
Learn more in the Portfolio optimization tutorial.
✅ PyPortfolioOpt is a popular financial portfolio optimization package that includes both classical methods (Markowitz 1952 and Black-Litterman), suggested best practices (such as covariance shrinkage), and many recent developments and novel features, like L2 regularization, shrunk covariance, and hierarchical risk parity.
data = vbt.YFData.pull(
["SPY", "TLT", "XLF", "XLE", "XLU", "XLK", "XLB", "XLP", "XLY", "XLI", "XLV"],
start="2020",
end="2023",
missing_index="drop"
)
pfo = vbt.PFO.from_pypfopt(
returns=data.returns,
optimizer="hrp",
target="optimize",
every="M"
)
pfo.plot().show()Tutorial
Learn more in the Portfolio optimization tutorial.
✅ Universal Portfolios is a package that brings together various Online Portfolio Selection (OLPS) algorithms.
data = vbt.YFData.pull(
["SPY", "TLT", "XLF", "XLE", "XLU", "XLK", "XLB", "XLP", "XLY", "XLI", "XLV"],
start="2020",
end="2023",
missing_index="drop"
)
pfo = vbt.PFO.from_universal_algo(
"MPT",
data.resample("W").close,
window=52,
min_history=4,
mu_estimator='historical',
cov_estimator='empirical',
method='mpt',
q=0
)
pfo.plot().show()Tutorial
Learn more in the Portfolio optimization tutorial.
And many more...
⏩ Look forward to more killer features being added every release!
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.