This walkthrough presents a multi-asset cryptocurrency strategy implemented in Backtrader, built around three ideas.
Trade only when the market is in a trend regime, as detected by Hilbert Transform Trend Mode.
Enter on a clean close-based breakout over a rolling highest close.
Require volatility expansion, approximated by a rising ATR, and manage risk with ATR-based stop-loss and take-profit levels.
The implementation runs an equal-weight portfolio across multiple tickers, produces standard performance diagnostics, and compares results to an equal-weight buy-and-hold benchmark aligned to the strategy’s trading calendar.
The Hilbert Transform Trend Mode indicator returns a binary regime classification.
Trend mode is treated as “trend regime allowed.”
Cycle mode is treated as “stand aside.”
This avoids breakout entries during conditions the indicator classifies as cyclical behavior.
A breakout is defined using the rolling highest close over a lookback window.
The current close must exceed the prior bar’s rolling highest close.
The prior bar’s close must not already be above its own prior rolling highest close.
That second condition suppresses repeated “breakout” triggers during extended runs by requiring a fresh cross rather than a continuing state.
ATR is computed over a short window and required to be rising.
Rising ATR acts as a basic volatility expansion filter.
This can reduce entries during low-volatility drift where breakouts may fail more often.
Upon entry, the strategy stores two price levels per asset.
Stop-loss is placed below entry by a multiple of ATR.
Take-profit is placed above entry by a larger multiple of ATR.
The exit logic closes the entire position when either threshold is
hit using close-based checks in next().
The implementation combines Backtrader with Yahoo Finance data via
yfinance, then uses pandas and numpy for post-run analytics
and matplotlib for plotting.
import math
import backtrader as bt
import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as pltThe strategy is defined as HTTrendBreakoutATR_SLTP. It
maintains a per-data “state” dictionary so each asset keeps its own
indicator references and exit levels.
class HTTrendBreakoutATR_SLTP(bt.Strategy):
params = dict(
lookback=30,
atr_period=7,
sl_atr=1.0, # stop = entry - sl_atr * ATR
tp_atr=2.0, # take = entry + tp_atr * ATR
)
def __init__(self):
self.state = {} # per-data state
for d in self.datas:
trendmode = bt.talib.HT_TRENDMODE(d.close) # 1 = trend mode, 0 = cycle mode
highest = bt.ind.Highest(d.close, period=self.p.lookback)
atr = bt.ind.ATR(d, period=self.p.atr_period)
self.state[d] = dict(
trendmode=trendmode,
highest=highest,
atr=atr,
sl=None,
tp=None,
)Key objects created per data feed:
trendmode is the regime filter.
highest is the rolling highest close over the
lookback.
atr is the volatility measure used for confirmation
and risk levels.
sl and tp are stored thresholds used by
the exit checks.
Backtrader calls next() on each new bar. The code
iterates through all assets and applies exits before entries.
def next(self):
for d in self.datas:
st = self.state[d]
pos = self.getposition(d)
close0 = d.close[0]
# Exits (no sizing needed): close the whole position on SL/TP hit
if pos.size > 0:
if st["sl"] is not None and close0 <= st["sl"]:
self.close(data=d)
st["sl"], st["tp"] = None, None
continue
if st["tp"] is not None and close0 >= st["tp"]:
self.close(data=d)
st["sl"], st["tp"] = None, None
continueExit behavior highlights:
The logic is close-based, not intrabar. Threshold hits are
evaluated on the bar close that arrives in next().
After an exit, stored thresholds are reset to avoid accidental reuse.
Entry logic is applied only when flat.
# Entries (sizer will provide size)
if pos.size == 0:
# Close breaks above MAX(7): true breakout vs previous bar’s MAX
breakout_up = (d.close[0] > st["highest"][-1]) and (d.close[-1] <= st["highest"][-2])
# ATR(7) rising
atr_rising = st["atr"][0] > st["atr"][-1]
if (st["trendmode"][0] == 1) and breakout_up and atr_rising:
self.buy(data=d) # size omitted -> your sizer is used
entry = close0
a = st["atr"][0]
st["sl"] = entry - self.p.sl_atr * a
st["tp"] = entry + self.p.tp_atr * aEntry gating conditions:
Trend regime must be active.
Breakout must be a fresh cross above the rolling highest close.
ATR must be rising relative to the previous bar.
Risk level initialization:
The sizer assigns capital per asset, targeting equal weights across all active data feeds unless an explicit weight mapping is provided.
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)Sizer mechanics:
Total portfolio value is read from the broker each time sizing is requested.
A target position value per asset is computed from the weight.
A target unit size is computed using the current close.
The returned sizing is the difference between current size and target size, ensuring the sizer supports rebalancing behavior when used with both buys and sells.
In this strategy, orders are placed only on entry and exit, so the sizer primarily controls initial allocation per position.
This section defines the asset universe, pulls data, configures costs, attaches the strategy, and adds analyzers.
# --- Config ---
tickers = ["BTC-USD", "ETH-USD", "BNB-USD", "SOL-USD", "XRP-USD", "ADA-USD"]
start, end, interval = "2022-01-01", "2026-01-01", "1d"
# --- Cerebro ---
cerebro = bt.Cerebro()
cerebro.broker.setcash(100_000)
cerebro.broker.setcommission(commission=0.001)
cerebro.broker.set_slippage_perc(perc=0.0005)
cerebro.addstrategy(HTTrendBreakoutATR_SLTP)
cerebro.addsizer(EqualWeightSizer)
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe', timeframe=bt.TimeFrame.Days, annualize=True)
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='dd')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trades')
cerebro.addanalyzer(bt.analyzers.TimeReturn, _name='timereturn')Notable choices:
Commission and slippage are modeled as simple proportional costs.
TimeReturn provides a time-indexed return series
used for equity curve reconstruction and benchmark comparisons.
Drawdown and trade analysis provide complementary perspectives: path risk and execution statistics.
Yahoo Finance data arrives with a multi-index column structure when downloading multiple fields. The code drops the extra level, renames columns to Backtrader conventions, and adds a constant open interest field.
# --- Data ---
for t in tickers:
df = yf.download(t, start=start, end=end, interval=interval, auto_adjust=False, progress=False)
df = df.dropna().droplevel(1, axis=1)
df = df.rename(columns={"Open":"open","High":"high","Low":"low","Close":"close","Volume":"volume"})
df["openinterest"] = 0
cerebro.adddata(bt.feeds.PandasData(dataname=df), name=t)Feed alignment notes:
Backtrader expects column names like open,
high, low, close, and
volume.
Setting openinterest avoids missing-field issues for
feeds that do not provide it.
Backtrader returns a list of strategy instances; analyzers are accessed from the instantiated strategy.
# --- Run ---
results = cerebro.run()
strat = results[0]The code then reconstructs the equity curve from the time-based returns.
# --- Portfolio stats ---
r = pd.Series(strat.analyzers.timereturn.get_analysis()).sort_index().fillna(0.0)
r.index = pd.to_datetime(r.index).tz_localize(None).normalize()
eq = (1.0 + r).cumprod()Performance fields are read from analyzers and printed.
sh = strat.analyzers.sharpe.get_analysis().get('sharperatio', np.nan)
dd = strat.analyzers.dd.get_analysis()
ta = strat.analyzers.trades.get_analysis()
total = ta.get('total', {}).get('total', 0)
won = ta.get('won', {}).get('total', 0)
final_value = cerebro.broker.getvalue()
cagr = eq.iloc[-1] ** (365 / len(eq)) - 1
maxdd = dd['max']['drawdown'] / 100.0
print(f"Final Value: {final_value:,.2f}")
print(f"CAGR: {cagr:.2%} | Sharpe: {sh:.2f} | MaxDD: {maxdd:.2%}")
print(f"Trades: {total} | Win rate: {(won/total if total else np.nan):.2%}")Statistic notes:
The annualization in the CAGR line is based on the count of return observations; it assumes a daily frequency consistent with the timeframe setup.
Max drawdown is normalized into a fraction for consistent formatting.
Win rate is derived from the TradeAnalyzer
breakdown.
The benchmark is built by normalizing each asset’s close series to its own starting value, taking the cross-sectional mean each day, then comparing that curve to the strategy equity curve over the same dates.
# --- Equal-weight Buy & Hold benchmark (aligned to strategy index) ---
idx = r.index
parts = []
for d in strat.datas:
dt_d = pd.to_datetime([bt.num2date(d.datetime[-i]) for i in range(len(d)-1, -1, -1)]).tz_localize(None).normalize()
cl_d = pd.Series([float(d.close[-i]) for i in range(len(d)-1, -1, -1)], index=dt_d).sort_index()
cl_d = cl_d[~cl_d.index.duplicated(keep="last")].reindex(idx).ffill().dropna()
parts.append(cl_d / cl_d.iloc[0])
bench_eq = pd.concat(parts, axis=1).mean(axis=1).dropna()
bench_ret = bench_eq.pct_change().fillna(0.0)
bench_cagr = bench_eq.iloc[-1] ** (365 / len(bench_eq)) - 1Two relative performance views are computed.
A relative equity curve, which is the strategy equity divided by benchmark equity.
Two information ratio variants, one on arithmetic active returns and one on log-active returns.
# Arithmetic IR (kept for reference)
active = r.reindex(bench_eq.index).fillna(0.0) - bench_ret
ir = (active.mean() / active.std(ddof=0)) * np.sqrt(365) if active.std(ddof=0) > 0 else np.nan
# Proper compounded comparison
eq_plot = eq.reindex(bench_eq.index).dropna()
bench_plot = bench_eq.reindex(eq_plot.index).dropna()
rel_eq = (eq_plot / bench_plot)
rel_cagr = rel_eq.iloc[-1] ** (365 / len(rel_eq)) - 1
# Log-active IR (better for compounding)
log_active = np.log1p(r.reindex(bench_ret.index).fillna(0.0)) - np.log1p(bench_ret)
ir_log = (log_active.mean() / log_active.std(ddof=0)) * np.sqrt(365) if log_active.std(ddof=0) > 0 else np.nan
print(f"Benchmark CAGR: {bench_cagr:.2%}")
print(f"Relative CAGR (Strategy/Benchmark): {rel_cagr:.2%}")
print(f"Information Ratio (arithmetic): {ir:.2f}")
print(f"Information Ratio (log active): {ir_log:.2f}")Drawdown persistence is also measured as the fraction of time the equity curve sits below its prior peak.
peak = eq.cummax()
dd_series = eq / peak - 1.0
time_in_dd = (dd_series < 0).mean()
print(f"Time in drawdown: {time_in_dd:.2%}")The plot compares the strategy equity curve to the benchmark equity curve over the shared date range.
# --- Plot ---
plt.figure()
plt.plot(eq_plot.values, label="Strategy (Equity)")
plt.plot(bench_plot.values, label="Equal-Weight Buy & Hold")
plt.title("Equity Curve: Strategy vs Equal-Weight Buy & Hold")
plt.legend()
plt.show()Signal generation and exit evaluation occur on daily closes, and stop-loss or take-profit triggers are checked against the close of each bar. This is a clean and reproducible convention for daily data, but it is not an intraday stop simulation.
The sizer targets equal-weight allocation at the time an entry is placed. It does not continuously rebalance holdings unless the strategy generates additional sizing events.
Costs are modeled as proportional commission and proportional slippage. The combined effect can be meaningful for high-turnover systems.
This strategy couples regime filtering with breakout entries and volatility confirmation, then enforces disciplined exits using ATR-based thresholds. The Backtrader implementation is structured for multi-asset portfolios, uses an equal-weight sizing model, and includes a benchmark comparison that is aligned to the strategy’s realized trading calendar.