This article explores a trading strategy built with
backtrader
that identifies and acts on price breakouts,
using Volume Weighted Average Price (VWAP) as a key anchoring and
confirmation tool. The strategy aims to capture strong directional moves
confirmed by volume, trend strength (ADX), and volatility expansion
(ATR), while managing risk with trailing stops.
The strategy begins by importing necessary libraries and defining the
VWAPAnchoredBreakoutStrategy
class, inheriting from
bt.Strategy
.
import backtrader as bt
import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from datetime import datetime, time
class VWAPAnchoredBreakoutStrategy(bt.Strategy):
# Parameters for the strategy
= (
params # VWAP Parameters
'vwap_session_length', 7), # Session length for VWAP calculation
('vwap_weekly_length', 28), # Weekly VWAP length
(
# Breakout Parameters
'breakout_lookback', 7), # Lookback period for prior high/low
('adx_threshold', 25), # ADX > 20 for trend confirmation
('adx_period', 7), # ADX calculation period
(
# Volume and ATR Confirmation
'volume_multiplier', 1.1), # Volume > 1.5x average
('volume_period', 7), # Volume average period
('atr_period', 14), # ATR period
('atr_expansion_threshold', 1.1), # ATR expansion threshold
('atr_expansion_period', 7), # Period to compare ATR expansion
(
# Trailing Stop Parameters
'trailing_stop_atr_multiplier', 5.0), # Trailing stop distance
('initial_stop_atr_multiplier', 1.), # Initial stop loss
(
# Risk Management
'position_size_pct', 0.95), # Position size percentage
('printlog', True),
( )
Explanation:
import backtrader as bt
: Imports the
core backtrader
library.import yfinance as yf
: Used for
downloading historical market data.import pandas as pd
,
import numpy as np
: Standard libraries for data
manipulation.import matplotlib.pyplot as plt
: For
plotting results (though not directly used in the provided
cerebro.run()
block, it’s common for analysis).from datetime import datetime, time
:
For handling dates and times.class VWAPAnchoredBreakoutStrategy(bt.Strategy):
:
Defines our custom strategy.params
: A tuple defining configurable
parameters for the strategy. These allow easy tuning without changing
the core logic. Parameters include lengths for VWAP, lookback periods
for breakouts, thresholds for ADX, multipliers for volume and ATR, and
settings for stop losses and position sizing.__init__
, log
)The __init__
method sets up the data feeds and
calculates various technical indicators used in the strategy. The
log
method provides a consistent way to print messages
during backtesting.
def log(self, txt, dt=None, doprint=False):
if self.params.printlog or doprint:
= dt or self.datas[0].datetime.date(0)
dt print(f'{dt.isoformat()} - {txt}')
def __init__(self):
self.dataclose = self.datas[0].close
self.datahigh = self.datas[0].high
self.datalow = self.datas[0].low
self.datavolume = self.datas[0].volume
self.order = None # To keep track of pending orders
# Technical Indicators
self.atr = bt.indicators.ATR(period=self.params.atr_period)
self.adx = bt.indicators.ADx(period=self.params.adx_period) # Note: Backtrader's ADX is ADX by default. Using ADx to be explicit.
# VWAP Calculations
# Session VWAP (shorter term)
= (self.datahigh + self.datalow + self.dataclose) / 3
typical_price self.vwap_session = bt.indicators.WeightedAverage(
self.datavolume,
typical_price, =self.params.vwap_session_length
period
)
# Weekly VWAP (longer term)
self.vwap_weekly = bt.indicators.WeightedAverage(
self.datavolume,
typical_price, =self.params.vwap_weekly_length
period
)
# Volume indicators
self.volume_sma = bt.indicators.SMA(self.datavolume, period=self.params.volume_period)
# ATR expansion detection
self.atr_sma = bt.indicators.SMA(self.atr, period=self.params.atr_expansion_period)
# Prior session high/low tracking
self.prior_high = bt.indicators.Highest(self.datahigh, period=self.params.breakout_lookback)
self.prior_low = bt.indicators.Lowest(self.datalow, period=self.params.breakout_lookback)
# Tracking variables for current trade
self.entry_price = None
self.stop_price = None
self.trail_price = None
self.position_type = None # 1 for long, -1 for short
self.breakout_confirmed = False
Explanation:
log(self, txt, dt=None, doprint=False)
:
A utility function to print logs, prepending the current date from the
data feed. doprint
allows overriding printlog
parameter for specific important messages.__init__(self)
:
self.dataclose
, self.datahigh
,
self.datalow
, self.datavolume
:
Aliases to access the current bar’s close, high, low, and volume data
more easily. self.datas[0]
refers to the first (and in this
case, only) data feed.self.order = None
: Initializes a
variable to hold any active order, preventing new orders while one is
pending.self.atr = bt.indicators.ATR(...)
:
Calculates the Average True Range (ATR), a measure of market
volatility.self.adx = bt.indicators.ADX(...)
:
Calculates the Average Directional Index (ADX), which measures the
strength of a trend.typical_price = (self.datahigh + self.datalow + self.dataclose) / 3
:
Computes the typical price for each bar.self.vwap_session
and self.vwap_weekly
:
Calculate Volume Weighted Average Price over a shorter (“session”) and
longer (“weekly”) period. bt.indicators.WeightedAverage
calculates VWAP when fed typical_price
and
volume
.self.volume_sma
: A Simple Moving
Average (SMA) of volume, used to compare current volume against its
recent average.self.atr_sma
: An SMA of ATR, used to
detect if current volatility is expanding relative to recent
volatility.self.prior_high
,
self.prior_low
: bt.indicators.Highest
and bt.indicators.Lowest
track the highest high and lowest
low over a specified lookback period, excluding the current bar
(accessed with [-1]
).entry_price
,
stop_price
, trail_price
,
position_type
, breakout_confirmed
are
initialized to keep track of trade-specific information.notify_order
,
notify_trade
)These methods are backtrader
callbacks that get invoked
when an order’s status changes or a trade is closed. They are useful for
logging and debugging.
def notify_order(self, order):
if order.status in [order.Submitted, order.Accepted]:
return # Order submitted/accepted - nothing to do yet
if order.status in [order.Completed]:
if order.isbuy():
self.log(f'BUY EXECUTED, Price: {order.executed.price:.2f}, Cost: {order.executed.value:.2f}')
elif order.issell():
self.log(f'SELL EXECUTED, Price: {order.executed.price:.2f}, Cost: {order.executed.value:.2f}')
elif order.status in [order.Canceled, order.Margin, order.Rejected]:
self.log('Order Canceled/Margin/Rejected')
self.order = None # Clear pending order
def notify_trade(self, trade):
if not trade.isclosed:
return # Trade is not yet closed
self.log(f'TRADE CLOSED - P&L: {trade.pnlcomm:.2f}')
Explanation:
notify_order(self, order)
:
order.status
.Submitted
or Accepted
, it means the
order is in progress, so we do nothing.Completed
, it logs the execution details (price,
cost) for buy or sell orders.Canceled
, Margin
, or
Rejected
, it logs the failure.self.order = None
clears the tracking
variable, allowing new orders to be placed.notify_trade(self, trade)
:
if not trade.isclosed: return
ensures we only process
closed trades.pnlcomm
- P&L with
commission) of the closed trade.These are the core logic functions that determine if the market conditions align with the strategy’s entry criteria.
def check_vwap_alignment(self, breakout_direction):
"""Check if current price vs VWAP supports the breakout direction"""
if len(self.vwap_session) < 1 or len(self.vwap_weekly) < 1:
return False
= self.dataclose[0]
current_price = self.vwap_session[0]
session_vwap = self.vwap_weekly[0]
weekly_vwap
if breakout_direction == "LONG":
# For long breakouts, price should be above both VWAPs (or at least session VWAP)
return current_price > session_vwap # A stricter check could be current_price > weekly_vwap as well
elif breakout_direction == "SHORT":
# For short breakouts, price should be below both VWAPs (or at least session VWAP)
return current_price < session_vwap # A stricter check could be current_price < weekly_vwap as well
return False
def check_volume_confirmation(self):
"""Check if volume > 1.1x average"""
if len(self.datavolume) < 1 or len(self.volume_sma) < 1:
return False
= self.datavolume[0]
current_volume = self.volume_sma[0]
avg_volume
return current_volume > (avg_volume * self.params.volume_multiplier)
def check_atr_expansion(self):
"""Check if ATR is expanding (current ATR > recent average)"""
if len(self.atr) < 1 or len(self.atr_sma) < 1:
return False
= self.atr[0]
current_atr = self.atr_sma[0]
avg_atr
return current_atr > (avg_atr * self.params.atr_expansion_threshold)
def check_breakout_conditions(self):
"""Check for breakout setup conditions"""
if len(self.dataclose) < self.params.breakout_lookback:
return False, None
= self.dataclose[0]
current_price = self.datahigh[0]
current_high = self.datalow[0]
current_low
# Get prior session high/low (from yesterday, not including today)
= self.prior_high[-1] # Previous bar's highest
prior_session_high = self.prior_low[-1] # Previous bar's lowest
prior_session_low
= None
breakout_direction
# Check for breakout of prior high
if current_high > prior_session_high:
= "LONG"
breakout_direction self.log(f'HIGH BREAKOUT DETECTED: Current High {current_high:.2f} > Prior High {prior_session_high:.2f}')
# Check for breakout of prior low
elif current_low < prior_session_low:
= "SHORT"
breakout_direction self.log(f'LOW BREAKOUT DETECTED: Current Low {current_low:.2f} < Prior Low {prior_session_low:.2f}')
if breakout_direction is None:
return False, None
# Check VWAP alignment
if not self.check_vwap_alignment(breakout_direction):
self.log(f'VWAP ALIGNMENT FAILED for {breakout_direction}')
return False, None
# Check ADX > threshold
if len(self.adx) < 1 or self.adx[0] <= self.params.adx_threshold:
self.log(f'ADX TOO LOW: {self.adx[0]:.1f} <= {self.params.adx_threshold}')
return False, None
# Check volume confirmation
if not self.check_volume_confirmation():
self.log(f'VOLUME CONFIRMATION FAILED: {self.datavolume[0]} vs {self.volume_sma[0] * self.params.volume_multiplier:.0f}')
return False, None
# Check ATR expansion
if not self.check_atr_expansion():
self.log(f'ATR EXPANSION FAILED: {self.atr[0]:.4f} vs {self.atr_sma[0] * self.params.atr_expansion_threshold:.4f}')
return False, None
return True, breakout_direction
Explanation:
check_vwap_alignment(self, breakout_direction)
:
check_volume_confirmation(self)
:
check_atr_expansion(self)
:
check_breakout_conditions(self)
: This
is the main entry condition checker.
prior_high
/prior_low
from the
lookback period (using [-1]
to get the value from the
previous bar, representing the prior session’s extreme).check_vwap_alignment()
ADX
(trend strength) against a threshold. A high
ADX (e.g., > 25) indicates a strong trend.check_volume_confirmation()
check_atr_expansion()
True
along with the breakout_direction
.update_trailing_stop
,
check_exit_conditions
,
calculate_position_size
)These functions manage open positions, including stop-loss and trailing stop mechanisms, and handle position sizing.
def update_trailing_stop(self):
"""Update trailing stop based on ATR"""
if not self.position or self.trail_price is None:
return
= self.dataclose[0]
current_price = self.atr[0]
atr_value = self.params.trailing_stop_atr_multiplier * atr_value
trail_distance
if self.position_type == 1: # Long position
= current_price - trail_distance
new_trail if new_trail > self.trail_price: # Move stop up if price moves favorably
self.trail_price = new_trail
self.log(f'TRAIL UPDATED (Long): New trail stop at {self.trail_price:.2f}')
elif self.position_type == -1: # Short position
= current_price + trail_distance
new_trail if new_trail < self.trail_price: # Move stop down if price moves favorably
self.trail_price = new_trail
self.log(f'TRAIL UPDATED (Short): New trail stop at {self.trail_price:.2f}')
def check_exit_conditions(self):
"""Check for exit conditions"""
if not self.position:
return False, None
= self.dataclose[0]
current_price
# Check initial stop loss
if self.stop_price is not None:
if self.position_type == 1 and current_price <= self.stop_price:
return True, "STOP_LOSS"
elif self.position_type == -1 and current_price >= self.stop_price:
return True, "STOP_LOSS"
# Check trailing stop
if self.trail_price is not None:
if self.position_type == 1 and current_price <= self.trail_price:
return True, "TRAILING_STOP"
elif self.position_type == -1 and current_price >= self.trail_price:
return True, "TRAILING_STOP"
# Check VWAP mean reversion (optional exit condition)
if len(self.vwap_session) >= 1:
= self.vwap_session[0]
session_vwap if self.position_type == 1 and current_price < session_vwap: # Price falls below session VWAP for long
return True, "VWAP_REVERSION"
elif self.position_type == -1 and current_price > session_vwap: # Price rises above session VWAP for short
return True, "VWAP_REVERSION"
return False, None
def calculate_position_size(self):
"""Position sizing handled by PercentSizer"""
return None # Not used - PercentSizer handles this
Explanation:
update_trailing_stop(self)
:
check_exit_conditions(self)
:
initial_stop_atr_multiplier
based stop loss.trailing_stop_atr_multiplier
based trailing stop.calculate_position_size(self)
: This
strategy uses bt.sizers.PercentSizer
externally, so this
method is a placeholder and returns None
.next
Method: The Heart of the StrategyThe next
method is called by backtrader
for
each bar of data. It contains the main trading logic.
def next(self):
if self.order:
return # A pending order exists, do nothing
# Skip if not enough data for all indicators to calculate
= max(
required_data self.params.breakout_lookback,
self.params.vwap_session_length,
self.params.adx_period,
self.params.atr_period,
self.params.volume_period, # Ensure volume_period is included here for correct indexing
self.params.atr_expansion_period # Ensure atr_expansion_period is included here for correct indexing
)# Added a check for sufficient bars available for all indicators to be calculated
if len(self.dataclose) < required_data + max(
self.params.vwap_session_length,
self.params.vwap_weekly_length,
self.params.adx_period,
self.params.atr_period,
self.params.volume_period,
self.params.atr_expansion_period,
self.params.breakout_lookback # Ensure this is also included for prior_high/low
):return
= self.dataclose[0]
current_price
# 1. If in position, manage it (update stop, check exits)
if self.position:
self.update_trailing_stop()
= self.check_exit_conditions()
should_exit, exit_reason if should_exit:
self.log(f'EXIT SIGNAL ({exit_reason}): Closing position at {current_price:.2f}')
self.order = self.close()
# Reset tracking variables
self.entry_price = None
self.stop_price = None
self.trail_price = None
self.position_type = None
self.breakout_confirmed = False
return # Do not look for new entries if already in position
# 2. If not in position, look for entry signals
if not self.position:
= self.check_breakout_conditions()
breakout_valid, direction
if breakout_valid and direction:
= self.atr[0]
atr_value
if direction == "LONG":
# Enter long position
self.log(f'VWAP BREAKOUT LONG SETUP:')
self.log(f' Price: {current_price:.2f}, Session VWAP: {self.vwap_session[0]:.2f}')
self.log(f' ADX: {self.adx[0]:.1f}, Volume: {self.datavolume[0]:.0f} (Avg: {self.volume_sma[0]:.0f})')
self.log(f' ATR: {self.atr[0]:.4f} (Avg: {self.atr_sma[0]:.4f})')
# Calculate stops
self.stop_price = current_price - (self.params.initial_stop_atr_multiplier * atr_value)
self.trail_price = current_price - (self.params.trailing_stop_atr_multiplier * atr_value)
self.log(f'LONG ENTRY: Stop={self.stop_price:.2f}, Trail={self.trail_price:.2f}')
self.order = self.buy()
self.entry_price = current_price
self.position_type = 1
self.breakout_confirmed = True
elif direction == "SHORT":
# Enter short position
self.log(f'VWAP BREAKOUT SHORT SETUP:')
self.log(f' Price: {current_price:.2f}, Session VWAP: {self.vwap_session[0]:.2f}')
self.log(f' ADX: {self.adx[0]:.1f}, Volume: {self.datavolume[0]:.0f} (Avg: {self.volume_sma[0]:.0f})')
self.log(f' ATR: {self.atr[0]:.4f} (Avg: {self.atr_sma[0]:.4f})')
# Calculate stops
self.stop_price = current_price + (self.params.initial_stop_atr_multiplier * atr_value)
self.trail_price = current_price + (self.params.trailing_stop_atr_multiplier * atr_value)
self.log(f'SHORT ENTRY: Stop={self.stop_price:.2f}, Trail={self.trail_price:.2f}')
self.order = self.sell()
self.entry_price = current_price
self.position_type = -1
self.breakout_confirmed = True
Explanation:
if self.order: return
: Prevents the
strategy from issuing new orders if an existing order is still being
processed.required_data
check: Ensures that
there are enough historical bars for all indicators to be fully
calculated before attempting to trade. This prevents
IndexError
when indicators try to access data that doesn’t
yet exist.if self.position:
: If the strategy
currently holds an open position:
self.update_trailing_stop()
to adjust the
trailing stop.self.check_exit_conditions()
to see if
any exit criteria (stop-loss, trailing stop, VWAP reversion) are
met.self.close()
), sets self.order
to prevent
re-entry, and resets all trade tracking variables.return
ensures that no new entry signals are processed
on the same bar if an exit occurred.if not self.position:
: If the strategy
is not in a position:
self.check_breakout_conditions()
to see if an
entry signal is present.buy()
or sell()
order.entry_price
, position_type
,
and breakout_confirmed
tracking variables.Adding the strategy to Backtester and running a single backtest as well as a rolling backtest, we get the following results:
This comprehensive breakdown covers the purpose and functionality of
each part of the provided backtrader
strategy, explaining
how it implements the VWAP Anchored Breakout logic and how it’s
backtested.