Signals·Chart Pattern Detection·Intermediate

Head and Shoulders Detection

Implement algorithmic detection of head and shoulders topping patterns and inverse head and shoulders bottoming formations using local extrema identification, pattern geometry validation, and neckline breakout confirmation logic.

pattern-recognitiontrading-signals

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

Conclusion

This notebook demonstrates the detection of Head and Shoulders and Inverse Head and Shoulders patterns using scipy.signal.argrelextrema. The strategy identifies potential bullish (+1) or bearish (-1) reversal signals based on the pattern's breakout.

Key Takeaways:

  • H&S patterns are identified by three peaks where the middle one (head) is higher than the two flanking ones (shoulders).
  • Inverse H&S patterns are identified by three troughs where the middle one (head) is lower than the two flanking ones (shoulders).
  • The order parameter in argrelextrema controls the sensitivity of peak/trough detection.
  • symmetry_tol helps in filtering out highly asymmetric patterns, reducing false positives.

Further Improvements:

  • Implement dynamic order and symmetry_tol based on market volatility.
  • Integrate volume analysis for pattern confirmation.
  • Backtest the strategy with real-world data and evaluate performance metrics.
  • Add a neckline breakout confirmation logic.