← Back to Home
Building and Backtesting a Multi-Indicator Trading Strategy with Backtrader

Building and Backtesting a Multi-Indicator Trading Strategy with Backtrader

Technical indicators are fundamental tools in a trader’s toolkit, helping to quantify price momentum, trend strength, and potential reversals. Two of the most widely used indicators are the Exponential Moving Average (EMA) and the Average Directional Index (ADX). In this article, we’ll explore their mathematical foundations, trading applications, and how to implement them using the popular TA-Lib Python library. ## Exponential Moving Average (EMA)

Definition & Formula

An EMA is a type of moving average that places greater weight on more recent prices, making it more responsive to new information than a Simple Moving Average (SMA). The EMA at time t is calculated as:

\[\text{EMA}_t = \alpha \times P_t + (1 - \alpha) \times \text{EMA}_{t-1}\]

where:

Trading Applications

Average Directional Index (ADX)

Definition & Components

The ADX measures trend strength rather than direction. It’s derived from two other indicators, the Positive Directional Indicator (+DI) and the Negative Directional Indicator (–DI). The steps are:

  1. Compute True Range (TR) and smoothed directional movements:

    • \(+DM_t = \max(H_t - H_{t-1}, 0)\)

    • \(-DM_t = \max(L_{t-1} - L_t, 0)\)

  2. Smooth these values (often via an EMA or Wilder’s smoothing).

  3. Calculate the Directional Indicators:

    • \(\text{+DI}_t = 100 \times (\text{Smoothed +DM}/\text{ATR})\)

    • \(\text{-DI}_t = 100 \times (\text{Smoothed -DM}/\text{ATR})\)

  4. The ADX is then the EMA (or Wilder’s) of the Directional Movement Index (DX):

    \[\text{DX}_t = 100 \times \frac{\lvert \text{+DI}_t - \text{-DI}_t\rvert}{\text{+DI}_t + \text{-DI}_t}\] \[\text{ADX}_t = \text{EMA}(\text{DX}, \; \text{period})\]

Trading Applications

Average True Range (ATR)

Definition & Formula

The Average True Range (ATR) measures market volatility by decomposing the True Range (TR) over a given period and then smoothing it. The True Range for period t is:

\[\text{TR}_t = \max\bigl(H_t - L_t,\; |H_t - C_{t-1}|,\; |L_t - C_{t-1}|\bigr)\]

where:

The ATR is then an n-period moving average of the TR. In Wilder’s smoothing (the default):

\[\text{ATR}_t = \frac{( \text{ATR}_{t-1} \times (n - 1)) + \text{TR}_t}{n}\]

Trading Applications

Implementing with TA-Lib

Here’s a compact example showing EMA(9/21) cross, ADX(14) filter, and ATR(14) for stop-loss, all via TA-Lib:

Python

import talib
import pandas as pd

# Load your OHLCV data as a DataFrame 'df'
# df = pd.read_csv('your_data.csv', parse_dates=True, index_col='Date')

# Compute indicators
df['EMA9']   = talib.EMA(df['Close'], timeperiod=9)
df['EMA21']  = talib.EMA(df['Close'], timeperiod=21)
df['ADX14']  = talib.ADX(df['High'], df['Low'], df['Close'], timeperiod=14)
df['ATR14']  = talib.ATR(df['High'], df['Low'], df['Close'], timeperiod=14)

# Signal logic (example for a long entry)
df['Signal'] = (
    (df['EMA9'].shift(1) < df['EMA21'].shift(1)) &  # previously below
    (df['EMA9']    > df['EMA21'])        &  # now above
    (df['ADX14']  > 25)                  &  # trend strong
    (df['ATR14']/df['Close'] > 0.005)       # minimum volatility
)

# Compute stop level for each signal bar
df['StopPrice'] = df['Close'] - 2.0 * df['ATR14']

print(df[['Close','EMA9','EMA21','ADX14','ATR14','Signal','StopPrice']].tail(10))

Building and Backtesting A Strategy

Algorithmic trading relies on well-defined strategies that can be rigorously tested against historical data. Python, with libraries like backtrader, yfinance, and matplotlib, provides a powerful ecosystem for developing, backtesting, and analyzing such strategies.

Now we will go through a custom trading strategy named SimpleDualMA. This strategy demonstrates how multiple common technical indicators—Dual Exponential Moving Averages (EMAs), Average Directional Index (ADX), Volume Simple Moving Average (SMA), and Average True Range (ATR) for a trailing stop-loss—can be combined to make trading decisions. We’ll explore its implementation using backtrader and then set up a backtest using historical Bitcoin (BTC-USD) data.

The Strategy: SimpleDualMA

The SimpleDualMA strategy is designed to be a trend-following system. It aims to enter trades when a trend is established and confirmed by multiple factors, and it uses a dynamic trailing stop-loss to manage risk and lock in profits.

Core Components & Indicators:

  1. Dual Exponential Moving Averages (EMAs):

    • Purpose: EMAs are used to identify trend direction. A faster EMA (e.g., 9-period) reacting more quickly to price changes, and a slower EMA (e.g., 21-period) providing a more stable trendline.
    • Signal: A “golden cross” (fast EMA crosses above slow EMA) signals a potential buy. A “death cross” (fast EMA crosses below slow EMA) signals a potential sell.
    • Parameters: fast=9, slow=21
  2. Average Directional Index (ADX):

    • Purpose: ADX measures the strength of a trend, irrespective of its direction. It helps filter out trades during choppy or non-trending market conditions.
    • Signal: A trade is considered only if the ADX value is above a certain threshold (e.g., 25), indicating a sufficiently strong trend.
    • Parameters: adx_period=14, adx_limit=25
  3. Volume Filter (Volume SMA):

    • Purpose: Volume can confirm the strength behind a price move. A breakout or trend continuation on high volume is generally considered more significant.
    • Signal: A trade is considered only if the current bar’s volume is greater than its Simple Moving Average (SMA) multiplied by a certain factor, suggesting increased market participation.
    • Parameters: vol_sma=7 (period for volume SMA), vol_mul=1.2 (multiplier for current volume vs. SMA)
  4. Average True Range (ATR) Trailing Stop-Loss:

    • Purpose: ATR measures market volatility. Using it for a stop-loss allows the stop to be wider during volatile periods and tighter during calm periods. A trailing stop adjusts as the trade moves in a favorable direction, protecting profits.
    • Signal (Exit):
      • For a long position, the stop is initially set at entry_price - (stop_atr_multiplier * ATR). If the price rises, the stop-loss also rises, maintaining the same ATR distance from the current high (or a modified high). If the price falls below this trailing stop, the position is closed.
      • For a short position, the stop is initially set at entry_price + (stop_atr_multiplier * ATR). If the price falls, the stop-loss also falls. If the price rises above this trailing stop, the position is closed.
    • Parameters: atr_period=30, stop_atr=10.0 (multiplier for ATR value to set stop distance)

Strategy Implementation in Python with backtrader

Let’s look at the Python code.

Python

import backtrader as bt
import yfinance as yf
import matplotlib.pyplot as plt
# %matplotlib qt5 # Use this magic in Jupyter for interactive Qt plots

class SimpleDualMA(bt.Strategy):
    params = dict(
        fast=9, slow=21,           # EMA periods
        adx_period=14, adx_limit=25, # ADX parameters
        vol_sma=7, vol_mul=1.2,     # Volume filter parameters
        atr_period=30, stop_atr=10.0, # ATR Trailing Stop parameters
    )

    def __init__(self):
        d = self.datas[0] # Primary data feed

        # Indicators
        self.ema_fast = bt.ind.EMA(d.close, period=self.p.fast)
        self.ema_slow = bt.ind.EMA(d.close, period=self.p.slow)
        self.adx      = bt.ind.ADX(d, period=self.p.adx_period)
        self.vol_sma  = bt.ind.SMA(d.volume, period=self.p.vol_sma)
        self.atr      = bt.ind.ATR(d, period=self.p.atr_period)

        # To keep track of pending orders, entry price, and trailing stop
        self.order = None
        self.price_entry = None # Price at which the position was entered
        self.trailing_stop_price = None # Current trailing stop price

        # Crossover signal for EMAs
        self.crossover = bt.indicators.CrossOver(self.ema_fast, self.ema_slow)

    def log(self, txt):
        ''' Logging function for this strategy'''
        dt = self.data.datetime.date(0).isoformat()
        print(f"{dt} {txt}")

    def notify_order(self, order):
        ''' Handles order notifications and sets initial trailing stop '''
        if order.status in [order.Submitted, order.Accepted]:
            # Buy/Sell order submitted/accepted to/by broker - Nothing to do
            return

        # Check if an order has been completed
        if order.status in [order.Completed]:
            if order.isbuy():
                self.log(f'BUY EXECUTED, Price: {order.executed.price:.2f}, Cost: {order.executed.value:.2f}, Comm: {order.executed.comm:.2f}')
                self.price_entry = order.executed.price
                # Calculate initial trailing stop for a long position
                self.trailing_stop_price = self.price_entry - self.p.stop_atr * self.atr[0]
                self.log(f'INITIAL TRAILING STOP (LONG) SET AT: {self.trailing_stop_price:.2f}')
            elif order.issell():
                self.log(f'SELL EXECUTED, Price: {order.executed.price:.2f}, Cost: {order.executed.value:.2f}, Comm: {order.executed.comm:.2f}')
                self.price_entry = order.executed.price
                 # Calculate initial trailing stop for a short position
                self.trailing_stop_price = self.price_entry + self.p.stop_atr * self.atr[0]
                self.log(f'INITIAL TRAILING STOP (SHORT) SET AT: {self.trailing_stop_price:.2f}')

            self.bar_executed = len(self) # Bar number when order was executed

        elif order.status in [order.Canceled, order.Margin, order.Rejected]:
            self.log(f'Order Canceled/Margin/Rejected: Status {order.getstatusname()}')
            if self.order and order.ref == self.order.ref: # Check if it was the main entry order
                self.price_entry = None # Reset on rejected entry
                self.trailing_stop_price = None

        # Clear the pending order flag
        self.order = None

    def next(self):
        ''' Core logic executed on each new bar of data '''
        price = self.data.close[0] # Current closing price

        # 1) If an order is pending, do nothing
        if self.order:
            return

        # 2) Manage existing position with trailing stop
        if self.position.size > 0:  # Currently LONG
            # Re-initialize trailing_stop_price if it's somehow None (e.g., after deserialization, though less common in simple scripts)
            if self.trailing_stop_price is None and self.price_entry is not None:
                self.trailing_stop_price = self.price_entry - self.p.stop_atr * self.atr[0]
                self.log(f'RE-INITIALIZED TRAILING STOP (LONG) AT: {self.trailing_stop_price:.2f}')

            if self.trailing_stop_price is not None:
                # Calculate new potential stop price based on current price and ATR
                new_potential_stop = price - self.p.stop_atr * self.atr[0]
                # Update trailing stop price only if the new potential stop is higher (moves in favor of the trade)
                if new_potential_stop > self.trailing_stop_price:
                    self.trailing_stop_price = new_potential_stop
                    # self.log(f"LONG TRAILING STOP UPDATED TO: {self.trailing_stop_price:.2f}") # Optional: log every update

                # Check if price hits the trailing stop
                if price < self.trailing_stop_price:
                    self.log(f"LONG TRAILING STOP HIT @ {price:.2f} (stop={self.trailing_stop_price:.2f})")
                    self.order = self.close() # self.close() will create and assign an order to self.order
                    return

        elif self.position.size < 0:  # Currently SHORT
            if self.trailing_stop_price is None and self.price_entry is not None:
                self.trailing_stop_price = self.price_entry + self.p.stop_atr * self.atr[0]
                self.log(f'RE-INITIALIZED TRAILING STOP (SHORT) AT: {self.trailing_stop_price:.2f}')

            if self.trailing_stop_price is not None:
                # Calculate new potential stop price
                new_potential_stop = price + self.p.stop_atr * self.atr[0]
                # Update trailing stop price only if the new potential stop is lower
                if new_potential_stop < self.trailing_stop_price:
                    self.trailing_stop_price = new_potential_stop
                    # self.log(f"SHORT TRAILING STOP UPDATED TO: {self.trailing_stop_price:.2f}") # Optional

                # Check if price hits the trailing stop
                if price > self.trailing_stop_price:
                    self.log(f"SHORT TRAILING STOP HIT @ {price:.2f} (stop={self.trailing_stop_price:.2f})")
                    self.order = self.close()
                    return

        # 3) No open position OR stop loss triggered and closed -> check for new entry
        if not self.position: # Check if position is effectively zero (no open trades)
            self.trailing_stop_price = None # Reset trailing stop when out of market
            self.price_entry = None         # Reset entry price

            # Entry condition filters
            vol_ok = self.data.volume[0] > self.vol_sma[0] * self.p.vol_mul
            adx_ok = self.adx.adx[0] >= self.p.adx_limit # Access .adx line for ADX value

            # 3a) LONG entry: fast EMA crosses above slow EMA, with ADX and Volume confirmation
            if self.crossover[0] > 0 and adx_ok and vol_ok: # crossover > 0 for fast crossing above slow
                self.log(f"BUY CREATE  @ {price:.2f}  (ADX={self.adx.adx[0]:.1f}, Vol={self.data.volume[0]:.0f})")
                self.order = self.buy()
                return # Important to return after placing an order to avoid conflicting logic in the same bar

            # 3b) SHORT entry: fast EMA crosses below slow EMA, with ADX and Volume confirmation
            if self.crossover[0] < 0 and adx_ok and vol_ok: # crossover < 0 for fast crossing below slow
                self.log(f"SELL CREATE @ {price:.2f}  (ADX={self.adx.adx[0]:.1f}, Vol={self.data.volume[0]:.0f})")
                self.order = self.sell()
                return

Explanation of the SimpleDualMA Class:

Backtesting Setup

The if __name__ == "__main__": block sets up and runs the backtest:

Python

if __name__ == "__main__":
    cerebro = bt.Cerebro() # Create the backtesting engine
    cerebro.addstrategy(SimpleDualMA) # Add our strategy

    # Fetch daily BTC-USD data from yfinance
    # Using yf.download for more robust data fetching, or yf.Ticker().history()
    print("Fetching BTC-USD data...")
    df = yf.Ticker("BTC-USD").history(start="2020-01-01", interval="1d")
    # Ensure column names match backtrader's expectations
    df.rename(columns={
        "Open": "open", "High": "high", "Low": "low",
        "Close": "close", "Volume": "volume"
    }, inplace=True)
    df["openinterest"] = 0 # Add a zero openinterest column as backtrader requires it

    data = bt.feeds.PandasData(dataname=df) # Create a backtrader data feed
    cerebro.adddata(data) # Add data to Cerebro

    # Set initial cash, commission, and sizer
    cerebro.broker.setcash(100_000.00) # Initial capital
    cerebro.broker.setcommission(commission=0.001) # 0.1% commission per trade
    cerebro.addsizer(bt.sizers.PercentSizer, percents=90) # Use 90% of portfolio equity for each trade

    print("Starting Portfolio Value: {:.2f}".format(cerebro.broker.getvalue()))
    results = cerebro.run() # Run the backtest
    print("Final Portfolio Value:    {:.2f}".format(cerebro.broker.getvalue()))

    # Plotting the results
    # For non-Jupyter environments, ensure a GUI backend for matplotlib is available.
    # Example:
    # import matplotlib
    # matplotlib.use('TkAgg') # Or 'Qt5Agg', etc.
    try:
        # Set iplot=False for static plots, style for candlestick appearance
        cerebro.plot(iplot=False, style='candlestick', barup='green', bardown='red')
    except Exception as e:
        print(f"Plotting error: {e}. If not in Jupyter, try cerebro.plot(iplot=False). Ensure a GUI backend for matplotlib is set up (e.g., TkAgg, Qt5Agg).")
        # Fallback to a simpler plot if the styled one fails
        # cerebro.plot(iplot=False)

Backtesting Setup Explanation:

  1. Cerebro Engine: cerebro = bt.Cerebro() creates the main backtesting controller.
  2. Add Strategy: cerebro.addstrategy(SimpleDualMA) adds our defined strategy.
  3. Data Fetching:
    • yf.Ticker("BTC-USD").history(start="2020-01-01", interval="1d") fetches daily Bitcoin data from Yahoo Finance starting from January 1, 2020.
    • Column names are renamed to lowercase (open, high, low, close, volume) as expected by backtrader’s PandasData feed.
    • An openinterest column is added and set to 0, as it’s required by backtrader.
  4. Data Feed: data = bt.feeds.PandasData(dataname=df) converts the pandas DataFrame into a format backtrader can use.
  5. Broker Simulation:
    • cerebro.broker.setcash(100_000.00) sets the initial portfolio value.
    • cerebro.broker.setcommission(commission=0.001) sets a 0.1% commission fee for each trade, simulating real-world costs.
    • cerebro.addsizer(bt.sizers.PercentSizer, percents=90) tells backtrader to use 90% of the current portfolio equity for each trade. This is a form of position sizing.
  6. Run Backtest: results = cerebro.run() executes the strategy over the historical data.
  7. Plotting: cerebro.plot(...) generates a chart showing the price, trades, and potentially indicators.
    • iplot=False is generally recommended for scripts run outside of Jupyter notebooks to produce static plots.
    • style='candlestick' provides a familiar chart type.
    • A try-except block is included to handle potential plotting issues, especially in environments without a properly configured Matplotlib GUI backend.

Pasted image 20250506235654.png ### Further Considerations and Potential Improvements

Conclusion

This SimpleDualMA strategy provides a practical example of how multiple technical indicators can be combined to create a comprehensive trading system. By using backtrader, we can effectively implement, backtest, and visualize the performance of such strategies. The detailed logging, ATR-based trailing stop, and inclusion of trend strength and volume confirmation make it a more robust approach than a simple crossover system. Remember that historical performance is not indicative of future results, and any strategy should be thoroughly tested and understood before being deployed with real capital.