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.
Strategy — Donchian Channel Breakout
1. Dependency Installation
!pip install pandas numpy plotlyRequirement 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 go3. Strategy Overview
The Donchian Channel is a price envelope constructed from the highest high and lowest low over a rolling lookback window N.
| Band | Definition |
|---|---|
| Upper channel | Highest high over the last N candles |
| Lower channel | Lowest low over the last N candles |
| Middle channel | Average of upper and lower |
Signal logic:
- Price closes at or above the upper channel → Buy (+1): price has broken above the range highs, signaling bullish momentum continuation.
- Price closes at or below the lower channel → Sell (−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)onhighandlowbefore 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_uppercaptures 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_lowercaptures 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.