This article explores a volatility breakout strategy designed to capitalize on significant price movements by combining breakout detection with trend and volatility filters and adaptive ATR-based trailing stops for risk management. We’ll explore the strategy’s intricate logic, its implementation, and the power of a rolling backtesting framework to thoroughly evaluate its performance.
The VolatilityBreakoutSystem
is built on the premise
that strong trends often begin with a decisive price breakout,
especially when volatility is increasing and a clear trend is
establishing itself. The strategy uses multiple filters to confirm these
conditions before entering a trade.
Key Components:
breakout_period
(default 7) highs for long entries and new
lows for short entries. This aims to catch the initiation of strong
price moves.adx_threshold
(default 20) is used to confirm
a trending market.vwap_multiplier
(default 1.1) times the Average
True Range (ATR) away from the VWAP. This helps confirm that price is
moving with conviction away from its volume-weighted average, indicating
strong momentum.atr[0] > atr[-1] > atr[-2]
). This explicitly filters
for increasing market volatility, a common precursor to strong
breakouts.atr_stop_multiplier
(default 3.0) times the ATR from the
entry price.trailing_active
only after the trade reaches a
trail_activation
(default 1.5) multiple of the
entry_atr
in profit. Once activated, the stop trails the
price by atr_trail_multiplier
(default 1.5) times the
current ATR, dynamically adjusting to lock in profits.VolatilityBreakoutSystem
Implementationimport backtrader as bt
import numpy as np
class VolatilityBreakoutSystem(bt.Strategy):
"""
Volatility-Adjusted Breakout System with Trailing Stops
Features:
- Breakout detection using N-period highs/lows
- ADX trend strength filter
- VWAP-based volatility filter
- ATR-based initial and trailing stops
"""
= (
params # Breakout Parameters
'breakout_period', 7),
('adx_period', 14),
('adx_threshold', 20),
(
# VWAP Parameters
'vwap_period', 7),
('vwap_multiplier', 1.1), # Price must be X times ATR from VWAP
(
# ATR Parameters
'atr_period', 14),
('atr_stop_multiplier', 3.0), # Initial stop loss distance
('atr_trail_multiplier', 1.5), # Trailing stop distance
(
# Trailing Stop Activation/Step
'trail_activation', 1.5), # Activate trailing after X times ATR profit
('trail_step', 0.5), # Trail by X times ATR steps (currently not directly used in 'update_trailing_stop' as it re-calculates from current ATR)
(
)
def __init__(self):
# Data feeds for indicators
self.high = self.data.high
self.low = self.data.low
self.close = self.data.close
self.volume = self.data.volume
# Technical indicators
self.atr = bt.indicators.ATR(period=self.params.atr_period)
self.adx = bt.indicators.ADX(period=self.params.adx_period)
# Breakout indicators (Highest High / Lowest Low over breakout_period)
self.highest = bt.indicators.Highest(self.high, period=self.params.breakout_period)
self.lowest = bt.indicators.Lowest(self.low, period=self.params.breakout_period)
# VWAP calculation (simplified for backtrader indicator use)
self.vwap = self._calculate_vwap_indicator()
# Order tracking variables
self.order = None # To track active entry/exit orders
self.stop_order = None # To track the initial stop loss order
self.trail_order = None # To track the dynamic trailing stop order
# Position tracking variables
self.entry_price = None # Price at which the current position was entered
self.entry_atr = None # ATR at the time of entry, for profit calculation
self.highest_profit = 0 # Highest unrealized profit in ATR terms
self.trailing_active = False # Flag to indicate if trailing stop is active
# Performance tracking (for internal logging, analyzers are more comprehensive)
self.trade_count = 0
self.win_count = 0
self.total_pnl = 0
def _calculate_vwap_indicator(self):
"""Internal helper to calculate VWAP as a Backtrader indicator."""
= (self.high + self.low + self.close) / 3
typical_price = typical_price * self.volume
volume_price
# Use Simple Moving Averages for the numerator and denominator
# For a true cumulative VWAP, you would need to implement it as a custom indicator
= bt.indicators.SMA(volume_price, period=self.params.vwap_period)
vwap_numerator = bt.indicators.SMA(self.volume, period=self.params.vwap_period)
vwap_denominator
# Avoid division by zero if volume_denominator is zero
return vwap_numerator / vwap_denominator
def log(self, txt, dt=None):
"""Logging function for strategy events."""
= dt or self.datas[0].datetime.date(0)
dt print(f'{dt.isoformat()}: {txt}')
def notify_order(self, order):
"""Handle order notifications for execution and state changes."""
if order.status in [order.Submitted, order.Accepted]:
# Order submitted or accepted, nothing to do yet
return
if order.status in [order.Completed]:
if order.isbuy():
self.log(f'BUY EXECUTED: Price: {order.executed.price:.2f}, '
f'Size: {order.executed.size:.4f}, Cost: {order.executed.value:.2f}')
# Initialize position tracking for a new long
self.entry_price = order.executed.price
self.entry_atr = self.atr[0] # ATR at entry
self.highest_profit = 0 # Reset highest profit for new trade
self.trailing_active = False # Trailing stop not active initially
else: # Order is a SELL (either entry or exit)
self.log(f'SELL EXECUTED: Price: {order.executed.price:.2f}, '
f'Size: {order.executed.size:.4f}, Cost: {order.executed.value:.2f}')
# If it's a short entry (size will be negative, but executed.size positive)
if order.isbuy() == False and self.position.size < 0: # This check indicates a short entry
self.entry_price = order.executed.price
self.entry_atr = self.atr[0]
self.highest_profit = 0
self.trailing_active = False
# If it's an exit for a long or cover for a short, no entry details needed
elif order.status in [order.Canceled, order.Margin, order.Rejected]:
self.log(f'Order {order.getstatusname()}: Ref={order.ref}')
# Clear order tracking references
if order == self.order:
self.order = None
if order == self.stop_order:
self.stop_order = None
if order == self.trail_order:
self.trail_order = None
def notify_trade(self, trade):
"""Handle trade notifications (profit/loss on closed trades)."""
if not trade.isclosed:
return
self.trade_count += 1
self.total_pnl += trade.pnl
if trade.pnl > 0:
self.win_count += 1
= (self.win_count / self.trade_count) * 100 if self.trade_count > 0 else 0
win_rate
self.log(f'TRADE CLOSED: PnL: {trade.pnl:.2f}, Net PnL: {trade.pnlcomm:.2f}, '
f'Total Trades: {self.trade_count}, Win Rate: {win_rate:.1f}%')
# Reset position-specific tracking
self.entry_price = None
self.entry_atr = None
self.highest_profit = 0
self.trailing_active = False
def can_open_position(self):
"""Determines if a new position can be opened."""
# Simple check: no existing position and no pending orders
return not self.position and not self.order
def is_breakout_long(self):
"""Check for long breakout condition: new highest high."""
if len(self.highest) < 2: # Need at least current and previous highest
return False
return self.close[0] > self.highest[-1] # Close above previous highest
def is_breakout_short(self):
"""Check for short breakout condition: new lowest low."""
if len(self.lowest) < 2: # Need at least current and previous lowest
return False
return self.close[0] < self.lowest[-1] # Close below previous lowest
def is_adx_strong(self):
"""Check if ADX indicates strong trend."""
if len(self.adx) < 1:
return False
return self.adx[0] > self.params.adx_threshold
def is_price_away_from_vwap(self):
"""Check if price is sufficiently away from VWAP, indicating conviction."""
if len(self.vwap) < 1 or len(self.atr) < 1:
return False
= self.close[0]
current_price = self.vwap[0]
vwap_value = self.atr[0]
atr_value
# Price should be at least X * ATR away from VWAP
= atr_value * self.params.vwap_multiplier
distance_threshold
= abs(current_price - vwap_value)
distance_from_vwap
return distance_from_vwap >= distance_threshold
def is_atr_rising(self):
"""Check if ATR is rising, indicating increasing volatility."""
if len(self.atr) < 3: # Need at least 3 periods to check for rising pattern
return False
# ATR should be rising over the last 2 periods (current > prev > prev_prev)
return (self.atr[0] > self.atr[-1] and
self.atr[-1] > self.atr[-2])
def update_trailing_stop(self):
"""Update trailing stop loss for an existing position."""
if not self.position or not self.entry_price or len(self.atr) < 1:
return # No position or insufficient data
= self.close[0]
current_price = self.atr[0]
current_atr
# Calculate unrealized profit in terms of ATR at entry
if self.position.size > 0: # Long position
= (current_price - self.entry_price) / self.entry_atr
unrealized_profit_atr # Update highest profit for potential trail activation
self.highest_profit = max(self.highest_profit, unrealized_profit_atr)
# Activate trailing stop if sufficient profit is reached
if self.highest_profit >= self.params.trail_activation:
self.trailing_active = True
if self.trailing_active:
# Calculate new trailing stop level
= current_price - (current_atr * self.params.atr_trail_multiplier)
new_trail_stop_price
# If there's no existing trail order or the new stop is higher, update it
if (not self.trail_order or
> self.trail_order.price):
new_trail_stop_price
# Cancel existing trailing stop order if it exists and is active
if self.trail_order and self.trail_order.alive():
self.cancel(self.trail_order)
# Place a new stop sell order to close the long position
self.trail_order = self.sell(
=bt.Order.Stop,
exectype=new_trail_stop_price,
price=self.position.size # Ensure it closes the full position
size
)self.log(f'LONG TRAILING STOP UPDATED: New Stop: {new_trail_stop_price:.2f}')
elif self.position.size < 0: # Short position
= (self.entry_price - current_price) / self.entry_atr
unrealized_profit_atr self.highest_profit = max(self.highest_profit, unrealized_profit_atr)
if self.highest_profit >= self.params.trail_activation:
self.trailing_active = True
if self.trailing_active:
= current_price + (current_atr * self.params.atr_trail_multiplier)
new_trail_stop_price
if (not self.trail_order or
< self.trail_order.price):
new_trail_stop_price
if self.trail_order and self.trail_order.alive():
self.cancel(self.trail_order)
self.trail_order = self.buy(
=bt.Order.Stop,
exectype=new_trail_stop_price,
price=abs(self.position.size) # Ensure it covers the full position
size
)self.log(f'SHORT TRAILING STOP UPDATED: New Stop: {new_trail_stop_price:.2f}')
def next(self):
"""Main strategy logic executed on each bar."""
# Ensure all indicators have warmed up with enough data
if (len(self.atr) < self.params.atr_period or
len(self.adx) < self.params.adx_period or
len(self.vwap) < self.params.vwap_period or
len(self.highest) < self.params.breakout_period or
len(self.lowest) < self.params.breakout_period): # Check for lowest as well for short entries
return
# First, manage existing positions (update trailing stop)
self.update_trailing_stop()
# If an order is pending (entry or initial stop), do nothing else
if self.order or self.stop_order:
return
# Check if we are allowed to open new positions (e.g., if flat)
if not self.can_open_position():
return
# Current market data and indicator values
= self.close[0]
current_price = self.atr[0]
current_atr
# Apply market filters
= self.is_adx_strong()
adx_strong = self.is_price_away_from_vwap()
price_away_vwap = self.is_atr_rising()
atr_rising
# Entry conditions for new positions
if not self.position: # Only consider new entries if currently flat
# Long breakout entry conditions
if (self.is_breakout_long() and adx_strong and price_away_vwap and atr_rising):
= current_atr * self.params.atr_stop_multiplier
stop_distance = current_price - stop_distance
stop_price
# Enter long position (sizing handled by Cerebro's sizer)
self.order = self.buy()
# Set the initial stop loss
self.stop_order = self.sell(
=bt.Order.Stop,
exectype=stop_price,
price# size is automatically handled by backtrader if buy order is implied for sizing
)
self.log(f'LONG BREAKOUT: Price: {current_price:.2f}, '
f'Initial Stop: {stop_price:.2f}, ADX: {self.adx[0]:.2f}, '
f'VWAP Away: {price_away_vwap}, ATR Rising: {atr_rising}')
# Short breakout entry conditions
elif (self.is_breakout_short() and adx_strong and price_away_vwap and atr_rising):
= current_atr * self.params.atr_stop_multiplier
stop_distance = current_price + stop_distance
stop_price
# Enter short position (sizing handled by Cerebro's sizer)
self.order = self.sell()
# Set the initial stop loss
self.stop_order = self.buy(
=bt.Order.Stop,
exectype=stop_price,
price
)
self.log(f'SHORT BREAKOUT: Price: {current_price:.2f}, '
f'Initial Stop: {stop_price:.2f}, ADX: {self.adx[0]:.2f}, '
f'VWAP Away: {price_away_vwap}, ATR Rising: {atr_rising}')
def stop(self):
"""Called at the end of the backtest."""
self.log(f'Final Portfolio Value: {self.broker.getvalue():.2f}')
Explanation of
VolatilityBreakoutSystem
:
params
: Defines numerous parameters to
fine-tune the breakout detection, ADX and VWAP filters, and ATR-based
stops (breakout_period
, adx_period
,
adx_threshold
, vwap_period
,
vwap_multiplier
, atr_period
,
atr_stop_multiplier
, atr_trail_multiplier
,
trail_activation
, trail_step
).__init__(self)
:
backtrader
indicators:
ATR
and ADX
.Highest
and Lowest
indicators
to detect multi-period highs/lows for breakouts._calculate_vwap_indicator()
to set up the
VWAP.self.order
,
self.stop_order
, self.trail_order
) and
position state (self.entry_price
,
self.entry_atr
, self.highest_profit
,
self.trailing_active
)._calculate_vwap_indicator(self)
: A
helper method to calculate a simple moving average-based VWAP. (For a
more precise cumulative VWAP, a custom indicator or a different library
might be needed.)log(self, txt, dt=None)
: A utility for
printing strategy logs.notify_order(self, order)
: Handles
order lifecycle:
entry_price
and
entry_atr
, and resets highest_profit
and
trailing_active
.order
and
stop_order
/trail_order
references when they
are completed or fail.notify_trade(self, trade)
: Called when
a trade is closed. It logs the PnL, updates internal trade count and
win/loss metrics, and resets position-specific tracking variables.calculate_position_size(self, stop_distance)
:
A placeholder method. In this setup, the
bt.sizers.PercentSizer
attached to cerebro
will handle the actual position sizing, using 95% of available
cash.can_open_position(self)
: Returns
True
if no position is currently open and no order is
pending, ensuring only one trade at a time.is_breakout_long()
/
is_breakout_short()
: Checks if the current close
price breaks above the breakout_period
highest high or
below the lowest low, respectively.is_adx_strong()
: Returns
True
if the current ADX value exceeds the
adx_threshold
.is_price_away_from_vwap()
: Returns
True
if the absolute difference between the current close
and VWAP is greater than vwap_multiplier
times the ATR,
indicating strong directional movement away from the average price.is_atr_rising()
: Checks if ATR is
consistently increasing over the last two periods, signaling rising
volatility.update_trailing_stop()
: This is where
the core trailing stop logic resides:
entry_atr
.trail_activation
, the
trailing_active
flag is set.new_trail_stop_price
(current price minus/plus
atr_trail_multiplier
* current ATR).new_trail_stop_price
moves favorably (higher
for long, lower for short) compared to the existing
trail_order
’s price, the old trailing stop order is
canceled, and a new one is placed at the updated level.next(self)
: The main logic flow for
each bar:
self.update_trailing_stop()
first, ensuring position
management takes precedence.adx_strong
, price_away_vwap
, and
atr_rising
.if not self.position
):
is_breakout_long()
and all market filters
(adx_strong
, price_away_vwap
,
atr_rising
). If true, a buy()
order is placed,
and an initial stop loss
order is set.is_breakout_short()
and all
market filters. If true, a sell()
order (for shorting) is
placed, and an initial stop loss
order is set.stop(self)
: Logs the final portfolio
value at the end of the backtest.A rolling backtest is crucial for thoroughly evaluating a strategy. Instead of testing over one continuous historical period, it breaks down the entire historical range into smaller, sequential, non-overlapping windows. This provides a more realistic assessment of the strategy’s performance consistency and adaptability across varying market regimes and economic cycles.
import backtrader as bt
import yfinance as yf
import pandas as pd
import numpy as np
import dateutil.relativedelta as rd
import matplotlib.pyplot as plt
import seaborn as sns
# Assuming VolatilityBreakoutSystem class is defined above
# from VolatilityBreakoutSystem import VolatilityBreakoutSystem
= VolatilityBreakoutSystem # Set the strategy to be tested
strategy
def run_rolling_backtest(
="ETH-USD",
ticker="2018-01-01",
start="2025-06-24", # Current date as per your prompt
end=3,
window_months=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 # Adjust the end of the current window if it exceeds the overall end date
if current_end > end_dt:
= end_dt
current_end if current_start >= current_end: # No valid period left
break
print(f"\nROLLING BACKTEST: {current_start.date()} to {current_end.date()}")
# Data download using yfinance, respecting the user's preference for auto_adjust=False and droplevel
# Using the saved preference: yfinance download with auto_adjust=False and droplevel(axis=1, level=1)
= yf.download(ticker, start=current_start, end=current_end, auto_adjust=False, progress=False)
data
# Apply droplevel if data is a MultiIndex, as per user's preference
if isinstance(data.columns, pd.MultiIndex):
= data.droplevel(1, axis=1)
data
# Check for sufficient data after droplevel
# Calculate min data needed based on strategy parameters
# These are default values if not overridden in strategy_params
= strategy_params.get('atr_period', 14)
min_atr_period = strategy_params.get('adx_period', 14)
min_adx_period = strategy_params.get('vwap_period', 7)
min_vwap_period = strategy_params.get('breakout_period', 7)
min_breakout_period
# The strategy requires all indicators to have sufficient data for their calculations
# The longest period among them plus a buffer (e.g., 2-3 bars for safe comparisons like ATR rising)
= max(min_atr_period, min_adx_period, min_vwap_period, min_breakout_period) + 3
min_data_for_indicators
if data.empty or len(data) < min_data_for_indicators:
print(f"Not enough data for period {current_start.date()} to {current_end.date()} (requires {min_data_for_indicators} bars). Skipping.")
# Move to the next window. If moving to the next window makes us pass overall end, break.
if current_end == end_dt: # If the current window already reached overall end_dt
break
= current_end # Move to the end of the current (insufficient) period
current_start continue
= bt.feeds.PandasData(dataname=data)
feed = bt.Cerebro()
cerebro **strategy_params) # Pass strategy params to the strategy
cerebro.addstrategy(strategy,
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
cerebro.run()= cerebro.broker.getvalue()
final_val = (final_val - start_val) / start_val * 100
ret
all_results.append({'start': current_start.date(),
'end': current_end.date(),
'return_pct': ret,
'final_value': final_val,
})
print(f"Return: {ret:.2f}% | Final Value: {final_val:.2f}")
# Move to the next window. If current_end already reached overall end_dt, then break.
if current_end == end_dt:
break
= current_end # For non-overlapping windows, next start is current end
current_start
return pd.DataFrame(all_results)
Explanation of
run_rolling_backtest
:
ticker
,
start
, end
dates (with end
defaulted to the current date), window_months
for the
length of each sub-period, and strategy_params
to pass
custom parameters to the VolatilityBreakoutSystem
.while
loop
iterates, defining consecutive, non-overlapping monthly windows. The
current_end
is adjusted if it exceeds the overall
end
date, ensuring the last period doesn’t go past the
specified end.yf.download
. As per your saved
information, auto_adjust=False
is used, and
droplevel(1, 1)
is applied to the MultiIndex columns if
present, ensuring consistent data format.min_data_for_indicators
based on the longest
period required by the strategy’s indicators (atr_period
,
adx_period
, vwap_period
,
breakout_period
) plus a buffer. It then checks if the
downloaded data has enough bars for these indicators to warm up and
provide valid values, skipping windows with insufficient data.bt.Cerebro
instance is created for each window, ensuring a fresh start for each
backtest. The VolatilityBreakoutSystem
is added, along with
the downloaded data, initial cash, commission, and a sizer (95% capital
allocation).cerebro.run()
executes the strategy for the current window. The percentage return and
final portfolio value are calculated and appended to
all_results
.current_start
is
updated to the current_end
of the just-processed window to
move to the next non-overlapping period. The loop breaks if the overall
end
date is reached.The provided functions report_stats
and
plot_four_charts
are invaluable for summarizing and
visualizing the rolling backtest results, giving you a clear picture of
the strategy’s performance characteristics.
# ... (all code as above) ...
def report_stats(df):
= df['return_pct']
returns = {
stats 'Mean Return %': np.mean(returns),
'Median Return %': np.median(returns),
'Std Dev %': np.std(returns),
'Min Return %': np.min(returns),
'Max Return %': np.max(returns),
'Sharpe Ratio': np.mean(returns) / np.std(returns) if np.std(returns) > 0 else np.nan
}print("\n=== ROLLING BACKTEST STATISTICS ===")
for k, v in stats.items():
print(f"{k}: {v:.2f}")
return stats
def plot_return_distribution(df):
set(style="whitegrid")
sns.=(10, 5))
plt.figure(figsize'return_pct'], bins=20, kde=True, color='dodgerblue')
sns.histplot(df['return_pct'].mean(), color='black', linestyle='--', label='Mean')
plt.axvline(df['Rolling Backtest Return Distribution')
plt.title('Return %')
plt.xlabel('Frequency')
plt.ylabel(
plt.legend()
plt.tight_layout()
plt.show()
def plot_four_charts(df, rolling_sharpe_window=4):
"""
Generates four analytical plots for rolling backtest results.
"""
= plt.subplots(2, 2, figsize=(12, 8)) # Adjusted figsize for clarity
fig, ((ax1, ax2), (ax3, ax4))
# Calculate period numbers (0, 1, 2, 3, ...)
= list(range(len(df)))
periods = df['return_pct']
returns
# 1. Period Returns (Top Left)
= ['green' if r >= 0 else 'red' for r in returns]
colors =colors, alpha=0.7)
ax1.bar(periods, returns, color'Period Returns', fontsize=14, fontweight='bold')
ax1.set_title('Period')
ax1.set_xlabel('Return %')
ax1.set_ylabel(=0, color='black', linestyle='-', alpha=0.3)
ax1.axhline(yTrue, alpha=0.3)
ax1.grid(
# 2. Cumulative Returns (Top Right)
= (1 + returns / 100).cumprod() * 100 - 100
cumulative_returns ='o', linewidth=2, markersize=4, color='blue') # Smaller markers
ax2.plot(periods, cumulative_returns, marker'Cumulative Returns', fontsize=14, fontweight='bold')
ax2.set_title('Period')
ax2.set_xlabel('Cumulative Return %')
ax2.set_ylabel(True, alpha=0.3)
ax2.grid(
# 3. Rolling Sharpe Ratio (Bottom Left)
= returns.rolling(window=rolling_sharpe_window).apply(
rolling_sharpe lambda x: x.mean() / x.std() if x.std() > 0 else np.nan, raw=False # Added raw=False for lambda
)# Only plot where we have valid rolling calculations
= ~rolling_sharpe.isna()
valid_mask = [i for i, valid in enumerate(valid_mask) if valid]
valid_periods = rolling_sharpe[valid_mask]
valid_sharpe
='o', linewidth=2, markersize=4, color='orange') # Smaller markers
ax3.plot(valid_periods, valid_sharpe, marker=0, color='red', linestyle='--', alpha=0.5)
ax3.axhline(yf'Rolling Sharpe Ratio ({rolling_sharpe_window}-period)', fontsize=14, fontweight='bold')
ax3.set_title('Period')
ax3.set_xlabel('Sharpe Ratio')
ax3.set_ylabel(True, alpha=0.3)
ax3.grid(
# 4. Return Distribution (Bottom Right)
= min(15, max(5, len(returns)//2))
bins =bins, alpha=0.7, color='steelblue', edgecolor='black')
ax4.hist(returns, bins= returns.mean()
mean_return ='red', linestyle='--', linewidth=2,
ax4.axvline(mean_return, color=f'Mean: {mean_return:.2f}%')
label'Return Distribution', fontsize=14, fontweight='bold')
ax4.set_title('Return %')
ax4.set_xlabel('Frequency')
ax4.set_ylabel(
ax4.legend()True, alpha=0.3)
ax4.grid(
plt.tight_layout()
plt.show()
if __name__ == '__main__':
# Run the rolling backtest with default parameters (ETH-USD, 3-month windows)
= run_rolling_backtest(end="2025-06-24") # End date updated to current date
df
print("\n=== ROLLING BACKTEST RESULTS ===")
print(df) # Display the results DataFrame
= report_stats(df) # Print aggregated statistics
stats # Display the four analytical plots plot_four_charts(df)
The VolatilityBreakoutSystem
offers a robust and
adaptive approach to trend following. By integrating breakout
signals with trend strength (ADX),
volatility confirmation (VWAP, rising ATR), and
dynamic trailing stops, it aims to enter
high-conviction trades and manage risk effectively by preserving
profits. The rolling backtesting framework is an
indispensable tool for validating the strategy’s consistency across
various market cycles, providing a more reliable assessment than a
single, long backtest.
Evaluating the comprehensive statistics and visual plots from the rolling backtest will allow for a deeper understanding of the strategy’s strengths, weaknesses, and potential areas for fine-tuning parameters to optimize its performance in different assets and market conditions.