In the intricate world of financial markets, price movements are rarely uniform. Periods of strong, clear trends can give way to choppy, range-bound consolidation, or sudden bursts of extreme volatility. A rigid trading strategy, blind to these shifting market regimes, often struggles to maintain consistent performance. The challenge lies in building systems that not only identify a trend but also adapt to the prevailing market environment.
This article explores an algorithmic strategy that builds upon a foundational trend-following approach by integrating filters based on trend strength and volatility. It combines a simple moving average for trend identification with the Average True Range (ATR) for adaptive stop-losses and Average Directional Index (ADX) for trend strength confirmation. Furthermore, the exploration incorporates a walk-forward analysis framework, a critical tool for assessing a strategy’s robustness across changing market conditions.
The aim is to investigate whether layering these filters and employing dynamic risk management can enhance a basic trend-following system, potentially leading to more resilient performance by attempting to trade only when conditions are favorable and adapting risk to market volatility.
This strategy investigates a refined hypothesis for trend trading, emphasizing the importance of market context:
adx_threshold (e.g., 20-25), the strategy
attempts to trade only when the market is clearly trending,
thus avoiding choppy, sideways markets where basic SMA crossovers often
generate whipsaws and false signals. This is a crucial filter for a
trend-following system.atr_threshold (e.g., 1.5% of price), the strategy seeks
opportunities in markets that have enough “life.”The overarching strategy explores whether combining robust trend identification with adaptive risk management and intelligent market regime filtering can lead to a more resilient and potentially profitable trading system across diverse market environments.
backtrader StrategyThe following backtrader code provides a concrete
implementation for exploring this Enhanced ATR Strategy. Each snippet
will be presented and analyzed to understand how the theoretical ideas
are translated into executable code.
This section outlines the basic backtrader setup,
including data loading and the EnhancedATRStrategy class’s
initialization, where parameters and core indicators are defined.
import backtrader as bt
import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import warnings
from datetime import datetime, timedelta # Used in walk-forward analysis
warnings.filterwarnings("ignore") # Suppress warnings, often from pandas/yfinance
%matplotlib inline
plt.rcParams['figure.figsize'] = (10, 8) # Larger default figure size
class EnhancedATRStrategy(bt.Strategy):
params = (
('entry_sma_window', 90), # SMA period for trend identification
('atr_window', 14), # ATR period
('atr_multiplier', 1.0), # ATR multiplier for trailing stop distance
('atr_threshold', 0.015), # Minimum ATR as % of price for trading
('adx_period', 14), # ADX period for trend strength
('adx_threshold', 30), # Minimum ADX for trending market
('printlog', False), # Flag to enable/disable detailed trade logs
)
def __init__(self):
# Main indicators: SMA for trend, ATR for volatility/stops, ADX for trend strength
self.sma = bt.indicators.SMA(period=self.params.entry_sma_window)
self.atr = bt.indicators.ATR(period=self.params.atr_window)
self.adx = bt.indicators.DirectionalMovementIndex(period=self.params.adx_period) # ADX includes +DI, -DI
# Variables to store previous values of indicators for signal comparison
self.prev_close = None
self.prev_sma = None
self.prev_atr = None
# Position tracking variables
self.trailing_stop = 0 # The current calculated price level of the trailing stop
self.entry_price = 0 # Price at which the position was entered
self.entry_date = None # Date of entry (for logging, though not used in strategy logic)
self.stop_order = None # Reference to the active stop-loss order
# backtrader's main order tracking variable
self.order = None # Reference to the main buy/sell order
# Performance tracking (for internal logging, not final analysis)
self.trade_count = 0 # Count of trades for loggingAnalysis of this Snippet:
warnings for control over console
messages and datetime for date handling, are imported.
matplotlib.pyplot is configured for inline plotting with a
larger default figure size (10, 8), suitable for detailed
charts.EnhancedATRStrategy Class &
params: This defines the strategy with adjustable
parameters:
entry_sma_window: Period for the SMA used in entry
signals.atr_window, atr_multiplier: Periods and
multiplier for the ATR, dictating stop-loss distance.atr_threshold: Minimum ATR (as a percentage of price)
required for trading, filtering out low-volatility regimes.adx_period, adx_threshold: Period for ADX
and minimum ADX value to confirm a trending market.printlog: A boolean flag to enable/disable detailed
logging during the backtest.__init__(self) Method:
self.sma
(for trend direction), self.atr (for volatility and stop
placement), and self.adx (for trend strength) are
initialized as backtrader indicators. These indicators
automatically calculate their values for each incoming bar.self.prev_close,
self.prev_sma, and self.prev_atr are
initialized to store indicator values from the previous bar,
enabling direct comparison for signal generation (e.g., price crossing
SMA).self.trailing_stop,
self.entry_price, self.stop_order) and to
track any active main trade orders (self.order), preventing
duplicate commands. self.trade_count is introduced for
logging purposes.This section contains helper methods that check market regime filters
(check_trend_filters), manage order status updates
(notify_order), and handle the dynamic adjustment of the
trailing stop (update_trailing_stop,
check_stop_hit).
def log(self, txt, dt=None):
"""Logging function to print messages if printlog is enabled."""
if self.params.printlog:
dt = dt or self.datas[0].datetime.date(0)
print(f'{dt.isoformat()}: {txt}')
def check_trend_filters(self):
"""Checks if current market conditions (ADX, ATR) allow trading."""
# ADX filter: Market must be trending above a certain threshold
if self.adx.adx[0] < self.params.adx_threshold:
return False, "ADX too low (not trending)"
# ATR filter: Current volatility must be sufficient (as a percentage of price)
atr_pct = self.atr[0] / self.data.close[0]
if atr_pct < self.params.atr_threshold:
return False, f"ATR too low ({atr_pct:.3f} < {self.params.atr_threshold})"
return True, "Filters passed" # Both filters cleared
def notify_order(self, order):
"""Manages order status updates, logs trade executions, and sets initial trailing stop."""
# Ignore orders that are just submitted or accepted, wait for completion or failure
if order.status in [order.Submitted, order.Accepted]:
return
# If order has completed (filled)
if order.status in [order.Completed]:
if order.isbuy():
self.log(f'BUY EXECUTED: Price: {order.executed.price:.4f}')
# If a new long position is opened (size > 0), set the initial trailing stop
if self.position.size > 0:
self.entry_price = order.executed.price
self.entry_date = self.datas[0].datetime.date(0) # Record entry date
# Initial stop is entry price minus ATR_multiplier * previous ATR
self.trailing_stop = self.entry_price - (self.params.atr_multiplier * self.prev_atr)
self.trade_count += 1 # Increment trade count
self.log(f'LONG ENTRY #{self.trade_count}: Stop at {self.trailing_stop:.4f}')
elif order.issell(): # Could be for short entry or closing a long
self.log(f'SELL EXECUTED: Price: {order.executed.price:.4f}')
# If a new short position is opened (size < 0), set the initial trailing stop
if self.position.size < 0:
self.entry_price = order.executed.price
self.entry_date = self.datas[0].datetime.date(0)
# Initial stop is entry price plus ATR_multiplier * previous ATR
self.trailing_stop = self.entry_price + (self.params.atr_multiplier * self.prev_atr)
self.trade_count += 1
self.log(f'SHORT ENTRY #{self.trade_count}: Stop at {self.trailing_stop:.4f}')
elif self.position.size == 0: # If it was a closing order (long closed or short covered)
self.entry_date = None # Clear entry date
self.log(f'POSITION CLOSED')
# If order failed (canceled, margin call, rejected)
elif order.status in [order.Canceled, order.Margin, order.Rejected]:
self.log(f'Order Failed: {order.status}')
self.order = None # Clear the main order reference once it's processed
def update_trailing_stop(self):
"""Updates the trailing stop level based on current price and ATR."""
if not self.position: # Only update if a position is open
return
current_price = self.data.close[0]
current_atr = self.atr[0]
if self.position.size > 0: # Long position: trail stop up (never down)
new_stop = current_price - (self.params.atr_multiplier * current_atr)
if new_stop > self.trailing_stop: # Only update if the new stop is higher (in profit direction)
self.trailing_stop = new_stop
self.log(f'LONG TRAILING STOP UPDATED: {self.trailing_stop:.4f}')
elif self.position.size < 0: # Short position: trail stop down (never up)
new_stop = current_price + (self.params.atr_multiplier * current_atr)
if new_stop < self.trailing_stop: # Only update if the new stop is lower (in profit direction)
self.trailing_stop = new_stop
self.log(f'SHORT TRAILING STOP UPDATED: {self.trailing_stop:.4f}')
def check_stop_hit(self):
"""Checks if the trailing stop has been hit by the current bar's price action."""
if not self.position: # No position, no stop to check
return False
if self.position.size > 0: # Long position
# If current low breaks below trailing stop, stop is hit
if self.data.low[0] <= self.trailing_stop:
self.log(f'LONG STOP HIT: Low {self.data.low[0]:.4f} <= Stop {self.trailing_stop:.4f}')
return True
elif self.position.size < 0: # Short position
# If current high breaks above trailing stop, stop is hit
if self.data.high[0] >= self.trailing_stop:
self.log(f'SHORT STOP HIT: High {self.data.high[0]:.4f} >= Stop {self.trailing_stop:.4f}')
return True
return False # Stop not hitAnalysis of Market Filters and Order Management:
log(): A utility function for printing
messages, useful for debugging strategy behavior during a backtest when
printlog is enabled.check_trend_filters(): This function
implements the key market regime filters. It checks:
self.adx.adx[0] < self.params.adx_threshold ensures that
the market’s trend strength is sufficient. If ADX is below the
threshold, the market is considered non-trending, and trading is
disallowed.atr_pct < self.params.atr_threshold ensures that there
is sufficient volatility (as a percentage of price) for meaningful
movement. If volatility is too low, trading is disallowed. This function
returns a boolean indicating if filters pass, and a string explaining
why they failed.notify_order(): This crucial
backtrader callback logs trade executions and manages the
initial placement of the ATR-scaled trailing_stop. Upon a
successful buy or sell order
Completed, it records the entry_price and
entry_date, calculates the trailing_stop based
on the atr_multiplier and prev_atr, and
increments the trade_count. It also handles logging
position closures and order failures.update_trailing_stop(): This function
dynamically adjusts the trailing_stop level for any open
position. For a long position, it calculates a new_stop
based on the current_price and current_atr. If
this new_stop is higher than the current
trailing_stop (meaning profit has increased or risk has
decreased), the trailing_stop is updated. A similar,
inverse logic applies to short positions. This manual update ensures the
stop always moves in the direction of profit.check_stop_hit(): This function
determines if the calculated trailing_stop has been
triggered by the current bar’s price action (i.e., if the bar’s low has
fallen below the stop for a long position, or its high has risen above
the stop for a short position).This section contains the next method, the main
execution loop for each bar. It orchestrates the strategy’s flow,
combining trend signals, market regime filters, and adaptive exits.
def next(self):
# Store previous values of indicators. This is done early for comparison in current bar's logic.
if len(self.data) > 1: # Ensure a previous bar exists
self.prev_close = self.data.close[-1]
self.prev_sma = self.sma[-1]
self.prev_atr = self.atr[-1]
# If an order is pending completion, do nothing this bar to avoid conflicting commands.
if self.order:
return
# Skip if indicators haven't warmed up or have NaN values (common at the start of data)
if (self.prev_close is None or
np.isnan(self.prev_sma) or
np.isnan(self.prev_atr) or
np.isnan(self.adx.adx[0])): # Also check ADX for NaN
return
# First priority: Check if a trailing stop has been hit for an open position.
# This handles profit-taking or loss-cutting.
if self.check_stop_hit():
self.order = self.close() # Close the position immediately
return
# If stop wasn't hit, update its level for the current bar's new price
self.update_trailing_stop()
# Check market regime filters (ADX and ATR thresholds)
filters_ok, filter_info = self.check_trend_filters()
if not filters_ok:
# If filters fail and we have a position, it's a "filter exit"
if self.position:
self.log(f'FILTER EXIT: {filter_info}')
self.order = self.close() # Close the position due to unfavorable market conditions
return # Do not consider new entries if filters fail
# --- Entry/Reversal Logic (only executed if filters pass) ---
# This part determines whether to open a new position or reverse an existing one.
# Long signal: Previous close was above Previous SMA (indicating an uptrend)
if self.prev_close > self.prev_sma:
if self.position.size < 0: # If currently short, close short and prepare to reverse to long
self.log(f'REVERSAL: Short to Long (ADX: {self.adx.adx[0]:.1f})')
self.order = self.close() # Close current short position
# A new long order will be placed on the next bar once this closing order completes.
elif self.position.size == 0: # If currently flat, go long
self.log(f'LONG SIGNAL (ADX: {self.adx.adx[0]:.1f})')
self.order = self.buy() # Place a buy order
# Short signal: Previous close was below Previous SMA (indicating a downtrend)
elif self.prev_close < self.prev_sma:
if self.position.size > 0: # If currently long, close long and prepare to reverse to short
self.log(f'REVERSAL: Long to Short (ADX: {self.adx.adx[0]:.1f})')
self.order = self.close() # Close current long position
# A new short order will be placed on the next bar once this closing order completes.
elif self.position.size == 0: # If currently flat, go short
self.log(f'SHORT SIGNAL (ADX: {self.adx.adx[0]:.1f})')
self.order = self.sell() # Place a sell order
def stop(self):
"""Called at the end of the backtest. Logs final summary."""
self.log(f'Total Trades: {self.trade_count}')
self.log(f'Final Portfolio Value: {self.broker.getvalue():.2f}')
# Calculate basic total return (initial cash of 100000 assumed)
initial_cash = 100000
total_return = (self.broker.getvalue() - initial_cash) / initial_cash * 100
self.log(f'Total Return: {total_return:.2f}%')Analysis of the Bar-by-Bar Trading Logic:
next() first
updates self.prev_close, self.prev_sma, and
self.prev_atr with values from the previous bar.
This is essential for signal generation, as comparisons are often made
between the current bar and the previous bar’s context.if self.order: return) and that all necessary
indicators have “warmed up” (i.e., have valid numerical values, not
NaN) before proceeding with the logic.if self.check_stop_hit(): gives top priority to stop-loss
checks. If the trailing stop is triggered, the position is immediately
closed. This is a critical risk management step.self.update_trailing_stop() is called next to adjust the
trailing stop level for any open position based on the current bar’s
price.filters_ok, filter_info = self.check_trend_filters()
applies the ADX and ATR percentage filters.
if not filters_ok:
implements a “filter exit” logic. If the market conditions become
unfavorable (filters fail) while a position is open, the strategy closes
the position, aiming to avoid exposure during choppy or low-volatility
periods. If filters fail and no position is open, no new entries are
considered.check_trend_filters() function indicates favorable
market conditions.
prev_close > prev_sma (indicating an uptrend) and
filters pass: If currently short, the strategy closes the short position
(with the intent to go long on the next bar). If flat, it opens a long
position.prev_close < prev_sma (indicating a downtrend) and
filters pass: If currently long, the strategy closes the long position
(with the intent to go short on the next bar). If flat, it opens a short
position.self.order = self.close()) and then intending to open the
new, reversed position on the next bar, once the closing order
completes.stop() Method: This special
backtrader method is called automatically at the very end
of the backtest. It’s used to log final strategy performance summaries,
such as the total number of trades and final portfolio value.This section outlines the setup for running both a single, comprehensive backtest and a more rigorous walk-forward analysis, providing a deeper assessment of the strategy’s robustness.
# --- Parameters ---
ticker = "BTC-USD" # Asset for backtesting
start_date = "2020-01-01"
end_date = "2024-12-31"
print(f"=== ENHANCED ATR STRATEGY WITH FILTERS (SIMPLIFIED) ===")
print(f"Asset: {ticker}")
print(f"Period: {start_date} to {end_date}")
print("Enhancements:")
print("✓ ADX trend filter (>25)") # Note: This prints the description, actual value is in cerebro.addstrategy
print("✓ ATR volatility filter (>1.5%)") # Note: This prints the description, actual value is in cerebro.addstrategy
print("=" * 50)
# Download full dataset for main backtest
print("\nDownloading data for main backtest...")
data = yf.download(ticker, start=start_date, end=end_date, progress=False)
if hasattr(data.columns, 'levels'):
data.columns = data.columns.droplevel(1) # Handle multi-level columns from yfinance
print(f"Data downloaded. Shape: {data.shape}")
# --- Run main backtest ---
print("\n=== MAIN BACKTEST ===")
cerebro = bt.Cerebro() # Initialize cerebro engine
cerebro.addstrategy(EnhancedATRStrategy,
entry_sma_window=100, # Override default params for this run
atr_window=14,
atr_multiplier=3.0,
atr_threshold=0.015, # 1.5% minimum ATR
adx_threshold=25, # ADX > 25 required for trending
printlog=False)
cerebro.adddata(bt.feeds.PandasData(dataname=data)) # Add data feed
cerebro.broker.setcash(100000.0) # Set initial capital
cerebro.broker.setcommission(commission=0.001) # Set commission (0.1%)
cerebro.addsizer(bt.sizers.PercentSizer, percents=95) # Allocate 95% of capital per trade
# Add analyzers for comprehensive performance analysis
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trades')
print(f'Starting Portfolio Value: ${cerebro.broker.getvalue():,.2f}')
results = cerebro.run() # Execute the backtest
strat = results[0] # Get the strategy instance from the results
print(f'Final Portfolio Value: ${cerebro.broker.getvalue():,.2f}')
# Print comprehensive performance metrics
print('\n--- PERFORMANCE METRICS ---')
sharpe_analysis = strat.analyzers.sharpe.get_analysis()
sharpe_ratio = sharpe_analysis.get('sharperatio', None)
print(f'Sharpe Ratio: {sharpe_ratio:.3f}' if sharpe_ratio else 'Sharpe Ratio: N/A')
drawdown_analysis = strat.analyzers.drawdown.get_analysis()
max_drawdown = drawdown_analysis.get('max', {}).get('drawdown', 0)
print(f'Max Drawdown: {max_drawdown:.2f}%')
returns_analysis = strat.analyzers.returns.get_analysis()
total_return = returns_analysis.get('rtot', 0) * 100
print(f'Total Return: {total_return:.2f}%')
trade_analysis = strat.analyzers.trades.get_analysis()
total_trades = trade_analysis.get('total', {}).get('total', 0)
won_trades = trade_analysis.get('won', {}).get('total', 0)
win_rate = (won_trades / total_trades * 100) if total_trades > 0 else 0
print(f'Total Trades: {total_trades}')
print(f'Win Rate: {win_rate:.1f}%')
if total_trades > 0:
avg_win = trade_analysis.get('won', {}).get('pnl', {}).get('average', 0)
avg_loss = trade_analysis.get('lost', {}).get('pnl', {}).get('average', 0)
print(f'Average Win: ${avg_win:.2f}')
print(f'Average Loss: ${avg_loss:.2f}')
if avg_loss != 0:
profit_factor = abs(trade_analysis.get('won', {}).get('pnl', {}).get('total', 0) / trade_analysis.get('lost', {}).get('pnl', {}).get('total', 0))
print(f'Profit Factor: {profit_factor:.2f}')
print('\n--- COMPARISON WITH BUY & HOLD ---')
# Calculate buy & hold return for the full period
bh_return = ((data['Close'][-1] / data['Close'][0]) - 1) * 100
print(f'Buy & Hold Return: {bh_return:.2f}%')
print(f'Strategy vs B&H: {total_return - bh_return:.2f}% outperformance')
# Plot results of the main backtest
cerebro.plot(iplot=False, style='line', volume=False)
plt.suptitle(f'Enhanced ATR Strategy (Simplified) - {ticker}', fontsize=16)
plt.tight_layout()
plt.show()
# --- Walk-forward analysis execution ---
def run_walk_forward_analysis(ticker, start_date, end_date, window_months=12, step_months=6):
"""
Performs a simplified walk-forward analysis by repeatedly backtesting
on out-of-sample periods using a fixed set of parameters.
"""
print("=== WALK-FORWARD ANALYSIS ===\n")
start = pd.to_datetime(start_date)
end = pd.to_datetime(end_date)
results = []
current_start = start
while current_start < end:
# Define in-sample and out-of-sample periods
insample_end = current_start + pd.DateOffset(months=window_months)
outsample_start = insample_end # Out-sample starts immediately after in-sample
outsample_end = outsample_start + pd.DateOffset(months=step_months) # Out-sample duration
# Break if out-sample period extends beyond overall end date
if outsample_end > end:
break
print(f"In-sample Period: {current_start.date()} to {insample_end.date()}")
print(f"Out-sample Period: {outsample_start.date()} to {outsample_end.date()}")
# Download data for the current out-of-sample period
oos_data = yf.download(ticker, start=outsample_start, end=outsample_end, progress=False)
if hasattr(oos_data.columns, 'levels'):
oos_data.columns = oos_data.columns.droplevel(1)
# Check if enough data is available for indicator warm-up
min_required_days = 120 # Roughly SMA period + ADX period
if len(oos_data) < min_required_days:
print(f"Insufficient data for out-sample: {len(oos_data)} days < {min_required_days} required. Skipping.\n")
current_start = outsample_start # Move window forward even if skipped
continue
try:
# Run strategy on the out-of-sample period with a predefined parameter set
cerebro = bt.Cerebro()
cerebro.addstrategy(EnhancedATRStrategy,
entry_sma_window=50, # Parameters are fixed for this walk-forward
atr_window=14,
atr_multiplier=3.0,
atr_threshold=0.01, # Slightly lower ATR threshold for more trades
adx_threshold=20, # Slightly lower ADX threshold for more trades
printlog=False)
cerebro.adddata(bt.feeds.PandasData(dataname=oos_data))
cerebro.broker.setcash(100000.0)
cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
result = cerebro.run() # Run the backtest for this period
final_value = cerebro.broker.getvalue()
oos_return = (final_value - 100000) / 100000 * 100 # Calculate return for this period
# Calculate Buy & Hold return for comparison in this out-sample period
bh_return = ((oos_data['Close'][-1] / oos_data['Close'][0]) - 1) * 100
results.append({
'period': f"{outsample_start.date()} to {outsample_end.date()}",
'strategy_return': oos_return,
'buyhold_return': bh_return,
'outperformance': oos_return - bh_return
})
print(f"Strategy Return: {oos_return:.2f}%")
print(f"Buy & Hold: {bh_return:.2f}%")
print(f"Outperformance: {oos_return - bh_return:.2f}%\n")
except Exception as e:
print(f"Error in backtest for period {outsample_start.date()} to {outsample_end.date()}: {str(e)}")
print("Skipping this period...\n")
current_start = outsample_start # Move the window forward for the next iteration
# Summarize walk-forward results
if results:
avg_strategy = np.mean([r['strategy_return'] for r in results])
avg_bh = np.mean([r['buyhold_return'] for r in results])
avg_outperform = np.mean([r['outperformance'] for r in results])
win_rate = sum(1 for r in results if r['outperformance'] > 0) / len(results) * 100 # % periods outperforming B&H
print("=== WALK-FORWARD SUMMARY ===")
print(f"Average Strategy Return (per period): {avg_strategy:.2f}%")
print(f"Average Buy & Hold Return (per period): {avg_bh:.2f}%")
print(f"Average Outperformance (per period): {avg_outperform:.2f}%")
print(f"Outperformance Win Rate (vs B&H): {win_rate:.1f}%")
print(f"Number of periods evaluated: {len(results)}")
else:
print("No valid periods found for walk-forward analysis")
return results
# Main execution block
if __name__ == "__main__":
# Parameters for the main backtest and walk-forward analysis
ticker = "BTC-USD"
start_date = "2020-01-01"
end_date = "2024-12-31" # Up to current time
print(f"=== ENHANCED ATR STRATEGY WITH FILTERS (SIMPLIFIED) ===")
print(f"Asset: {ticker}")
print(f"Period: {start_date} to {end_date}")
print("Enhancements:")
print("✓ ADX trend filter (>25)") # Describes the intent, parameter set in cerebro.addstrategy
print("✓ ATR volatility filter (>1.5%)") # Describes the intent
print("=" * 50)
# Download full dataset for the main backtest run
print("\nDownloading data for main backtest...")
data = yf.download(ticker, start=start_date, end=end_date, progress=False)
if hasattr(data.columns, 'levels'):
data.columns = data.columns.droplevel(1) # Flatten columns if multi-level
print(f"Data downloaded. Shape: {data.shape}")
# --- Run the full period main backtest ---
print("\n=== MAIN BACKTEST ===")
cerebro = bt.Cerebro() # Initialize cerebro engine
cerebro.addstrategy(EnhancedATRStrategy,
entry_sma_window=100, # Parameters for this full backtest
atr_window=14,
atr_multiplier=3.0,
atr_threshold=0.015, # 1.5% minimum ATR
adx_threshold=25, # ADX > 25 required
printlog=False)
cerebro.adddata(bt.feeds.PandasData(dataname=data)) # Add data feed
cerebro.broker.setcash(100000.0) # Set initial capital
cerebro.broker.setcommission(commission=0.001) # Set commission (0.1%)
cerebro.addsizer(bt.sizers.PercentSizer, percents=95) # Allocate 95% of capital per trade
# Add analyzers for comprehensive performance analysis (for main backtest)
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trades')
print(f'Starting Portfolio Value: ${cerebro.broker.getvalue():,.2f}')
results = cerebro.run() # Execute the main backtest
strat = results[0] # Get the strategy instance from the results list
print(f'Final Portfolio Value: ${cerebro.broker.getvalue():,.2f}')
# Print comprehensive performance metrics for the main backtest
print('\n--- PERFORMANCE METRICS (MAIN BACKTEST) ---')
sharpe_analysis = strat.analyzers.sharpe.get_analysis()
sharpe_ratio = sharpe_analysis.get('sharperatio', None)
print(f'Sharpe Ratio: {sharpe_ratio:.3f}' if sharpe_ratio else 'Sharpe Ratio: N/A')
drawdown_analysis = strat.analyzers.drawdown.get_analysis()
max_drawdown = drawdown_analysis.get('max', {}).get('drawdown', 0)
print(f'Max Drawdown: {max_drawdown:.2f}%')
returns_analysis = strat.analyzers.returns.get_analysis()
total_return = returns_analysis.get('rtot', 0) * 100
print(f'Total Return: {total_return:.2f}%')
trade_analysis = strat.analyzers.trades.get_analysis()
total_trades = trade_analysis.get('total', {}).get('total', 0)
won_trades = trade_analysis.get('won', {}).get('total', 0)
win_rate = (won_trades / total_trades * 100) if total_trades > 0 else 0
print(f'Total Trades: {total_trades}')
print(f'Win Rate: {win_rate:.1f}%')
if total_trades > 0:
avg_win = trade_analysis.get('won', {}).get('pnl', {}).get('average', 0)
avg_loss = trade_analysis.get('lost', {}).get('pnl', {}).get('average', 0)
print(f'Average Win: ${avg_win:.2f}')
print(f'Average Loss: ${avg_loss:.2f}')
if avg_loss != 0:
profit_factor = abs(trade_analysis.get('won', {}).get('pnl', {}).get('total', 0) / trade_analysis.get('lost', {}).get('pnl', {}).get('total', 0))
print(f'Profit Factor: {profit_factor:.2f}')
# Buy & Hold comparison for the main backtest
bh_return = ((data['Close'][-1] / data['Close'][0]) - 1) * 100
print(f'Buy & Hold Return: {bh_return:.2f}%')
print(f'Strategy vs B&H: {total_return - bh_return:.2f}% outperformance')
# Plot results of the main backtest
cerebro.plot(iplot=False, style='line', volume=False)
plt.suptitle(f'Enhanced ATR Strategy (Simplified) - {ticker}', fontsize=16)
plt.tight_layout()
plt.show()
# --- Execute Walk-Forward Analysis ---
print("\n" + "="*50)
wf_results = run_walk_forward_analysis(ticker, "2021-01-01", "2024-12-31",
window_months=12, step_months=6)Analysis of the Backtest Execution and Walk-Forward Analysis:
ticker, start_date, and end_date
for the overall backtest and walk-forward analysis, focusing on
BTC-USD.cerebro instance is set up, and the
EnhancedATRStrategy is added. Notably, specific parameter
values (entry_sma_window=100,
atr_multiplier=3.0, adx_threshold=25,
atr_threshold=0.015) are provided directly to
addstrategy, overriding the default values in the
params tuple within the class definition. This allows for
specific testing.backtrader
analyzers is added to calculate detailed performance
metrics: Sharpe Ratio, Max Drawdown, Total/Annualized Returns, and Trade
Analysis (total trades, win rate, average win/loss, profit factor).cerebro.run() executes the full backtest.run_walk_forward_analysis() Function:
This is a key feature of the script.
start to end) into overlapping (or
sequential) “out-of-sample” periods. For each out-of-sample period, it
runs a backtest with a predefined set of parameters (e.g.,
entry_sma_window=50, adx_threshold=20), and
records the strategy’s performance against a buy-and-hold benchmark for
that specific period.cerebro.run() for each segment, collects returns, and
compares them to buy-and-hold returns for that same segment.if __name__ == "__main__":) This ensures the code
runs when the script is executed directly. It orchestrates the entire
process: describing the strategy and its parameters, executing the full
main backtest, and then calling run_walk_forward_analysis
to perform the walk-forward validation.This backtrader strategy represents a robust and
well-thought-out exploration into resilient trend-following,
particularly distinguished by its sophisticated filtering and the
inclusion of a walk-forward analysis framework.
run_walk_forward_analysis is a standout feature. This
systematic approach to testing across unseen market segments provides a
much more realistic assessment of the strategy’s robustness and
generalizability than a single historical backtest. It is crucial for
building confidence in a strategy’s long-term viability.entry_sma_window, atr_window,
atr_multiplier, adx_threshold, and
atr_threshold remain critical. Their optimal tuning is
highly asset- and timeframe-dependent. The walk-forward framework is
ideal for researching these optimal parameters, though the current
implementation uses fixed parameters for the out-of-sample periods
rather than dynamically optimizing them.This strategy serves as an excellent, well-engineered foundation for further research into resilient algorithmic trading. It embodies a sophisticated approach to market regime awareness and adaptive risk management, making it a compelling candidate for exploring robust performance across varying market conditions.