← Back to Home
A Minimal Grid Search With Vectorbt Using MultiIndex Signals

A Minimal Grid Search With Vectorbt Using MultiIndex Signals

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.

Strategy idea

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:

This creates a clean parameter surface to search:

Why MultiIndex columns matter

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:

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"].

The full script

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()

Pasted image 20260225141653.png Pasted image 20260225141715.png

What the script is doing

It downloads one year of daily ETH-USD close prices using vbt.YFData.

It computes EMA for multiple window sizes in one call:

It builds a full parameter grid by constructing three aligned DataFrames:

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:

It evaluates and selects the best:

Practical notes when you extend this

This pattern generalizes cleanly:

The key is to keep everything aligned in column space so vectorbt can broadcast and compute at scale.