← Back to Home
Enhancing ADX Trend Strategy with Ranging Filters, and Trailing Stops from 36% to 182% Profit

Enhancing ADX Trend Strategy with Ranging Filters, and Trailing Stops from 36% to 182% Profit

In algorithmic trading, trend-following strategies can capture profit from sustained market movements. One popular method for filtering trades is using the Average Directional Index (ADX) along with its related directional indicators, +DI and –DI. In this article, we will build a basic ADX trend strength strategy, then demonstrate step-by-step how to enhance it by filtering out sideways (ranging) markets with Bollinger Bands and by implementing trailing stops for improved risk management.

1. Basic ADX Trend Strength Strategy

Strategy Overview

The ADX Trend Strength Strategy relies on the following key principles:

Code Snippet

Below is the code for the basic ADX trend strategy:

import backtrader as bt
import yfinance as yf
import pandas as pd
import datetime

class ADXTrendStrengthStrategy(bt.Strategy):
    """
    ADX Trend Strength Strategy:
    - Trades only when ADX is above a specified threshold (default 25), indicating a strong trend.
    - Trade direction is determined by the directional indicators:
      * If +DI > -DI, enter long.
      * If -DI > +DI, enter short.
    """
    params = (
        ('adx_period', 14),     # Period for ADX and directional indicators
        ('adx_threshold', 25),  # ADX threshold for strong trend
        ('printlog', True),     # Enable logging of events
    )

    def log(self, txt, dt=None, doprint=False):
        if self.params.printlog or doprint:
            dt = dt or self.datas[0].datetime.datetime(0)
            print(f'{dt.isoformat()} - {txt}')

    def __init__(self):
        self.dataclose = self.datas[0].close
        # Initialize ADX, PlusDI, and MinusDI indicators using the same period.
        self.adx = bt.indicators.ADX(self.datas[0], period=self.params.adx_period)
        self.plusdi = bt.indicators.PlusDI(self.datas[0], period=self.params.adx_period)
        self.minusdi = bt.indicators.MinusDI(self.datas[0], period=self.params.adx_period)
        self.order = None

    def notify_order(self, order):
        if order.status in [order.Submitted, order.Accepted]:
            return  # Order is being processed

        if order.status == order.Completed:
            if order.isbuy():
                self.log(
                    f'BUY EXECUTED, Price: {order.executed.price:.2f}, '
                    f'Cost: {order.executed.value:.2f}, Comm: {order.executed.comm:.2f}'
                )
            elif order.issell():
                self.log(
                    f'SELL EXECUTED, Price: {order.executed.price:.2f}, '
                    f'Cost: {order.executed.value:.2f}, Comm: {order.executed.comm:.2f}'
                )
            self.bar_executed = len(self)
        elif order.status in [order.Canceled, order.Margin, order.Rejected]:
            self.log(f'Order Canceled/Margin/Rejected, Status: {order.getstatusname()}')
        self.order = None

    def notify_trade(self, trade):
        if not trade.isclosed:
            return
        self.log(f'OPERATION PROFIT, GROSS {trade.pnl:.2f}, NET {trade.pnlcomm:.2f}')

    def next(self):
        if self.order or len(self) < self.params.adx_period:
            return

        if self.adx[0] < self.params.adx_threshold:
            self.log(f"Market not trending strongly (ADX: {self.adx[0]:.2f}). Skipping trade.")
            return

        # Check directional indicators and take trade positions accordingly.
        if self.plusdi[0] > self.minusdi[0]:
            if self.position and self.position.size < 0:
                self.log(
                    f'REVERSING TO LONG at Close {self.dataclose[0]:.2f} '
                    f'(+DI: {self.plusdi[0]:.2f} vs -DI: {self.minusdi[0]:.2f})'
                )
                self.order = self.buy()  # Reverse short to long.
            elif not self.position:
                self.log(
                    f'GOING LONG at Close {self.dataclose[0]:.2f} '
                    f'(+DI: {self.plusdi[0]:.2f} vs -DI: {self.minusdi[0]:.2f})'
                )
                self.order = self.buy()
        elif self.minusdi[0] > self.plusdi[0]:
            if self.position and self.position.size > 0:
                self.log(
                    f'REVERSING TO SHORT at Close {self.dataclose[0]:.2f} '
                    f'(-DI: {self.minusdi[0]:.2f} vs +DI: {self.plusdi[0]:.2f})'
                )
                self.order = self.sell()  # Reverse long to short.
            elif not self.position:
                self.log(
                    f'GOING SHORT at Close {self.dataclose[0]:.2f} '
                    f'(-DI: {self.minusdi[0]:.2f} vs +DI: {self.plusdi[0]:.2f})'
                )
                self.order = self.sell()

    def stop(self):
        strategy_params = f'ADX({self.params.adx_period}, threshold={self.params.adx_threshold})'
        self.log(f'({strategy_params}) Ending Portfolio Value {self.broker.getvalue():.2f}', doprint=True)

Explanation

2. Enhancing the Strategy

While the basic ADX trend strategy is effective for trending markets, two common issues often arise:

To address these, we implemented two enhancements:

  1. Ranging Market Filter Using Bollinger Bands:
    By incorporating Bollinger Bands, the strategy checks the width of the bands to determine if the market is range-bound. A narrow band (e.g., less than 1% of the current close) suggests low volatility; in such cases, the strategy will skip trading to avoid whipsaws.

  2. Trailing Stop Exits:
    Instead of using fixed exits or immediate reversals, a trailing stop is employed to dynamically manage exits. This mechanism allows profits to run while protecting gains if the market reverses.

Enhanced Strategy Code

Below is the enhanced strategy that integrates these improvements:

import backtrader as bt
import datetime
import yfinance as yf
import pandas as pd

class ADXTrendStrengthWithFilters(bt.Strategy):
    params = (
        ('adx_period', 14),
        ('adx_threshold', 25),
        ('boll_period', 20),          # Period for Bollinger Bands
        ('boll_devfactor', 2),        # Deviation factor for Bollinger Bands
        ('confirmation_bars', 3),     # Number of bars to confirm reversal signal
        ('trail_percent', 0.02),      # Trailing stop percentage (2% by default)
        ('printlog', True),
    )

    def log(self, txt, dt=None, doprint=False):
        if self.params.printlog or doprint:
            dt = dt or self.datas[0].datetime.datetime(0)
            print(f"{dt.isoformat()} - {txt}")

    def __init__(self):
        self.dataclose = self.datas[0].close

        # Initialize ADX, PlusDI, and MinusDI
        self.adx = bt.indicators.ADX(self.datas[0], period=self.params.adx_period)
        self.plusdi = bt.indicators.PlusDI(self.datas[0], period=self.params.adx_period)
        self.minusdi = bt.indicators.MinusDI(self.datas[0], period=self.params.adx_period)
        
        # Bollinger Bands to measure market range
        self.boll = bt.indicators.BollingerBands(self.datas[0], 
                                                 period=self.params.boll_period, 
                                                 devfactor=self.params.boll_devfactor)
        
        # Counter to confirm reversal signals
        self.reversal_counter = 0

        # Track market and trailing orders
        self.order = None
        self.trail_order = None

    def notify_order(self, order):
        # Skip processing if order is submitted or accepted
        if order.status in [order.Submitted, order.Accepted]:
            return
        
        if order.status in [order.Completed]:
            if order.isbuy():
                self.log(f"BUY EXECUTED at {order.executed.price:.2f}")
            elif order.issell():
                self.log(f"SELL EXECUTED at {order.executed.price:.2f}")
            self.bar_executed = len(self)
        elif order.status in [order.Canceled, order.Margin, order.Rejected]:
            self.log(f"Order Canceled/Margin/Rejected: {order.getstatusname()}")
            
        # Reset order reference if it was our order.
        if order == self.order:
            self.order = None
        if order == self.trail_order:
            self.trail_order = None

    def notify_trade(self, trade):
        if not trade.isclosed:
            return
        self.log(f"Trade Profit: GROSS {trade.pnl:.2f}, NET {trade.pnlcomm:.2f}")

    def cancel_trail(self):
        if self.trail_order:
            self.log("Canceling active trailing stop order.")
            self.cancel(self.trail_order)
            self.trail_order = None

    def next(self):
        # Skip processing if an order is pending
        if self.order:
            return

        # If there is an open position, ensure trailing stop order is active.
        if self.position:
            if not self.trail_order:
                if self.position.size > 0:
                    self.log(f"Placing trailing stop order for long position at {self.dataclose[0]:.2f}")
                    self.trail_order = self.sell(
                        exectype=bt.Order.StopTrail,
                        trailpercent=self.params.trail_percent)
                elif self.position.size < 0:
                    self.log(f"Placing trailing stop order for short position at {self.dataclose[0]:.2f}")
                    self.trail_order = self.buy(
                        exectype=bt.Order.StopTrail,
                        trailpercent=self.params.trail_percent)
            # If in position, do not open a new trade.
            return

        # Ensure sufficient data is available
        if len(self) < max(self.params.adx_period, self.params.boll_period):
            return

        # Check if the market is trending strongly via ADX
        if self.adx[0] < self.params.adx_threshold:
            self.log(f"Low ADX ({self.adx[0]:.2f}). Market trending weakly. Skipping trade.")
            self.reversal_counter = 0  # Reset confirmation counter if market weakens
            return

        # Check for range-bound market using Bollinger Band width.
        boll_width = self.boll.top[0] - self.boll.bot[0]
        if boll_width < 0.01 * self.dataclose[0]:  # Example threshold: 1% of price
            self.log(f"Bollinger Bands narrow ({boll_width:.2f}). Market is range-bound. Skipping trade.")
            self.reversal_counter = 0
            return

        # Define directional signal
        long_signal = self.plusdi[0] > self.minusdi[0]
        short_signal = self.minusdi[0] > self.plusdi[0]

        # Confirm the directional signal persists for a number of bars
        if (long_signal and self.position.size <= 0) or (short_signal and self.position.size >= 0):
            self.reversal_counter += 1
        else:
            self.reversal_counter = 0

        # Check if confirmation condition is met
        if self.reversal_counter < self.params.confirmation_bars:
            return  # Wait for more confirmation

        # Cancel any active trailing stop before entering a new trade.
        self.cancel_trail()

        # Execute orders when confirmation condition is met
        if long_signal:
            if self.position and self.position.size < 0:
                self.log(f"Reversing to long at {self.dataclose[0]:.2f}")
                self.order = self.buy()  # Reverse position (buy closes short and opens long)
            elif not self.position:
                self.log(f"Going long at {self.dataclose[0]:.2f}")
                self.order = self.buy()
        elif short_signal:
            if self.position and self.position.size > 0:
                self.log(f"Reversing to short at {self.dataclose[0]:.2f}")
                self.order = self.sell()  # Reverse position (sell closes long and opens short)
            elif not self.position:
                self.log(f"Going short at {self.dataclose[0]:.2f}")
                self.order = self.sell()

    def stop(self):
        self.log(f"Ending Portfolio Value: {self.broker.getvalue():.2f}", doprint=True)

Explanation of Enhancements

  1. Filtering Ranging Markets with Bollinger Bands:

    • The Bollinger Bands indicator (self.boll) is used to calculate the width between the upper and lower bands.

    • If the width is too narrow (less than 1% of the current close), it suggests the market is in a range-bound state; hence, the strategy skips taking new trades in order to avoid false signals.

  2. Trailing Stop Exit for Better Risk Management:

    • A new parameter (trail_percent) defines the trailing stop distance (e.g., 2%).

    • When a position is open and no trailing stop order exists, the strategy creates a trailing stop order:

      • For a long position, a trailing stop sell order is issued.

      • For a short position, a trailing stop buy order is issued.

    • Prior to entering a new trade (especially when reversing positions), any active trailing stop orders are canceled using the cancel_trail() method. This ensures that exit orders from prior trades do not interfere with new trade logic.

  3. Reversal Confirmation Mechanism:

    • The strategy uses a confirmation counter (self.reversal_counter) to ensure that the directional signal persists over a few bars (set by confirmation_bars). This helps avoid premature or false reversals.

3. Backtesting and Analysis

Both strategies are then fed to Backtrader for backtesting. In the provided code, historical price data for BTC-USD is downloaded using the yfinance library. The backtest sets up an initial portfolio, commissions, position sizing, and several analyzers such as the Sharpe Ratio, total return, maximum drawdown, and trade statistics to help evaluate the performance of the strategy.

Sample Backtest Setup

def run_backtest():
    cerebro = bt.Cerebro()
    cerebro.addstrategy(ADXTrendStrengthWithFilters, printlog=False)

    # Data Loading: Using BTC-USD as an example.
    ticker = 'BTC-USD'
    current_date = datetime.datetime(2025, 4, 14)
    start_date = datetime.datetime(2020, 1, 1)
    end_date = current_date - datetime.timedelta(days=1)

    print(f"Fetching data for {ticker} from {start_date.date()} to {end_date.date()}")
    try:
        data_df = yf.download(ticker, start=start_date, end=end_date, progress=False)
        if data_df.empty:
            raise ValueError("No data fetched. Check ticker symbol or date range.")

        data_df.columns = data_df.columns.droplevel(1).str.lower()
        data_df.index = pd.to_datetime(data_df.index)
        data = bt.feeds.PandasData(dataname=data_df)
        cerebro.adddata(data)
    except Exception as e:
        print(f"Error fetching or processing data: {e}")
        return

    initial_cash = 100000.0
    cerebro.broker.setcash(initial_cash)
    cerebro.broker.setcommission(commission=0.001)
    cerebro.addsizer(bt.sizers.PercentSizer, percents=95)

    # Add performance analyzers.
    cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe_ratio', timeframe=bt.TimeFrame.Days, riskfreerate=0.0)
    cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
    cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
    cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trades')

    print(f"Starting Portfolio Value: {initial_cash:.2f}")
    results = cerebro.run()
    print(f"Final Portfolio Value: {cerebro.broker.getvalue():.2f}")

    # Print and analyze results...
    try:
        cerebro.plot(iplot=False, style='candlestick', subplot=True)
    except Exception as e:
        print(f"Could not generate plot. Error: {e}.")

3. Sample Results

Base Strategy

Pasted image 20250414071750.png
Starting Portfolio Value: 100000.00
Final Portfolio Value: 143590.60
--------------------------------------------------
Strategy Analysis (ADX Trend Strength Strategy - BTC-USD):
--------------------------------------------------
Sharpe Ratio: 0.021
Total Return: 36.18%
Annualized Return: 4.84%
Max Drawdown: 80.89%
Total Trades: 47
Winning Trades: 15
Losing Trades: 31
Win Rate: 31.91%
Avg Winning Trade: 56464.96
Avg Losing Trade: -26395.70
Profit Factor: 1.0350837315910437
--------------------------------------------------

Analysis

  1. Return Performance:
    The overall portfolio experienced a 36% total return, which is positive. However, the annualized return of 4.84% is relatively modest. This suggests that while the strategy can capture gains, the growth is not aggressively outperforming market benchmarks on an annual basis.

  2. Risk-Adjusted Return:
    The Sharpe Ratio is extremely low at 0.021, indicating that the strategy’s returns are not significantly compensating for the risk taken. Investors typically look for higher Sharpe ratios to ensure the excess return adequately compensates for volatility.

  3. Drawdown Concerns:
    A maximum drawdown of 80.89% is alarmingly high. This means there were periods when the portfolio value plunged significantly, which could be intolerable for many investors. High drawdowns can also lead to psychological stress and potential forced exits from the strategy.

  4. Trade Frequency and Quality:

    • Trade Count and Win Rate:
      With only 47 trades over the backtest period and a win rate of approximately 32%, the strategy generates fewer winning trades compared to losing ones.

    • Profit vs. Loss:
      On average, winning trades are much larger ($56K) than the losing trades ($26K), which helps the strategy break even overall. However, this reliance on a small number of big winners can be problematic, as missing or delaying these trades can significantly hurt performance.

    • Profit Factor:
      A profit factor of about 1.035 indicates that for every dollar lost, about $1.03 is gained. This slim margin suggests that the strategy is just barely profitable on a per-dollar risk basis. ### Enhanced Strategy

Pasted image 20250414072340.png
Starting Portfolio Value: 100000.00
Final Portfolio Value: 622875.73
--------------------------------------------------
Strategy Analysis (ADX Trend Strength with Trailing Stop - BTC-USD):
--------------------------------------------------
Sharpe Ratio: 0.054
Total Return: 182.92%
Annualized Return: 26.99%
Max Drawdown: 37.54%
Total Trades: 321
Winning Trades: 154
Losing Trades: 167
Win Rate: 47.98%
Avg Winning Trade: 15863.18
Avg Losing Trade: -11497.33
Profit Factor: 1.2723234247163178
--------------------------------------------------

Key Enhancements Impact

  1. Enhanced Returns and Lower Drawdowns:

    • The significant boost in total and annualized returns is one of the most compelling outcomes.

    • The dramatic reduction in maximum drawdown—from over 80% in the basic strategy to 37.54%—shows that the introduction of trailing stops and filtering out ranging markets helps limit large losses during volatile periods.

  2. Improved Trade Consistency:

    • The enhanced strategy executes more trades (321 vs. 47), which provides a more statistically meaningful performance record.

    • The win rate improvement from 31.91% to nearly 48% indicates better selection of trades, likely due to the extra filtering (via Bollinger Bands) and the confirmation mechanism that reduces false signals.

  3. Risk Management with Trailing Stops:

    • Incorporating trailing stops has contributed to locking in profits while allowing winners to run, without exposing the portfolio to significant reversals.

    • By canceling trailing stops before reversing trades, the strategy avoids conflicts between exit orders and new trade signals.

Conclusion

The enhancement from a basic ADX Trend Strength Strategy to a more refined version with a Bollinger Band ranging filter and trailing stop exit represents a thoughtful approach to mitigating common trading pitfalls:

By iterating through these enhancements and validating performance with thorough backtesting, traders can achieve a more robust strategy that adapts better to changing market conditions.

This approach shows how combining multiple technical indicators and advanced order management techniques can lead to a more refined and potentially profitable trading system.