In the complex world of algorithmic trading, a common pitfall is an obsessive focus on finding the perfect entry signal. While a good entry is crucial, it’s merely the opening act. A truly robust strategy is a complete system—a symphony of a core concept, rigorous qualification filters, and, most importantly, disciplined risk management.
This article provides a deep dive into the anatomy of such a system:
a Volatility-Oscillator Divergence Strategy. We’ll
dissect its logic, from the initial signal to the final exit, using a
sophisticated Python implementation in the backtrader
library. This strategy doesn’t just find a potential trade; it
scrutinizes it through multiple layers of confirmation and manages it
dynamically from start to finish, offering a blueprint for building more
resilient automated trading systems.
At its heart, this strategy trades on divergence, a classic technical analysis concept where a technical indicator moves in the opposite direction of the price. However, instead of a standard momentum oscillator like RSI, this strategy employs a custom Volatility Oscillator. This is created by calculating the Rate of Change (ROC) of the 30-day historical volatility.
This leads to two powerful, non-intuitive divergence signals:
These divergence signals form the foundation of our strategy, but they are never traded in isolation. They are simply the first question asked; the answer must be confirmed by a series of filters.
A raw signal is not enough. To improve the probability of success,
the strategy layers on several conditions that must be met before a
trade is even considered. This filtering process is handled within the
strategy’s __init__
and next
methods.
class VolatilityOscillatorDivergenceStrategy(bt.Strategy):
"""Volatility-Oscillator Divergence: When vol-osc diverges from price, lean into trend (with ADX + Rising ADX filters + ATR Trailing Stops)"""
= (
params 'vol_window', 30), # Volatility calculation period
('vol_osc_roc_period', 7), # ROC period for volatility oscillator
('divergence_price_lookback', 7), # Price change lookback for divergence
('divergence_vol_osc_lookback', 7), # Vol oscillator change lookback for divergence
('long_term_sma_window', 30), # SMA for overall trend detection
('atr_window', 14), # ATR period for stops
('atr_multiplier', 2.0), # ATR multiplier for stops
('adx_period', 14), # ADX period for trend strength
('adx_threshold', 20), # Minimum ADX for trade
('rising_adx_lookback', 7), # Periods to check if ADX is rising
(
)
def __init__(self):
# --- Volatility Oscillator ---
self.returns = bt.indicators.PctChange(self.data.close, period=1)
self.volatility = bt.indicators.StandardDeviation(self.returns, period=self.params.vol_window)
self.vol_oscillator = bt.indicators.ROC(self.volatility, period=self.params.vol_osc_roc_period)
# --- Divergence Calculation ---
self.price_change = self.data.close - self.data.close(-self.params.divergence_price_lookback)
self.vol_osc_change = self.vol_oscillator - self.vol_oscillator(-self.params.divergence_vol_osc_lookback)
# --- Filters and Stops ---
self.long_term_sma = bt.indicators.SMA(self.data.close, period=self.params.long_term_sma_window)
self.adx = bt.indicators.ADX(self.data, period=self.params.adx_period)
self.atr = bt.indicators.ATR(self.data, period=self.params.atr_window)
# ... trading variables for analysis ...
def next(self):
# ... [stop loss logic comes first] ...
# --- Entry Logic ---
# 1. Detect a raw divergence signal
= False
signal_detected if not self.position:
if (is_price_up and is_vol_osc_down and is_overall_uptrend) or \
and is_vol_osc_up and is_overall_downtrend):
(is_price_down = True
signal_detected
# 2. Apply all filters
= (is_trending_market and is_adx_rising)
all_filters_pass
# 3. Enter trade only if divergence AND all filters pass
if not self.position and signal_detected and all_filters_pass:
if is_price_up and is_vol_osc_down and is_overall_uptrend:
self.buy()
# ... [set initial stop] ...
elif is_price_down and is_vol_osc_up and is_overall_downtrend:
self.sell()
# ... [set initial stop] ...
The entry logic is a multi-stage validation process:
Only when a divergence signal passes all three of these filters does it become a high-conviction entry signal worthy of risking capital.
The most sophisticated part of this strategy is its exit logic. A simple target or static stop loss is often inefficient. This strategy employs a dynamic ATR-based Trailing Stop, which adapts to the market’s recent volatility. This mechanism is designed to let winning trades run while cutting losses decisively.
def next(self):
# Need enough data for all indicators
if len(self) < 50:
return
= self.data.close[0]
current_price
# 1. Check trailing stop loss FIRST
if self.position:
if self.position.size > 0 and current_price <= self.stop_price:
self.close()
self.trailing_stop_hits += 1
self.log(f'TRAILING STOP LOSS - Long closed at {current_price:.2f}')
return
elif self.position.size < 0 and current_price >= self.stop_price:
self.close()
self.trailing_stop_hits += 1
self.log(f'TRAILING STOP LOSS - Short closed at {current_price:.2f}')
return
# 2. Update trailing stop for existing positions BEFORE checking entries
if self.position:
if self.position.size > 0: # Long position - trail stop upward
= current_price - (self.atr[0] * self.params.atr_multiplier)
new_stop if new_stop > self.stop_price:
self.stop_price = new_stop
self.stop_updates += 1
elif self.position.size < 0: # Short position - trail stop downward
= current_price + (self.atr[0] * self.params.atr_multiplier)
new_stop if new_stop < self.stop_price:
self.stop_price = new_stop
self.stop_updates += 1
# ... [Entry Logic as shown in previous snippet] ...
The risk management logic is paramount and operates with two key principles:
A backtest is more than just a final equity number. This strategy’s
stop()
method is customized to provide a detailed report,
offering deep insights into the system’s behavior.
# --- Backtesting Setup ---
= "ETH-USD"
ticker
# Per user instructions, use yfinance download with auto_adjust=False and droplevel
= yf.download(ticker, period="5y", interval="1d", auto_adjust=False)
data = data.columns.droplevel(1)
data.columns
= bt.Cerebro()
cerebro
cerebro.addstrategy(VolatilityOscillatorDivergenceStrategy)# ... [add data, capital, commission, sizer, analyzers] ...
# Run the backtest
= cerebro.run()
results
# --- Custom Analysis Output from the strategy's stop() method ---
# === TRAILING STOP ANALYSIS ===
# Trailing Stop Hits: 20 (62.5% of trades)
# Total Stop Updates: 150
# Avg Stop Updates per Trade: 4.7
#
# === FILTER ANALYSIS ===
# Total Signals Detected: 125
# Signals Executed: 32
# Signals Filtered Out: 93
# • ADX < 20: 75
# • ADX Not Rising: 18
This deep dive demonstrates that a successful trading strategy is a complete, well-reasoned system. It begins with a unique market insight (the volatility divergence), rigorously qualifies it with a series of logical filters (trend alignment, strength, and momentum), and manages the resulting trade with a dynamic, adaptive risk management plan. By building systems that are not only profitable but also transparent and analyzable, we move from chasing random signals to executing a well-defined trading philosophy.