Signals·TA Strategy Implementations·Intermediate

Donchian Channel Breakout

Build a Donchian channel breakout strategy that identifies momentum breakouts above and below rolling N-period high-low price channels with configurable lookback windows and volume confirmation for entry validation.

ta-strategy-implementationstrading-signals

Strategy — Donchian Channel Breakout


1. Dependency Installation

[ ]
!pip install pandas numpy plotly
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)

2. Library Imports

[ ]
import warnings
warnings.filterwarnings("ignore")

import pandas as pd
import numpy as np
import plotly.graph_objects as go

3. Strategy Overview

The Donchian Channel is a price envelope constructed from the highest high and lowest low over a rolling lookback window N.

BandDefinition
Upper channelHighest high over the last N candles
Lower channelLowest low over the last N candles
Middle channelAverage of upper and lower

Signal logic:

  • Price closes at or above the upper channelBuy (+1): price has broken above the range highs, signaling bullish momentum continuation.
  • Price closes at or below the lower channelSell (−1): price has broken below the range lows, signaling bearish momentum continuation.
  • Price inside the channel → No signal (0): market is within its recent range.

Why it works: Donchian breakouts capture the moment when price escapes from a consolidation range. The assumption is that a sustained move beyond the N-period extreme reflects genuine directional conviction rather than intrabar noise. This is a pure price-action momentum strategy — no volume or momentum indicator is required for the basic signal.


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
    volatility_scale = 0.005; wick_deviation_scale = 0.002
    for i in range(periods):
        open_price   = last_close + np.random.normal(0, last_close * volatility_scale * 0.1)
        close_price  = open_price + np.random.normal(0, last_close * volatility_scale)
        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 * wick_deviation_scale)), open_price, close_price)
        low_price    = min(body_low  - abs(np.random.normal(0, last_close * wick_deviation_scale)), 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 41982 41987 41778 41796 158.215049 2024-01-01 00:00:00+00:00
1 41825 41968 41723 41784 488.991237 2024-01-01 00:01:00+00:00
2 41806 42106 41712 42020 396.355608 2024-01-01 00:02:00+00:00
3 42033 42320 42003 42281 276.695743 2024-01-01 00:03:00+00:00
4 42299 42299 41932 42071 263.737667 2024-01-01 00:04:00+00:00

5. Strategy Function

[ ]
def donchian_breakout_strategy(
    df:     pd.DataFrame,
    window: int = 20,
) -> pd.DataFrame:
    df = df.copy().sort_values("datetime", ignore_index=True)

    # Channel boundaries from the previous candle's rolling window
    # (shift(1) prevents lookahead — the current candle cannot see its own high/low
    #  as part of the channel that triggered its own breakout signal)
    df["dc_upper"]  = df["high"].shift(1).rolling(window).max()
    df["dc_lower"]  = df["low"].shift(1).rolling(window).min()
    df["dc_middle"] = (df["dc_upper"] + df["dc_lower"]) / 2

    df["signal"] = np.where(df["close"] >= df["dc_upper"],  1,
                   np.where(df["close"] <= df["dc_lower"], -1, 0))

    return df

df_signals = donchian_breakout_strategy(df, window=20)

print("--- Signal Distribution ---")
print(df_signals["signal"].value_counts())
display(df_signals[["datetime","close","dc_upper","dc_lower","dc_middle","signal"]].dropna().head(20))
--- Signal Distribution ---
signal
 0    436
-1     43
 1     21
Name: count, dtype: int64
datetime close dc_upper dc_lower dc_middle signal
20 2024-01-01 00:20:00+00:00 43690 43321.0 41712.0 42516.5 1
21 2024-01-01 00:21:00+00:00 43723 43795.0 41712.0 42753.5 0
22 2024-01-01 00:22:00+00:00 43738 43810.0 41712.0 42761.0 0
23 2024-01-01 00:23:00+00:00 43561 43810.0 41932.0 42871.0 0
24 2024-01-01 00:24:00+00:00 43703 43857.0 41932.0 42894.5 0
25 2024-01-01 00:25:00+00:00 43667 43857.0 42005.0 42931.0 0
26 2024-01-01 00:26:00+00:00 43731 43857.0 42135.0 42996.0 0
27 2024-01-01 00:27:00+00:00 43848 43857.0 42135.0 42996.0 0
28 2024-01-01 00:28:00+00:00 43999 43973.0 42141.0 43057.0 1
29 2024-01-01 00:29:00+00:00 44214 44050.0 42141.0 43095.5 1
30 2024-01-01 00:30:00+00:00 44233 44280.0 42142.0 43211.0 0
31 2024-01-01 00:31:00+00:00 44269 44294.0 42207.0 43250.5 0
32 2024-01-01 00:32:00+00:00 44870 44301.0 42448.0 43374.5 1
33 2024-01-01 00:33:00+00:00 45150 44949.0 42589.0 43769.0 1
34 2024-01-01 00:34:00+00:00 45710 45223.0 42596.0 43909.5 1
35 2024-01-01 00:35:00+00:00 45328 45802.0 42642.0 44222.0 0
36 2024-01-01 00:36:00+00:00 45602 45802.0 42707.0 44254.5 0
37 2024-01-01 00:37:00+00:00 45291 45802.0 42707.0 44254.5 0
38 2024-01-01 00:38:00+00:00 45053 45802.0 42767.0 44284.5 0
39 2024-01-01 00:39:00+00:00 45312 45802.0 43069.0 44435.5 0

Explanation:

  • shift(1) on high and low before the rolling window is critical — it ensures the channel is computed from historical data only, preventing the current candle's price from contributing to the threshold that triggers its own signal (lookahead bias).
  • dc_upper captures the highest resistance the market has encountered over N candles. A close at or above this level means the market has printed a new N-period high — a classically bullish breakout event.
  • dc_lower captures the deepest support. A close at or below signals a new N-period low — a classically bearish breakdown.
  • The middle channel is provided as a reference level but does not generate signals.

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["dc_upper"],
    mode="lines", name="DC Upper", line=dict(color="blue",  width=1, dash="dash")))
fig.add_trace(go.Scatter(x=df_signals["datetime"], y=df_signals["dc_lower"],
    mode="lines", name="DC Lower", line=dict(color="blue",  width=1, dash="dash")))
fig.add_trace(go.Scatter(x=df_signals["datetime"], y=df_signals["dc_middle"],
    mode="lines", name="DC Middle",line=dict(color="gray",  width=1, dash="dot")))

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="Donchian Channel Breakout Strategy",
    xaxis_rangeslider_visible=False,
    xaxis_title="Datetime", yaxis_title="Price",
    height=600, yaxis=dict(autorange=True),
)
fig.show()

Explanation: The dashed blue lines trace the upper and lower channel boundaries. Buy signals appear immediately after a close above the upper channel; sell signals appear after a close below the lower channel. The middle channel provides visual context for where the midpoint of the range sits.

Conclusion

This notebook demonstrates the implementation of a Donchian Channel Breakout strategy. We generated synthetic price data, applied the strategy to identify buy and sell signals, and visualized the results using Plotly. The strategy identifies momentum shifts by looking for price breaks above the highest high or below the lowest low of a specified lookback window.