Signals·Patterns·Intermediate

BOS & CHoCH Detection

Detect Break of Structure (BOS) and Change of Character (CHoCH) events for smart money concept (SMC) based trading.

SMCBOSCHoCHstructure

Strategy — Break of Structure / Change of Character Detection


1. Dependency Installation

[3]
!pip install pandas numpy plotly scipy
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: scipy in /usr/local/lib/python3.12/dist-packages (1.16.3)
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

[4]
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
from scipy.signal import argrelextrema

3. Strategy Overview

In Smart Money Concept (SMC) analysis, Break of Structure (BOS) and Change of Character (CHoCH) describe transitions in price structure.

EventDefinitionImplication
BOS BullishClose breaks above the last significant swing high in an uptrendTrend continuation
BOS BearishClose breaks below the last significant swing low in a downtrendTrend continuation
CHoCH BullishClose breaks above the last swing high during a downtrendPotential reversal
CHoCH BearishClose breaks below the last swing low during an uptrendPotential reversal

Detection logic:

  1. Maintain a running record of the last confirmed swing high and swing low.
  2. Track the current market structure (uptrend / downtrend) from HH/HL analysis.
  3. On each bar, compare the close to the last swing extremum against the expected structure direction.
  4. Emit the appropriate BOS or CHoCH label and signal.

Limitation: CHoCH is prone to false signals during high-volatility spikes; confirmation with volume or a subsequent BOS improves reliability.

4. Data Generation

[5]
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 42003 42114 41499 41579 475.365899 2024-01-01 00:00:00+00:00
1 41563 41700 41514 41620 288.282963 2024-01-01 00:01:00+00:00
2 41668 41905 41666 41861 232.496981 2024-01-01 00:02:00+00:00
3 41835 41946 41525 41568 240.202838 2024-01-01 00:03:00+00:00
4 41587 41602 41493 41512 305.269412 2024-01-01 00:04:00+00:00

5. Strategy Function

[6]
def bos_choch_detection(
    df: pd.DataFrame,
    order: int = 10,
) -> pd.DataFrame:
    """
    Detect Break of Structure (BOS) and Change of Character (CHoCH) events.

    Core logic
    ----------
    1. Identify swing highs and swing lows using argrelextrema.
    2. Track the current structure (uptrend / downtrend) using the most recent
       swing high/low labels.
    3. On each bar, check whether the close breaches the last known swing level:
       - In uptrend: close > last swing high → BOS (continuation, +1).
       - In uptrend: close < last swing low  → CHoCH (bearish reversal, -1).
       - In downtrend: close < last swing low  → BOS (continuation, -1).
       - In downtrend: close > last swing high → CHoCH (bullish reversal, +1).

    Parameters
    ----------
    df : pd.DataFrame
        OHLCV DataFrame with columns: open, high, low, close, volume, datetime.
    order : int
        Bars on each side required to qualify as a swing high or low.

    Returns
    -------
    pd.DataFrame
        Original DataFrame extended with: event, signal.
    """
    df = df.copy().sort_values("datetime", ignore_index=True)
    df["event"]  = "none"
    df["signal"] = 0

    highs = df["high"].values
    lows  = df["low"].values
    close = df["close"].values

    peak_idx   = argrelextrema(highs, np.greater, order=order)[0]
    trough_idx = argrelextrema(lows,  np.less,    order=order)[0]

    # Build sorted list of (bar_index, level, type) for all swing points
    swings = (
        [(i, highs[i], "high") for i in peak_idx] +
        [(i, lows[i],  "low")  for i in trough_idx]
    )
    swings.sort(key=lambda x: x[0])

    if len(swings) < 2:
        return df  # insufficient data

    # State machine tracking current structure
    structure      = "ranging"
    last_sh        = None  # last swing high level
    last_sl        = None  # last swing low level
    prev_sh_label  = None
    prev_sl_label  = None
    sh_vals        = [s[1] for s in swings if s[2] == "high"]
    sl_vals        = [s[1] for s in swings if s[2] == "low"]

    if len(sh_vals) >= 2:
        structure = "uptrend" if sh_vals[-1] > sh_vals[-2] else "downtrend"
    last_sh = sh_vals[-1] if sh_vals else None
    last_sl = sl_vals[-1] if sl_vals else None

    # Walk bars from the last swing onward
    start = swings[-1][0] if swings else 0
    for i in range(start, len(df)):
        c = close[i]
        if last_sh is None or last_sl is None:
            continue

        if structure == "uptrend":
            if c > last_sh:
                df.at[i, "event"]  = "BOS_bullish"
                df.at[i, "signal"] = 1
                last_sh = c  # update swing high reference
            elif c < last_sl:
                df.at[i, "event"]   = "CHoCH_bearish"
                df.at[i, "signal"]  = -1
                structure = "downtrend"

        elif structure == "downtrend":
            if c < last_sl:
                df.at[i, "event"]  = "BOS_bearish"
                df.at[i, "signal"] = -1
                last_sl = c
            elif c > last_sh:
                df.at[i, "event"]   = "CHoCH_bullish"
                df.at[i, "signal"]  = 1
                structure = "uptrend"

    return df

df_signals = bos_choch_detection(df, order=10)

print("--- Event Distribution ---")
print(df_signals["event"].value_counts())
print("\n--- Signal Distribution ---")
print(df_signals["signal"].value_counts())
--- Event Distribution ---
event
none             481
BOS_bullish       18
CHoCH_bullish      1
Name: count, dtype: int64

--- Signal Distribution ---
signal
0    481
1     19
Name: count, dtype: int64

Explanation:

  • State machine: The detector maintains a structure variable that flips between uptrend and downtrend upon each CHoCH, ensuring BOS and CHoCH are always evaluated relative to the confirmed prior structure.
  • last_sh / last_sl: Updated on every BOS, anchoring the new structural reference level for subsequent bars.

6. Visualization

[7]
bos_bull   = df_signals[df_signals["event"] == "BOS_bullish"]
bos_bear   = df_signals[df_signals["event"] == "BOS_bearish"]
choch_bull = df_signals[df_signals["event"] == "CHoCH_bullish"]
choch_bear = df_signals[df_signals["event"] == "CHoCH_bearish"]

fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
    subplot_titles=["Price + BOS / CHoCH Events", "Signal"],
    row_heights=[0.7, 0.3])

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)

for data, symbol, color, label in [
    (bos_bull,   "triangle-up",   "green", "BOS Bull"),
    (bos_bear,   "triangle-down", "red",   "BOS Bear"),
    (choch_bull, "star",          "lime",  "CHoCH Bull"),
    (choch_bear, "star",          "orange","CHoCH Bear"),
]:
    fig.add_trace(go.Scatter(
        x=data["datetime"],
        y=data["low"] * 0.999 if "bull" in label.lower() else data["high"] * 1.001,
        mode="markers", marker=dict(symbol=symbol, size=11, color=color),
        name=label), row=1, col=1)

fig.add_trace(go.Scatter(
    x=df_signals["datetime"], y=df_signals["signal"],
    mode="lines", name="Signal", line=dict(color="purple", width=1)),
    row=2, col=1)
fig.add_hline(y=0, line_dash="dot", line_color="gray", row=2, col=1)

fig.update_layout(
    title_text="Break of Structure / Change of Character",
    xaxis_rangeslider_visible=False,
    height=700, xaxis2_title="Datetime",
)
fig.show()
[7]