The Keltner Channel is a popular volatility-based indicator used in technical analysis to identify potential breakouts and define trend direction. It consists of an Exponential Moving Average (EMA) as its centerline, with upper and lower bands set a multiple of the Average True Range (ATR) away from the EMA. This article introduces a Keltner Channel Breakout Strategy that seeks to enter trades when price decisively moves outside these channels, and exit when price reverts to the EMA. The article also provides functions for optimizing the strategy’s parameters, performing a robust rolling backtest, and generating a detailed statistical report with performance plots.
First, let’s define the custom Keltner Channel indicator used by the strategy.
import backtrader as bt
import yfinance as yf
import pandas as pd
import numpy as np
import dateutil.relativedelta as rd
import warnings
"ignore")
warnings.filterwarnings(
# ------------------------------------------------------------------
# 1. Keltner Channel Components
# ------------------------------------------------------------------
class KeltnerChannel(bt.Indicator):
"""Keltner Channel indicator with EMA centerline and ATR-based bands"""
= ('mid', 'top', 'bot')
lines = (
params 'ema_period', 30),
('atr_period', 14),
('atr_multiplier', 1.0),
(
)
def __init__(self):
self.lines.mid = bt.indicators.EMA(self.data.close, period=self.params.ema_period)
= bt.indicators.ATR(self.data, period=self.params.atr_period)
atr self.lines.top = self.lines.mid + (atr * self.params.atr_multiplier)
self.lines.bot = self.lines.mid - (atr * self.params.atr_multiplier)
class KeltnerBreakoutStrategy(bt.Strategy):
"""Keltner Channel Breakout Strategy"""
= (
params 'ema_period', 30),
('atr_period', 7),
('atr_multiplier', 1.0),
('printlog', False),
(
)
def log(self, txt, dt=None):
if self.params.printlog:
= dt or self.datas[0].datetime.date(0)
dt print(f'{dt.isoformat()}: {txt}')
def __init__(self):
self.dataclose = self.datas[0].close
self.dataopen = self.datas[0].open
self.keltner = KeltnerChannel(
self.data,
=self.params.ema_period,
ema_period=self.params.atr_period,
atr_period=self.params.atr_multiplier
atr_multiplier
)
self.order = None
def notify_order(self, order):
if order.status in [order.Submitted, order.Accepted]:
return
if order.status in [order.Completed]:
if order.isbuy():
self.log(f'BUY EXECUTED: {order.executed.price:.2f}')
elif order.issell():
self.log(f'SELL EXECUTED: {order.executed.price:.2f}')
elif order.status in [order.Canceled, order.Margin, order.Rejected]:
self.log('Order Canceled/Margin/Rejected')
self.order = None
def notify_trade(self, trade):
if not trade.isclosed:
return
self.log(f'TRADE PROFIT: {trade.pnl:.2f}')
def next(self):
if len(self.data) < max(self.params.ema_period, self.params.atr_period):
return
if self.order:
return
if len(self.data) < 2:
return
= self.dataclose[-1]
prev_close = self.keltner.top[-1]
prev_upper = self.keltner.bot[-1]
prev_lower = self.keltner.mid[0]
current_ema = self.dataclose[0]
current_close
if not self.position:
# Long entry: Previous close > Previous upper band
if prev_close > prev_upper:
self.log(f'BUY CREATE: Breakout above {prev_upper:.2f}')
self.order = self.buy()
# Short entry: Previous close < Previous lower band
elif prev_close < prev_lower:
self.log(f'SELL CREATE: Breakout below {prev_lower:.2f}')
self.order = self.sell()
else:
# Exit conditions based on EMA
if self.position.size > 0: # Long position
if current_close < current_ema:
self.log(f'CLOSE LONG: {current_close:.2f} < EMA {current_ema:.2f}')
self.order = self.close()
elif self.position.size < 0: # Short position
if current_close > current_ema:
self.log(f'CLOSE SHORT: {current_close:.2f} > EMA {current_ema:.2f}')
self.order = self.close()
KeltnerChannel
Indicator Explanation:The KeltnerChannel
indicator is a custom
backtrader
indicator that defines the channel:
lines = ('mid', 'top', 'bot')
: Defines the three output
lines for the centerline, upper band, and lower band.params
: ema_period
for the centerline,
atr_period
for the ATR calculation, and
atr_multiplier
to adjust the band width.__init__
:
self.lines.mid
: Calculated as an Exponential Moving
Average (EMA) of the closing price.atr
: Calculated as the Average True Range (ATR).self.lines.top
and self.lines.bot
: The
upper and lower bands are set by adding/subtracting a multiple of the
ATR from the centerline.KeltnerBreakoutStrategy
Explanation:This strategy implements a trend-following approach based on price breaking out of the Keltner Channel and exiting when price reverts to the channel’s centerline.
Parameters (params
):
ema_period
, atr_period
,
atr_multiplier
: These parameters are passed directly to the
KeltnerChannel
indicator to define the channel’s
characteristics.printlog
: A boolean flag to control logging.Logging (log
, notify_order
,
notify_trade
):
log(self, txt, dt=None)
: A utility method to print
time-stamped messages if printlog
is
True
.notify_order(self, order)
: This backtrader
method is called when an order’s status changes. It logs order
executions (buy/sell) and cancellations/rejections.notify_trade(self, trade)
: This method is called when a
trade is fully closed (buy and sell matched). It logs the profit/loss of
the completed trade.Initialization (__init__
):
self.dataclose
, self.dataopen
: References
to the close and open price lines of the data.self.keltner
: An instance of our
KeltnerChannel
indicator is created with the strategy’s
parameters.self.order
: A variable to keep track of pending
orders.Main Logic (next
): This method contains
the core trading logic, executed on each new bar:
if not self.position
):
previous close price
was above the
previous upper Keltner band
, a buy order is placed,
signaling an upside breakout.previous close price
was below the
previous lower Keltner band
, a sell order is placed,
signaling a downside breakout.else
- if
self.position
is active):
current close price
falls below
the current EMA centerline
. This acts as a reversal to the
mean exit.current close price
rises
above the current EMA centerline
. This also acts
as a reversal to the mean exit.This function automates the process of finding the most effective combination of strategy parameters by running multiple backtests and evaluating their performance using metrics like Sharpe Ratio.
# ------------------------------------------------------------------
# 2. Simple Optimization Function
# ------------------------------------------------------------------
def optimize_keltner_parameters():
"""Run optimization to find best parameters"""
print("="*60)
print("KELTNER CHANNEL STRATEGY OPTIMIZATION")
print("="*60)
# Fetch data for optimization
print("Fetching data for optimization...")
= yf.download('BTC-USD', start='2020-01-01', end='2025-01-01', auto_adjust=False, progress=False)
df if isinstance(df.columns, pd.MultiIndex):
= df.droplevel(1, axis=1)
df
# Set up optimization
= bt.Cerebro()
cerebro = bt.feeds.PandasData(dataname=df)
data
cerebro.adddata(data)
= 10000.0
start_cash
cerebro.broker.setcash(start_cash)=0.001)
cerebro.broker.setcommission(commission
# Add analyzers
='sharpe',
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name=bt.TimeFrame.Days, annualize=True, riskfreerate=0.0)
timeframe='returns')
cerebro.addanalyzer(bt.analyzers.Returns, _name
print("Testing parameter combinations...")
# Parameter optimization ranges
cerebro.optstrategy(
KeltnerBreakoutStrategy,=[20, 30, 40, 50], # 4 values
ema_period=[7, 14, 21], # 3 values
atr_period=[0.5, 1.0, 1.5, 2.0, 2.5] # 5 values
atr_multiplier
)# Total: 60 combinations
= cerebro.run()
stratruns print("Optimization complete!")
# Collect and analyze results
= []
results for run in stratruns:
= run[0]
strategy = strategy.analyzers.sharpe.get_analysis()
sharpe_analysis = strategy.analyzers.returns.get_analysis()
returns_analysis
= returns_analysis.get('rtot', 0.0)
rtot = start_cash * (1 + rtot)
final_value = sharpe_analysis.get('sharperatio', None)
sharpe_ratio
if sharpe_ratio is None or np.isnan(sharpe_ratio):
= -999.0
sharpe_ratio
results.append({'sharpe_ratio': sharpe_ratio,
'final_value': final_value,
'return_pct': rtot * 100,
'ema_period': strategy.p.ema_period,
'atr_period': strategy.p.atr_period,
'atr_multiplier': strategy.p.atr_multiplier,
})
# Filter valid results and sort by Sharpe ratio
= [r for r in results if r['sharpe_ratio'] != -999.0]
valid_results if not valid_results:
print("No valid results found!")
return None
= sorted(valid_results, key=lambda x: x['sharpe_ratio'], reverse=True)
results_sorted
print(f"\n{'='*80}")
print("TOP 10 PARAMETER COMBINATIONS BY SHARPE RATIO")
print(f"{'='*80}")
print("Rank | Sharpe | Return% | Value | EMA | ATR | Multiplier")
print("-" * 80)
for i, result in enumerate(results_sorted[:10]):
print(f"{i+1:4d} | {result['sharpe_ratio']:5.2f} | {result['return_pct']:6.1f}% | "
f"${result['final_value']:8,.0f} | {result['ema_period']:3d} | {result['atr_period']:3d} | "
f"{result['atr_multiplier']:10.1f}")
= results_sorted[0]
best_params print(f"\n{'='*60}")
print("BEST PARAMETERS FOUND:")
print(f"{'='*60}")
print(f"EMA Period: {best_params['ema_period']}")
print(f"ATR Period: {best_params['atr_period']}")
print(f"ATR Multiplier: {best_params['atr_multiplier']:.1f}")
print(f"Sharpe Ratio: {best_params['sharpe_ratio']:.3f}")
print(f"Total Return: {best_params['return_pct']:.1f}%")
print(f"Final Value: ${best_params['final_value']:,.2f}")
return best_params
The optimize_keltner_parameters
function performs a
parameter optimization for the KeltnerBreakoutStrategy
.
bt.Cerebro
instance with initial cash and
commission, and adds SharpeRatio
and Returns
analyzers.cerebro.optstrategy
is used to run the strategy with
various combinations of ema_period
,
atr_period
, and atr_multiplier
.sharpe_ratio
, return_pct
, and
final_value
for each run.A rolling backtest evaluates a strategy’s performance over sequential, overlapping or non-overlapping time windows. This provides a more robust assessment of its consistency across different market conditions than a single historical backtest.
# ------------------------------------------------------------------
# 3. Your Rolling Backtest Function (slightly modified)
# ------------------------------------------------------------------
def run_rolling_backtest(ticker, start, end, window_months, strategy_params=None):
"""Your clean rolling backtest function"""
= strategy_params or {}
strategy_params = []
all_results = pd.to_datetime(start)
start_dt = pd.to_datetime(end)
end_dt = start_dt
current_start
while True:
= current_start + rd.relativedelta(months=window_months)
current_end if current_end > end_dt:
break
print(f"\nROLLING BACKTEST: {current_start.date()} to {current_end.date()}")
# Fetch data using yfinance
= yf.download(ticker, start=current_start, end=current_end, auto_adjust=False, progress=False)
data
if data.empty or len(data) < 90:
print("Not enough data for this period.")
+= rd.relativedelta(months=window_months)
current_start continue
if isinstance(data.columns, pd.MultiIndex):
= data.droplevel(1, 1)
data
# Calculate Buy & Hold return for the period
= data['Close'].iloc[0]
start_price = data['Close'].iloc[-1]
end_price = (end_price - start_price) / start_price * 100
benchmark_ret
= bt.feeds.PandasData(dataname=data)
feed = bt.Cerebro()
cerebro
**strategy_params)
cerebro.addstrategy(KeltnerBreakoutStrategy,
cerebro.adddata(feed)100000)
cerebro.broker.setcash(=0.001)
cerebro.broker.setcommission(commission=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents
= cerebro.broker.getvalue()
start_val
cerebro.run()= cerebro.broker.getvalue()
final_val = (final_val - start_val) / start_val * 100
strategy_ret
all_results.append({'start': current_start.date(),
'end': current_end.date(),
'return_pct': strategy_ret,
'benchmark_pct': benchmark_ret,
'final_value': final_val,
})
print(f"Strategy Return: {strategy_ret:.2f}% | Buy & Hold Return: {benchmark_ret:.2f}%")
+= rd.relativedelta(months=window_months)
current_start
return pd.DataFrame(all_results)
The run_rolling_backtest
function takes a
ticker
, an overall start
and end
date, and a window_months
parameter to define the size of
each rolling window.
bt.Cerebro
instance, adds the
KeltnerBreakoutStrategy
with the specified
strategy_params
, and runs the backtest.================================================================================
TOP 10 PARAMETER COMBINATIONS BY SHARPE RATIO
================================================================================
Rank | Sharpe | Return% | Value | EMA | ATR | Multiplier
--------------------------------------------------------------------------------
1 | 1.11 | 228.9% | $ 32,890 | 50 | 14 | 2.0
2 | 1.10 | 225.7% | $ 32,570 | 50 | 7 | 2.0
3 | 0.87 | 183.0% | $ 28,296 | 30 | 21 | 0.5
4 | 0.84 | 177.8% | $ 27,777 | 30 | 14 | 0.5
5 | 0.78 | 164.3% | $ 26,432 | 30 | 21 | 1.0
6 | 0.75 | 150.8% | $ 25,077 | 30 | 7 | 1.5
7 | 0.73 | 147.2% | $ 24,720 | 30 | 21 | 1.5
8 | 0.73 | 140.9% | $ 24,092 | 30 | 7 | 2.0
9 | 0.72 | 144.0% | $ 24,396 | 30 | 14 | 1.5
10 | 0.68 | 137.0% | $ 23,696 | 30 | 14 | 2.0
============================================================
BEST PARAMETERS FOUND:
============================================================
EMA Period: 50
ATR Period: 14
ATR Multiplier: 2.0
Sharpe Ratio: 1.108
Total Return: 228.9%
Final Value: $32,889.97
============================================================
RUNNING ROLLING BACKTEST WITH OPTIMIZED PARAMETERS
============================================================
Using parameters: EMA(50) | ATR(14,2.0)
ROLLING BACKTEST: 2018-01-01 to 2019-01-01
Strategy Return: 7.17% | Buy & Hold Return: -72.60%
ROLLING BACKTEST: 2019-01-01 to 2020-01-01
Strategy Return: 105.84% | Buy & Hold Return: 87.16%
ROLLING BACKTEST: 2020-01-01 to 2021-01-01
Strategy Return: 186.16% | Buy & Hold Return: 302.79%
ROLLING BACKTEST: 2021-01-01 to 2022-01-01
Strategy Return: 46.56% | Buy & Hold Return: 57.64%
ROLLING BACKTEST: 2022-01-01 to 2023-01-01
Strategy Return: -14.79% | Buy & Hold Return: -65.30%
ROLLING BACKTEST: 2023-01-01 to 2024-01-01
Strategy Return: 14.53% | Buy & Hold Return: 154.23%
ROLLING BACKTEST: 2024-01-01 to 2025-01-01
Strategy Return: -8.65% | Buy & Hold Return: 111.53%
============================================================
ROLLING BACKTEST STATISTICS
============================================================
Total Periods: 7
Strategy Wins: 3 (42.9%)
Strategy Losses: 4 (57.1%)
STRATEGY PERFORMANCE:
Mean Return: 48.12%
Std Deviation: 73.46%
Best Period: 186.16%
Worst Period: -14.79%
BUY & HOLD PERFORMANCE:
Mean Return: 82.21%
Std Deviation: 129.78%
Best Period: 302.79%
Worst Period: -72.60%
CUMULATIVE PERFORMANCE:
Strategy Total: 724.8%
Buy & Hold Total: 507.8%
Outperformance: +217.0 percentage points
RISK-ADJUSTED METRICS:
Strategy Sharpe Ratio: 0.655
Buy & Hold Sharpe Ratio: 0.633
============================================================
PERFORMANCE COMPARISON SUMMARY
============================================================
Strategy Average Return: 48.12%
Buy & Hold Average Return: 82.21%
Average Outperformance: -34.09%
Strategy Volatility: 73.46%
Buy & Hold Volatility: 129.78%
Volatility Difference: -56.32%
Strategy Sharpe Ratio: 0.655
Buy & Hold Sharpe Ratio: 0.633
Sharpe Difference: +0.022
============================================================
PERIOD-BY-PERIOD RESULTS
============================================================
Start Date | Strategy | Benchmark | Outperformance
------------------------------------------------------------
2018-01-01 | 7.2% | -72.6% | +79.8%
2019-01-01 | 105.8% | 87.2% | +18.7%
2020-01-01 | 186.2% | 302.8% | -116.6%
2021-01-01 | 46.6% | 57.6% | -11.1%
2022-01-01 | -14.8% | -65.3% | +50.5%
2023-01-01 | 14.5% | 154.2% | -139.7%
2024-01-01 | -8.7% | 111.5% | -120.2%
This structured approach allows for a thorough analysis of the Keltner Channel Breakout Strategy, from identifying optimal parameters to understanding its performance consistency across different market conditions.