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.
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.
Entries are triggered by a two-stage process:
Divergence Detection:
divergence_lookback
period, allowing for a certain
divergence_sensitivity
in price and OBV movements to
account for minor fluctuations.Price Action Reversal Confirmation: Once a divergence is detected, the strategy waits for confirmation from the immediate price action, specifically a reversal candle.
Both divergence and price action confirmation must be present for a trade to be initiated.
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.
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
# Re-using the CustomOBV indicator
class CustomOBV(bt.Indicator):
= ('obv',)
lines = dict(subplot=True)
plotinfo 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
= 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,
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
Implementationimport backtrader as bt
import numpy as np
# Re-using the CustomOBV indicator
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 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)
= False
bullish_divergence = False
bearish_divergence
# Calculate divergence threshold in points based on current price
= self.p.divergence_sensitivity * self.dataclose[0]
divergence_threshold_points
# 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)):
= True
bullish_divergence 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)):
= True
bearish_divergence break # Found divergence, no need to check further in this direction
# Price Action Reversal Confirmation (simplified for this example: simply bullish/bearish candle)
= False
bullish_reversal_candle = False
bearish_reversal_candle
# Bullish candle: Close > Open
if self.dataclose[0] > self.dataopen[0]:
= True
bullish_reversal_candle # Bearish candle: Close < Open
elif self.dataclose[0] < self.dataopen[0]:
= True
bearish_reversal_candle
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()
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, potentially used for overall OBV trend
filtering (though not explicitly used for entry in the provided
next
logic).divergence_lookback
: The number of past bars to look
back when searching for price-OBV divergence.divergence_sensitivity
: A percentage threshold used to
define a “significant” divergence in both price and OBV values. This
helps filter out minor fluctuations.reversal_lookback
: A parameter intended for looking
back for price reversal confirmation patterns. In the provided code, the
price action confirmation is simplified to just the current candle’s
open/close, so this specific parameter isn’t fully utilized.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 being placed.self.dataclose
, self.dataopen
,
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.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:
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.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.divergence_lookback
period:
True
, and the loop breaks.Price Action Reversal Confirmation:
bullish_reversal_candle
and
bearish_reversal_candle
flags are initialized.dataclose[0]
is greater than
dataopen[0]
, bullish_reversal_candle
is set to
True
.dataclose[0]
is less than dataopen[0]
,
bearish_reversal_candle
is set to True
.Entry Conditions
(if not self.position
): The strategy only looks to
enter a trade if no position is currently open.
bullish_divergence
is
True
AND bullish_reversal_candle
is
True
, a buy
order is placed
(self.order = self.buy()
).bearish_divergence
is
True
AND bearish_reversal_candle
is
True
, a sell
order is placed
(self.order = self.sell()
).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,=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.,
OBVReversionStrategy
).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 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.