Notebooks/Head & Shoulders Detection
Signals·Patterns·Advanced

Head & Shoulders Detection

Detect head and shoulders (and inverse) patterns programmatically using peak/trough analysis and neckline confirmation.

head and shoulderschart patternsreversal

Strategy — Head and Shoulders Pattern Detection


1. Dependency Installation

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

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

Head and Shoulders (H&S) is a classical reversal pattern with three peaks: a central peak (head) taller than both flanking peaks (shoulders). The neckline connects the troughs between the peaks.

Detection logic:

  1. Identify local maxima (peaks) using a rolling-window extrema finder.
  2. For each set of three consecutive peaks: verify peak[1] > peak[0] and peak[1] > peak[2] (head higher than shoulders).
  3. Optionally enforce shoulder symmetry: abs(peak[0] - peak[2]) / peak[1] < tolerance.
  4. The neckline breakout (close < neckline) confirms the pattern and emits a Sell (−1) signal.

Inverse H&S (three troughs, middle lowest) signals a bullish reversal (+1).

Limitation: Pattern identification is sensitive to the peak-detection window and symmetry tolerance; false positives increase in choppy markets.

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 42004 42122 41940 42000 384.016416 2024-01-01 00:00:00+00:00
1 42002 42035 41723 41724 151.607109 2024-01-01 00:01:00+00:00
2 41738 41740 41550 41581 470.298317 2024-01-01 00:02:00+00:00
3 41592 41807 41541 41716 280.615738 2024-01-01 00:03:00+00:00
4 41670 41834 41425 41485 381.849756 2024-01-01 00:04:00+00:00

5. Strategy Function

[ ]
def head_and_shoulders_detection(
    df: pd.DataFrame,
    order: int = 10,
    symmetry_tol: float = 0.05,
) -> pd.DataFrame:
    """
    Detect Head and Shoulders (H&S) and Inverse H&S patterns in price data.

    Core logic
    ----------
    1. Locate local high maxima (peaks) and low minima (troughs) via scipy argrelextrema.
    2. Iterate consecutive triplets of peaks: classify as H&S if the middle peak
       (head) exceeds both shoulders and shoulder heights are within symmetry_tol.
    3. Iterate consecutive triplets of troughs: classify as Inverse H&S if the
       middle trough is lower than both sides and depths are within symmetry_tol.
    4. Mark the bar immediately following each confirmed pattern with the signal.

    Parameters
    ----------
    df : pd.DataFrame
        OHLCV DataFrame with columns: open, high, low, close, volume, datetime.
    order : int
        Number of bars on each side required to qualify as a local extremum.
    symmetry_tol : float
        Maximum allowed fractional difference between left and right shoulder heights.

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

    close = df["close"].values

    # ── Locate local extrema ─────────────────────────────────────────────────
    peak_idx   = argrelextrema(close, np.greater, order=order)[0]  # local highs
    trough_idx = argrelextrema(close, np.less,    order=order)[0]  # local lows

    # ── Head and Shoulders (bearish reversal) ────────────────────────────────
    for i in range(len(peak_idx) - 2):
        ls_i, h_i, rs_i = peak_idx[i], peak_idx[i+1], peak_idx[i+2]
        ls, h, rs = close[ls_i], close[h_i], close[rs_i]
        if h > ls and h > rs and abs(ls - rs) / h < symmetry_tol:
            # Neckline = average of the two intervening troughs
            signal_bar = min(rs_i + 1, len(df) - 1)
            df.at[signal_bar, "pattern"] = "head_and_shoulders"
            df.at[signal_bar, "signal"]  = -1  # bearish breakout

    # ── Inverse Head and Shoulders (bullish reversal) ────────────────────────
    for i in range(len(trough_idx) - 2):
        ls_i, h_i, rs_i = trough_idx[i], trough_idx[i+1], trough_idx[i+2]
        ls, h, rs = close[ls_i], close[h_i], close[rs_i]
        if h < ls and h < rs and abs(ls - rs) / abs(h) < symmetry_tol:
            signal_bar = min(rs_i + 1, len(df) - 1)
            df.at[signal_bar, "pattern"] = "inverse_head_and_shoulders"
            df.at[signal_bar, "signal"]  = 1   # bullish breakout

    return df

df_signals = head_and_shoulders_detection(df, order=10, symmetry_tol=0.05)

print("--- Pattern Distribution ---")
print(df_signals["pattern"].value_counts())
print("\n--- Signal Distribution ---")
print(df_signals["signal"].value_counts())
--- Pattern Distribution ---
pattern
none                          495
inverse_head_and_shoulders      3
head_and_shoulders              2
Name: count, dtype: int64

--- Signal Distribution ---
signal
 0    495
 1      3
-1      2
Name: count, dtype: int64

Explanation:

  • argrelextrema(close, np.greater, order=N): Returns indices where close[i] is strictly greater than all neighbours within ±N bars — these are local peaks.
  • symmetry_tol: Constrains the fractional height difference between left and right shoulders, filtering asymmetric formations that may be noise.

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 + H&S Signals", "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)

fig.add_trace(go.Scatter(
    x=buy_signals["datetime"], y=buy_signals["low"] * 0.999,
    mode="markers", marker=dict(symbol="triangle-up", size=12, color="green"),
    name="Inv H&S (+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=12, color="red"),
    name="H&S (-1)"), 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="Head and Shoulders Detection",
    xaxis_rangeslider_visible=False,
    height=700,
    xaxis2_title="Datetime",
)
fig.show()
[ ]