← Back to Home
Trading Bitcoin with Adaptive Volatility

Trading Bitcoin with Adaptive Volatility

Trading volatile assets like Bitcoin presents unique challenges. Prices can exhibit strong trends but also experience rapid changes in volatility and sharp reversals. A rigid trading strategy might struggle in such dynamic conditions. This article explores a Python strategy using the Backtrader framework designed to adapt – employing Kaufman’s Adaptive Moving Average (KAMA) combined with volatility bands and a trailing stop-loss.

The Strategy Concept: Adapting to the Flow

The core idea is to build a system that adjusts its parameters based on market conditions:

  1. Adaptive Trend Following: Instead of a simple moving average (SMA) or exponential moving average (EMA) with fixed lookback periods, we use Kaufman’s Adaptive Moving Average (KAMA). KAMA automatically adjusts its smoothing based on market volatility (price chop). It becomes more sensitive during strong trends and less sensitive during choppy periods, potentially reducing whipsaws.
  2. Dynamic Volatility Bands: Standard deviation bands are created, but instead of being based on the price’s standard deviation (like Bollinger Bands), they are based on the standard deviation of the KAMA line itself. The idea is that when KAMA is trending smoothly (low internal volatility), the bands will be tighter, and when KAMA is choppy (high internal volatility), the bands will widen.
  3. Flexible Entry: The strategy incorporates two distinct entry modes:
    • 'pullback': Enter when the price crosses the KAMA line, aiming to catch moves as price returns to the dynamic average.
    • 'breakout': Enter when the price breaks outside the calculated volatility bands, aiming to capture strong momentum moves.
  4. Dual Exit Logic: Positions are exited based on whichever condition occurs first:
    • The price crossing back over the KAMA line against the direction of the trade.
    • A percentage-based trailing stop-loss being hit, designed to lock in profits or cut losses.

Implementation in Backtrader

Let’s look at the key code sections within the AdaptiveMAVolatilityStrategy class.

1. Initialization and Indicator Setup (__init__)

Python

    def __init__(self):
        self.dataclose = self.datas[0].close # Reference to close price

        # KAMA: Adaptive Moving Average (uses internal fast/slow periods)
        self.kama = bt.indicators.KAMA(period=self.p.kama_period)

        # Standard Deviation of the KAMA line itself
        self.kama_stddev = bt.indicators.StandardDeviation(self.kama, period=self.p.vol_period)

        # Calculate adaptive volatility bands
        self.upper_band = self.kama + self.p.vol_mult * self.kama_stddev
        self.lower_band = self.kama - self.p.vol_mult * self.kama_stddev

        # Indicator to detect price crossing KAMA
        self.kama_cross = bt.indicators.CrossOver(self.dataclose, self.kama)

        # Order tracking variables
        self.order = None         # Tracks main entry/exit orders
        self.order_trail = None   # Tracks the trailing stop order
        # ... other initializations ...

Here, we set up the core indicators: KAMA, its standard deviation, the upper/lower bands based on that deviation, and a CrossOver indicator for easy signal detection against KAMA. We also initialize variables to keep track of pending orders.

2. Core Trading Logic (next)

The next method runs on each bar and contains the primary decision-making logic.

Python

    def next(self):
        # Check if any order is pending; if so, do nothing
        if self.order or self.order_trail:
            return

        # --- Entry Logic ---
        if not self.position: # Only enter if not already in the market
            entry_signal = False
            # ** Pullback Mode Entry **
            if self.p.trade_mode == 'pullback':
                if self.kama_cross[0] > 0: # Buy on cross above KAMA
                    self.log(f'PULLBACK BUY SIGNAL...')
                    self.order = self.buy()
                    entry_signal = True
                elif self.kama_cross[0] < 0: # Sell on cross below KAMA
                    self.log(f'PULLBACK SELL SIGNAL...')
                    self.order = self.sell()
                    entry_signal = True

            # ** Breakout Mode Entry **
            elif self.p.trade_mode == 'breakout':
                # Buy on close above Upper Band (after being below/on it)
                if self.dataclose[0] > self.upper_band[0] and self.dataclose[-1] <= self.upper_band[-1]:
                     self.log(f'BREAKOUT BUY SIGNAL...')
                     self.order = self.buy()
                     entry_signal = True
                # Sell on close below Lower Band (after being above/on it)
                elif self.dataclose[0] < self.lower_band[0] and self.dataclose[-1] >= self.lower_band[-1]:
                     self.log(f'BREAKOUT SELL SIGNAL...')
                     self.order = self.sell()
                     entry_signal = True

            if entry_signal: return # Stop processing if entry order placed

        # --- KAMA-Based Exit Logic ---
        else: # Already in the market
            # Close Long if price crosses below KAMA
            if self.position.size > 0 and self.kama_cross[0] < 0:
                 self.log(f'KAMA CLOSE LONG SIGNAL...')
                 self.order = self.close()
            # Close Short if price crosses above KAMA
            elif self.position.size < 0 and self.kama_cross[0] > 0:
                 self.log(f'KAMA CLOSE SHORT SIGNAL...')
                 self.order = self.close()

This section first checks if we can trade (no pending orders, not already in a position for entries). Based on the chosen trade_mode, it looks for either KAMA crosses or band breakouts. If an entry occurs, it exits the next method for that bar. If already in a position, it checks for the KAMA cross exit condition.

3. Risk Management: The Trailing Stop (notify_order)

A crucial part is managing the trailing stop. This logic resides primarily within the notify_order method, which Backtrader calls whenever an order’s status changes.

Python

# Inside notify_order, when an ENTRY order completes successfully:
        # ... (logic to detect entry completion) ...
        if order.isbuy(): # If entry was a BUY
             self.log(f'BUY EXECUTED ...')
             if self.p.trail_perc: # If trailing stop is enabled
                  self.log(f'PLACING TRAILING SELL ORDER...')
                  # Place the sell trailing stop order
                  self.order_trail = self.sell(exectype=bt.Order.StopTrail,
                                               trailpercent=self.p.trail_perc)
        elif order.issell(): # If entry was a SELL
             self.log(f'SELL EXECUTED ...')
             if self.p.trail_perc: # If trailing stop is enabled
                 self.log(f'PLACING TRAILING BUY ORDER...')
                 # Place the buy trailing stop order
                 self.order_trail = self.buy(exectype=bt.Order.StopTrail,
                                              trailpercent=self.p.trail_perc)

# Also in notify_order, if the KAMA-based EXIT order completes:
        # ... (logic to detect KAMA exit completion) ...
        if not self.position and self.order_trail is not None: # If position closed by KAMA exit
             self.log(f'KAMA EXIT EXECUTED. CANCELLING PENDING TRAIL ORDER...')
             self.cancel(self.order_trail) # Cancel the now redundant trail order
             self.order_trail = None

This logic ensures that after an entry order is successfully filled, the corresponding trailing stop order (bt.Order.StopTrail) is placed. It also handles canceling this trailing stop if the position is closed by the KAMA cross signal first.

Backtesting Setup

The if __name__ == '__main__': block sets up and runs the backtest. The provided code uses specific settings for a test run:

Python

if __name__ == '__main__':
    cerebro = bt.Cerebro()

    # Strategy Configuration for this run
    selected_trade_mode = 'breakout'
    trailing_stop_percentage = 0.10 # 10% trailing stop

    cerebro.addstrategy(AdaptiveMAVolatilityStrategy,
                        trade_mode=selected_trade_mode,
                        kama_period=30,   # Slower KAMA
                        vol_period=7,     # Faster Volatility calc
                        vol_mult=3.0,     # Wider Bands
                        trail_perc=trailing_stop_percentage)

    # Data Fetching (BTC-USD for 2021-2023)
    ticker = 'BTC-USD'
    start_date = '2021-01-01'
    end_date = '2023-12-31'
    # ... (yf.download and data feed creation) ...
    cerebro.adddata(data_feed)

    # Realistic Broker Simulation Settings
    cerebro.broker.setcash(100000.0)     # Starting capital
    cerebro.broker.setcommission(commission=0.001) # 0.1% commission
    cerebro.addsizer(bt.sizers.PercentSizer, percents=95) # Position sizing (Note: 95% is very aggressive)

    # Analyzers for performance metrics
    cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe_ratio')
    # ... (add Returns, DrawDown, TradeAnalyzer) ...

    # Run, Print Results, Plot
    print('Starting Portfolio Value: %.2f' % cerebro.broker.getvalue())
    results = cerebro.run()
    print('Final Portfolio Value: %.2f' % cerebro.broker.getvalue())
    # ... (print analyzer results) ...
    # ... (plotting code) ...

This setup tests the strategy in 'breakout' mode with specific parameters (KAMA 30, Vol Period 7, Vol Mult 3.0, Trail 10%) on Bitcoin data from 2021 to 2023, using realistic starting capital, commission, and percentage-based position sizing (although 95% is very high risk).

Complete Code

import backtrader as bt
import yfinance as yf
import datetime
import matplotlib.pyplot as plt
%matplotlib qt5

class AdaptiveMAVolatilityStrategy(bt.Strategy):
    """
    Strategy using KAMA with volatility bands and a trailing stop-loss.
    Trades pullbacks or breakouts, exits on KAMA cross OR trailing stop.
    """
    params = (
        ('kama_period', 20),
        ('fast_ema', 2),      # Only relevant if using custom KAMA
        ('slow_ema', 30),     # Only relevant if using custom KAMA
        ('vol_period', 20),
        ('vol_mult', 2.0),
        ('trade_mode', 'pullback'), # 'pullback' or 'breakout'
        ('trail_perc', 0.05), # Trailing stop percentage (e.g., 0.05 = 5%)
        ('printlog', True),
    )

    def log(self, txt, dt=None, doprint=False):
        ''' Logging function '''
        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.kama = bt.indicators.KAMA(period=self.p.kama_period) # Uses internal fast/slow
        self.kama_stddev = bt.indicators.StandardDeviation(self.kama, period=self.p.vol_period)
        self.upper_band = self.kama + self.p.vol_mult * self.kama_stddev
        self.lower_band = self.kama - self.p.vol_mult * self.kama_stddev
        self.kama_cross = bt.indicators.CrossOver(self.dataclose, self.kama)

        # Order tracking
        self.order = None         # For entry or KAMA-based exit orders
        self.order_trail = None   # For the trailing stop order
        self.buyprice = None
        self.buycomm = None

        if self.p.trade_mode not in ['pullback', 'breakout']:
            raise ValueError("trade_mode parameter must be 'pullback' or 'breakout'")
        if self.p.trail_perc is not None and self.p.trail_perc <= 0:
            raise ValueError("trail_perc must be positive or None")

        self.log(f"Strategy Initialized: KAMA(Period={self.p.kama_period}), "
                 f"VolBands(StdDev({self.p.vol_period}), Mult={self.p.vol_mult}), "
                 f"TradeMode={self.p.trade_mode}, TrailPerc={self.p.trail_perc}", doprint=True)
        self.log(f"Note: Standard bt.indicators.KAMA uses internal fast/slow periods (typically 2 and 30).", doprint=True)


    def notify_order(self, order):
        # --- Order Status ---
        if order.status == order.Submitted:
            self.log(f'ORDER SUBMITTED: Ref:{order.ref}, Type:{order.ordtypename()}, Size:{order.size}, Price:{order.price}')
            return
        if order.status == order.Accepted:
             self.log(f'ORDER ACCEPTED: Ref:{order.ref}, Type:{order.ordtypename()}')
             return

        # --- Order Completion/Rejection ---
        if order.status in [order.Completed, order.Canceled, order.Margin, order.Rejected]:
            otype = order.ordtypename()
            # Get execution details safely, providing defaults for non-completed orders
            price = order.executed.price if order.status == order.Completed else None
            # Use executed size if available, otherwise submitted size (submitted size might be more informative for rejected orders)
            size = order.executed.size if order.status == order.Completed and order.executed.size is not None else order.size
            comm = order.executed.comm if order.status == order.Completed else 0.0
            pnl = order.executed.pnl if order.status == order.Completed and order.executed.pnl else 0.0 # Defaults to 0.0

            # Prepare formatted strings to handle None values gracefully for logging
            exec_price_str = f"{price:.2f}" if price is not None else "N/A"
            # Handle order.price which might be 0 or None for Market orders/some brokers
            req_price_val = order.price if order.price is not None and order.price != 0.0 else None
            req_price_str = f"{req_price_val:.2f}" if req_price_val is not None else "Market/None"
            comm_str = f"{comm:.2f}" # Safe as it defaults to 0.0
            pnl_str = f"{pnl:.2f}"   # Safe as it defaults to 0.0

            # --- CORRECTED LOG LINE ---
            # Uses the pre-formatted safe strings (exec_price_str, req_price_str, etc.)
            self.log(f'ORDER COMPLETE/CANCEL/REJECT: Ref:{order.ref}, Type:{otype}, Status:{order.getstatusname()}, '
                     f'Size:{size}, ExecPrice:{exec_price_str} (Req:{req_price_str}), Comm:{comm_str}, Pnl:{pnl_str}')

            # --- Order Tracking Logic --- (Rest of the function is the same)
            is_entry = order.ref == getattr(self.order, 'ref', None)
            is_trail = order.ref == getattr(self.order_trail, 'ref', None)

            if is_entry:
                if order.status == order.Completed:
                    # --- ENTRY COMPLETED ---
                    if order.isbuy():
                        self.log(f'BUY EXECUTED @ {price:.2f}, Size: {size}') # Use 'price' here as it's confirmed not None
                        self.buyprice = price
                        self.buycomm = comm
                        if self.p.trail_perc:
                            stop_price = price * (1.0 - self.p.trail_perc)
                            self.log(f'PLACING TRAILING SELL ORDER: Trail %: {self.p.trail_perc * 100:.2f}, Initial Stop: {stop_price:.2f}')
                            self.order_trail = self.sell(exectype=bt.Order.StopTrail, trailpercent=self.p.trail_perc)
                            self.order_trail.addinfo(name="TrailStopSell")
                    elif order.issell():
                        self.log(f'SELL EXECUTED @ {price:.2f}, Size: {size}') # Use 'price' here as it's confirmed not None
                        self.buyprice = price
                        self.buycomm = comm
                        if self.p.trail_perc:
                             stop_price = price * (1.0 + self.p.trail_perc)
                             self.log(f'PLACING TRAILING BUY ORDER: Trail %: {self.p.trail_perc * 100:.2f}, Initial Stop: {stop_price:.2f}')
                             self.order_trail = self.buy(exectype=bt.Order.StopTrail, trailpercent=self.p.trail_perc)
                             self.order_trail.addinfo(name="TrailStopBuy")

                # --- KAMA-EXIT COMPLETED ---
                if not self.position and self.order_trail is not None:
                     # Check status just to be sure the order causing flatness was completed, not rejected.
                     if order.status == order.Completed:
                          self.log(f'KAMA EXIT EXECUTED. CANCELLING PENDING TRAIL ORDER Ref: {self.order_trail.ref}')
                          self.cancel(self.order_trail)
                          self.order_trail = None

                # Reset main order tracker only if the main order event finished (completed, canceled, rejected)
                if order.status in [order.Completed, order.Canceled, order.Margin, order.Rejected]:
                    self.order = None

            elif is_trail:
                 # --- TRAILING STOP EVENT (EXECUTED, CANCELED, REJECTED) ---
                 if order.status == order.Completed:
                      # Use 'price' here as it's confirmed not None
                      self.log(f'TRAILING STOP EXECUTED @ {price:.2f}, Size: {size}, Pnl: {pnl:.2f}')
                 else:
                      self.log(f'TRAILING STOP CANCELED/REJECTED Status: {order.getstatusname()}')

                 # Reset trail order tracker regardless of completion status
                 self.order_trail = None

            else:
                 # Order completion notification for an unknown order ref? Should not happen often.
                 # Could be an manually cancelled order if broker had such API interaction?
                 self.log(f"WARN: notify_order received for unrecognized order Ref: {order.ref}, Status: {order.getstatusname()}")

        # --- End notify_order -----


    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 any order is pending
        if self.order or self.order_trail:
            # self.log(f"Skipping next(): Pending order exists. self.order: {self.order}, self.order_trail: {self.order_trail}")
            return

        # --- Entry Logic ---
        if not self.position:
            entry_signal = False
            # ** Pullback Mode **
            if self.p.trade_mode == 'pullback':
                if self.kama_cross[0] > 0: # Buy Signal
                    self.log(f'PULLBACK BUY SIGNAL: Close {self.dataclose[0]:.2f} > KAMA {self.kama[0]:.2f}')
                    self.order = self.buy()
                    entry_signal = True
                elif self.kama_cross[0] < 0: # Sell Signal (optional shorting)
                    self.log(f'PULLBACK SELL SIGNAL: Close {self.dataclose[0]:.2f} < KAMA {self.kama[0]:.2f}')
                    self.order = self.sell()
                    entry_signal = True

            # ** Breakout Mode **
            elif self.p.trade_mode == 'breakout':
                if self.dataclose[0] > self.upper_band[0] and self.dataclose[-1] <= self.upper_band[-1]: # Buy Signal
                     self.log(f'BREAKOUT BUY SIGNAL: Close {self.dataclose[0]:.2f} > Upper Band {self.upper_band[0]:.2f}')
                     self.order = self.buy()
                     entry_signal = True
                elif self.dataclose[0] < self.lower_band[0] and self.dataclose[-1] >= self.lower_band[-1]: # Sell Signal (optional shorting)
                     self.log(f'BREAKOUT SELL SIGNAL: Close {self.dataclose[0]:.2f} < Lower Band {self.lower_band[0]:.2f}')
                     self.order = self.sell()
                     entry_signal = True

            # If an entry order was placed, stop processing for this bar
            if entry_signal:
                return

        # --- KAMA-Based Exit Logic ---
        # Only consider KAMA exit if we are in a position AND no trailing stop order is currently active
        # Note: We place the KAMA exit even if a trail order exists, letting the broker handle which executes first.
        # The cancellation logic is handled in notify_order.
        else: # Already in the market
            # Exit Long: Price crosses below KAMA
            if self.position.size > 0 and self.kama_cross[0] < 0:
                 self.log(f'KAMA CLOSE LONG SIGNAL: Close {self.dataclose[0]:.2f} < KAMA {self.kama[0]:.2f}')
                 self.order = self.close() # Close position via KAMA cross

            # Exit Short: Price crosses above KAMA
            elif self.position.size < 0 and self.kama_cross[0] > 0:
                 self.log(f'KAMA CLOSE SHORT SIGNAL: Close {self.dataclose[0]:.2f} > KAMA {self.kama[0]:.2f}')
                 self.order = self.close() # Close position via KAMA cross

    def stop(self):
        trail_info = f"TrailPerc={self.p.trail_perc}" if self.p.trail_perc else "NoTrail"
        self.log(f'(KAMA Period {self.p.kama_period}, {trail_info}) Ending Value {self.broker.getvalue():.2f}', doprint=True)


# --- Main Execution ---
if __name__ == '__main__':
    # Create a Cerebro entity
    cerebro = bt.Cerebro()
    
    # selected_trade_mode = 'pullback' 
    selected_trade_mode = 'breakout' 
    # Set trail_perc=None to disable trailing stop
    trailing_stop_percentage = 0.1 # e.g., 5% trailing stop

    cerebro.addstrategy(AdaptiveMAVolatilityStrategy,
                        trade_mode=selected_trade_mode,
                        kama_period=30,
                        vol_period=7,
                        vol_mult=3.0,
                        trail_perc=trailing_stop_percentage,
                        printlog=False)

    # --- Data Loading --- (Same as before)
    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}")

    # Fetch data using yfinance
    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 ticker symbol or date range.")
             exit()
        
        data_df.columns = data_df.columns.droplevel(1)
          

        # Create a Backtrader data feed
        data_feed = bt.feeds.PandasData(dataname=data_df)

        # Add the Data Feed to Cerebro
        cerebro.adddata(data_feed)

    except Exception as e:
        print(f"Error fetching or processing data: {e}")
        exit()


    # --- Backtest Configuration ---
    # Set starting cash
    cerebro.broker.setcash(100000.0)
    cerebro.broker.setcommission(commission=0.001)
    cerebro.addsizer(bt.sizers.PercentSizer, percents=95) 
    
    # --- Analyzers --- (Same as before)
    cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe_ratio', timeframe=bt.TimeFrame.Days)
    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 --- (Same as before)
    strat = results[0]
    print('\n--- Analyzer Results ---')
    print(f"Sharpe Ratio: {strat.analyzers.sharpe_ratio.get_analysis().get('sharperatio', 'N/A')}")
    returns_dict = strat.analyzers.returns.get_analysis()
    print(f"Total Return: {returns_dict.get('rtot', 'N/A'):.4f}")
    print(f"Average Annual Return: {returns_dict.get('ravg', 'N/A'):.4f}")
    print(f"Max Drawdown: {strat.analyzers.drawdown.get_analysis().max.drawdown:.2f}%")
    trade_analysis = strat.analyzers.trade_analyzer.get_analysis()
    if trade_analysis 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}")
         print(f"Win Rate: {trade_analysis.won.total / trade_analysis.total.total * 100:.2f}%")
         print(f"Average Win ($): {trade_analysis.won.pnl.average:.2f}")
         print(f"Average Loss ($): {trade_analysis.lost.pnl.average:.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:
        # To plot in environments like Jupyter, add %matplotlib inline at the top
        # or configure matplotlib backend appropriately.
        # Set plot volume=False if volume data is noisy or not needed on main chart
        plt.rcParams['figure.figsize'] = [10, 6]
        cerebro.plot(style='line', barup='green', bardown='red', volume=True, iplot=False)
        plt.tight_layout()
    except Exception as e:
        print(f"\nCould not plot results: {e}")
        print("Plotting requires matplotlib installed and a suitable display environment.")
Fetching data for BTC-USD from 2021-01-01 to 2023-12-31
Starting Portfolio Value: 100000.00
2023-12-30 - Strategy Initialized: KAMA(Period=30), VolBands(StdDev(7), Mult=3.0), TradeMode=breakout, TrailPerc=0.1
2023-12-30 - Note: Standard bt.indicators.KAMA uses internal fast/slow periods (typically 2 and 30).
2023-12-30 - (KAMA Period 30, TrailPerc=0.1) Ending Value 309628.34
Final Portfolio Value: 309628.34

--- Analyzer Results ---
Sharpe Ratio: 0.053511740895852936
Total Return: 1.1302
Average Annual Return: 0.0010
Max Drawdown: 39.60%

--- Trade Analysis ---
Total Trades: 36
Winning Trades: 19
Losing Trades: 16
Win Rate: 52.78%
Average Win ($): 13470.19
Average Loss ($): -9680.71
Profit Factor: 1.65
Pasted image 20250427140413.png

Potential Use Cases

This type of adaptive strategy could be useful in several scenarios:

Suggestions for Improvement

While the strategy incorporates adaptive elements, backtesting often reveals areas for enhancement:

  1. Parameter Optimization: The chosen parameters (kama_period, vol_period, vol_mult, trail_perc) heavily influence performance. Systematic optimization (e.g., using Backtrader’s built-in optimizer or manual grid search) across different market periods is crucial. Remember to optimize after setting realistic sizing.
  2. Risk Management Refinement:
    • Sizing: The 95% PercentSizer is extremely aggressive. Test much lower percentages (e.g., 2%, 5%, 10%, 20%) for more realistic risk control and reduced impact of losing streaks.
    • Trailing Stop Method: A fixed percentage trail might not be optimal for Bitcoin’s volatility. Consider an ATR (Average True Range)-based trailing stop (bt.Order.StopTrailATR or manual calculation) which adapts the stop distance to market volatility.
  3. Entry/Exit Signal Filters:
    • Trend Filter: Add a longer-term moving average (e.g., SMA100 or SMA200) and only allow long entries above it / short entries below it to avoid trading against the dominant trend.
    • Volatility Filter: Avoid entries if volatility (e.g., ATR percentage) is excessively high (indicating potential blow-offs) or too low (indicating potential chop).
    • Confirmation: Require price to close outside the band or across the KAMA for two bars instead of one to reduce false signals.
    • Alternative Exits: Test different exit criteria, such as reaching the opposite volatility band or fixed profit targets.
  4. Band Calculation: Experiment with using price ATR for bands (similar to Keltner Channels) instead of the KAMA’s standard deviation. Compare performance.
  5. Regime Filtering: Advanced: Attempt to classify the market (e.g., high-trend/low-volatility vs. low-trend/high-volatility) and potentially adjust strategy parameters or enable/disable trading based on the detected regime.
  6. Robustness Testing: Test across different assets, timeframes, and market periods (including bear markets) to assess how robust the strategy is. Perform walk-forward optimization for a more realistic performance expectation.

Conclusion

This Backtrader strategy provides a framework for trading volatile assets like Bitcoin by adapting to market conditions using KAMA and dynamic volatility bands derived from KAMA’s own behavior. The inclusion of a trailing stop adds a layer of risk management. However, like any trading strategy, its “out-of-the-box” performance depends heavily on parameter choices, market conditions, and realistic configuration (especially position sizing and costs). Thorough backtesting, optimization, and refinement are essential steps before considering any strategy for live deployment. This code serves as a solid starting point for exploring adaptive trading concepts in Python.