Keltner Channel Reversion
Implement a Keltner channel mean reversion strategy using ATR-based bands around an EMA centerline, trading the statistical tendency of price to revert after touching the outer channel boundaries in ranging markets.
Strategy — Keltner Channel Reversion
1–2. Installation and Imports
!pip install pandas numpy plotly
import warnings; warnings.filterwarnings("ignore")
import pandas as pd
import numpy as np
import plotly.graph_objects as goRequirement already satisfied: pandas in /usr/local/lib/python3.12/dist-packages (2.2.2) Requirement already satisfied: numpy in /usr/local/lib/python3.12/dist-packages (2.0.2) Requirement already satisfied: plotly in /usr/local/lib/python3.12/dist-packages (5.24.1) Requirement already satisfied: python-dateutil>=2.8.2 in /usr/local/lib/python3.12/dist-packages (from pandas) (2.9.0.post0) Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas) (2025.2) Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas) (2026.1) Requirement already satisfied: tenacity>=6.2.0 in /usr/local/lib/python3.12/dist-packages (from plotly) (9.1.4) Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from plotly) (26.1) Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.12/dist-packages (from python-dateutil>=2.8.2->pandas) (1.17.0)
3. Strategy Overview
Keltner Channels are a volatility envelope built around an Exponential Moving Average (EMA) using Average True Range (ATR) as the band width:
| Line | Formula |
|---|---|
| Middle (EMA) | EMA of close over N candles |
| Upper Band | EMA + multiplier × ATR |
| Lower Band | EMA − multiplier × ATR |
Comparison to Bollinger Bands
| Feature | Bollinger Bands | Keltner Channels |
|---|---|---|
| Band width based on | Standard deviation | ATR |
| Sensitivity to | Price spikes | Sustained volatility |
| Band behavior | Expands sharply on large single moves | Expands smoothly with average range |
Because ATR is a smoother volatility measure than standard deviation, Keltner Channels produce fewer whipsaws during short-lived price spikes and are more stable during trending conditions.
Signal Logic
- Close at or below lower Keltner Channel → Buy (+1): price has extended unusually far below the EMA relative to its typical range.
- Close at or above upper Keltner Channel → Sell (−1): price has extended unusually far above the EMA.
- Close inside the channels → No signal (0).
Resources
| Resource Name | Link |
|---|---|
| Keltner Channels on Investopedia | https://www.investopedia.com/articles/trading/10/keltner-channels.asp |
4. Data Generation
def generate_data(periods: int) -> pd.DataFrame:
start_date = pd.to_datetime("2024-01-01 00:00:00+00:00")
datetime_index = pd.date_range(start_date, periods=periods, freq="1min", tz="UTC")
price_data = []; last_close = 42000
for i in range(periods):
open_price = last_close + np.random.normal(0, last_close * 0.0005)
close_price = open_price + np.random.normal(0, last_close * 0.005)
body_high = max(open_price, close_price)
body_low = min(open_price, close_price)
high_price = max(body_high + abs(np.random.normal(0, last_close * 0.002)), open_price, close_price)
low_price = min(body_low - abs(np.random.normal(0, last_close * 0.002)), open_price, close_price)
if high_price < low_price: high_price, low_price = low_price, high_price
price_data.append({"open": max(1,int(open_price)), "high": max(1,int(high_price)),
"low": max(1,int(low_price)), "close": max(1,int(close_price))})
last_close = close_price
df = pd.DataFrame(price_data, index=datetime_index)
df.index.name = "datetime"
df["volume"] = np.random.uniform(100.0, 500.0, periods)
df["datetime"] = df.index.to_series()
return df.reset_index(drop=True)
df = generate_data(500)
display(df.head())| open | high | low | close | volume | datetime | |
|---|---|---|---|---|---|---|
| 0 | 41995 | 42501 | 41964 | 42428 | 414.471156 | 2024-01-01 00:00:00+00:00 |
| 1 | 42418 | 42503 | 42292 | 42294 | 363.906872 | 2024-01-01 00:01:00+00:00 |
| 2 | 42289 | 42418 | 42084 | 42119 | 424.876401 | 2024-01-01 00:02:00+00:00 |
| 3 | 42157 | 42408 | 42120 | 42340 | 380.261584 | 2024-01-01 00:03:00+00:00 |
| 4 | 42348 | 42481 | 42347 | 42381 | 143.867682 | 2024-01-01 00:04:00+00:00 |
5. Strategy Function
def keltner_channel_reversion(
df: pd.DataFrame,
ema_window: int = 20,
atr_window: int = 10,
multiplier: float = 2.0,
) -> pd.DataFrame:
# Create a copy of the DataFrame and sort it by datetime to ensure correct calculations
df = df.copy().sort_values("datetime", ignore_index=True)
# Calculate the Exponential Moving Average (EMA) of the 'close' price
# 'span' is the period for the EMA, 'adjust=False' uses simpler weighting
df["ema"] = df["close"].ewm(span=ema_window, adjust=False).mean()
# Calculate True Range (TR) for ATR calculation
# TR is the greatest of:
# 1. Current High - Current Low
# 2. Absolute difference between Current High and Previous Close
# 3. Absolute difference between Current Low and Previous Close
tr = pd.concat([
df["high"] - df["low"],
(df["high"] - df["close"].shift(1)).abs(),
(df["low"] - df["close"].shift(1)).abs(),
], axis=1).max(axis=1)
# Calculate Average True Range (ATR) by taking a rolling mean of the True Range
df["atr"] = tr.rolling(atr_window).mean()
# Calculate the Keltner Channel Upper Band
# EMA + (multiplier * ATR)
df["kc_upper"] = df["ema"] + multiplier * df["atr"]
# Calculate the Keltner Channel Lower Band
# EMA - (multiplier * ATR)
df["kc_lower"] = df["ema"] - multiplier * df["atr"]
# Generate trading signals based on Keltner Channels
# If close price is at or below the lower KC, signal is 1 (Buy)
# If close price is at or above the upper KC, signal is -1 (Sell)
# Otherwise (price is within channels), signal is 0 (No signal)
df["signal"] = np.where(df["close"] <= df["kc_lower"], 1,
np.where(df["close"] >= df["kc_upper"], -1, 0))
return df
df_signals = keltner_channel_reversion(df, ema_window=20, atr_window=10, multiplier=2.0)
print("--- Signal Distribution ---")
print(df_signals["signal"].value_counts())--- Signal Distribution --- signal 0 457 -1 32 1 11 Name: count, dtype: int64
Explanation:
ewm(span=ema_window, adjust=False).mean(): The EMA weights recent candles more heavily than older ones — it tracks price more responsively than a simple moving average, making it a better dynamic fair-value reference.ATRprovides a volatility-adaptive band width. Because ATR uses the full true range including gaps, it captures market conditions more completely than standard deviation of close-to-close returns.- The combination of EMA (trend-following center) and ATR (volatility-adaptive width) means the channels automatically adjust to both the direction of drift and the magnitude of daily price movement.
6. Visualization
buy_signals = df_signals[df_signals["signal"] == 1]
sell_signals = df_signals[df_signals["signal"] == -1]
fig = go.FigureWidget(data=[go.Candlestick(
x=df_signals["datetime"],
open=df_signals["open"], high=df_signals["high"],
low=df_signals["low"], close=df_signals["close"],
name="Price"
)])
fig.add_trace(go.Scatter(x=df_signals["datetime"], y=df_signals["ema"],
mode="lines", name="EMA", line=dict(color="blue", width=1.5)))
fig.add_trace(go.Scatter(x=df_signals["datetime"], y=df_signals["kc_upper"],
mode="lines", name="KC Upper", line=dict(color="orange", width=1, dash="dash")))
fig.add_trace(go.Scatter(x=df_signals["datetime"], y=df_signals["kc_lower"],
mode="lines", name="KC Lower", line=dict(color="orange", width=1, dash="dash")))
fig.add_trace(go.Scatter(
x=buy_signals["datetime"], y=buy_signals["low"] * 0.999,
mode="markers", marker=dict(symbol="triangle-up", size=10, color="green"), name="Buy (+1)"))
fig.add_trace(go.Scatter(
x=sell_signals["datetime"], y=sell_signals["high"] * 1.001,
mode="markers", marker=dict(symbol="triangle-down", size=10, color="red"), name="Sell (−1)"))
fig.update_layout(
title_text="Keltner Channel Reversion Strategy",
xaxis_rangeslider_visible=False,
xaxis_title="Datetime", yaxis_title="Price",
height=600, yaxis=dict(autorange=True),
)
fig.show()7. Conclusion
This notebook demonstrates the implementation of a Keltner Channel Reversion strategy. We have covered data generation, strategy logic, and visualization of the signals on a candlestick chart. This strategy identifies potential reversal points when the price moves outside the Keltner Channels, providing buy and sell signals.