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