Signals·Patterns·Beginner

Breakout Detection

Detect price breakouts from consolidation ranges, Donchian channels, and volatility compression zones with volume confirmation.

breakoutconsolidationvolume

Strategy — Price Breakout Detection


1. Dependency Installation

[1]
!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

[2]
import warnings; warnings.filterwarnings("ignore")
import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots

3. Strategy Overview

Breakout detection identifies when price decisively exits a prior consolidation range, signalling the start of a directional move.

Signal logic (Donchian Channel):

  • rolling_high = max(high, window) — upper boundary of the prior range.
  • rolling_low = min(low, window) — lower boundary of the prior range.
  • Close > rolling_high[−1]Buy (+1): upside breakout.
  • Close < rolling_low[−1]Sell (−1): downside breakout.
  • Close within range → No signal (0).

Volume confirmation: breakouts accompanied by above-average volume are flagged as high-conviction.

Limitation: Range breakouts frequently generate false signals in choppy, mean-reverting markets; ATR filtering or volume confirmation reduces noise.

4. Data Generation

[3]
def generate_data(periods: int) -> pd.DataFrame:
    """
    Generate synthetic OHLCV price data using a geometric random walk.

    Parameters
    ----------
    periods : int
        Number of 1-minute bars to generate.

    Returns
    -------
    pd.DataFrame
        DataFrame with columns: open, high, low, close, volume, datetime.
    """
    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 42035 42238 41951 42173 478.886901 2024-01-01 00:00:00+00:00
1 42190 42541 42122 42396 462.782841 2024-01-01 00:01:00+00:00
2 42378 42395 42015 42083 321.601953 2024-01-01 00:02:00+00:00
3 42085 42257 42056 42247 344.033609 2024-01-01 00:03:00+00:00
4 42267 42503 42079 42469 185.881410 2024-01-01 00:04:00+00:00

5. Strategy Function

[4]
def breakout_detection(
    df: pd.DataFrame,
    window: int = 20,
    volume_factor: float = 1.5,
) -> pd.DataFrame:
    """
    Detect price breakouts from a rolling Donchian Channel.

    Core logic
    ----------
    1. Compute the rolling maximum of highs (upper channel) and rolling minimum
       of lows (lower channel) over window bars, shifted by one to avoid look-ahead.
    2. Compare the current close against the prior bar's channel boundaries.
    3. Optionally flag breakouts where volume exceeds volume_factor × rolling average.

    Parameters
    ----------
    df : pd.DataFrame
        OHLCV DataFrame with columns: open, high, low, close, volume, datetime.
    window : int
        Lookback period for the Donchian Channel.
    volume_factor : float
        Multiplier; breakouts with volume > volume_factor × avg_volume are 'confirmed'.

    Returns
    -------
    pd.DataFrame
        Original DataFrame extended with: channel_high, channel_low,
        avg_volume, signal, confirmed_breakout.
    """
    df = df.copy().sort_values("datetime", ignore_index=True)

    # ── Donchian Channel (lagged by 1 bar to prevent look-ahead bias) ────────
    df["channel_high"] = df["high"].rolling(window).max().shift(1)
    df["channel_low"]  = df["low"].rolling(window).min().shift(1)

    # ── Rolling average volume ───────────────────────────────────────────────
    df["avg_volume"] = df["volume"].rolling(window).mean().shift(1)

    # ── Breakout signals ─────────────────────────────────────────────────────
    df["signal"] = np.where(
        df["close"] > df["channel_high"], 1,
        np.where(df["close"] < df["channel_low"], -1, 0)
    )

    # ── Volume confirmation flag ─────────────────────────────────────────────
    df["confirmed_breakout"] = (
        (df["signal"] != 0) &
        (df["volume"] > volume_factor * df["avg_volume"])
    )

    return df

df_signals = breakout_detection(df, window=20, volume_factor=1.5)

print("--- Signal Distribution ---")
print(df_signals["signal"].value_counts())
print("\n--- Confirmed Breakouts ---")
print(df_signals["confirmed_breakout"].value_counts())
--- Signal Distribution ---
signal
 0    439
 1     37
-1     24
Name: count, dtype: int64

--- Confirmed Breakouts ---
confirmed_breakout
False    494
True       6
Name: count, dtype: int64

Explanation:

  • .shift(1): Ensures the channel boundaries are computed from data strictly before the current bar, eliminating look-ahead bias.
  • confirmed_breakout: A secondary boolean flag that filters raw signals by volume, reducing false positives in low-participation moves.

6. Visualization

[5]
buy_signals  = df_signals[df_signals["signal"] ==  1]
sell_signals = df_signals[df_signals["signal"] == -1]

fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
    subplot_titles=["Price + Breakout Signals + Donchian Channel", "Volume"],
    row_heights=[0.65, 0.35])

fig.add_trace(go.Candlestick(
    x=df_signals["datetime"],
    open=df_signals["open"], high=df_signals["high"],
    low=df_signals["low"],   close=df_signals["close"],
    name="Price"), row=1, col=1)

fig.add_trace(go.Scatter(
    x=df_signals["datetime"], y=df_signals["channel_high"],
    mode="lines", name="Channel High", line=dict(color="red", width=1, dash="dash")),
    row=1, col=1)
fig.add_trace(go.Scatter(
    x=df_signals["datetime"], y=df_signals["channel_low"],
    mode="lines", name="Channel Low", line=dict(color="green", width=1, dash="dash")),
    row=1, col=1)

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="Breakout Up (+1)"), row=1, col=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="Breakout Down (-1)"), row=1, col=1)

colors = ["green" if s == 1 else "red" if s == -1 else "gray" for s in df_signals["signal"]]
fig.add_trace(go.Bar(
    x=df_signals["datetime"], y=df_signals["volume"],
    marker_color=colors, name="Volume"), row=2, col=1)

fig.update_layout(
    title_text="Price Breakout Detection",
    xaxis_rangeslider_visible=False,
    height=700, xaxis2_title="Datetime",
)
fig.show()
[ ]