This article presents an advanced quantitative trading strategy, the
OBVMarketRegimeStrategyBreakout
, designed to identify and
capitalize on significant market shifts by combining multiple indicators
to confirm trend initiation and strength. The strategy integrates
On-Balance Volume (OBV), Relative Strength
Index (RSI), Average Directional Index (ADX),
volume analysis, and price breakouts
to filter for high-probability trading opportunities. All positions are
dynamically managed with a trailing stop-loss.
The OBVMarketRegimeStrategyBreakout
aims to capture
confirmed breakouts during established trends, focusing on periods where
volume and momentum indicators align with price action.
A trade is initiated when a confluence of the following conditions signals a strong directional move:
breakout_lookback
periods.breakout_lookback
periods.All five conditions (OBV cross, RSI filter, volume confirmation, ADX trend, and price breakout) must simultaneously align in the same direction for an entry order to be placed.
Upon successful entry, the position is managed solely by a
trailing stop-loss order. This means that a stop-loss
order is automatically set to trail the market price by a fixed
trail_percent
. As the market moves favorably, this
stop-loss adjusts to lock in profits. However, it will not move against
the trade if the market reverses, thereby protecting gains and limiting
potential losses.
CustomOBV
IndicatorThe strategy relies on a custom On-Balance Volume (OBV) indicator
implementation to ensure proper calculation and integration within the
backtrader
framework.
import backtrader as bt
# Custom On-Balance Volume Indicator (remains the same)
class CustomOBV(bt.Indicator):
= ('obv',)
lines = dict(subplot=True)
plotinfo def next(self):
if len(self) == 1:
if self.data.close[0] > self.data.close[-1]:
self.lines.obv[0] = self.data.volume[0]
elif self.data.close[0] < self.data.close[-1]:
self.lines.obv[0] = -self.data.volume[0]
else:
self.lines.obv[0] = 0
else:
= self.lines.obv[-1]
prev_obv if self.data.close[0] > self.data.close[-1]:
self.lines.obv[0] = prev_obv + self.data.volume[0]
elif self.data.close[0] < self.data.close[-1]:
self.lines.obv[0] = prev_obv - self.data.volume[0]
else:
self.lines.obv[0] = prev_obv
This CustomOBV
indicator calculates On-Balance Volume, a
cumulative total of volume, either added or subtracted based on price
movement. If the closing price is higher than the previous close, the
current day’s volume is added to the OBV total. If the closing price is
lower, the volume is subtracted. If the price remains unchanged, the OBV
remains the same. This indicator is typically plotted on a separate
subplot.
OBVMarketRegimeStrategyBreakout
Implementationimport backtrader as bt
# Custom On-Balance Volume Indicator (remains the same)
class CustomOBV(bt.Indicator):
= ('obv',)
lines = dict(subplot=True)
plotinfo def next(self):
if len(self) == 1:
if self.data.close[0] > self.data.close[-1]:
self.lines.obv[0] = self.data.volume[0]
elif self.data.close[0] < self.data.close[-1]:
self.lines.obv[0] = -self.data.volume[0]
else:
self.lines.obv[0] = 0
else:
= self.lines.obv[-1]
prev_obv if self.data.close[0] > self.data.close[-1]:
self.lines.obv[0] = prev_obv + self.data.volume[0]
elif self.data.close[0] < self.data.close[-1]:
self.lines.obv[0] = prev_obv - self.data.volume[0]
else:
self.lines.obv[0] = prev_obv
class OBVMarketRegimeStrategyBreakout(bt.Strategy):
= (
params 'obv_ma_period', 50),
('rsi_period', 21),
('volume_ma_period', 21),
('adx_period', 7),
('adx_threshold', 30),
('breakout_lookback', 7), # Lookback for highest high/lowest low
('trail_percent', 0.02),
(
)
def __init__(self):
self.order = None
self.dataclose = self.datas[0].close
self.datahigh = self.datas[0].high
self.datalow = self.datas[0].low
self.obv = CustomOBV(self.datas[0])
self.obv_ma = bt.indicators.SMA(self.obv.lines.obv, period=self.p.obv_ma_period)
self.obv_cross = bt.indicators.CrossOver(self.obv.lines.obv, self.obv_ma)
self.rsi = bt.indicators.RSI(period=self.p.rsi_period)
self.volume_ma = bt.indicators.SMA(self.data.volume, period=self.p.volume_ma_period)
self.adx = bt.indicators.ADX(self.datas[0], period=self.p.adx_period)
# Highest high and Lowest low for breakout
self.highest_high = bt.indicators.Highest(self.datahigh, period=self.p.breakout_lookback)
self.lowest_low = bt.indicators.Lowest(self.datalow, period=self.p.breakout_lookback)
def notify_order(self, order):
if order.status in [order.Submitted, order.Accepted]:
return
if order.status in [order.Completed]:
if order.isbuy():
# On buy completion, place a sell trailing stop order
self.sell(exectype=bt.Order.StopTrail, trailpercent=self.p.trail_percent)
elif order.issell():
# On sell completion, place a buy trailing stop order
self.buy(exectype=bt.Order.StopTrail, trailpercent=self.p.trail_percent)
self.order = None # Clear the order reference after completion
def next(self):
if self.order: # If an order is pending, do nothing
return
# Ensure enough data for all indicators and lookback periods
if len(self) < max(self.p.obv_ma_period, self.p.rsi_period, self.p.volume_ma_period, self.p.adx_period, self.p.breakout_lookback):
return
# ADX Trend Strength Check
= self.adx.adx[0] > self.p.adx_threshold
is_trending
# Price Breakout Confirmation
# Current close must be higher than the highest high of the last 'breakout_lookback' bars (excluding current)
= self.dataclose[0] > self.highest_high[-1]
is_bullish_breakout # Current close must be lower than the lowest low of the last 'breakout_lookback' bars (excluding current)
= self.dataclose[0] < self.lowest_low[-1]
is_bearish_breakout
if not self.position: # Only enter if no position is open
# Long signal: OBV crosses up, RSI not overbought, Volume confirmed, Trending market, Price breaks out
if (self.obv_cross[0] > 0.0 and
self.rsi[0] < 70 and
self.data.volume[0] > self.volume_ma[0] and
and
is_trending
is_bullish_breakout):self.order = self.buy()
# Short signal: OBV crosses down, RSI not oversold, Volume confirmed, Trending market, Price breaks out
elif (self.obv_cross[0] < 0.0 and
self.rsi[0] > 30 and
self.data.volume[0] > self.volume_ma[0] and
and
is_trending
is_bearish_breakout):self.order = self.sell()
params
)The strategy’s behavior is configured through its parameters:
obv_ma_period
: The period for the
Simple Moving Average (SMA) applied to the OBV. This defines the OBV
trend.rsi_period
: The period for the
Relative Strength Index (RSI).volume_ma_period
: The period for the
Simple Moving Average (SMA) of the trading volume.adx_period
: The period for the Average
Directional Index (ADX) calculation.adx_threshold
: The threshold for ADX
to confirm the presence of a strong trend.breakout_lookback
: The number of past
bars to look back for the highest high or lowest low to identify a price
breakout.trail_percent
: The percentage at which
the trailing stop-loss trails the market price.__init__
)In the __init__
method, all necessary data lines and
indicators are set up:
self.order
: This variable tracks any
pending orders to prevent multiple orders from being placed.self.dataclose
,
self.datahigh
,
self.datalow
: References to the respective
price data lines.self.obv
: An instance of the
CustomOBV
indicator is created.self.obv_ma
: A Simple Moving Average
(SMA) applied to the self.obv
line.self.obv_cross
: A
CrossOver
indicator that outputs 1
when OBV
crosses above its MA, and -1
when it crosses below.self.rsi
: The Relative Strength Index
indicator.self.volume_ma
: A Simple Moving
Average (SMA) of the trading volume.self.adx
: The Average Directional
Index indicator.self.highest_high
,
self.lowest_low
: Indicators to find the
highest high and lowest low over the breakout_lookback
period, used for price breakout detection.notify_order
)This method is called automatically by backtrader
whenever an order’s status changes. It is crucial for implementing the
trailing stop-loss:
buy
order completes (meaning a
long position has just been opened), a corresponding sell
trailing stop order is immediately placed using
self.sell(exectype=bt.Order.StopTrail, trailpercent=self.p.trail_percent)
.sell
order completes (meaning a
short position has just been opened), a corresponding buy
trailing stop order is placed using
self.buy(exectype=bt.Order.StopTrail, trailpercent=self.p.trail_percent)
.self.order = None
ensures that the order reference is
cleared once the order is no longer in a submitted or accepted state,
allowing for new orders on subsequent bars if conditions are met.next
)The next
method contains the core trading logic and is
executed on each new bar of data:
if self.order: return
: Prevents the strategy from
attempting new actions if an order is already pending.if len(self) < max(...): return
: Ensures that
there’s enough historical data for all indicators and lookback periods
to have valid values before any trading decisions are made.is_trending
: Checks if the ADX value
is above the adx_threshold
, confirming a strong market
trend.is_bullish_breakout
: Checks if the
current closing price has exceeded the highest high of the past
breakout_lookback
bars.is_bearish_breakout
: Checks if the
current closing price has fallen below the lowest low of the past
breakout_lookback
bars.if not self.position
): The strategy only looks to
enter a trade if no position is currently open.
self.obv_cross[0] > 0.0
).self.rsi[0] < 70
).self.data.volume[0] > self.volume_ma[0]
).is_trending
is
True
).is_bullish_breakout
is True
).self.obv_cross[0] < 0.0
).self.rsi[0] > 30
).self.data.volume[0] > self.volume_ma[0]
).is_trending
is
True
).is_bearish_breakout
is True
).To comprehensively evaluate the strategy’s performance, a rolling backtest is used. This method assesses the strategy over multiple, successive time windows, providing a more robust view of its consistency compared to a single, fixed backtest.
from collections import deque
import backtrader as bt
import numpy as np
import pandas as pd
import yfinance as yf
import dateutil.relativedelta as rd
# Assuming CustomOBV and OBVMarketRegimeStrategyBreakout classes are defined above this section.
def run_rolling_backtest(
ticker,
start,
end,
window_months,
strategy_class,=None
strategy_params
):= strategy_params or {}
strategy_params = []
all_results = pd.to_datetime(start)
start_dt = pd.to_datetime(end)
end_dt = start_dt
current_start
while True:
= current_start + rd.relativedelta(months=window_months)
current_end if current_end > end_dt:
= end_dt
current_end if current_start >= current_end:
break
print(f"\nROLLING BACKTEST: {current_start.date()} to {current_end.date()}")
= yf.download(ticker, start=current_start, end=current_end, auto_adjust=False, progress=False)
data
if data.empty or len(data) < 90:
print("Not enough data for this period. Skipping.")
+= rd.relativedelta(months=window_months)
current_start continue
if isinstance(data.columns, pd.MultiIndex):
= data.droplevel(1, 1)
data
= data['Close'].iloc[0]
start_price = data['Close'].iloc[-1]
end_price = (end_price - start_price) / start_price * 100
benchmark_ret
= bt.feeds.PandasData(dataname=data)
feed = bt.Cerebro()
cerebro
**strategy_params)
cerebro.addstrategy(strategy_class,
cerebro.adddata(feed)100000)
cerebro.broker.setcash(=0.001)
cerebro.broker.setcommission(commission=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents
= cerebro.broker.getvalue()
start_val try:
cerebro.run()except Exception as e:
print(f"Error running backtest for {current_start.date()} to {current_end.date()}: {e}")
+= rd.relativedelta(months=window_months)
current_start continue
= cerebro.broker.getvalue()
final_val = (final_val - start_val) / start_val * 100
strategy_ret
all_results.append({'start': current_start.date(),
'end': current_end.date(),
'strategy_return_pct': strategy_ret,
'benchmark_return_pct': benchmark_ret,
'final_value': final_val,
})
print(f"Strategy Return: {strategy_ret:.2f}% | Buy & Hold Return: {benchmark_ret:.2f}%")
+= rd.relativedelta(months=window_months)
current_start
if current_start > end_dt:
break
return pd.DataFrame(all_results)
ticker
,
start
, end
:
The asset symbol and the overall historical period for the
backtest.window_months
: The duration of each
individual backtesting window in months.strategy_class
: The
backtrader.Strategy
class to be tested (e.g.,
OBVMarketRegimeStrategyBreakout
).strategy_params
: A dictionary to pass
specific parameters to the chosen strategy for each run.yfinance
with
auto_adjust=False
and droplevel
applied.backtrader.Cerebro
instance.strategy_class
and its
parameters.Pandas DataFrame
containing the
comprehensive results for each rolling window.
The OBVMarketRegimeStrategyBreakout
offers a
multi-faceted approach to trend-following breakout
trading. By integrating On-Balance Volume
crossovers with RSI momentum filtering,
volume confirmation, ADX trend strength
validation, and price breakouts, it aims to
identify and capitalize on robust market shifts. The strategy’s emphasis
on comprehensive confirmation reduces false signals, while the
trailing stop-loss provides essential risk management.
The utilization of a rolling backtest is crucial for
rigorously evaluating the strategy’s consistency and adaptability across
various market conditions, offering a more reliable assessment of its
performance potential.