← Back to Home
HT Trendmode Breakout With ATR Stop-Loss and Take-Profit in Backtrader

HT Trendmode Breakout With ATR Stop-Loss and Take-Profit in Backtrader

This walkthrough presents a multi-asset cryptocurrency strategy implemented in Backtrader, built around three ideas.

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.

Strategy logic overview

Trend filter using HT_TRENDMODE

The Hilbert Transform Trend Mode indicator returns a binary regime classification.

This avoids breakout entries during conditions the indicator classifies as cyclical behavior.

Breakout definition

A breakout is defined using the rolling highest close over a lookback window.

That second condition suppresses repeated “breakout” triggers during extended runs by requiring a fresh cross rather than a continuing state.

Volatility confirmation with ATR

ATR is computed over a short window and required to be rising.

Exits via ATR-based stop-loss and take-profit

Upon entry, the strategy stores two price levels per asset.

The exit logic closes the entire position when either threshold is hit using close-based checks in next().

Code walkthrough

Imports and dependencies

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 plt

The strategy class

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

Bar-by-bar execution

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
                    continue

Exit behavior highlights:

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 * a

Entry gating conditions:

Risk level initialization:

Portfolio construction with an equal-weight sizer

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:

In this strategy, orders are placed only on entry and exit, so the sizer primarily controls initial allocation per position.

Engine configuration and analyzers

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:

Data ingestion with yfinance and Backtrader feeds

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:

Running the backtest and extracting results

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:

Equal-weight buy-and-hold benchmark

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

Two relative performance views are computed.

# 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%}")

Plotting the equity curves

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()
Pasted image 20260125085331.png

Implementation notes that materially affect interpretation

Summary

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.