This article demonstrates how to find the best parameters for a
strategy. The workflow is: download data, build a parameter grid,
generate signals, backtest with vectorbt, rank results, and
visualize a Sharpe heatmap. Code blocks are complete, runnable, and kept
simple.
The strategy used here is an EMA-ATR breakout with ADX trend strength filters. The logic combines three ideas:
EMA baseline
An exponential moving average acts as a dynamic reference level.
ATR-based breakout
A long entry triggers when price closes above
EMA + ATR * multiplier. This scales the breakout distance
with volatility.
ADX trend-strength filter
Entry requires ADX above an “entry threshold” to avoid weak-trend
breakouts. Exit triggers either on mean-reversion (close below EMA) or
ADX falling below an “exit threshold”.
The most common gotcha is index alignment across OHLC series. The safest approach is to intersect indices, then reindex everything to that shared index.
import numpy as np
import pandas as pd
import vectorbt as vbt
SYMBOL = "ETH-USD"
START = "2025-01-01"
END = None
data = vbt.YFData.download(SYMBOL, start=START, end=END)
close = data.get("Close").dropna()
high = data.get("High").dropna()
low = data.get("Low").dropna()
idx = close.index.intersection(high.index).intersection(low.index)
close = close.loc[idx]
high = high.loc[idx]
low = low.loc[idx]A grid search is just a structured way to test many combinations consistently. Keep ranges realistic to control compute time.
ema_range = range(10, 81, 5)
atr_period_range = [10, 14, 20, 28]
atr_mult_range = [0.5, 1.0, 1.5, 2.0]
adx_period_range = [10, 14, 20, 28]
adx_entry_range = [20, 25, 30]
adx_exit_range = [15, 20, 25]Compute EMA for all EMA windows at once using
vbt.MA.run, and compute ATR/ADX via TA-Lib wrappers. For
TA-Lib indicators, you often receive a DataFrame even for a single
input; convert to Series when needed.
ema_all = vbt.MA.run(close, window=list(ema_range), ewm=True).ma
atr_if = vbt.IndicatorFactory.from_talib("ATR")
adx_if = vbt.IndicatorFactory.from_talib("ADX")
atr_all = {}
for ap in atr_period_range:
a = atr_if.run(high, low, close, timeperiod=ap).real
if isinstance(a, pd.DataFrame):
a = a.iloc[:, 0]
atr_all[ap] = a.reindex(idx)
adx_all = {}
for dp in adx_period_range:
a = adx_if.run(high, low, close, timeperiod=dp).real
if isinstance(a, pd.DataFrame):
a = a.iloc[:, 0]
adx_all[dp] = a.reindex(idx)Build one boolean Series per parameter set, then combine into DataFrames where each column is one configuration. Using a MultiIndex for columns makes grouping and heatmaps straightforward.
entries = {}
exits = {}
for e in ema_range:
ema = ema_all[e]
if isinstance(ema, pd.DataFrame):
ema = ema.iloc[:, 0]
ema = ema.reindex(idx)
for ap in atr_period_range:
atr = atr_all[ap]
for m in atr_mult_range:
upper = ema + m * atr
for dp in adx_period_range:
adx = adx_all[dp]
for ae in adx_entry_range:
for ax in adx_exit_range:
key = (e, ap, m, dp, ae, ax)
entries[key] = (close > upper) & (adx > ae)
exits[key] = (close < ema) | (adx < ax)
entries = pd.DataFrame(entries)
exits = pd.DataFrame(exits)
names = ["ema", "atr_p", "atr_m", "adx_p", "adx_entry", "adx_exit"]
entries.columns = pd.MultiIndex.from_tuples(entries.columns, names=names)
exits.columns = pd.MultiIndex.from_tuples(exits.columns, names=names)Fees and slippage matter a lot in breakout systems. Keep them explicit and consistent across all runs.
pf = vbt.Portfolio.from_signals(
close,
entries,
exits,
init_cash=10_000,
fees=0.001,
slippage=0.0005,
direction="longonly"
)Sharpe and CAGR often disagree. Printing both helps you see whether high returns are coming with unstable risk.
TOP_N = 20
sharpe = pf.sharpe_ratio()
cagr = pf.annualized_return()
print("\nTop Sharpe parameter sets:")
print(sharpe.sort_values(ascending=False).head(TOP_N))
print("\nTop CAGR parameter sets:")
print(cagr.sort_values(ascending=False).head(TOP_N))
best_params = sharpe.idxmax()
print("\nBest (by Sharpe):", best_params)This collapses the grid to show, for each
(EMA window, ADX window) pair, the best Sharpe achieved
over the remaining parameters. It’s a fast way to spot stable regions
rather than one-off spikes.
sharpe_hm = sharpe.groupby(level=["ema", "adx_p"]).max().unstack("adx_p")
sharpe_hm = sharpe_hm.sort_index().sort_index(axis=1)
sharpe_hm.vbt.heatmap(
xaxis_title="ADX window",
yaxis_title="EMA window",
trace_kwargs=dict(colorbar_title="Sharpe")
).show()Once you pick best_params, slice the portfolio and
review stats and the equity curve.
pf_best = pf[best_params]
print("\nBest stats (first 10 rows):")
print(pf_best.stats().head(10))
pf_best.plot().show()Overfitting risk
A wide grid can “discover” parameters that fit noise. Use out-of-sample
testing or walk-forward validation if you plan to rely on the
result.
Regime dependence
ADX filters often behave differently in trending versus choppy periods.
Consider splitting results by market regimes or time blocks.
Signal semantics
close > EMA + m*ATR is a strict breakout. If you see too
few trades, reduce the multiplier range or allow >=.
Costs sensitivity
Breakout strategies can degrade quickly under higher fees/slippage. Try
re-running with different cost assumptions to see robustness.