← Back to Home
Breakout Strategy with OBV Confirmation Across Regimes

Breakout Strategy with OBV Confirmation Across Regimes

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.

Strategy Overview

The OBVMarketRegimeStrategyBreakout aims to capture confirmed breakouts during established trends, focusing on periods where volume and momentum indicators align with price action.

Entry Logic

A trade is initiated when a confluence of the following conditions signals a strong directional move:

  1. OBV Trend Confirmation: The On-Balance Volume (OBV), a cumulative volume indicator, must cross its Simple Moving Average (SMA).
    • Bullish Signal: OBV crosses above its SMA, suggesting increasing buying pressure.
    • Bearish Signal: OBV crosses below its SMA, indicating increasing selling pressure.
  2. RSI Momentum Filter: The Relative Strength Index (RSI) is used to confirm that the market is not already overbought or oversold, ensuring there’s room for the trend to continue.
    • For long entries, RSI should be below 70.
    • For short entries, RSI should be above 30.
  3. Volume Confirmation: The current trading volume must be above its Simple Moving Average (SMA), indicating significant participation and conviction behind the price move.
  4. ADX Trend Strength Filter: The Average Directional Index (ADX) is employed to ensure that a measurable trend is in place.
    • ADX must be above a specified threshold (e.g., 30), signaling a sufficiently strong trending environment.
  5. Price Breakout: The current closing price must break out of a recent price extreme.
    • For long entries, the close must be above the highest high of the last breakout_lookback periods.
    • For short entries, the close must be below the lowest low of the last 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.

Exit Logic

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.

Custom CustomOBV Indicator

The 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):
    lines = ('obv',)
    plotinfo = dict(subplot=True)
    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:
            prev_obv = self.lines.obv[-1]
            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 Implementation

import backtrader as bt

# Custom On-Balance Volume Indicator (remains the same)
class CustomOBV(bt.Indicator):
    lines = ('obv',)
    plotinfo = dict(subplot=True)
    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:
            prev_obv = self.lines.obv[-1]
            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
        is_trending = self.adx.adx[0] > self.p.adx_threshold

        # Price Breakout Confirmation
        # Current close must be higher than the highest high of the last 'breakout_lookback' bars (excluding current)
        is_bullish_breakout = self.dataclose[0] > self.highest_high[-1] 
        # Current close must be lower than the lowest low of the last 'breakout_lookback' bars (excluding current)
        is_bearish_breakout = self.dataclose[0] < self.lowest_low[-1] 

        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
                is_trending and
                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
                  is_trending and
                  is_bearish_breakout):
                self.order = self.sell()

Parameters (params)

The strategy’s behavior is configured through its parameters:

Initialization (__init__)

In the __init__ method, all necessary data lines and indicators are set up:

Order Notification (notify_order)

This method is called automatically by backtrader whenever an order’s status changes. It is crucial for implementing the trailing stop-loss:

Main Logic (next)

The next method contains the core trading logic and is executed on each new bar of data:

Rolling Backtesting Setup

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,
    strategy_params=None
):
    strategy_params = strategy_params or {}
    all_results = []
    start_dt = pd.to_datetime(start)
    end_dt = pd.to_datetime(end)
    current_start = start_dt

    while True:
        current_end = current_start + rd.relativedelta(months=window_months)
        if current_end > end_dt:
            current_end = end_dt
            if current_start >= current_end:
                break

        print(f"\nROLLING BACKTEST: {current_start.date()} to {current_end.date()}")

        data = yf.download(ticker, start=current_start, end=current_end, auto_adjust=False, progress=False)

        if data.empty or len(data) < 90:
            print("Not enough data for this period. Skipping.")
            current_start += rd.relativedelta(months=window_months)
            continue

        if isinstance(data.columns, pd.MultiIndex):
            data = data.droplevel(1, 1)

        start_price = data['Close'].iloc[0]
        end_price = data['Close'].iloc[-1]
        benchmark_ret = (end_price - start_price) / start_price * 100

        feed = bt.feeds.PandasData(dataname=data)
        cerebro = bt.Cerebro()
        
        cerebro.addstrategy(strategy_class, **strategy_params)
        cerebro.adddata(feed)
        cerebro.broker.setcash(100000)
        cerebro.broker.setcommission(commission=0.001)
        cerebro.addsizer(bt.sizers.PercentSizer, percents=95)

        start_val = cerebro.broker.getvalue()
        try:
            cerebro.run()
        except Exception as e:
            print(f"Error running backtest for {current_start.date()} to {current_end.date()}: {e}")
            current_start += rd.relativedelta(months=window_months)
            continue

        final_val = cerebro.broker.getvalue()
        strategy_ret = (final_val - start_val) / start_val * 100

        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}%")

        current_start += rd.relativedelta(months=window_months)

        if current_start > end_dt:
            break

    return pd.DataFrame(all_results)

How the Rolling Backtest Works:

Pasted image 20250722022515.png Pasted image 20250722022541.png Pasted image 20250722022549.png

Conclusion

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.