← Back to Home
Vortex, Volatility, and Trailing Stops - A Portfolio-Ready Backtrader Strategy

Vortex, Volatility, and Trailing Stops - A Portfolio-Ready Backtrader Strategy

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.

Imports and basic dependencies

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 plt

Strategy goal and parameter surface

Define 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,
    )

Per-asset indicators and per-asset trade state

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] = None

What you just created per asset:

Adaptive volatility math

Two adaptations happen:

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

Use 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:

The next() loop: iterate assets, evaluate signals, manage exits

The 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:
                continue

Pull 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 < 0

Entry 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]) * mult

Exit 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] = None

That completes the strategy. The important part is that stop_price, highest, and lowest are stored per asset, so exits are correct and independent.

Equal-weight sizing across assets

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.

Data ingestion with yfinance

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)

Engine configuration: strategy, sizing, broker realism

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)

Analyzers and run

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

Plot strategy equity vs per-asset Buy & Hold overlays

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

Pasted image 20260116022914.png 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.