← Back to Home
Building a Simple ETH Trend-Following Strategy with `vectorbt`

Building a Simple ETH Trend-Following Strategy with `vectorbt`

This example shows how to brute-force a compact trend-following strategy on ETH-USD using yfinance, pandas, and vectorbt.

The idea is simple:

That makes it a clean example of vectorized research in Python.

Imports and setup

Start with the libraries and a small parameter grid.

import numpy as np
import pandas as pd
import yfinance as yf
import vectorbt as vbt
import matplotlib.pyplot as plt
from itertools import product

symbol = "ETH-USD"
start = "2025-01-01"
interval = "1d"
init_cash = 100_000
fees = 0.001
risk_pct = 0.05

atr_window_list = [5, 10, 15]
ema_window_list = [10, 20, 30, 50]
roc_fast_bars_list = [5, 10, 15]
roc_slow_bars_list = [10, 20, 30, 50]
atr_mult_list = [1.0, 2.0, 3.0]

These parameters define both the market data and the search space.

risk_pct = 0.05 means each trade is sized as if the strategy can lose at most 5% of initial cash if price hits the stop.

Download price data

Next, pull ETH daily candles and keep the columns needed for signal generation.

df = yf.download(symbol, start=start, interval=interval, auto_adjust=False, progress=False).droplevel(1, 1)
df = df.dropna().copy()

close = df["Close"]
high = df["High"]
low = df["Low"]

The .droplevel(1, 1) call removes the extra multi-index level that yfinance often returns.

From here, the strategy works with Close, High, and Low.

Build signals across all parameter combinations

The main loop computes indicators and stores entries, exits, stops, and sizes for every parameter set.

entries = {}
exits = {}
stops = {}
sizes = {}

for atr_window, ema_window, roc_fast_bars, roc_slow_bars, atr_mult in product(
    atr_window_list, ema_window_list, roc_fast_bars_list, roc_slow_bars_list, atr_mult_list
):
    roc_fast = close.pct_change(roc_fast_bars)
    roc_slow = close.pct_change(roc_slow_bars)
    ema = close.ewm(span=ema_window, adjust=False).mean()

    tr1 = high - low
    tr2 = (high - close.shift()).abs()
    tr3 = (low - close.shift()).abs()
    tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
    atr = tr.rolling(atr_window).mean()

    cross_up = (roc_fast > roc_slow) & (roc_fast.shift(1) <= roc_slow.shift(1))
    signal = cross_up & (close > ema)

    entries[(atr_window, ema_window, roc_fast_bars, roc_slow_bars, atr_mult)] = signal
    exits[(atr_window, ema_window, roc_fast_bars, roc_slow_bars, atr_mult)] = roc_fast < 0

    stop_dist = atr_mult * atr
    sl_stop = (stop_dist / close).clip(lower=0.0)
    stops[(atr_window, ema_window, roc_fast_bars, roc_slow_bars, atr_mult)] = sl_stop

    risk_budget = init_cash * risk_pct
    size = (risk_budget / stop_dist).replace([np.inf, -np.inf], np.nan)
    size = np.minimum(size, init_cash / close)
    size = size.where(signal, np.nan)
    sizes[(atr_window, ema_window, roc_fast_bars, roc_slow_bars, atr_mult)] = size

This block does most of the work.

The fast ROC reacts quickly to recent momentum. The slow ROC is the baseline. A long signal appears when fast ROC crosses above slow ROC while price is already above the EMA.

The ATR estimates volatility. Multiplying ATR by atr_mult creates a stop distance that adapts to market conditions. Wider stops allow more room in volatile regimes.

The position size is then derived from:

risk_budget / stop_dist

So when volatility rises, the position automatically gets smaller.

Convert dictionaries into DataFrames

vectorbt can evaluate many strategies in parallel, so the stored dictionaries are converted into aligned DataFrames.

entries = pd.DataFrame(entries)
exits = pd.DataFrame(exits)
stops = pd.DataFrame(stops)
sizes = pd.DataFrame(sizes)

Each column now represents one full parameter combination.

That is what makes the grid search compact and fast.

Backtest with vectorbt

Now run all strategies in one portfolio object.

pf = vbt.Portfolio.from_signals(
    close,
    entries=entries,
    exits=exits,
    size=sizes,
    size_type="amount",
    init_cash=init_cash,
    fees=fees,
    sl_stop=stops,
    sl_trail=True,
    freq="1d"
)

A few details matter here.

size_type="amount" means sizes are interpreted as units of ETH, not percentages.

sl_stop=stops passes the stop as a fraction of price, which matches how vectorbt expects stop-loss input.

sl_trail=True turns that stop into a trailing stop, so profitable trades can lock in gains as price moves up.

Find the best parameter set

Once the backtest is done, select the strategy with the highest total return.

best = pf.total_return().idxmax()
print("Best params:", best)
print("Best total return:", pf.total_return().max())

This returns the tuple:

(atr_window, ema_window, roc_fast_bars, roc_slow_bars, atr_mult)

It is the simplest way to identify the top-performing configuration from the grid.

Plot the best strategy against buy and hold

The final chart compares all tested equity curves in the background, then highlights the winner against a benchmark.

plt.figure()
plt.plot(pf.value(), alpha=0.3)
plt.plot(pf[best].value(), label="Best Strategy", color="blue", linewidth=3)
plt.plot(pf[best].benchmark_value(), label="Benchmark (Buy & Hold)", color="red", linestyle="--", linewidth=3)
plt.title("Portfolio Value vs Benchmark")
plt.xlabel("Time")
plt.ylabel("Value ($)")
plt.legend()
plt.show()
Pasted image 20260312145211.png

The faint lines show how sensitive performance is to parameter choice.

The bold blue line isolates the best strategy, while the dashed red line shows whether the model actually adds value over simply holding ETH.

Why this approach is useful

This strategy is not complex, but it captures several strong research habits:

That makes it a solid template for systematic strategy development.

Final script

import numpy as np
import pandas as pd
import yfinance as yf
import vectorbt as vbt
import matplotlib.pyplot as plt
from itertools import product

symbol = "ETH-USD"
start = "2025-01-01"
interval = "1d"
init_cash = 100_000
fees = 0.001
risk_pct = 0.05

atr_window_list = [5, 10, 15]
ema_window_list = [10, 20, 30, 50]
roc_fast_bars_list = [5, 10, 15]
roc_slow_bars_list = [10, 20, 30, 50]
atr_mult_list = [1.0, 2.0, 3.0]

df = yf.download(symbol, start=start, interval=interval, auto_adjust=False, progress=False).droplevel(1, 1)
df = df.dropna().copy()

close = df["Close"]
high = df["High"]
low = df["Low"]

entries = {}
exits = {}
stops = {}
sizes = {}

for atr_window, ema_window, roc_fast_bars, roc_slow_bars, atr_mult in product(
    atr_window_list, ema_window_list, roc_fast_bars_list, roc_slow_bars_list, atr_mult_list
):
    roc_fast = close.pct_change(roc_fast_bars)
    roc_slow = close.pct_change(roc_slow_bars)
    ema = close.ewm(span=ema_window, adjust=False).mean()

    tr1 = high - low
    tr2 = (high - close.shift()).abs()
    tr3 = (low - close.shift()).abs()
    tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
    atr = tr.rolling(atr_window).mean()

    cross_up = (roc_fast > roc_slow) & (roc_fast.shift(1) <= roc_slow.shift(1))
    signal = cross_up & (close > ema)

    entries[(atr_window, ema_window, roc_fast_bars, roc_slow_bars, atr_mult)] = signal
    exits[(atr_window, ema_window, roc_fast_bars, roc_slow_bars, atr_mult)] = roc_fast < 0

    stop_dist = atr_mult * atr
    sl_stop = (stop_dist / close).clip(lower=0.0)
    stops[(atr_window, ema_window, roc_fast_bars, roc_slow_bars, atr_mult)] = sl_stop

    risk_budget = init_cash * risk_pct
    size = (risk_budget / stop_dist).replace([np.inf, -np.inf], np.nan)
    size = np.minimum(size, init_cash / close)
    size = size.where(signal, np.nan)
    sizes[(atr_window, ema_window, roc_fast_bars, roc_slow_bars, atr_mult)] = size

entries = pd.DataFrame(entries)
exits = pd.DataFrame(exits)
stops = pd.DataFrame(stops)
sizes = pd.DataFrame(sizes)

pf = vbt.Portfolio.from_signals(
    close,
    entries=entries,
    exits=exits,
    size=sizes,
    size_type="amount",
    init_cash=init_cash,
    fees=fees,
    sl_stop=stops,
    sl_trail=True,
    freq="1d"
)

best = pf.total_return().idxmax()
print("Best params:", best)
print("Best total return:", pf.total_return().max())

plt.figure()
plt.plot(pf.value(), alpha=0.3)
plt.plot(pf[best].value(), label="Best Strategy", color="blue", linewidth=3)
plt.plot(pf[best].benchmark_value(), label="Benchmark (Buy & Hold)", color="red", linestyle="--", linewidth=3)
plt.title("Portfolio Value vs Benchmark")
plt.xlabel("Time")
plt.ylabel("Value ($)")
plt.legend()
plt.show()

A good next step is to replace best total return with more robust metrics like Sharpe ratio, max drawdown, and out-of-sample performance.