In the dynamic world of financial markets, understanding and reacting to changing volatility is a cornerstone of many trading strategies. This article delves into an intriguing concept: volatility ratio reversion. We’ll explore a simplified strategy that leverages the relationship between short-term and long-term volatility, using the powerful Backtrader framework for robust backtesting and analysis.
Volatility, a measure of price fluctuations, is not constant. Periods of high volatility are often followed by periods of lower volatility, and vice versa. This tendency for volatility to revert to its mean is a key principle our strategy aims to exploit.
At the heart of our approach is the Volatility Ratio. This indicator is simply the ratio of short-term volatility to long-term volatility.
Trading solely on volatility reversion can be risky, especially in strong trending markets. A strategy might try to “revert” against a powerful trend, leading to losses. To mitigate this, we introduce a trend filter using a Simple Moving Average (SMA).
This combination aims to capture reversion opportunities that are “in line” with the broader market direction, potentially leading to more favorable trades.
No strategy is complete without robust risk management. For our exits, we employ an Average True Range (ATR) trailing stop. ATR is a measure of market volatility, and using it to set stops means our stop-loss levels adapt to current market conditions:
The trailing nature of the stop ensures that as a profitable trade moves in our favor, the stop-loss also moves, locking in gains.
Let’s dive into the Python code to implement this strategy using
backtrader
, a powerful and flexible backtesting
framework.
First, we define our custom
VolatilityRatioIndicator
:
import backtrader as bt
import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import warnings
"ignore")
warnings.filterwarnings(
# Ensure matplotlib plots are shown inline in Jupyter/IPython environments
# %matplotlib inline
class VolatilityRatioIndicator(bt.Indicator):
"""Calculate volatility ratio (short-term vol / long-term vol)"""
= ('vol_ratio', 'trend_sma')
lines
= (
params 'short_vol_window', 7),
('long_vol_window', 30),
('trend_sma_window', 50),
(
)
def __init__(self):
# Daily returns (manual calculation for Backtrader)
= (self.data.close - self.data.close(-1)) / self.data.close(-1)
daily_returns
# Rolling volatilities using standard deviation of returns
= bt.indicators.StdDev(daily_returns, period=self.params.short_vol_window)
short_vol = bt.indicators.StdDev(daily_returns, period=self.params.long_vol_window)
long_vol
# Volatility ratio: short-term volatility relative to long-term
self.lines.vol_ratio = short_vol / long_vol
# Trend filter: Simple Moving Average of closing price
self.lines.trend_sma = bt.indicators.SMA(self.data.close, period=self.params.trend_sma_window)
Next, we build the VolatilityRatioStrategy
itself,
incorporating the indicator, entry/exit logic, and risk management:
class VolatilityRatioStrategy(bt.Strategy):
"""
Volatility Ratio Reversion Strategy:
- Long when vol ratio < lower_threshold AND price > trend
- Short when vol ratio > upper_threshold AND price < trend
- ATR trailing stops for exits
"""
= (
params # Volatility parameters
'short_vol_window', 7),
('long_vol_window', 30),
('upper_threshold', 1.2), # Volatility is high, anticipate reversion down
('lower_threshold', 0.8), # Volatility is low, anticipate reversion up
('trend_sma_window', 50),
(
# Risk management
'atr_period', 14),
('atr_multiplier', 1.0), # Distance of stop from price, in multiples of ATR
('position_size', 0.95), # Percentage of cash to use per trade
(
# Output
'printlog', True),
(
)
def __init__(self):
# Initialize our custom Volatility Ratio Indicator
self.vol_ratio = VolatilityRatioIndicator(
=self.params.short_vol_window,
short_vol_window=self.params.long_vol_window,
long_vol_window=self.params.trend_sma_window
trend_sma_window
)# Initialize ATR for trailing stops
self.atr = bt.indicators.ATR(period=self.params.atr_period)
# To keep track of pending orders and trade details
self.entry_price = None
self.trailing_stop = None
self.order = None
# Performance tracking
self.trade_count = 0
self.winning_trades = 0
def log(self, txt, dt=None):
"""Logging function for strategy"""
if self.params.printlog:
= dt or self.datas[0].datetime.date(0)
dt print(f'{dt}: {txt}')
def notify_order(self, order):
"""Handles notifications for orders (submitted, accepted, completed, canceled, etc.)"""
if order.status in [order.Completed]:
if order.isbuy():
self.log(f'BUY EXECUTED: Price ${order.executed.price:.2f}, Size {order.executed.size}')
else:
self.log(f'SELL EXECUTED: Price ${order.executed.price:.2f}, Size {order.executed.size}')
elif order.status in [order.Canceled, order.Margin, order.Rejected]:
self.log(f'Order Canceled/Margin/Rejected: {order.status}')
self.order = None # Clear pending order
def notify_trade(self, trade):
"""Handles notifications for trades (open, closed, profit/loss)"""
if trade.isclosed:
self.trade_count += 1
if trade.pnl > 0:
self.winning_trades += 1
= (self.winning_trades / self.trade_count) * 100 if self.trade_count > 0 else 0
win_rate self.log(f'TRADE CLOSED: PnL ${trade.pnl:.2f}, Commission ${trade.commission:.2f}, Net PnL ${trade.pnlcomm:.2f}, Win Rate: {win_rate:.1f}%')
def next(self):
"""Main strategy logic executed on each new bar (day)"""
# Skip if insufficient data for indicators or a pending order exists
if (self.order or
len(self.data) < max(self.params.long_vol_window, self.params.trend_sma_window, self.params.atr_period) or
self.vol_ratio.vol_ratio[0]) or
np.isnan(self.atr[0])):
np.isnan(return
= self.data.close[0]
current_price = self.vol_ratio.vol_ratio[0]
vol_ratio = self.vol_ratio.trend_sma[0]
trend_sma
# Update trailing stop if in position
if self.position:
self._update_trailing_stop()
if self._check_stop_exit():
# If a stop exit occurred, we're out of the market, so return
return
# Generate signals based on volatility ratio and trend filter
= 0
signal
# Long signal: Low volatility ratio AND price above trend SMA
if vol_ratio < self.params.lower_threshold and current_price > trend_sma:
= 1
signal
# Short signal: High volatility ratio AND price below trend SMA
elif vol_ratio > self.params.upper_threshold and current_price < trend_sma:
= -1
signal
# Execute signals if not already in a position
if signal == 1 and not self.position:
self._enter_long()
elif signal == -1 and not self.position:
self._enter_short()
def _enter_long(self):
"""Places a buy order and sets initial trailing stop"""
# Calculate size based on position_size parameter and available cash
# Note: Backtrader's PercentSizer will handle the actual sizing if added
# This manual calculation is for logging/initial stop setting accuracy
= int(self.broker.getcash() * self.params.position_size / self.data.close[0])
size if size > 0:
self.order = self.buy(size=size)
self.entry_price = self.data.close[0]
# Initial trailing stop for long position: entry_price - (ATR * multiplier)
self.trailing_stop = self.entry_price - (self.params.atr_multiplier * self.atr[0])
self.log(f'LONG SIGNAL: Vol Ratio {self.vol_ratio.vol_ratio[0]:.3f}')
def _enter_short(self):
"""Places a sell (short) order and sets initial trailing stop"""
= int(self.broker.getcash() * self.params.position_size / self.data.close[0])
size if size > 0:
self.order = self.sell(size=size) # For shorting, this is a sell order
self.entry_price = self.data.close[0]
# Initial trailing stop for short position: entry_price + (ATR * multiplier)
self.trailing_stop = self.entry_price + (self.params.atr_multiplier * self.atr[0])
self.log(f'SHORT SIGNAL: Vol Ratio {self.vol_ratio.vol_ratio[0]:.3f}')
def _update_trailing_stop(self):
"""Adjusts the trailing stop upwards for long, downwards for short"""
if self.trailing_stop is None:
return
= self.data.close[0]
current_price = self.atr[0]
atr_value
if self.position.size > 0: # Long position
= current_price - (self.params.atr_multiplier * atr_value)
new_stop if new_stop > self.trailing_stop: # Only move stop up
self.trailing_stop = new_stop
else: # Short position
= current_price + (self.params.atr_multiplier * atr_value)
new_stop if new_stop < self.trailing_stop: # Only move stop down
self.trailing_stop = new_stop
def _check_stop_exit(self):
"""Checks if price has hit the trailing stop and closes position"""
if self.trailing_stop is None:
return False
# For long position, exit if low crosses below trailing stop
if self.position.size > 0: # Long
if self.data.low[0] <= self.trailing_stop:
self.order = self.close() # Close current long position
self.log(f'STOP EXIT (LONG): Trailing Stop @ ${self.trailing_stop:.2f}, Current Low ${self.data.low[0]:.2f}')
self._reset_position()
return True
# For short position, exit if high crosses above trailing stop
else: # Short
if self.data.high[0] >= self.trailing_stop:
self.order = self.close() # Close current short position
self.log(f'STOP EXIT (SHORT): Trailing Stop @ ${self.trailing_stop:.2f}, Current High ${self.data.high[0]:.2f}')
self._reset_position()
return True
return False
def _reset_position(self):
"""Resets position tracking variables after a trade closure"""
self.entry_price = None
self.trailing_stop = None
def stop(self):
"""Called at the end of the backtest to print final summary"""
= self.broker.getvalue()
final_value = (self.winning_trades / self.trade_count * 100) if self.trade_count > 0 else 0
win_rate
print('='*50)
print('STRATEGY RESULTS')
print('='*50)
print(f'Final Portfolio Value: ${final_value:,.2f}')
print(f'Total Trades Executed: {self.trade_count}')
print(f'Winning Trades: {self.winning_trades}')
print(f'Win Rate: {win_rate:.1f}%')
print('='*50)
Finally, we set up a run_backtest
function to
encapsulate the backtesting process, allowing easy configuration and
execution:
def run_backtest(
# Data Parameters
="BTC-USD",
ticker="2021-01-01",
start_date="2024-12-31",
end_date=100000,
initial_cash=0.001,
commission
# Strategy Parameters
=7,
short_vol_window=30,
long_vol_window=1.2,
upper_threshold=0.8,
lower_threshold=50,
trend_sma_window=14,
atr_period=1.0,
atr_multiplier=0.95,
position_size
# Output Parameters
=True,
printlog=True
show_plot
):"""
Run the volatility ratio strategy backtest with configurable parameters
"""
print(f"Volatility Ratio Strategy Backtest")
print(f"=" * 50)
print(f"Asset: {ticker}")
print(f"Period: {start_date} to {end_date}")
print(f"Initial Cash: ${initial_cash:,}")
print(f"Commission: {commission*100:.2f}%")
print(f"Volatility Windows: {short_vol_window}d / {long_vol_window}d")
print(f"Thresholds: Long < {lower_threshold}, Short > {upper_threshold}")
print(f"Trend Filter: SMA {trend_sma_window}")
print(f"ATR Stop: {atr_multiplier}x ATR({atr_period})")
print(f"Position Size: {position_size*100:.0f}% of cash")
print("-" * 50)
# Download data using yfinance. As per instructions, using auto_adjust=False and droplevel.
print("Downloading data...")
= yf.download(ticker, start=start_date, end=end_date, auto_adjust=False, progress=False).droplevel(axis=1, level=1)
df
if df.empty:
print(f"No data for {ticker} in the specified period.")
return None, None
# Ensure standard OHLCV column names for Backtrader
= df[['Open', 'High', 'Low', 'Close', 'Volume']].copy()
df print(f"Downloaded {len(df)} bars.")
# Setup Cerebro (the "brain" of Backtrader)
= bt.Cerebro()
cerebro
# Add data to Cerebro
= bt.feeds.PandasData(dataname=df)
data
cerebro.adddata(data)
# Add the strategy with its configurable parameters
cerebro.addstrategy(
VolatilityRatioStrategy,=short_vol_window,
short_vol_window=long_vol_window,
long_vol_window=upper_threshold,
upper_threshold=lower_threshold,
lower_threshold=trend_sma_window,
trend_sma_window=atr_period,
atr_period=atr_multiplier,
atr_multiplier=position_size,
position_size=printlog
printlog
)
# Setup broker settings
cerebro.broker.setcash(initial_cash)=commission)
cerebro.broker.setcommission(commission
# Set position sizing (e.g., using 95% of available cash for each trade)
=position_size*100)
cerebro.addsizer(bt.sizers.PercentSizer, percents
# Add analyzers for performance metrics
='sharpe', timeframe=bt.TimeFrame.Daily)
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='drawdown')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='trades')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='returns')
cerebro.addanalyzer(bt.analyzers.Returns, _name
# Run the backtest
print("\nRunning backtest...")
= cerebro.run()
results = results[0] # Get the strategy instance from the results
strategy
# Print comprehensive results
= cerebro.broker.getvalue()
final_value = (final_value / initial_cash - 1) * 100
total_return
print(f"\nPERFORMANCE SUMMARY:")
print(f"Final Portfolio Value: ${final_value:,.2f}")
print(f"Initial Cash: ${initial_cash:,.2f}")
print(f"Total Return: {total_return:.2f}%")
# Sharpe ratio
= strategy.analyzers.sharpe.get_analysis()
sharpe_data = sharpe_data.get('sharperatio', 'N/A')
sharpe_ratio if sharpe_ratio != 'N/A':
print(f"Sharpe Ratio (Daily): {sharpe_ratio:.2f}")
# Max drawdown
= strategy.analyzers.drawdown.get_analysis()
drawdown_data = drawdown_data.get('max', {}).get('drawdown', 0)
max_dd print(f"Max Drawdown: {max_dd:.2f}%")
# Trade stats
= strategy.analyzers.trades.get_analysis()
trades_data = trades_data.get('total', {}).get('total', 0)
total_trades print(f"Total Trades: {total_trades}")
if total_trades > 0:
= trades_data.get('won', {}).get('total', 0)
won_trades = (won_trades / total_trades) * 100
win_rate print(f"Win Rate: {win_rate:.1f}%")
# Optional: Print more detailed trade stats if available
# trades_data['long']['total']
# trades_data['short']['total']
# trades_data['len']['average']
# Buy & Hold comparison (benchmark)
= ((df['Close'].iloc[-1] / df['Close'].iloc[0]) - 1) * 100
buy_hold_return print(f"Buy & Hold Return ({ticker}): {buy_hold_return:.2f}%")
print(f"Excess Return (vs. Buy & Hold): {total_return - buy_hold_return:.2f}%")
# Plot results
if show_plot:
print("\nGenerating charts (may take a moment)...")
# Ensure plot shows custom indicators
='candlestick', volume=False, figsize=(16, 10), iplot=False)
cerebro.plot(stylef'Volatility Ratio Strategy - {ticker} ({start_date} to {end_date})', fontsize=16)
plt.suptitle(
plt.show()
return cerebro, results
if __name__ == "__main__":
"""
Main execution block to run the backtest with specific parameters.
"""
= run_backtest(
cerebro, results ="BTC-USD", # Asset to test (e.g., "BTC-USD", "SPY", "TSLA")
ticker="2023-01-01", # Start of backtest period
start_date="2024-06-01", # End of backtest period (adjusted to current date for fresh data)
end_date=1.5, # Higher threshold for short signals (more extreme high volatility)
upper_threshold=0.5, # Lower threshold for long signals (more extreme low volatility)
lower_threshold=2.0, # Wider stops (2x ATR)
atr_multiplier=10, # Slightly longer short-term vol window
short_vol_window=40, # Slightly longer long-term vol window
long_vol_window=100, # Longer-term trend filter
trend_sma_window=True, # Print trade logs
printlog=True # Show equity curve and trades plot
show_plot )
When you execute the run_backtest
function, it will:
ticker
and date range. Here, we’re using
“BTC-USD” (Bitcoin to US Dollar), a highly volatile asset, from
2023-01-01 to 2024-06-01.VolatilityRatioStrategy
and adds various
bt.analyzers
to provide detailed performance metrics
(Sharpe Ratio, Drawdown, Trade Analysis, Returns).This exploratory strategy, while robust in its implementation, has several areas for further investigation:
optstrategy
or grid search) would be necessary to find
combinations that yield better risk-adjusted returns across different
assets and market conditions. However, beware of
over-optimization (curve fitting) to historical data,
which may not translate to future performance.The “Volatility Ratio Reversion Strategy” provides a fascinating glimpse into leveraging volatility dynamics for trading. The Backtrader implementation is clean, modular, and provides a solid foundation for backtesting.
While our initial backtest results on BTC-USD were not spectacular compared to a simple buy-and-hold, this is just one slice of data and one set of parameters. The true value of such an exploration lies in:
This strategy serves as an excellent starting point for further research and development in quantitative trading. The journey from a basic idea to a profitable, robust trading system is iterative, involving continuous testing, refinement, and a deep understanding of market behavior.