← Back to Home
Building a Multi-Asset Crypto Trend-Pullback Strategy with Backtrader

Building a Multi-Asset Crypto Trend-Pullback Strategy with Backtrader

This article walks through a complete Python backtest for a multi-asset crypto trading strategy using Backtrader, yfinance, pandas, NumPy, and Matplotlib.

The strategy combines four simple ideas:

  1. Trade only assets that are in an upward trend.

  2. Enter when price pulls back into a moving-average ribbon.

  3. Allocate capital using inverse volatility weighting.

  4. Protect open positions with a trailing stop and an EMA-cross exit.

The result is a rules-based portfolio strategy that rotates across major crypto assets instead of betting on one coin only.

This is not financial advice. It is an educational backtesting example. Real trading requires additional validation, transaction-cost modeling, liquidity checks, out-of-sample testing, and risk controls.

Strategy Concept

The strategy trades a basket of crypto assets:

assets = [
    "BTC-USD", "ETH-USD", "BNB-USD", "SOL-USD",
    "XRP-USD", "ADA-USD", "DOGE-USD",
]

For each asset, the system calculates a moving-average ribbon using EMAs with periods of 10, 15, 20, and 30 days.

The trend filter is simple:

uptrend = (
    ind["ema_fastest"][0] > ind["ema_slowest"][0]
    and ind["slowest_ema_slope"][0] > self.p.min_slope_threshold
)

An asset is considered tradable only when the fastest EMA is above the slowest EMA and the slowest EMA has a positive enough slope.

The pullback condition checks whether the daily low touches or falls below the fastest EMA:

pullback_touch = d.low[0] <= ind["ema_fastest"][0]

This means the strategy is not buying random breakouts. It waits for an asset to be in an uptrend, then tries to enter during a short-term pullback.

Why Use an EMA Ribbon?

A moving-average ribbon is useful because it gives a quick visual and mechanical way to detect trend structure.

When short EMAs are above longer EMAs, the market is usually in an upward regime. When they compress, flatten, or invert, trend strength is weakening.

This strategy does not use all EMAs directly in the entry rule. Instead, it uses the fastest and slowest EMA as a compact representation of the ribbon:

ribbon_emas = [bt.ind.EMA(d.close, period=p) for p in self.p.ema_periods]
ema_fastest = ribbon_emas[0]
ema_slowest = ribbon_emas[-1]

The fastest EMA is used for pullback detection. The slowest EMA is used for trend confirmation.

Measuring EMA Slope

Backtrader does not include this exact custom slope indicator, so the strategy defines one:

class Slope(bt.Indicator):
    lines = ("slope",)
    params = (("period", 7),)

    def __init__(self):
        self.lines.slope = (self.data(0) - self.data(-self.p.period)) / self.p.period

This indicator compares the current EMA value to its value several bars ago, then divides by the lookback period.

The strategy uses it here:

slowest_ema_slope = Slope(ema_slowest, period=self.p.slope_period)

A positive slope requirement helps avoid buying assets where the EMA ribbon is technically aligned but flat.

Exit Logic

The strategy has two exit mechanisms.

First, it uses a trailing stop:

self.highest_close[d] = max(self.highest_close[d], d.close[0])
trailing_stop_price = self.highest_close[d] * (1 - self.p.trailing_stop_pct)

if d.close[0] <= trailing_stop_price:
    self.order_refs[d] = self.close(data=d)
    self.weights[d] = 0.0
    continue

The default trailing stop is 5%. If the asset closes 5% below its highest close since entry, the position is closed.

Second, it uses a fast/slow EMA cross exit:

exit_ema_fast = bt.ind.EMA(d.close, period=self.p.exit_ema_cross_short)
exit_ema_slow = bt.ind.EMA(d.close, period=self.p.exit_ema_cross_long)
exit_cross = bt.ind.CrossOver(exit_ema_fast, exit_ema_slow)

When the fast exit EMA crosses below the slow exit EMA, the strategy exits:

exit_signal = ind["exit_cross"][0] < 0

if pos.size > 0 and exit_signal:
    self.order_refs[d] = self.close(data=d)
    self.weights[d] = 0.0
    continue

The trailing stop protects against sharp reversals. The EMA-cross exit handles slower trend deterioration.

Inverse Volatility Position Sizing

The portfolio does not allocate equally to every active asset. Instead, it uses inverse volatility weighting.

The idea is simple: lower-volatility assets receive larger weights, and higher-volatility assets receive smaller weights.

The strategy estimates volatility from recent daily returns:

rets = []

for i in range(1, self.p.vol_lookback + 1):
    if d.close[-i] == 0:
        continue

    r = d.close[-i + 1] / d.close[-i] - 1
    rets.append(r)

Then it calculates the standard deviation:

vol = np.std(rets)

The portfolio weights are calculated like this:

inv_sum = sum(1 / v for v in vols.values())

if inv_sum > 0:
    for d, vol in vols.items():
        self.weights[d] = (1 / vol) / inv_sum

This makes the final weights add up to 100% across active assets before applying the gross exposure limit.

Rebalancing

The portfolio is rebalanced every 10 bars by default:

rebalance_days=10

The rebalance block checks which assets are active, calculates volatility-adjusted weights, and then adjusts positions toward target sizes.

target_value = equity * self.weights[d] * self.p.max_gross_exposure
target_size = math.floor(target_value / price)
current_size = self.getposition(d).size
delta = target_size - current_size

If the target size is larger than the current position, the strategy buys more. If it is smaller, the strategy sells the difference.

if delta > 0:
    self.order_refs[d] = self.buy(data=d, size=delta)

elif delta < 0:
    self.order_refs[d] = self.sell(data=d, size=abs(delta))

The strategy uses whole-unit sizing with math.floor. For crypto, fractional sizing may be more realistic, but this script keeps the order logic simple.

Tracking Portfolio History

The strategy records portfolio value, cash, and per-asset position value during the backtest:

self.date_history.append(self.datas[0].datetime.datetime(0))
self.value_history.append(self.broker.getvalue())
self.cash_history.append(self.broker.getcash())

for d in self.datas:
    pos = self.getposition(d)
    self.asset_value_history[d._name].append(max(0.0, pos.size * d.close[0]))

This makes it possible to plot both performance and allocation breakdown after the backtest finishes.

Full Python Script

import math
import backtrader as bt
import yfinance as yf
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np


class Slope(bt.Indicator):
    lines = ("slope",)
    params = (("period", 7),)

    def __init__(self):
        self.lines.slope = (self.data(0) - self.data(-self.p.period)) / self.p.period


class MultiAssetMaRibbonPullbackInvVol(bt.Strategy):
    params = dict(
        ema_periods=(10, 15, 20, 30),
        slope_period=10,
        exit_ema_cross_short=10,
        exit_ema_cross_long=20,
        min_slope_threshold=0.01,
        vol_lookback=30,
        rebalance_days=10,
        max_gross_exposure=1.00,
        trailing_stop_pct=0.05,
    )

    def __init__(self):
        self.ind = {}
        self.weights = {}
        self.last_rebalance = -999
        self.order_refs = {}
        self.highest_close = {}
        self.date_history = []
        self.value_history = []
        self.cash_history = []
        self.asset_value_history = {d._name: [] for d in self.datas}

        for d in self.datas:
            ribbon_emas = [bt.ind.EMA(d.close, period=p) for p in self.p.ema_periods]
            ema_fastest = ribbon_emas[0]
            ema_slowest = ribbon_emas[-1]
            slowest_ema_slope = Slope(ema_slowest, period=self.p.slope_period)
            exit_ema_fast = bt.ind.EMA(d.close, period=self.p.exit_ema_cross_short)
            exit_ema_slow = bt.ind.EMA(d.close, period=self.p.exit_ema_cross_long)
            exit_cross = bt.ind.CrossOver(exit_ema_fast, exit_ema_slow)

            self.ind[d] = dict(
                ema_fastest=ema_fastest,
                ema_slowest=ema_slowest,
                slowest_ema_slope=slowest_ema_slope,
                exit_cross=exit_cross,
            )

            self.weights[d] = 0.0
            self.highest_close[d] = 0.0

    def next(self):
        for d in self.datas:
            if d in self.order_refs:
                continue

            pos = self.getposition(d)

            if pos.size > 0:
                self.highest_close[d] = max(self.highest_close[d], d.close[0])
                trailing_stop_price = self.highest_close[d] * (1 - self.p.trailing_stop_pct)

                if d.close[0] <= trailing_stop_price:
                    self.order_refs[d] = self.close(data=d)
                    self.weights[d] = 0.0
                    continue
            else:
                self.highest_close[d] = 0.0

        if len(self) - self.last_rebalance >= self.p.rebalance_days:
            self.last_rebalance = len(self)
            active = []

            for d in self.datas:
                if d in self.order_refs:
                    continue

                ind = self.ind[d]
                pos = self.getposition(d)

                uptrend = (
                    ind["ema_fastest"][0] > ind["ema_slowest"][0]
                    and ind["slowest_ema_slope"][0] > self.p.min_slope_threshold
                )

                pullback_touch = d.low[0] <= ind["ema_fastest"][0]
                exit_signal = ind["exit_cross"][0] < 0

                if pos.size > 0 and exit_signal:
                    self.order_refs[d] = self.close(data=d)
                    self.weights[d] = 0.0
                    continue

                if pos.size > 0 or (pos.size == 0 and uptrend and pullback_touch):
                    active.append(d)

            vols = {}

            for d in active:
                rets = []

                for i in range(1, self.p.vol_lookback + 1):
                    if d.close[-i] == 0:
                        continue

                    r = d.close[-i + 1] / d.close[-i] - 1
                    rets.append(r)

                if len(rets) >= 10:
                    vol = np.std(rets)

                    if vol > 0 and np.isfinite(vol):
                        vols[d] = vol

            for d in self.datas:
                self.weights[d] = 0.0

            inv_sum = sum(1 / v for v in vols.values())

            if inv_sum > 0:
                for d, vol in vols.items():
                    self.weights[d] = (1 / vol) / inv_sum

            equity = self.broker.getvalue()

            for d in self.datas:
                if d in self.order_refs:
                    continue

                price = d.close[0]

                if price <= 0:
                    continue

                target_value = equity * self.weights[d] * self.p.max_gross_exposure
                target_size = math.floor(target_value / price)
                current_size = self.getposition(d).size
                delta = target_size - current_size

                if delta > 0:
                    self.order_refs[d] = self.buy(data=d, size=delta)

                elif delta < 0:
                    self.order_refs[d] = self.sell(data=d, size=abs(delta))

        self.date_history.append(self.datas[0].datetime.datetime(0))
        self.value_history.append(self.broker.getvalue())
        self.cash_history.append(self.broker.getcash())

        for d in self.datas:
            pos = self.getposition(d)
            self.asset_value_history[d._name].append(max(0.0, pos.size * d.close[0]))

    def notify_order(self, order):
        d = order.data

        if order.status in [order.Submitted, order.Accepted]:
            return

        if order.status == order.Completed and order.isbuy():
            self.highest_close[d] = d.close[0]

        if order.status == order.Completed and self.getposition(d).size == 0:
            self.highest_close[d] = 0.0
            self.weights[d] = 0.0

        if d in self.order_refs and order.ref == self.order_refs[d].ref:
            del self.order_refs[d]


cerebro = bt.Cerebro()

assets = [
    "BTC-USD", "ETH-USD", "BNB-USD", "SOL-USD",
    "XRP-USD", "ADA-USD", "DOGE-USD",
]

START_DATE = "2025-01-01"
END_DATE = "2025-12-31"
INIT_CASH = 100_000

min_bars = max(
    max(MultiAssetMaRibbonPullbackInvVol.params.ema_periods),
    MultiAssetMaRibbonPullbackInvVol.params.exit_ema_cross_long,
    MultiAssetMaRibbonPullbackInvVol.params.vol_lookback,
) + MultiAssetMaRibbonPullbackInvVol.params.slope_period + 10

raw_data = {}

for t in assets:
    df = yf.download(t, start=START_DATE, end=END_DATE, interval="1d", progress=False).droplevel(1, 1)
    df.dropna(inplace=True)

    if df.empty or len(df) < min_bars:
        print(f"Skipping {t}: only {len(df)} bars.")
        continue

    raw_data[t] = df.copy()
    data = bt.feeds.PandasData(dataname=df, name=t)
    cerebro.adddata(data)

cerebro.addstrategy(MultiAssetMaRibbonPullbackInvVol)

cerebro.broker.setcash(INIT_CASH)
cerebro.broker.setcommission(commission=0.001)
cerebro.broker.set_slippage_perc(perc=0.0005)

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"Return:      {(end_value / start_value - 1) * 100:.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()}")

eq = pd.Series(strat.value_history, index=pd.to_datetime(strat.date_history), name="Strategy")
asset_values = pd.DataFrame(strat.asset_value_history, index=eq.index)

asset_values = asset_values.clip(lower=0)
cash_plot = (eq - asset_values.sum(axis=1)).clip(lower=0)

bench_close = pd.DataFrame(index=eq.index)

for t, df in raw_data.items():
    bench_close[t] = df["Close"].reindex(eq.index, method="ffill")

bench_ret = bench_close.pct_change().fillna(0.0)
equal_bh = INIT_CASH * (1 + bench_ret.mean(axis=1)).cumprod()

btc_close = bench_close["BTC-USD"].dropna()
btc_bh = INIT_CASH * (btc_close / btc_close.iloc[0])
btc_bh = btc_bh.reindex(eq.index, method="ffill")

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 9), sharex=True)

ax1.plot(eq.index, eq.values, color="black", linewidth=2, label="MA Ribbon Pullback InvVol + Trailing Stop")
ax1.plot(equal_bh.index, equal_bh.values, linestyle="--", label="Equal-Weight Buy & Hold")
ax1.plot(btc_bh.index, btc_bh.values, linestyle="--", label="BTC Buy & Hold")

pf_ret = eq.iloc[-1] / eq.iloc[0] - 1
ew_ret = equal_bh.iloc[-1] / equal_bh.iloc[0] - 1
btc_ret = btc_bh.iloc[-1] / btc_bh.iloc[0] - 1

x_last = eq.index[-1]

ax1.annotate(f"PF: {pf_ret * 100:.2f}%", xy=(x_last, eq.iloc[-1]), xytext=(8, 0),
             textcoords="offset points", va="center", ha="left", color="black", clip_on=False)

ax1.annotate(f"EW: {ew_ret * 100:.2f}%", xy=(x_last, equal_bh.iloc[-1]), xytext=(8, 0),
             textcoords="offset points", va="center", ha="left", clip_on=False)

ax1.annotate(f"BTC: {btc_ret * 100:.2f}%", xy=(x_last, btc_bh.iloc[-1]), xytext=(8, 0),
             textcoords="offset points", va="center", ha="left", clip_on=False)

ax1.grid(True, alpha=0.3)
ax1.legend()

labels = ["Cash"] + list(asset_values.columns)
colors = ["#d3d3d3"] + list(plt.cm.tab10.colors)[:len(asset_values.columns)]

ax2.stackplot(
    eq.index,
    cash_plot,
    *[asset_values[col] for col in asset_values.columns],
    labels=labels,
    colors=colors,
    alpha=0.8,
)

ax2.set_title("Portfolio Allocation Breakdown")
ax2.set_ylabel("Value ($)")
ax2.grid(True, alpha=0.3)
ax2.legend(loc="upper left", bbox_to_anchor=(1.0, 1), frameon=True)

plt.tight_layout()
plt.show()

How the Backtest Works

The script starts by creating a Backtrader engine:

cerebro = bt.Cerebro()

It then downloads daily data from yfinance for each asset:

df = yf.download(t, start=START_DATE, end=END_DATE, interval="1d", progress=False).droplevel(1, 1)

The .droplevel(1, 1) part is important when yfinance returns a MultiIndex column structure. It flattens the downloaded data so Backtrader can read standard OHLCV columns.

Each valid dataframe is passed into Backtrader:

data = bt.feeds.PandasData(dataname=df, name=t)
cerebro.adddata(data)

The script skips assets that do not have enough data:

if df.empty or len(df) < min_bars:
    print(f"Skipping {t}: only {len(df)} bars.")
    continue

This prevents indicator lookback errors.

Broker Settings

The strategy starts with $100,000:

INIT_CASH = 100_000
cerebro.broker.setcash(INIT_CASH)

It also includes simple trading costs:

cerebro.broker.setcommission(commission=0.001)
cerebro.broker.set_slippage_perc(perc=0.0005)

That means each trade assumes a 0.10% commission and 0.05% slippage.

These assumptions matter because active strategies can look much better when transaction costs are ignored.

Performance Analyzers

The script adds three Backtrader analyzers:

cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name="sharpe")
cerebro.addanalyzer(bt.analyzers.DrawDown, _name="dd")
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades")

After the backtest finishes, it prints starting value, ending value, total return, Sharpe output, drawdown output, and trade statistics.

print(f"Start Value: {start_value:,.2f}")
print(f"End Value:   {end_value:,.2f}")
print(f"Return:      {(end_value / start_value - 1) * 100:.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()}")

Benchmark Comparison

The script compares the active strategy against two benchmarks.

The first benchmark is an equal-weight buy-and-hold basket:

bench_ret = bench_close.pct_change().fillna(0.0)
equal_bh = INIT_CASH * (1 + bench_ret.mean(axis=1)).cumprod()

The second benchmark is Bitcoin buy-and-hold:

btc_close = bench_close["BTC-USD"].dropna()
btc_bh = INIT_CASH * (btc_close / btc_close.iloc[0])
btc_bh = btc_bh.reindex(eq.index, method="ffill")

These benchmarks help answer an important question: did the strategy add value compared with simply holding the market?

Plotting the Results

The first chart shows portfolio equity versus the two benchmarks:

ax1.plot(eq.index, eq.values, color="black", linewidth=2, label="MA Ribbon Pullback InvVol + Trailing Stop")
ax1.plot(equal_bh.index, equal_bh.values, linestyle="--", label="Equal-Weight Buy & Hold")
ax1.plot(btc_bh.index, btc_bh.values, linestyle="--", label="BTC Buy & Hold")

The chart also annotates final returns directly beside each line:

ax1.annotate(f"PF: {pf_ret * 100:.2f}%", xy=(x_last, eq.iloc[-1]), xytext=(8, 0),
             textcoords="offset points", va="center", ha="left", color="black", clip_on=False)

The second chart shows allocation breakdown:

ax2.stackplot(
    eq.index,
    cash_plot,
    *[asset_values[col] for col in asset_values.columns],
    labels=labels,
    colors=colors,
    alpha=0.8,
)

This makes it easier to see when the strategy is concentrated, diversified, or sitting mostly in cash.

Strengths of the Strategy

The main strength is that it combines trend following with risk-adjusted allocation.

The strategy does not blindly hold every asset. It only allocates to assets that are either already held or currently meet the trend-pullback condition.

It also avoids equal-weighting highly volatile assets. Inverse volatility weighting helps reduce the dominance of the most volatile coins.

The trailing stop adds another layer of risk control by forcing exits when price drops materially from its recent high.

Weaknesses and Limitations

This backtest is still simplified.

The strategy uses daily candles, so it cannot model intraday stop behavior precisely. The trailing stop is checked using closing prices only.

The script also uses whole-unit position sizing:

target_size = math.floor(target_value / price)

That is conservative for expensive assets but less realistic for crypto, where fractional units are commonly tradable.

Another limitation is the short test period:

START_DATE = "2025-01-01"
END_DATE = "2025-12-31"

A single year is not enough to validate a strategy. A stronger test should include multiple regimes: bull markets, bear markets, sideways markets, high-volatility periods, and low-volatility periods.

Possible Improvements

Several improvements can make this research more robust:

  1. Test across a longer historical period.

  2. Add walk-forward validation.

  3. Use fractional crypto position sizing.

  4. Add maximum position limits per asset.

  5. Add portfolio-level drawdown controls.

  6. Compare against more benchmarks.

  7. Export trades to a dataframe for deeper analysis.

  8. Test different EMA, slope, rebalance, and stop parameters.

  9. Add volatility targeting at the portfolio level.

  10. Include realistic exchange-specific fees and liquidity assumptions.

Final Thoughts

This strategy is a practical example of combining trend detection, pullback entries, inverse volatility weighting, and trailing-stop risk management in a multi-asset crypto portfolio.

The code is intentionally compact enough to study but complete enough to run as a full Backtrader experiment.

Its most important feature is not any single indicator. The value comes from the structure: filter for trend, wait for pullback, size positions by risk, rebalance periodically, and exit when the trend weakens or the position falls from its high.

That framework can be reused, modified, and tested across many markets beyond crypto.