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:
'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.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
= False
entry_signal # ** 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()
= True
entry_signal elif self.kama_cross[0] < 0: # Sell on cross below KAMA
self.log(f'PULLBACK SELL SIGNAL...')
self.order = self.sell()
= True
entry_signal
# ** 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()
= True
entry_signal # 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()
= True
entry_signal
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 or self.datas[0].datetime.date(0)
dt 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]:
= order.ordtypename()
otype # Get execution details safely, providing defaults for non-completed orders
= order.executed.price if order.status == order.Completed else None
price # Use executed size if available, otherwise submitted size (submitted size might be more informative for rejected orders)
= order.executed.size if order.status == order.Completed and order.executed.size is not None else order.size
size = order.executed.comm if order.status == order.Completed else 0.0
comm = order.executed.pnl if order.status == order.Completed and order.executed.pnl else 0.0 # Defaults to 0.0
pnl
# Prepare formatted strings to handle None values gracefully for logging
= f"{price:.2f}" if price is not None else "N/A"
exec_price_str # Handle order.price which might be 0 or None for Market orders/some brokers
= order.price if order.price is not None and order.price != 0.0 else None
req_price_val = f"{req_price_val:.2f}" if req_price_val is not None else "Market/None"
req_price_str = f"{comm:.2f}" # Safe as it defaults to 0.0
comm_str = f"{pnl:.2f}" # Safe as it defaults to 0.0
pnl_str
# --- 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)
= order.ref == getattr(self.order, 'ref', None)
is_entry = order.ref == getattr(self.order_trail, 'ref', None)
is_trail
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:
= price * (1.0 - self.p.trail_perc)
stop_price 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:
= price * (1.0 + self.p.trail_perc)
stop_price 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:
= False
entry_signal # ** 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()
= True
entry_signal 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()
= True
entry_signal
# ** 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()
= True
entry_signal 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()
= True
entry_signal
# 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):
= f"TrailPerc={self.p.trail_perc}" if self.p.trail_perc else "NoTrail"
trail_info 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
= bt.Cerebro()
cerebro
# selected_trade_mode = 'pullback'
= 'breakout'
selected_trade_mode # Set trail_perc=None to disable trailing stop
= 0.1 # e.g., 5% trailing stop
trailing_stop_percentage
cerebro.addstrategy(AdaptiveMAVolatilityStrategy,=selected_trade_mode,
trade_mode=30,
kama_period=7,
vol_period=3.0,
vol_mult=trailing_stop_percentage,
trail_perc=False)
printlog
# --- Data Loading --- (Same as before)
= 'BTC-USD'
ticker = '2021-01-01'
start_date = '2023-12-31'
end_date
print(f"Fetching data for {ticker} from {start_date} to {end_date}")
# Fetch data using yfinance
try:
= yf.download(ticker, start=start_date, end=end_date, progress=False)
data_df
if data_df.empty:
print(f"No data fetched for {ticker}. Check ticker symbol or date range.")
exit()
= data_df.columns.droplevel(1)
data_df.columns
# Create a Backtrader data feed
= bt.feeds.PandasData(dataname=data_df)
data_feed
# 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
100000.0)
cerebro.broker.setcash(=0.001)
cerebro.broker.setcommission(commission=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents
# --- Analyzers --- (Same as before)
='sharpe_ratio', timeframe=bt.TimeFrame.Days)
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='returns')
cerebro.addanalyzer(bt.analyzers.Returns, _name='drawdown')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='trade_analyzer')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name
# --- Run Backtest ---
print('Starting Portfolio Value: %.2f' % cerebro.broker.getvalue())
= cerebro.run()
results print('Final Portfolio Value: %.2f' % cerebro.broker.getvalue())
# --- Print Analyzer Results --- (Same as before)
= results[0]
strat print('\n--- Analyzer Results ---')
print(f"Sharpe Ratio: {strat.analyzers.sharpe_ratio.get_analysis().get('sharperatio', 'N/A')}")
= strat.analyzers.returns.get_analysis()
returns_dict 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}%")
= strat.analyzers.trade_analyzer.get_analysis()
trade_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}")
= abs(trade_analysis.won.pnl.total / trade_analysis.lost.pnl.total) if trade_analysis.lost.pnl.total != 0 else float('inf')
pf 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
'figure.figsize'] = [10, 6]
plt.rcParams[='line', barup='green', bardown='red', volume=True, iplot=False)
cerebro.plot(style
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
Potential Use Cases
This type of adaptive strategy could be useful in several scenarios:
trade_mode
allows testing both common entry styles within
the same adaptive framework.Suggestions for Improvement
While the strategy incorporates adaptive elements, backtesting often reveals areas for enhancement:
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.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.bt.Order.StopTrailATR
or
manual calculation) which adapts the stop distance to market
volatility.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.