This example shows how to brute-force a compact trend-following
strategy on ETH-USD using yfinance,
pandas, and vectorbt.
The idea is simple:
use rate of change crossovers to detect momentum
use an EMA filter to only trade with trend
use ATR-based trailing stops for risk control
size positions from a fixed portfolio risk budget
test many parameter combinations at once
That makes it a clean example of vectorized research in Python.
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.
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.
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)] = sizeThis 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_distSo when volatility rises, the position automatically gets smaller.
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.
vectorbtNow 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.
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.
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()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.
This strategy is not complex, but it captures several strong research habits:
combining trend, momentum, and volatility
using risk-based sizing instead of fixed size
evaluating many variants in a single pass
comparing results against a realistic benchmark
That makes it a solid template for systematic strategy development.
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.