← Back to Home
Parameter-sweeping an EMA–ATR breakout with ADX filter in VectorBT

Parameter-sweeping an EMA–ATR breakout with ADX filter in VectorBT

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.

What the strategy is doing

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”.

Data download and alignment

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]

Parameter grid

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]

Indicator computation

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)

Signal generation over the full grid

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)

Backtest with realistic trading frictions

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

Ranking results and selecting a champion

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)

Heatmap: best Sharpe by EMA and ADX period

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()
Pasted image 20260221215113.png

Inspect and plot the best run

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()
Pasted image 20260221215135.png

Practical notes when interpreting the output

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.