← Back to Home
An OBV Divergence Strategy with Price Action Confirmation for Trend Reversals

An OBV Divergence Strategy with Price Action Confirmation for Trend Reversals

This article introduces an advanced mean-reversion strategy that identifies potential trend reversals by detecting On-Balance Volume (OBV) divergence from price, subsequently confirmed by specific price action patterns. The strategy aims to uncover instances where the momentum (as reflected by OBV) does not align with new price extremes, signaling a potential weakening of the current trend, and then enters only when price itself shows signs of reversal. All positions are managed with a robust trailing stop-loss.

Strategy Overview

The OBVReversionStrategy operates on the principle that divergence between price and volume-based indicators often precedes a reversal. It combines a divergence detection mechanism with candlestick pattern analysis for confirmation.

Entry Logic

Entries are triggered by a two-stage process:

  1. Divergence Detection:

    • Bullish Divergence: Occurs when the price makes a lower low, but the OBV makes a higher low. This suggests that while price is falling, the selling pressure is diminishing, indicating potential for an upward reversal.
    • Bearish Divergence: Occurs when the price makes a higher high, but the OBV makes a lower high. This indicates that while price is rising, the buying pressure is weakening, suggesting potential for a downward reversal. The strategy actively searches for these divergences within a specified divergence_lookback period, allowing for a certain divergence_sensitivity in price and OBV movements to account for minor fluctuations.
  2. Price Action Reversal Confirmation: Once a divergence is detected, the strategy waits for confirmation from the immediate price action, specifically a reversal candle.

    • For bullish divergence, a bullish reversal candle is sought, defined by the current closing price being greater than its opening price. This implies that buyers are stepping in at the current level.
    • For bearish divergence, a bearish reversal candle is sought, defined by the current closing price being less than its opening price. This indicates that sellers are gaining control.

Both divergence and price action confirmation must be present for a trade to be initiated.

Exit Logic

Upon entering a position, the strategy employs a trailing stop-loss order. This means that a stop-loss is automatically placed 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

# Re-using the CustomOBV indicator
class CustomOBV(bt.Indicator):
    lines = ('obv',)
    plotinfo = dict(subplot=True)
    def next(self):
        if len(self) == 1: # For the very first bar
            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: # For subsequent bars
            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, which is 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 plotted on a separate subplot for better visualization.

OBVReversionStrategy Implementation

import backtrader as bt
import numpy as np

# Re-using the CustomOBV indicator
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 OBVReversionStrategy(bt.Strategy):
    """
    Trades on mean reversion signals where OBV diverges from price,
    confirmed by specific price action (e.g., a reversal close).
    1. Bullish Reversion: Price makes lower low, OBV makes higher low (bullish divergence).
        Confirmed by price closing above its open (hammer/doji-like) or above a recent low.
    2. Bearish Reversion: Price makes higher high, OBV makes lower high (bearish divergence).
        Confirmed by price closing below its open (shooting star/doji-like) or below a recent high.
    3. Exit is managed with a trailing stop-loss.
    """
    params = (
        ('obv_ma_period', 30), # OBV MA period for trend
        ('divergence_lookback', 7),
        ('divergence_sensitivity', 0.01), # Percentage for divergence detection
        ('reversal_lookback', 3), # Lookback for price reversal confirmation - Currently unused in this simplified price action
        ('trail_percent', 0.02),
    )

    def __init__(self):
        self.order = None
        self.dataclose = self.datas[0].close
        self.dataopen = self.datas[0].open
        self.datahigh = self.datas[0].high
        self.datalow = self.datas[0].low

        # OBV and its moving average
        self.obv = CustomOBV(self.datas[0])
        self.obv_ma = bt.indicators.SMA(self.obv, period=self.p.obv_ma_period)

    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.divergence_lookback, self.p.reversal_lookback):
            return

        # Check for divergence (similar to Variation 1)
        bullish_divergence = False
        bearish_divergence = False
        
        # Calculate divergence threshold in points based on current price
        divergence_threshold_points = self.p.divergence_sensitivity * self.dataclose[0]

        # Iterate through lookback period to find divergence
        for i in range(1, min(self.p.divergence_lookback + 1, len(self.data))):
            # Bullish Divergence: Price Lower Low, OBV Higher Low
            if (self.dataclose[0] < self.dataclose[-i] - divergence_threshold_points and
                self.obv[0] > self.obv[-i] + (abs(self.obv[-i]) * self.p.divergence_sensitivity)):
                bullish_divergence = True
                break # Found divergence, no need to check further in this direction
            # Bearish Divergence: Price Higher High, OBV Lower High
            elif (self.dataclose[0] > self.dataclose[-i] + divergence_threshold_points and
                  self.obv[0] < self.obv[-i] - (abs(self.obv[-i]) * self.p.divergence_sensitivity)):
                bearish_divergence = True
                break # Found divergence, no need to check further in this direction
        
        # Price Action Reversal Confirmation (simplified for this example: simply bullish/bearish candle)
        bullish_reversal_candle = False
        bearish_reversal_candle = False

        # Bullish candle: Close > Open
        if self.dataclose[0] > self.dataopen[0]:
            bullish_reversal_candle = True
        # Bearish candle: Close < Open
        elif self.dataclose[0] < self.dataopen[0]:
            bearish_reversal_candle = True

        if not self.position: # Only enter if no position is open
            # Long signal: Bullish divergence detected AND a bullish reversal candle
            if (bullish_divergence and bullish_reversal_candle):
                self.order = self.buy()
            
            # Short signal: Bearish divergence detected AND a bearish reversal candle
            elif (bearish_divergence and bearish_reversal_candle):
                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:

  1. Pending Order and Data Warm-up Checks:

    • 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.
  2. Divergence Detection:

    • bullish_divergence and bearish_divergence flags are initialized.
    • divergence_threshold_points is calculated based on divergence_sensitivity and the current close price. This allows the strategy to define how far apart price and OBV need to be to constitute a significant divergence.
    • A loop iterates backward through the divergence_lookback period:
      • It checks for bullish divergence: current close is significantly lower than a past close, while current OBV is significantly higher than the corresponding past OBV.
      • It checks for bearish divergence: current close is significantly higher than a past close, while current OBV is significantly lower than the corresponding past OBV.
      • If a divergence is found, the respective flag is set to True, and the loop breaks.
  3. Price Action Reversal Confirmation:

    • bullish_reversal_candle and bearish_reversal_candle flags are initialized.
    • A simplified check is performed for the current candle:
      • If dataclose[0] is greater than dataopen[0], bullish_reversal_candle is set to True.
      • If dataclose[0] is less than dataopen[0], bearish_reversal_candle is set to True.
  4. Entry Conditions (if not self.position): The strategy only looks to enter a trade if no position is currently open.

    • Long Signal: If bullish_divergence is True AND bullish_reversal_candle is True, a buy order is placed (self.order = self.buy()).
    • Short Signal: If bearish_divergence is True AND bearish_reversal_candle is True, a sell order is placed (self.order = self.sell()).

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 OBVReversionStrategy 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:

Conclusion

The OBVReversionStrategy presents a focused approach to mean-reversion trading by systematically identifying divergences between price and On-Balance Volume, further validated by direct price action confirmations. This method seeks to anticipate turning points in the market by recognizing shifts in underlying volume accumulation or distribution that are not yet reflected in price extremes. The implementation of a trailing stop-loss provides essential risk management, aiming to protect profits as trades move favorably. The utilization of a rolling backtest is crucial for rigorously evaluating the strategy’s consistency and adaptability across varying market conditions, offering a more reliable assessment of its performance potential.