Vectorbt makes parameter optimization straightforward because it can
backtest many strategy variants in one run. Instead of looping and
running separate backtests, you build a “grid” of signals and parameter
series as a single wide object, then let vbt.Portfolio
evaluate everything in parallel.
The script below implements exactly that idea: it generates a grid over EMA window length, stop-loss level, and take-profit level (defined as a multiple of stop-loss), runs a single portfolio simulation across the full grid, and then selects the best parameter set by Sharpe ratio.
The strategy is deliberately simple to keep the optimization mechanics clear.
An entry signal triggers when price is above an EMA, meaning it trades with the trend:
Long entry when close > EMA(window)
No explicit exit signal
Positions are closed by risk management rules:
Stop-loss (sl_stop)
Take-profit (tp_stop), computed as
tp = sl * ratio
This creates a clean parameter surface to search:
ema_window: trend sensitivity
sl: risk per trade
tp: reward target derived from
sl * ratio
The core grid-search trick in vectorbt is to represent each parameter
combination as a column. If you use a MultiIndex for
columns, each level can store one parameter dimension (window, SL, TP).
That gives you:
A single backtest object containing all variants
Easy metric comparison across variants
Simple “best parameters” selection using idxmax() on
a metric series
In the provided code, each column key is a tuple
(w, sl, tp), then converted into a labeled
MultiIndex with names
["ema_window", "sl", "tp"].
import vectorbt as vbt
import pandas as pd
# data
close = vbt.YFData.download('ETH-USD', period='1y').get('Close')
# grid params
ema_window_list = [7, 14, 21, 30, 50, 90]
sl_levels = [0.01, 0.02, 0.03]
ratio_levels = [1.5, 2.0, 2.5, 3.0] # tp_stop = sl_stop * ratio
# indicators
ema = vbt.MA.run(close, window=ema_window_list, ewm=True)
# build grid (MultiIndex columns)
entries = {}
sl_stop = {}
tp_stop = {}
for w in ema_window_list:
ent = close > ema.ma[w].iloc[:, 0]
for sl in sl_levels:
for ratio in ratio_levels:
tp = sl * ratio
key = (w, sl, tp)
entries[key] = ent
sl_stop[key] = pd.Series(sl, index=close.index)
tp_stop[key] = pd.Series(tp, index=close.index)
entries = pd.DataFrame(entries)
sl_stop = pd.DataFrame(sl_stop)
tp_stop = pd.DataFrame(tp_stop)
# add labels to column levels
names = ["ema_window", "sl", "tp"]
entries.columns = pd.MultiIndex.from_tuples(entries.columns, names=names)
sl_stop.columns = pd.MultiIndex.from_tuples(sl_stop.columns, names=names)
tp_stop.columns = pd.MultiIndex.from_tuples(tp_stop.columns, names=names)
# portfolio
pf = vbt.Portfolio.from_signals(
close,
entries=entries,
exits=None,
direction="longonly",
init_cash=10_000,
fees=0.001,
slippage=0.0005,
sl_stop=sl_stop,
tp_stop=tp_stop,
freq="1d"
)
import matplotlib.pyplot as plt
pf.value().plot(legend=False)
plt.show()
pf.drawdown().plot(legend=False)
plt.show()
sharpe = pf.sharpe_ratio()
best_col = sharpe.idxmax()
print("Best params:")
for item in zip(names, best_col):
print(f"{item[0]}: {item[1]}")
pf[best_col].plot().show()
It downloads one year of daily ETH-USD close prices using
vbt.YFData.
It computes EMA for multiple window sizes in one call:
vbt.MA.run(close, window=ema_window_list, ewm=True)
returns an indicator object with outputs aligned for each window.It builds a full parameter grid by constructing three aligned DataFrames:
entries: boolean signals per parameter
combination
sl_stop: stop-loss percentage series per
combination
tp_stop: take-profit percentage series per
combination
Each DataFrame column corresponds to one strategy variant, and the
column labels store the parameters in a structured, queryable way via
MultiIndex.
It runs one backtest across all variants:
vbt.Portfolio.from_signals accepts wide DataFrames, so
it simulates every column in parallel.It evaluates and selects the best:
pf.sharpe_ratio() returns a Sharpe ratio per
column
idxmax() finds the parameter tuple that maximizes
Sharpe
pf[best_col] slices out the single best portfolio
for inspection and plotting
This pattern generalizes cleanly:
Add more parameters by extending the column key tuple and
names
Replace the entry logic with any indicator condition
Add exits (time-based, crossunder, regime filters) while keeping the grid structure
Swap the selection metric (CAGR, Calmar, Sortino, custom score)
The key is to keep everything aligned in column space so vectorbt can broadcast and compute at scale.