Breakout trading is a popular technique aiming to capitalize on strong price movements when an asset breaks out of a consolidation pattern or trend channel. However, many apparent breakouts quickly reverse, resulting in losses (known as “false breakouts” or “fakeouts”). To combat this, traders often seek confirmation signals.
This article explores a strategy implemented in Python using the Backtrader framework that attempts to filter potential breakouts. It uses Linear Regression Channels to define the expected price range and then validates breakout signals by checking if the breakout bar exhibits statistically significant increases in both its range (volatility) and trading volume, suggesting stronger conviction behind the move.
The core idea is to combine a dynamic trend channel with objective, statistical filters applied to the breakout event itself:
channel_period
). The
midline represents the linear regression trend, and upper/lower bands
are placed a certain number of standard deviations
(channel_mult
) away. This provides an objective, adaptive
measure of the current trend and expected volatility range. Note:
The specific implementation here uses TALib’s linear regression
functions and the standard deviation of price for the bands, which
approximates a classic regression channel.valid_period
).valid_mult
over
valid_period
).The hypothesis is that breakouts accompanied by such statistical anomalies in range and volume are less likely to be random noise and have a higher probability of follow-through.
Let’s examine the key components of the Python code provided.
1. The LinearRegressionChannel
Indicator
This custom indicator calculates the channel lines. It’s defined
using Backtrader’s __init__
style, relying on built-in
TALib and Backtrader indicators.
Python
import backtrader as bt
import backtrader.indicators as btind
import backtrader.talib as btalib # Import talib functions
class LinearRegressionChannel(bt.Indicator):
"""
Approximates a Linear Regression Channel using built-in TALib/BT indicators
defined in __init__, matching the user-requested style.
Note: Calculation differs from a manual regression calculation.
Midline uses TALib's LINEARREG endpoint.
Bands use standard deviation of price, not residuals from regression line.
"""
lines = ('midline', 'upper', 'lower', 'slope',)
params = (
('period', 20), # Lookback period for calculations
('stdev_mult', 2.0), # Multiplier for standard deviation bands
)
plotinfo = dict(subplot=False) # Plot on the main price chart
plotlines = dict(
midline=dict(_name='LR Mid'), # Labels for plot legend
upper=dict(_name='LR Upper'),
lower=dict(_name='LR Lower'),
slope=dict(alpha=0.0), # Hide slope line on plot by default
)
def __init__(self):
# Calculate the endpoint of the Linear Regression line using TALib
# LINEARREG forecasts the *next* value based on the trend over the period.
self.lines.midline = btalib.LINEARREG(self.data.close, timeperiod=self.p.period)
# Calculate the slope of the Linear Regression line using TALib
self.lines.slope = btalib.LINEARREG_SLOPE(self.data.close, timeperiod=self.p.period)
# Calculate the standard deviation of the closing price over the period
stdev = btind.StandardDeviation(self.data.close, period=self.p.period)
# Calculate the upper and lower channel lines based on midline and stdev
self.lines.upper = self.lines.midline + self.p.stdev_mult * stdev
self.lines.lower = self.lines.midline - self.p.stdev_mult * stdev
# Call the parent __init__ after defining lines
super(LinearRegressionChannel, self).__init__()
lines
: Defines the outputs:
midline
, upper
, lower
,
slope
.params
: Defines configurable
parameters (period
, stdev_mult
).plotinfo
/plotlines
:
Controls how the indicator appears on the chart.__init__
:
btalib.LINEARREG
: Calculates the linear regression
forecast endpoint, used as the midline
.btalib.LINEARREG_SLOPE
: Calculates the regression
slope
.btind.StandardDeviation
: Calculates the standard
deviation of the closing price.upper
and lower
lines are derived by
adding/subtracting the (multiplied) standard deviation from the
midline.2. The StatValidatedRegChannelBreakout
Strategy:
Initialization (__init__
)
This method sets up the strategy’s indicators and variables.
Python
class StatValidatedRegChannelBreakout(bt.Strategy):
# Parameters defining channel lookback, validation lookback, multipliers, etc.
params = (
('channel_period', 50), # Lookback for regression channel
('channel_mult', 2.0), # Std Dev multiplier for channel width
('valid_period', 20), # Lookback for range/volume validation stats
('valid_mult', 1.5), # Std Dev multiplier for range/volume validation
('stop_loss_perc', 0.03), # Stop loss percentage (e.g., 0.03 = 3%)
('printlog', True),
)
# ... (log function) ...
def __init__(self):
# Data references
self.dataclose = self.datas[0].close
self.datahigh = self.datas[0].high
self.datalow = self.datas[0].low
self.datavolume = self.datas[0].volume
# --- Indicators ---
# 1. Instantiate the Linear Regression Channel indicator
self.regchannel = LinearRegressionChannel(
period=self.p.channel_period,
stdev_mult=self.p.channel_mult
)
# 2. Calculate the simple range of each bar
self.bar_range = self.datahigh - self.datalow
# 3. Calculate moving average and standard deviation for range and volume
self.avg_range = btind.SimpleMovingAverage(self.bar_range, period=self.p.valid_period)
self.std_range = btind.StdDev(self.bar_range, period=self.p.valid_period)
self.avg_volume = btind.SimpleMovingAverage(self.datavolume, period=self.p.valid_period)
self.std_volume = btind.StdDev(self.datavolume, period=self.p.valid_period)
# --- Order Tracking ---
self.order = None # Tracks the main entry/exit order
self.stop_order = None # Tracks the stop loss order
channel_period
,
valid_period
, multipliers, and
stop_loss_perc
.LinearRegressionChannel
.bar_range
as High - Low.SimpleMovingAverage
, StdDev
) to calculate the
necessary statistics for the validation filter based on
bar_range
and datavolume
.order
and stop_order
to
None
.3. Core Trading Logic (next
)
This method is executed for each bar of data and contains the primary decision-making logic.
Python
def next(self):
# 1. Check if an order is pending or indicators are not ready
if self.order or not math.isfinite(self.regchannel.lines.upper[0]) or \
not math.isfinite(self.avg_range[0]) or not math.isfinite(self.std_range[0]) or \
not math.isfinite(self.avg_volume[0]) or not math.isfinite(self.std_volume[0]):
return
# 2. Get current bar's range and volume
current_range = self.bar_range[0]
current_volume = self.datavolume[0]
# 3. Define validation thresholds using averages and standard deviations
range_threshold = self.avg_range[0] + self.p.valid_mult * self.std_range[0]
volume_threshold = self.avg_volume[0] + self.p.valid_mult * self.std_volume[0]
# 4. Perform the statistical validation check
min_std_dev_threshold = 1e-9 # Safety check for near-zero std dev
if self.std_range[0] < min_std_dev_threshold or self.std_volume[0] < min_std_dev_threshold:
is_validated = False # Cannot validate if std dev is too low
# ... (optional warning log) ...
else:
# Validation passes if BOTH range and volume exceed their thresholds
is_validated = (current_range > range_threshold and current_volume > volume_threshold)
# --- Entry Logic ---
if not self.position: # Only enter if not already in the market
# 5a. Check for Long Breakout (Close > Upper Channel)
if self.dataclose[0] > self.regchannel.lines.upper[0]:
self.log(f'Potential LONG Breakout: Close={self.dataclose[0]:.2f} > Upper={self.regchannel.lines.upper[0]:.2f}')
# 6a. If breakout AND validated, place BUY order
if is_validated:
self.log(f'--> VALIDATED: Range={current_range:.2f} > Thr={range_threshold:.2f}, Vol={current_volume:.0f} > Thr={volume_threshold:.0f}')
self.log(f'>>> Placing BUY Order')
if self.stop_order: self.cancel(self.stop_order) # Safety cancel
self.order = self.buy() # Place market buy order
else:
self.log(f'--> NOT Validated: Range={current_range:.2f} <= Thr={range_threshold:.2f} or Vol={current_volume:.0f} <= Thr={volume_threshold:.0f}')
# 5b. Check for Short Breakout (Close < Lower Channel)
elif self.dataclose[0] < self.regchannel.lines.lower[0]:
self.log(f'Potential SHORT Breakout: Close={self.dataclose[0]:.2f} < Lower={self.regchannel.lines.lower[0]:.2f}')
# 6b. If breakout AND validated, place SELL order
if is_validated:
self.log(f'--> VALIDATED: Range={current_range:.2f} > Thr={range_threshold:.2f}, Vol={current_volume:.0f} > Thr={volume_threshold:.0f}')
self.log(f'>>> Placing SELL Order')
if self.stop_order: self.cancel(self.stop_order) # Safety cancel
self.order = self.sell() # Place market sell order
else:
self.log(f'--> NOT Validated: Range={current_range:.2f} <= Thr={range_threshold:.2f} or Vol={current_volume:.0f} <= Thr={volume_threshold:.0f}')
# --- Exit Logic ---
else: # Already in the market
# 7. Example Exit: Close if price crosses back over midline (optional)
if self.position.size > 0 and self.dataclose[0] < self.regchannel.lines.midline[0]:
self.log(f'Midline CLOSE LONG SIGNAL...')
if self.stop_order: self.cancel(self.stop_order) # Cancel stop before closing
self.order = self.close()
elif self.position.size < 0 and self.dataclose[0] > self.regchannel.lines.midline[0]:
self.log(f'Midline CLOSE SHORT SIGNAL...')
if self.stop_order: self.cancel(self.stop_order) # Cancel stop before closing
self.order = self.close()
# Note: The primary exit mechanism is the stop-loss order placed via notify_order
is_validated
check.is_validated
condition is
True
.4. Risk Management (notify_order
)
This method handles order lifecycle events and is used here primarily to place the stop-loss order automatically after an entry is confirmed.
Python
def notify_order(self, order):
# ... (logging for Submitted/Accepted/Rejected/etc.) ...
if order.status == order.Completed:
# Check if this completed order was our main entry/exit order
if self.order and order.ref == self.order.ref:
# Check if it was an entry that resulted in a position
if self.position and order.executed.size != 0:
price = order.executed.price # Execution price
stop_price = 0.0
# Place stop loss based on entry type and stop_loss_perc parameter
if order.isbuy():
stop_price = price * (1.0 - self.p.stop_loss_perc)
self.log(f'BUY EXECUTED @ {price:.2f}. Placing STOP SELL @ {stop_price:.2f}')
self.stop_order = self.sell(exectype=bt.Order.Stop, price=stop_price)
self.stop_order.addinfo(name="StopLossSell")
elif order.issell():
stop_price = price * (1.0 + self.p.stop_loss_perc)
self.log(f'SELL EXECUTED @ {price:.2f}. Placing STOP BUY @ {stop_price:.2f}')
self.stop_order = self.buy(exectype=bt.Order.Stop, price=stop_price)
self.stop_order.addinfo(name="StopLossBuy")
# ... (handle closing order completion - cancel stop) ...
# ... (handle stop loss order completion) ...
# ... (handle other statuses like Canceled/Margin/Rejected/Expired) ...
self.order
) completes successfully
and creates a position:
stop_price
based on the execution
price and the stop_loss_perc
strategy parameter.Stop
order (self.sell
or
self.buy
with exectype=bt.Order.Stop
) at the
calculated price. This order is stored in
self.stop_order
.stop_order
if the
position is closed manually (e.g., by the midline cross exit).5. Backtesting Setup
(if __name__ == '__main__':
)
This standard Backtrader structure sets up and runs the backtest.
Python
if __name__ == '__main__':
cerebro = bt.Cerebro() # Create main backtesting controller
# Add the strategy with its parameters
cerebro.addstrategy(StatValidatedRegChannelBreakout,
channel_period=50, channel_mult=2.0,
valid_period=20, valid_mult=1.5,
stop_loss_perc=0.05, printlog=True)
# --- Data Loading ---
ticker = 'BTC-USD' # Example asset
# ... (fetch data using yfinance) ...
data_feed = bt.feeds.PandasData(dataname=data_df)
cerebro.adddata(data_feed) # Add data to Cerebro
# --- Backtest Configuration ---
cerebro.broker.setcash(10000.0) # Starting capital
cerebro.broker.setcommission(commission=0.001) # Commission per trade
cerebro.addsizer(bt.sizers.PercentSizer, percents=95) # Position sizing (e.g., 95% of portfolio)
# --- Analyzers for performance metrics ---
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe_ratio', ...)
cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trade_analyzer')
# --- Run Backtest ---
print('Starting Portfolio Value: %.2f' % cerebro.broker.getvalue())
results = cerebro.run() # Execute the backtest
print('Final Portfolio Value: %.2f' % cerebro.broker.getvalue())
# --- Print Analyzer Results ---
# ... (extract and print Sharpe, Returns, Drawdown, Trade stats) ...
# --- Plotting ---
# ... (configure matplotlib and call cerebro.plot()) ...
Cerebro
.cerebro.addstrategy
.yfinance
in this
example) and adds it.cerebro.run()
.matplotlib
to plot the price chart with trades and
the LinearRegressionChannel
indicator.import backtrader as bt
import backtrader.indicators as btind # Alias for clarity
import numpy as np # For linear regression calculation
import yfinance as yf
import datetime
import math
import backtrader as bt
import backtrader.indicators as btind
import backtrader.talib as btalib # Import talib functions
class LinearRegressionChannel(bt.Indicator):
"""
Approximates a Linear Regression Channel using built-in TALib/BT indicators
defined in __init__, matching the user-requested style.
Note: Calculation differs from a manual regression calculation.
Midline uses TALib's LINEARREG endpoint.
Bands use standard deviation of price, not residuals from regression line.
"""
lines = ('midline', 'upper', 'lower', 'slope',)
params = (
('period', 20),
('stdev_mult', 2.0),
)
plotinfo = dict(subplot=False) # Plot on the main price chart
plotlines = dict(
midline=dict(_name='LR Mid'), # Label for plot legend
upper=dict(_name='LR Upper'),
lower=dict(_name='LR Lower'),
slope=dict(alpha=0.0), # Often don't plot the slope directly with channels
)
def __init__(self):
# Calculate the endpoint of the Linear Regression line using TALib
# Note: LINEARREG forecasts the *next* value based on the trend over the period.
self.lines.midline = btalib.LINEARREG(self.data.close, timeperiod=self.p.period)
# Calculate the slope of the Linear Regression line using TALib
self.lines.slope = btalib.LINEARREG_SLOPE(self.data.close, timeperiod=self.p.period)
# Calculate the standard deviation of the closing price over the period
stdev = btind.StandardDeviation(self.data.close, period=self.p.period)
# Calculate the upper and lower channel lines
self.lines.upper = self.lines.midline + self.p.stdev_mult * stdev
self.lines.lower = self.lines.midline - self.p.stdev_mult * stdev
# Ensure super().__init__() is called AFTER lines are defined if needed
super(LinearRegressionChannel, self).__init__()
class StatValidatedRegChannelBreakout(bt.Strategy):
"""
Trades breakouts from a Linear Regression Channel, validated by
statistically significant bar range and volume increases.
"""
params = (
('channel_period', 50), # Lookback for regression channel
('channel_mult', 2.0), # Std Dev multiplier for channel width
('valid_period', 20), # Lookback for range/volume validation stats
('valid_mult', 1.5), # Std Dev multiplier for range/volume validation
('stop_loss_perc', 0.03), # Stop loss percentage (e.g., 0.03 = 3%)
('printlog', True),
)
def log(self, txt, dt=None, doprint=False):
''' Logging function for this strategy'''
if self.params.printlog or doprint:
dt = dt or self.datas[0].datetime.date(0)
print(f'{dt.isoformat()} - {txt}')
def __init__(self):
self.dataclose = self.datas[0].close
self.datahigh = self.datas[0].high
self.datalow = self.datas[0].low
self.datavolume = self.datas[0].volume
# --- Indicators ---
# 1. Linear Regression Channel
self.regchannel = LinearRegressionChannel(
period=self.p.channel_period,
stdev_mult=self.p.channel_mult
)
# Access lines using: self.regchannel.lines.midline, .upper, .lower, .slope
# 2. Bar Range (High - Low)
# Note: ATR is common, but the request specified 'bar's range'
self.bar_range = self.datahigh - self.datalow
# 3. Statistics for Validation (Range & Volume)
self.avg_range = btind.SimpleMovingAverage(self.bar_range, period=self.p.valid_period)
self.std_range = btind.StdDev(self.bar_range, period=self.p.valid_period)
self.avg_volume = btind.SimpleMovingAverage(self.datavolume, period=self.p.valid_period)
self.std_volume = btind.StdDev(self.datavolume, period=self.p.valid_period)
# --- Order Tracking ---
self.order = None # Tracks the main entry order
self.stop_order = None # Tracks the stop loss order
def notify_order(self, order):
otype = order.ordtypename()
ostatus = order.getstatusname()
if order.status in [order.Submitted, order.Accepted]:
# --- THIS IS THE CORRECTED SECTION ---
# Handle cases where order.price might be None (e.g., Market orders before execution)
price_str = f"{order.price:.2f}" if order.price is not None and order.price != 0.0 else "Market/None"
self.log(f'ORDER {otype} {ostatus}: Ref:{order.ref}, Size:{order.size}, Price:{price_str}')
# --- END CORRECTION ---
return
# --- Order Completion/Rejection ---
if order.status in [order.Completed, order.Canceled, order.Margin, order.Rejected]:
price = order.executed.price if order.status == order.Completed else None
size = order.executed.size if order.status == order.Completed else order.size # Use executed size if available
comm = order.executed.comm if order.status == order.Completed else 0.0
pnl = order.executed.pnl if order.status == order.Completed else 0.0
exec_price_str = f"{price:.2f}" if price is not None else "N/A"
comm_str = f"{comm:.2f}"
pnl_str = f"{pnl:.2f}"
self.log(f'ORDER COMPLETE/CANCEL/REJECT: Ref:{order.ref}, Type:{otype}, Status:{ostatus}, '
f'Size:{size}, ExecPrice:{exec_price_str}, Comm:{comm_str}, Pnl:{pnl_str}')
# --- Order Tracking Logic ---
is_entry_exit_order = self.order and order.ref == self.order.ref
is_stop_order = self.stop_order and order.ref == self.stop_order.ref
if is_entry_exit_order: # This was our main entry or explicit close order
if order.status == order.Completed:
# If entry was completed, place stop loss
# Check if it was an entry (created position) vs a close (flattened position)
if self.position and size != 0: # Check size to be sure it's not a zero-size completion artifact
stop_price = 0.0
if order.isbuy():
stop_price = price * (1.0 - self.p.stop_loss_perc)
self.log(f'BUY EXECUTED @ {price:.2f}. Placing STOP SELL @ {stop_price:.2f}')
self.stop_order = self.sell(exectype=bt.Order.Stop, price=stop_price)
self.stop_order.addinfo(name="StopLossSell")
elif order.issell():
stop_price = price * (1.0 + self.p.stop_loss_perc)
self.log(f'SELL EXECUTED @ {price:.2f}. Placing STOP BUY @ {stop_price:.2f}')
self.stop_order = self.buy(exectype=bt.Order.Stop, price=stop_price)
self.stop_order.addinfo(name="StopLossBuy")
# If position is now flat AND a stop_order exists, it means the close() order executed. Cancel the stop.
# Check order.ref == self.order.ref ensures this completion event belongs to the close() order we issued
elif not self.position and self.stop_order:
self.log(f'MANUAL CLOSE EXECUTED (Ref: {order.ref}). Cancelling pending STOP ORDER Ref: {self.stop_order.ref}')
self.cancel(self.stop_order)
self.stop_order = None
elif order.status in [order.Canceled, order.Margin, order.Rejected]:
# If entry/close failed, cancel any related stop if somehow placed (shouldn't happen for close)
if self.stop_order:
self.log(f"Entry/Close order {ostatus} (Ref: {order.ref}). Cancelling related Stop Order Ref: {self.stop_order.ref}")
self.cancel(self.stop_order)
self.stop_order = None
self.order = None # Reset main order tracker
elif is_stop_order: # This was our stop loss order
if order.status == order.Completed:
self.log(f'STOP LOSS EXECUTED @ {price:.2f} (Ref: {order.ref})')
else: # Canceled, Rejected, Margin
self.log(f'Stop loss order {ostatus}. Ref: {order.ref}')
self.stop_order = None # Reset stop order tracker
elif order.status == order.Expired:
self.log(f'ORDER EXPIRED: Ref: {order.ref}, Type: {otype}')
if self.order and order.ref == self.order.ref:
self.order = None
elif self.stop_order and order.ref == self.stop_order.ref:
self.stop_order = None
def notify_trade(self, trade):
if not trade.isclosed:
return
self.log(f'OPERATION PROFIT, GROSS {trade.pnl:.2f}, NET {trade.pnlcomm:.2f}')
def next(self):
# Check if an order is pending or if we cannot calculate indicators yet
if self.order or not math.isfinite(self.regchannel.lines.upper[0]) or \
not math.isfinite(self.avg_range[0]) or not math.isfinite(self.std_range[0]) or \
not math.isfinite(self.avg_volume[0]) or not math.isfinite(self.std_volume[0]):
return
current_range = self.bar_range[0]
current_volume = self.datavolume[0]
# Define validation thresholds
range_threshold = self.avg_range[0] + self.p.valid_mult * self.std_range[0]
volume_threshold = self.avg_volume[0] + self.p.valid_mult * self.std_volume[0]
# Check if range or volume standard deviation is near zero to avoid issues
# (Can happen in very flat markets or with short validation periods)
min_std_dev_threshold = 1e-9
if self.std_range[0] < min_std_dev_threshold or self.std_volume[0] < min_std_dev_threshold:
is_validated = False # Cannot validate if std dev is essentially zero
self.log(f"WARN: Range or Volume StdDev too low ({self.std_range[0]:.2f}, {self.std_volume[0]:.2f}). Skipping validation.", dt=self.datas[0].datetime.date(0))
else:
is_validated = (current_range > range_threshold and current_volume > volume_threshold)
# --- Entry Logic ---
if not self.position:
# Check for Long Breakout
if self.dataclose[0] > self.regchannel.lines.upper[0]:
self.log(f'Potential LONG Breakout: Close={self.dataclose[0]:.2f} > Upper={self.regchannel.lines.upper[0]:.2f}')
if is_validated:
self.log(f'--> VALIDATED: Range={current_range:.2f} > Thr={range_threshold:.2f}, Vol={current_volume:.0f} > Thr={volume_threshold:.0f}')
self.log(f'>>> Placing BUY Order')
# Cancel any existing stop order (shouldn't be one, but safety check)
if self.stop_order: self.cancel(self.stop_order)
self.order = self.buy()
else:
self.log(f'--> NOT Validated: Range={current_range:.2f} <= Thr={range_threshold:.2f} or Vol={current_volume:.0f} <= Thr={volume_threshold:.0f}')
# Check for Short Breakout
elif self.dataclose[0] < self.regchannel.lines.lower[0]:
self.log(f'Potential SHORT Breakout: Close={self.dataclose[0]:.2f} < Lower={self.regchannel.lines.lower[0]:.2f}')
if is_validated:
self.log(f'--> VALIDATED: Range={current_range:.2f} > Thr={range_threshold:.2f}, Vol={current_volume:.0f} > Thr={volume_threshold:.0f}')
self.log(f'>>> Placing SELL Order')
# Cancel any existing stop order (shouldn't be one, but safety check)
if self.stop_order: self.cancel(self.stop_order)
self.order = self.sell()
else:
self.log(f'--> NOT Validated: Range={current_range:.2f} <= Thr={range_threshold:.2f} or Vol={current_volume:.0f} <= Thr={volume_threshold:.0f}')
# --- Exit Logic (using stop loss managed in notify_order) ---
# No explicit exit based on crossing back into channel in this version, relies on stop loss.
# Could add exit if close crosses back over self.regchannel.lines.midline[0] for example.
else:
# Example: Exit if price crosses back over the midline against the trade
if self.position.size > 0 and self.dataclose[0] < self.regchannel.lines.midline[0]:
self.log(f'Midline CLOSE LONG SIGNAL: Close {self.dataclose[0]:.2f} < Midline {self.regchannel.lines.midline[0]:.2f}')
if self.stop_order: # Cancel existing stop loss first
self.log(f'Cancelling Stop Order Ref: {self.stop_order.ref} before closing.')
self.cancel(self.stop_order)
self.stop_order = None
self.order = self.close() # Place the close order
elif self.position.size < 0 and self.dataclose[0] > self.regchannel.lines.midline[0]:
self.log(f'Midline CLOSE SHORT SIGNAL: Close {self.dataclose[0]:.2f} > Midline {self.regchannel.lines.midline[0]:.2f}')
if self.stop_order: # Cancel existing stop loss first
self.log(f'Cancelling Stop Order Ref: {self.stop_order.ref} before closing.')
self.cancel(self.stop_order)
self.stop_order = None
self.order = self.close() # Place the close order
pass # Relying on stop loss managed in notify_order
def stop(self):
self.log(f'(Channel Period {self.p.channel_period}, Channel Mult {self.p.channel_mult}, '
f'Valid Period {self.p.valid_period}, Valid Mult {self.p.valid_mult}, '
f'Stop Loss {self.p.stop_loss_perc*100:.1f}%) Ending Value {self.broker.getvalue():.2f}', doprint=True)
# --- Main Execution ---
if __name__ == '__main__':
cerebro = bt.Cerebro()
# Add strategy
cerebro.addstrategy(StatValidatedRegChannelBreakout,
channel_period=50,
channel_mult=2.0,
valid_period=20,
valid_mult=1.5,
stop_loss_perc=0.05, # 5% stop loss
printlog=True) # Set to True for detailed logs
# --- Data Loading ---
ticker = 'BTC-USD'
start_date = '2021-01-01'
end_date = '2023-12-31'
print(f"Fetching data for {ticker} from {start_date} to {end_date}")
try:
data_df = yf.download(ticker, start=start_date, end=end_date, progress=False)
if data_df.empty:
print(f"No data fetched for {ticker}. Check symbol/dates.")
exit()
# Ensure standard column names for Backtrader
data_df.columns = data_df.columns.droplevel(1)
data_feed = bt.feeds.PandasData(dataname=data_df)
cerebro.adddata(data_feed)
except Exception as e:
print(f"Error fetching or processing data: {e}")
exit()
# --- Backtest Configuration ---
cerebro.broker.setcash(10000.0)
cerebro.broker.setcommission(commission=0.001) # 0.1% commission
cerebro.addsizer(bt.sizers.PercentSizer, percents=95) # Example sizing
# --- Analyzers ---
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe_ratio', timeframe=bt.TimeFrame.Days, riskfreerate=0.0) # Set appropriate risk-free rate if needed
cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trade_analyzer')
# --- Run Backtest ---
print('Starting Portfolio Value: %.2f' % cerebro.broker.getvalue())
results = cerebro.run()
print('Final Portfolio Value: %.2f' % cerebro.broker.getvalue())
# --- Print Analyzer Results ---
strat = results[0]
print('\n--- Analyzer Results ---')
sharpe_analysis = strat.analyzers.sharpe_ratio.get_analysis()
print(f"Sharpe Ratio: {sharpe_analysis.get('sharperatio', 'N/A')}")
returns_dict = strat.analyzers.returns.get_analysis()
print(f"Total Return: {returns_dict.get('rtot', 'N/A')*100:.2f}%")
print(f"Average Annual Return: {returns_dict.get('ravg', 'N/A'):.4f}") # Note: This is simple average, not CAGR usually
print(f"Max Drawdown: {strat.analyzers.drawdown.get_analysis().max.drawdown:.2f}%")
trade_analysis = strat.analyzers.trade_analyzer.get_analysis()
if trade_analysis and hasattr(trade_analysis, 'total') and trade_analysis.total.total > 0:
print("\n--- Trade Analysis ---")
print(f"Total Trades: {trade_analysis.total.total}")
print(f"Winning Trades: {trade_analysis.won.total}")
print(f"Losing Trades: {trade_analysis.lost.total}")
win_rate = (trade_analysis.won.total / trade_analysis.total.total * 100) if trade_analysis.total.total > 0 else 0
print(f"Win Rate: {win_rate:.2f}%")
avg_win = trade_analysis.won.pnl.average if trade_analysis.won.total > 0 else 0
avg_loss = trade_analysis.lost.pnl.average if trade_analysis.lost.total > 0 else 0
print(f"Average Win ($): {avg_win:.2f}")
print(f"Average Loss ($): {avg_loss:.2f}")
pf = abs(trade_analysis.won.pnl.total / trade_analysis.lost.pnl.total) if trade_analysis.lost.pnl.total != 0 else float('inf')
print(f"Profit Factor: {pf:.2f}")
else:
print("\n--- Trade Analysis ---")
print("No trades executed.")
# --- Plotting ---
try:
# Make plots larger
import matplotlib.pyplot as plt
plt.rcParams['figure.figsize'] = [10, 6] # Adjust width, height as needed
plt.rcParams['font.size'] = 10
cerebro.plot(style='candlestick', barup='green', bardown='red', volume=True, iplot=False) # Set iplot=False for standard matplotlib window
plt.tight_layout() # May or may not improve layout
plt.show() # Explicitly show plot if not using interactive backend like %matplotlib inline
except Exception as e:
print(f"\nCould not plot results: {e}")
print("Plotting requires matplotlib installed and a suitable display environment.")
--- Analyzer Results ---
Sharpe Ratio: 0.048944898643241946
Total Return: 52.89%
Average Annual Return: 0.0005
Max Drawdown: 11.40%
--- Trade Analysis ---
Total Trades: 10
Winning Trades: 7
Losing Trades: 3
Win Rate: 70.00%
Average Win ($): 1295.67
Average Loss ($): -699.57
Profit Factor: 4.32
channel_period
, channel_mult
,
valid_period
, valid_mult
, and
stop_loss_perc
. These should be optimized for the specific
asset and timeframe using Backtrader’s optimization tools or other
methods.PercentSizer
at 95% is very aggressive; test lower
percentages (e.g., 2-20%) for more realistic risk control.LinearRegressionChannel
implementation (with
next()
) to see if the different calculation method yields
better results. Use ATR instead of bar_range
for validation
stats.This Backtrader strategy implements a statistically validated regression channel breakout system. By requiring breakouts to occur with significantly increased range and volume, it attempts to filter out market noise and improve the quality of entry signals. While the concept is sound, successful application requires careful parameter tuning, robust risk management, and thorough backtesting to understand its performance characteristics for a given market and timeframe. The provided code serves as a solid foundation for further exploration and refinement.