← Back to Home
When Volume & Volatility Align Regression Channel Breakout with Python

When Volume & Volatility Align Regression Channel Breakout with Python

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 Strategy Concept: Adding Statistical Rigor to Breakouts

The core idea is to combine a dynamic trend channel with objective, statistical filters applied to the breakout event itself:

  1. Linear Regression Channels: Instead of manually drawn lines, the strategy employs channels based on linear regression calculated over a rolling window (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.
  2. Breakout Signal: A potential entry signal occurs when the closing price moves decisively outside the calculated upper or lower channel band.
  3. Statistical Validation: This is the crucial filter. A breakout signal is only considered valid if the bar on which the breakout occurs shows both:
    • Significantly Higher Range: The bar’s range (High - Low) is substantially larger than the recent average range. “Substantially” is defined statistically (e.g., > 1.5 standard deviations above the mean range over valid_period).
    • Significantly Higher Volume: The bar’s volume is substantially higher than the recent average volume, again measured using a standard deviation threshold (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.

Implementation in Backtrader

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__()

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

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

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) ...

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()) ...

Complete Code and Results

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.")
Pasted image 20250505155219.png
--- 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

Potential Use Cases

Suggestions for Improvement

Conclusion

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.