Signals·Chart Pattern Detection·Intermediate

Breakout Detection

Build a comprehensive breakout detection system that identifies price breaks through established support or resistance levels with volume surge confirmation, retest handling logic, and false breakout filtering mechanisms.

pattern-recognitiontrading-signals

Strategy — Price Breakout Detection


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
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

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

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

[ ]
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()

Conclusion

This notebook demonstrates a basic price breakout detection strategy using the Donchian Channel. It identifies potential entry points when the price breaks above the rolling high or below the rolling low. Volume confirmation is incorporated to filter out low-conviction signals.