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 scipyRequirement 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 argrelextrema3. Strategy Overview
In Smart Money Concept (SMC) analysis, Break of Structure (BOS) and Change of Character (CHoCH) describe transitions in price structure.
| Event | Definition | Implication |
|---|---|---|
| BOS Bullish | Close breaks above the last significant swing high in an uptrend | Trend continuation |
| BOS Bearish | Close breaks below the last significant swing low in a downtrend | Trend continuation |
| CHoCH Bullish | Close breaks above the last swing high during a downtrend | Potential reversal |
| CHoCH Bearish | Close breaks below the last swing low during an uptrend | Potential reversal |
Detection logic:
- Maintain a running record of the last confirmed swing high and swing low.
- Track the current market structure (uptrend / downtrend) from HH/HL analysis.
- On each bar, compare the close to the last swing extremum against the expected structure direction.
- 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
structurevariable that flips betweenuptrendanddowntrendupon 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]