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.
The ADX Trend Strength Strategy relies on the following key principles:
Trend Strength Measurement:
The ADX indicator measures market trend strength. A high ADX value
(e.g., above 25) suggests that the market is trending, while a low value
points to weak or sideways movement.
Directional Signals:
The strategy uses the +DI (positive directional indicator) and -DI
(negative directional indicator) to determine trade direction:
If +DI > -DI, the market is considered bullish and the strategy goes long.
If -DI > +DI, it is considered bearish and the strategy goes short.
Order Handling and Logging:
The strategy logs important events such as trade execution, order status
updates, and overall performance once the backtest concludes.
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 or self.datas[0].datetime.datetime(0)
dt 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):
= f'ADX({self.params.adx_period}, threshold={self.params.adx_threshold})'
strategy_params self.log(f'({strategy_params}) Ending Portfolio Value {self.broker.getvalue():.2f}', doprint=True)
ADX, +DI, and -DI Initialization:
In the __init__
method, three indicators are initialized
with a 14-bar period. These drive the decision-making process by
filtering trades based on trend strength and direction.
Order Execution Logic:
In the next()
method, before executing any trades, the
strategy checks that the ADX is above the threshold. Then, using simple
if-else logic, it determines the appropriate action:
For a bullish signal (i.e., +DI > -DI
), the
strategy enters long positions.
For a bearish signal (i.e., -DI > +DI
), it enters
short positions.
If an existing position opposes the new signal, the strategy reverses the position.
While the basic ADX trend strategy is effective for trending markets, two common issues often arise:
Trading in Ranging (Sideways) Markets:
When the market is not trending, directional signals may produce false
entries and reversals.
Risk Management and Exits:
Fixed exits or immediate reversals may not protect profits
adequately.
To address these, we implemented two enhancements:
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.
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.
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 or self.datas[0].datetime.datetime(0)
dt 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],
=self.params.boll_period,
period=self.params.boll_devfactor)
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(
=bt.Order.StopTrail,
exectype=self.params.trail_percent)
trailpercentelif self.position.size < 0:
self.log(f"Placing trailing stop order for short position at {self.dataclose[0]:.2f}")
self.trail_order = self.buy(
=bt.Order.StopTrail,
exectype=self.params.trail_percent)
trailpercent# 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.
= self.boll.top[0] - self.boll.bot[0]
boll_width 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
= self.plusdi[0] > self.minusdi[0]
long_signal = self.minusdi[0] > self.plusdi[0]
short_signal
# 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)
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.
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.
Reversal Confirmation Mechanism:
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.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.
def run_backtest():
= bt.Cerebro()
cerebro =False)
cerebro.addstrategy(ADXTrendStrengthWithFilters, printlog
# Data Loading: Using BTC-USD as an example.
= 'BTC-USD'
ticker = datetime.datetime(2025, 4, 14)
current_date = datetime.datetime(2020, 1, 1)
start_date = current_date - datetime.timedelta(days=1)
end_date
print(f"Fetching data for {ticker} from {start_date.date()} to {end_date.date()}")
try:
= yf.download(ticker, start=start_date, end=end_date, progress=False)
data_df if data_df.empty:
raise ValueError("No data fetched. Check ticker symbol or date range.")
= data_df.columns.droplevel(1).str.lower()
data_df.columns = pd.to_datetime(data_df.index)
data_df.index = bt.feeds.PandasData(dataname=data_df)
data
cerebro.adddata(data)except Exception as e:
print(f"Error fetching or processing data: {e}")
return
= 100000.0
initial_cash
cerebro.broker.setcash(initial_cash)=0.001)
cerebro.broker.setcommission(commission=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents
# Add performance analyzers.
='sharpe_ratio', timeframe=bt.TimeFrame.Days, riskfreerate=0.0)
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='returns')
cerebro.addanalyzer(bt.analyzers.Returns, _name='drawdown')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='trades')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name
print(f"Starting Portfolio Value: {initial_cash:.2f}")
= cerebro.run()
results print(f"Final Portfolio Value: {cerebro.broker.getvalue():.2f}")
# Print and analyze results...
try:
=False, style='candlestick', subplot=True)
cerebro.plot(iplotexcept Exception as e:
print(f"Could not generate plot. Error: {e}.")
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
--------------------------------------------------
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.
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.
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.
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
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
--------------------------------------------------
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.
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.
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.
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:
Filtering Out Ranging Markets: Prevents false entries when the market lacks clear direction.
Using Trailing Stops: Enables dynamic exit management, letting profits run while limiting losses.
Confirmation Mechanisms: Ensure that trade signals are genuine and sustained.
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.