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
"ignore") # Suppress warnings, often from pandas/yfinance
warnings.filterwarnings(
%matplotlib inline
'figure.figsize'] = (10, 8) # Larger default figure size
plt.rcParams[
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 logging
Analysis 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 or self.datas[0].datetime.date(0)
dt 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)
= self.atr[0] / self.data.close[0]
atr_pct 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
= self.data.close[0]
current_price = self.atr[0]
current_atr
if self.position.size > 0: # Long position: trail stop up (never down)
= current_price - (self.params.atr_multiplier * current_atr)
new_stop 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)
= current_price + (self.params.atr_multiplier * current_atr)
new_stop 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 hit
Analysis 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
self.prev_sma) or
np.isnan(self.prev_atr) or
np.isnan(self.adx.adx[0])): # Also check ADX for NaN
np.isnan(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)
= self.check_trend_filters()
filters_ok, filter_info
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)
= 100000
initial_cash = (self.broker.getvalue() - initial_cash) / initial_cash * 100
total_return 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 ---
= "BTC-USD" # Asset for backtesting
ticker = "2020-01-01"
start_date = "2024-12-31"
end_date
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...")
= yf.download(ticker, start=start_date, end=end_date, progress=False)
data if hasattr(data.columns, 'levels'):
= data.columns.droplevel(1) # Handle multi-level columns from yfinance
data.columns
print(f"Data downloaded. Shape: {data.shape}")
# --- Run main backtest ---
print("\n=== MAIN BACKTEST ===")
= bt.Cerebro() # Initialize cerebro engine
cerebro
cerebro.addstrategy(EnhancedATRStrategy,=100, # Override default params for this run
entry_sma_window=14,
atr_window=3.0,
atr_multiplier=0.015, # 1.5% minimum ATR
atr_threshold=25, # ADX > 25 required for trending
adx_threshold=False)
printlog
=data)) # Add data feed
cerebro.adddata(bt.feeds.PandasData(dataname100000.0) # Set initial capital
cerebro.broker.setcash(=0.001) # Set commission (0.1%)
cerebro.broker.setcommission(commission=95) # Allocate 95% of capital per trade
cerebro.addsizer(bt.sizers.PercentSizer, percents
# Add analyzers for comprehensive performance analysis
='sharpe')
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='drawdown')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='returns')
cerebro.addanalyzer(bt.analyzers.Returns, _name='trades')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name
print(f'Starting Portfolio Value: ${cerebro.broker.getvalue():,.2f}')
= cerebro.run() # Execute the backtest
results = results[0] # Get the strategy instance from the results
strat
print(f'Final Portfolio Value: ${cerebro.broker.getvalue():,.2f}')
# Print comprehensive performance metrics
print('\n--- PERFORMANCE METRICS ---')
= strat.analyzers.sharpe.get_analysis()
sharpe_analysis = sharpe_analysis.get('sharperatio', None)
sharpe_ratio print(f'Sharpe Ratio: {sharpe_ratio:.3f}' if sharpe_ratio else 'Sharpe Ratio: N/A')
= strat.analyzers.drawdown.get_analysis()
drawdown_analysis = drawdown_analysis.get('max', {}).get('drawdown', 0)
max_drawdown print(f'Max Drawdown: {max_drawdown:.2f}%')
= strat.analyzers.returns.get_analysis()
returns_analysis = returns_analysis.get('rtot', 0) * 100
total_return print(f'Total Return: {total_return:.2f}%')
= strat.analyzers.trades.get_analysis()
trade_analysis = trade_analysis.get('total', {}).get('total', 0)
total_trades = trade_analysis.get('won', {}).get('total', 0)
won_trades = (won_trades / total_trades * 100) if total_trades > 0 else 0
win_rate print(f'Total Trades: {total_trades}')
print(f'Win Rate: {win_rate:.1f}%')
if total_trades > 0:
= trade_analysis.get('won', {}).get('pnl', {}).get('average', 0)
avg_win = trade_analysis.get('lost', {}).get('pnl', {}).get('average', 0)
avg_loss print(f'Average Win: ${avg_win:.2f}')
print(f'Average Loss: ${avg_loss:.2f}')
if avg_loss != 0:
= abs(trade_analysis.get('won', {}).get('pnl', {}).get('total', 0) / trade_analysis.get('lost', {}).get('pnl', {}).get('total', 0))
profit_factor print(f'Profit Factor: {profit_factor:.2f}')
print('\n--- COMPARISON WITH BUY & HOLD ---')
# Calculate buy & hold return for the full period
= ((data['Close'][-1] / data['Close'][0]) - 1) * 100
bh_return 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
=False, style='line', volume=False)
cerebro.plot(iplotf'Enhanced ATR Strategy (Simplified) - {ticker}', fontsize=16)
plt.suptitle(
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")
= pd.to_datetime(start_date)
start = pd.to_datetime(end_date)
end
= []
results = start
current_start
while current_start < end:
# Define in-sample and out-of-sample periods
= current_start + pd.DateOffset(months=window_months)
insample_end = insample_end # Out-sample starts immediately after in-sample
outsample_start = outsample_start + pd.DateOffset(months=step_months) # Out-sample duration
outsample_end
# 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
= yf.download(ticker, start=outsample_start, end=outsample_end, progress=False)
oos_data if hasattr(oos_data.columns, 'levels'):
= oos_data.columns.droplevel(1)
oos_data.columns
# Check if enough data is available for indicator warm-up
= 120 # Roughly SMA period + ADX period
min_required_days if len(oos_data) < min_required_days:
print(f"Insufficient data for out-sample: {len(oos_data)} days < {min_required_days} required. Skipping.\n")
= outsample_start # Move window forward even if skipped
current_start continue
try:
# Run strategy on the out-of-sample period with a predefined parameter set
= bt.Cerebro()
cerebro
cerebro.addstrategy(EnhancedATRStrategy, =50, # Parameters are fixed for this walk-forward
entry_sma_window=14,
atr_window=3.0,
atr_multiplier=0.01, # Slightly lower ATR threshold for more trades
atr_threshold=20, # Slightly lower ADX threshold for more trades
adx_threshold=False)
printlog=oos_data))
cerebro.adddata(bt.feeds.PandasData(dataname100000.0)
cerebro.broker.setcash(=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents
= cerebro.run() # Run the backtest for this period
result = cerebro.broker.getvalue()
final_value = (final_value - 100000) / 100000 * 100 # Calculate return for this period
oos_return
# Calculate Buy & Hold return for comparison in this out-sample period
= ((oos_data['Close'][-1] / oos_data['Close'][0]) - 1) * 100
bh_return
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")
= outsample_start # Move the window forward for the next iteration
current_start
# Summarize walk-forward results
if results:
= np.mean([r['strategy_return'] for r in results])
avg_strategy = np.mean([r['buyhold_return'] for r in results])
avg_bh = np.mean([r['outperformance'] for r in results])
avg_outperform = sum(1 for r in results if r['outperformance'] > 0) / len(results) * 100 # % periods outperforming B&H
win_rate
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
= "BTC-USD"
ticker = "2020-01-01"
start_date = "2024-12-31" # Up to current time
end_date
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...")
= yf.download(ticker, start=start_date, end=end_date, progress=False)
data if hasattr(data.columns, 'levels'):
= data.columns.droplevel(1) # Flatten columns if multi-level
data.columns
print(f"Data downloaded. Shape: {data.shape}")
# --- Run the full period main backtest ---
print("\n=== MAIN BACKTEST ===")
= bt.Cerebro() # Initialize cerebro engine
cerebro
cerebro.addstrategy(EnhancedATRStrategy,=100, # Parameters for this full backtest
entry_sma_window=14,
atr_window=3.0,
atr_multiplier=0.015, # 1.5% minimum ATR
atr_threshold=25, # ADX > 25 required
adx_threshold=False)
printlog
=data)) # Add data feed
cerebro.adddata(bt.feeds.PandasData(dataname100000.0) # Set initial capital
cerebro.broker.setcash(=0.001) # Set commission (0.1%)
cerebro.broker.setcommission(commission=95) # Allocate 95% of capital per trade
cerebro.addsizer(bt.sizers.PercentSizer, percents
# Add analyzers for comprehensive performance analysis (for main backtest)
='sharpe')
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='drawdown')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='returns')
cerebro.addanalyzer(bt.analyzers.Returns, _name='trades')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name
print(f'Starting Portfolio Value: ${cerebro.broker.getvalue():,.2f}')
= cerebro.run() # Execute the main backtest
results = results[0] # Get the strategy instance from the results list
strat
print(f'Final Portfolio Value: ${cerebro.broker.getvalue():,.2f}')
# Print comprehensive performance metrics for the main backtest
print('\n--- PERFORMANCE METRICS (MAIN BACKTEST) ---')
= strat.analyzers.sharpe.get_analysis()
sharpe_analysis = sharpe_analysis.get('sharperatio', None)
sharpe_ratio print(f'Sharpe Ratio: {sharpe_ratio:.3f}' if sharpe_ratio else 'Sharpe Ratio: N/A')
= strat.analyzers.drawdown.get_analysis()
drawdown_analysis = drawdown_analysis.get('max', {}).get('drawdown', 0)
max_drawdown print(f'Max Drawdown: {max_drawdown:.2f}%')
= strat.analyzers.returns.get_analysis()
returns_analysis = returns_analysis.get('rtot', 0) * 100
total_return print(f'Total Return: {total_return:.2f}%')
= strat.analyzers.trades.get_analysis()
trade_analysis = trade_analysis.get('total', {}).get('total', 0)
total_trades = trade_analysis.get('won', {}).get('total', 0)
won_trades = (won_trades / total_trades * 100) if total_trades > 0 else 0
win_rate print(f'Total Trades: {total_trades}')
print(f'Win Rate: {win_rate:.1f}%')
if total_trades > 0:
= trade_analysis.get('won', {}).get('pnl', {}).get('average', 0)
avg_win = trade_analysis.get('lost', {}).get('pnl', {}).get('average', 0)
avg_loss print(f'Average Win: ${avg_win:.2f}')
print(f'Average Loss: ${avg_loss:.2f}')
if avg_loss != 0:
= abs(trade_analysis.get('won', {}).get('pnl', {}).get('total', 0) / trade_analysis.get('lost', {}).get('pnl', {}).get('total', 0))
profit_factor print(f'Profit Factor: {profit_factor:.2f}')
# Buy & Hold comparison for the main backtest
= ((data['Close'][-1] / data['Close'][0]) - 1) * 100
bh_return 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
=False, style='line', volume=False)
cerebro.plot(iplotf'Enhanced ATR Strategy (Simplified) - {ticker}', fontsize=16)
plt.suptitle(
plt.tight_layout()
plt.show()
# --- Execute Walk-Forward Analysis ---
print("\n" + "="*50)
= run_walk_forward_analysis(ticker, "2021-01-01", "2024-12-31",
wf_results =12, step_months=6) window_months
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.