This article explores an updated version of the Market Structure Shift Trading Strategy implemented in Backtrader, which uses swing points to identify market structure shifts and trades breakouts to capture potential trend reversals. The strategy employs ATR-based trailing stops for risk management. Below, we detail the updated strategy and compare it with the base version to highlight key differences.
The best way to improve the signal success rate for a breakout strategy like this is to add confirmation filters.
The primary weakness of any breakout system is the “false breakout” or “liquidity grab”—where the price breaks a key level only to immediately reverse, trapping traders. To combat this, we can add filters that confirm there is genuine strength and conviction behind the move.
Here are two filters to enhance the
MarketStructureShiftStrategy
.
1. Volume Confirmation: The “Conviction” Filter
A true breakout, driven by institutional interest, should be accompanied by a significant increase in trading volume. A breakout on weak, anemic volume is highly suspect and likely to fail.
The Logic: We will only consider a break of structure valid if the volume on the breakout candle is substantially higher than the recent average volume.
Implementation: We’ll add a Simple Moving Average of the volume and require the breakout volume to be at least 1.5 times that average.
2. RSI Momentum Confirmation: The “Follow-Through” Filter
A price break is good, but a price break confirmed by underlying momentum is much better. This filter ensures the engine of the market is revving in the direction of the breakout.
The Logic: We will use the Relative Strength Index (RSI) to confirm that momentum has shifted in favor of the new trend direction. The 50-level on the RSI is a classic bull/bear territory line.
Implementation: For a bullish break of structure, we will require the RSI to be above 50. For a bearish break, we’ll require the RSI to be below 50. This confirms momentum is on our side at the moment of entry.
The Updated Market Structure Shift Trading Strategy integrates the following components:
Below is the complete Backtrader code for the updated strategy, including the custom Swing Point indicator:
import backtrader as bt
import numpy as np
from collections import deque
class SwingPoint(bt.Indicator):
"""
A more robust implementation that identifies and plots the last
confirmed swing high and swing low using built-in indicators.
"""
= ('swing_high', 'swing_low',)
lines = dict(subplot=False)
plotinfo = (('period', 10),) # Period to look left and right for a pivot
params
def __init__(self):
# Calculate the full window size needed to confirm a pivot
self.p.window_size = self.p.period * 2 + 1
self.addminperiod(self.p.window_size)
# Use built-in indicators to find the max/min over the full window
self.highest_in_window = bt.indicators.Highest(self.data.high, period=self.p.window_size)
self.lowest_in_window = bt.indicators.Lowest(self.data.low, period=self.p.window_size)
def next(self):
# The bar we are checking to see if it's a pivot is the one
# at the center of the lookback window, which is `period` bars ago.
= -self.p.period
check_idx
# --- Swing High ---
# A swing high is confirmed if the high at the check_idx
# is the highest high of the entire surrounding window.
if self.highest_in_window[0] == self.data.high[check_idx]:
= self.data.high[check_idx]
new_high else:
# Not a new pivot, so carry forward the last valid value.
# Using np.nan allows us to handle the initial state gracefully.
= self.lines.swing_high[-1] if len(self) > 1 else np.nan
new_high
self.lines.swing_high[0] = new_high
# --- Swing Low ---
# A swing low is confirmed if the low at the check_idx
# is the lowest low of the entire surrounding window.
if self.lowest_in_window[0] == self.data.low[check_idx]:
= self.data.low[check_idx]
new_low else:
# Not a new pivot, so carry forward the last valid value.
= self.lines.swing_low[-1] if len(self) > 1 else np.nan
new_low
self.lines.swing_low[0] = new_low
class MarketStructureShiftStrategy(bt.Strategy):
"""
A price-action strategy that trades breakouts of confirmed market structure.
"""
= (
params 'swing_period', 10),
('atr_period', 14),
('atr_stop_multiplier', 3.0),
(
)
def __init__(self):
self.order = None
self.swing_points = SwingPoint(self.data, period=self.p.swing_period)
self.atr = bt.indicators.AverageTrueRange(self.data, period=self.p.atr_period)
# Deques to store the history of the last 2 swing points
self.swing_highs = deque(maxlen=2)
self.swing_lows = deque(maxlen=2)
# Trailing Stop State
self.stop_price = None
self.highest_price_since_entry = None
self.lowest_price_since_entry = None
def notify_order(self, order):
if order.status in [order.Submitted, order.Accepted]:
return
if order.status in [order.Completed]:
if self.position and self.stop_price is None:
if order.isbuy():
self.highest_price_since_entry = self.data.high[0]
self.stop_price = self.highest_price_since_entry - (self.atr[0] * self.p.atr_stop_multiplier)
elif order.issell():
self.lowest_price_since_entry = self.data.low[0]
self.stop_price = self.lowest_price_since_entry + (self.atr[0] * self.p.atr_stop_multiplier)
elif not self.position:
self.stop_price = None
self.highest_price_since_entry = None
self.lowest_price_since_entry = None
self.order = None
def next(self):
if self.order:
return
# --- Update Swing Point History ---
# Check if a new swing high was just confirmed
if self.swing_points.swing_high[-1] != self.swing_points.swing_high[0] and not bt.math.isnan(self.swing_points.swing_high[0]):
self.swing_highs.append(self.swing_points.swing_high[0])
# Check if a new swing low was just confirmed
if self.swing_points.swing_low[-1] != self.swing_points.swing_low[0] and not bt.math.isnan(self.swing_points.swing_low[0]):
self.swing_lows.append(self.swing_points.swing_low[0])
if not self.position and len(self.swing_highs) == 2 and len(self.swing_lows) == 2:
# --- Determine Market Structure ---
= (self.swing_highs[1] < self.swing_highs[0] and
is_downtrend self.swing_lows[1] < self.swing_lows[0])
= (self.swing_highs[1] > self.swing_highs[0] and
is_uptrend self.swing_lows[1] > self.swing_lows[0])
# --- Entry Logic: Look for a Break of Structure ---
if is_downtrend:
= self.swing_highs[1]
last_lower_high if self.data.close[0] > last_lower_high:
self.order = self.buy()
elif is_uptrend:
= self.swing_lows[1]
last_higher_low if self.data.close[0] < last_higher_low:
self.order = self.sell()
elif self.position:
# --- Manual ATR Trailing Stop Logic ---
if self.position.size > 0: # Long
self.highest_price_since_entry = max(self.highest_price_since_entry, self.data.high[0])
= self.highest_price_since_entry - (self.atr[0] * self.p.atr_stop_multiplier)
new_stop self.stop_price = max(self.stop_price, new_stop)
if self.data.close[0] < self.stop_price:
self.order = self.close()
elif self.position.size < 0: # Short
self.lowest_price_since_entry = min(self.lowest_price_since_entry, self.data.low[0])
= self.lowest_price_since_entry + (self.atr[0] * self.p.atr_stop_multiplier)
new_stop self.stop_price = min(self.stop_price, new_stop)
if self.data.close[0] > self.stop_price:
self.order = self.close()
The custom Swing Point indicator identifies confirmed swing highs and lows:
np.nan
for
initial states.The strategy trades breakouts of market structure based on swing points:
Indicators:
Trading Logic (next
):
Order Management
(notify_order
):
swing_period
,
atr_period
, or atr_stop_multiplier
to optimize
for specific assets or market conditions.This updated strategy is designed for markets with clear swing points and potential trend reversals, suitable for assets like forex, stocks, or cryptocurrencies. It builds on the base version by focusing on more significant market structure shifts and wider stop-loss levels, potentially improving performance in trending markets. Backtesting is recommended to compare its effectiveness against the base version across various timeframes and assets.