Multi-asset Backtrader systems fail for one main reason: people write
single-asset logic and assume it “just works” across multiple datas. The
correct approach is to treat indicators and trade state as per-data
objects, then iterate through self.datas each bar.
This walkthrough builds the full script in small pieces. Each code block is additive; paste them top-to-bottom into one file.
Start with the minimal imports you actually use.
import math
import backtrader as bt
import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as pltDefine the strategy class and keep parameters small and research-friendly. These are short periods, which makes it responsive on daily crypto data, but you can lengthen later.
class AdaptiveVortexMultiAsset(bt.Strategy):
params = dict(
vortex_period=7,
long_term_ma_period=30,
atr_period=7,
atr_threshold_base=0.05, # ATR/Close must be below this threshold (adaptive)
atr_avg_period=7,
volatility_sensitivity=0.8,
atr_stop_multiplier_base=3,
)The key design choice: use dictionaries keyed by data to
store indicators and state. This avoids state bleeding between
assets.
def __init__(self):
self.vortex = {}
self.vortex_cross = {}
self.ma = {}
self.atr = {}
self.avg_atr = {}
self.stop_price = {}
self.highest = {}
self.lowest = {}
for d in self.datas:
self.vortex[d] = bt.indicators.Vortex(d, period=self.p.vortex_period)
self.vortex_cross[d] = bt.indicators.CrossOver(
self.vortex[d].lines.vi_plus,
self.vortex[d].lines.vi_minus
)
self.ma[d] = bt.indicators.SMA(d, period=self.p.long_term_ma_period)
self.atr[d] = bt.indicators.ATR(d, period=self.p.atr_period)
self.avg_atr[d] = bt.indicators.SMA(self.atr[d], period=self.p.atr_avg_period)
self.stop_price[d] = None
self.highest[d] = None
self.lowest[d] = NoneWhat you just created per asset:
Vortex indicator and its crossover signal
SMA macro trend filter
ATR and an ATR moving average for “volatility regime”
A trailing stop price plus extremes (highest/lowest) since entry
Two adaptations happen:
The volatility gate (ATR/Close threshold) changes with volatility regime
The ATR stop multiplier changes with volatility regime
First compute the ratio current_atr / average_atr. When
the ratio is high, volatility is above its own recent baseline.
def _dyn_atr_ratio(self, d):
a = float(self.atr[d][0])
aa = float(self.avg_atr[d][0]) if self.avg_atr[d][0] else 0.0
return (a / aa) if aa > 0 else 1.0Use that ratio to make a dynamic threshold for “stable volatility.” This is your trade gate.
def _dyn_threshold(self, d):
r = self._dyn_atr_ratio(d)
return self.p.atr_threshold_base * (1 + (r - 1) * self.p.volatility_sensitivity)Use the same ratio to make a dynamic stop multiplier. This is your risk/exits layer.
def _dyn_stop_mult(self, d):
r = self._dyn_atr_ratio(d)
return self.p.atr_stop_multiplier_base * (1 + (r - 1) * self.p.volatility_sensitivity)Interpretation:
If ATR spikes above its recent average, the stop multiplier expands (unless you tune sensitivity down)
If ATR compresses below average, the stop multiplier tightens
next() loop: iterate assets, evaluate signals, manage
exitsThe core logic is one loop over self.datas. Every asset
gets evaluated every bar.
Start with enough-bars protection so indicators aren’t half-initialized.
def next(self):
minbars = max(self.p.vortex_period, self.p.long_term_ma_period, self.p.atr_avg_period)
for d in self.datas:
if len(d) < minbars:
continuePull current state and build the filters.
pos = self.getposition(d)
close = float(d.close[0])
atr_pct = float(self.atr[d][0]) / close if close else 0.0
stable_vol = atr_pct < self._dyn_threshold(d)
uptrend = close > float(self.ma[d][0])
downtrend = close < float(self.ma[d][0])
cross = float(self.vortex_cross[d][0])
buy_sig = cross > 0
sell_sig = cross < 0Entry logic: only enter if volatility is stable and macro regime agrees with the direction. Immediately initialize the trailing stop state (highest/lowest and stop level).
if not pos:
if stable_vol and uptrend and buy_sig:
self.buy(data=d)
self.highest[d] = float(d.high[0])
mult = self._dyn_stop_mult(d)
self.stop_price[d] = self.highest[d] - float(self.atr[d][0]) * mult
elif stable_vol and downtrend and sell_sig:
self.sell(data=d)
self.lowest[d] = float(d.low[0])
mult = self._dyn_stop_mult(d)
self.stop_price[d] = self.lowest[d] + float(self.atr[d][0]) * multExit logic: update the trailing extreme and update the stop in the correct direction only. Then close if price crosses the stop.
else:
mult = self._dyn_stop_mult(d)
a = float(self.atr[d][0])
if pos.size > 0:
self.highest[d] = max(self.highest[d], float(d.high[0]))
new_stop = self.highest[d] - a * mult
self.stop_price[d] = max(self.stop_price[d], new_stop)
if close < self.stop_price[d]:
self.close(data=d)
self.stop_price[d] = self.highest[d] = self.lowest[d] = None
else:
self.lowest[d] = min(self.lowest[d], float(d.low[0]))
new_stop = self.lowest[d] + a * mult
self.stop_price[d] = min(self.stop_price[d], new_stop)
if close > self.stop_price[d]:
self.close(data=d)
self.stop_price[d] = self.highest[d] = self.lowest[d] = NoneThat completes the strategy. The important part is that
stop_price, highest, and lowest
are stored per asset, so exits are correct and independent.
Sizing is a separate concern from signal logic. This sizer targets an equal fraction of current portfolio value per asset.
class EqualWeightSizer(bt.Sizer):
params = (('weights', None),)
def _getsizing(self, comminfo, cash, data, isbuy):
strat = self.strategy
datas = strat.datas
if not datas:
return 0
wmap = self.p.weights
w = (1.0 / len(datas)) if wmap is None else float(wmap.get(getattr(data, '_name', ''), 0.0))
if w <= 0:
return 0
target_value = strat.broker.getvalue() * w
price = float(data.close[0])
if price <= 0:
return 0
target_size = math.floor(target_value / price)
cur_size = strat.getposition(data).size
delta = target_size - cur_size
return max(0, delta) if isbuy else max(0, -delta)A practical note: this sizer is “target-to-value” at order time. It does not continuously rebalance each bar by itself; it sizes the next order to move toward the target. If you want systematic rebalancing, you would add explicit rebalance logic.
Download daily data for each ticker and add each DataFrame as a
Backtrader feed. The droplevel(1, 1) is used because
yf.download() returns a MultiIndex column structure for
some assets/modes.
cerebro = bt.Cerebro()
assets = ['BTC-USD', 'ETH-USD', 'BNB-USD', 'ADA-USD', 'SOL-USD']
for t in assets:
df = yf.download(t, start='2022-01-01', end=None).droplevel(1, 1)
df.dropna(inplace=True)
data = bt.feeds.PandasData(dataname=df, name=t)
cerebro.adddata(data)Wire everything together and add common “research realism” settings.
cerebro.addstrategy(AdaptiveVortexMultiAsset)
cerebro.addsizer(EqualWeightSizer)
cerebro.broker.setcash(100_000)
cerebro.broker.setcommission(commission=0.001)
cerebro.broker.set_slippage_perc(perc=0.0005)Attach analyzers and run the backtest. Then print key results.
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name="sharpe")
cerebro.addanalyzer(bt.analyzers.DrawDown, _name="dd")
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades")
start_value = cerebro.broker.getvalue()
results = cerebro.run()
strat = results[0]
end_value = cerebro.broker.getvalue()
print(f"Start Value: {start_value:,.2f}")
print(f"End Value: {end_value:,.2f}")
print(f"Sharpe: {strat.analyzers.sharpe.get_analysis()}")
print(f"DrawDown: {strat.analyzers.dd.get_analysis()}")
print(f"Trades: {strat.analyzers.trades.get_analysis()}")This section builds a clean comparison: your strategy’s equity curve versus simple Buy & Hold curves for each asset, using the same starting allocation per asset.
eq = np.array(strat.observers.broker.lines.value.array, dtype=float)
d0 = strat.datas[0]
dates = pd.to_datetime([bt.num2date(x) for x in d0.datetime.array])
n = min(len(eq), len(dates))
eq = pd.Series(eq[:n], index=dates[:n], name="Strategy")
start_value = float(eq.iloc[0])
alloc = start_value
plt.figure()
plt.plot(eq.index, eq.values, color='black', linewidth=2, label="Strategy")
for d in strat.datas:
closes = np.array(d.close.array, dtype=float)
d_dates = pd.to_datetime([bt.num2date(x) for x in d.datetime.array])
m = min(n, len(closes), len(d_dates))
close_s = pd.Series(closes[:m], index=d_dates[:m])
bh = alloc * (close_s / close_s.iloc[0])
bh = bh.reindex(eq.index, method="ffill")
plt.plot(bh.index, bh.values, linestyle="--", alpha=.5, label=f"B&H {d._name}")
plt.title("Strategy Equity vs Asset Buy & Hold (Equal Allocation)")
plt.xlabel("Date")
plt.ylabel("Value")
plt.legend()
plt.tight_layout()
plt.show() the strategy equity is compared
with each of the underlying assets. we see it outperforms all of them
consistently and manages risk very well with small drawdowns.