Signals·TA Strategy Implementations·Intermediate

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.

ta-strategy-implementationstrading-signals

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 go
Requirement 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:

LineFormula
Middle (EMA)EMA of close over N candles
Upper BandEMA + multiplier × ATR
Lower BandEMA − multiplier × ATR

Comparison to Bollinger Bands

FeatureBollinger BandsKeltner Channels
Band width based onStandard deviationATR
Sensitivity toPrice spikesSustained volatility
Band behaviorExpands sharply on large single movesExpands 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 NameLink
Keltner Channels on Investopediahttps://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.
  • ATR provides 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.