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.
Strategy — Head and Shoulders Pattern Detection
1. Dependency Installation
!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
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
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:
- Identify local maxima (peaks) using a rolling-window extrema finder.
- For each set of three consecutive peaks: verify
peak[1] > peak[0]andpeak[1] > peak[2](head higher than shoulders). - Optionally enforce shoulder symmetry:
abs(peak[0] - peak[2]) / peak[1] < tolerance. - 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 whereclose[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
orderparameter inargrelextremacontrols the sensitivity of peak/trough detection. symmetry_tolhelps in filtering out highly asymmetric patterns, reducing false positives.
Further Improvements:
- Implement dynamic
orderandsymmetry_tolbased 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.