KhueApps
Home/Python/Auto Detect Cup and Handle Pattern with Python

Auto Detect Cup and Handle Pattern with Python

Last updated: October 10, 2025

Overview

The cup and handle is a bullish continuation pattern: a rounded “cup” (two near-equal peaks with a trough) followed by a shallow “handle” pullback and a breakout above resistance. This guide shows how to auto-detect it in Python with pandas and NumPy, focusing on practical, tunable rules that work on end-of-day or intraday bars.

Quickstart

  • Inputs: a close-price series (optionally volume) as pandas Series or NumPy array.
  • Output: a list of detected patterns with indices for left peak, trough, right peak, handle low, and breakout.
  • Timeframe: tune widths for your data (e.g., daily bars vs. 5-minute bars).

Minimal working example

The example generates a synthetic cup and handle, runs detection, and prints matches.

import numpy as np
import pandas as pd

def detect_cup_handle(
    prices,
    min_cup=20,
    max_cup=200,
    depth_min=0.12,
    depth_max=0.50,
    peak_tolerance=0.03,
    left_right_width_ratio=2.0,
    monotonic_min_ratio=0.6,
    handle_min_len=3,
    handle_max_len=30,
    handle_max_drop=0.15,
    breakout_lookahead=20,
    breakout_buffer=0.005,
    smooth_span=10,
):
    s = pd.Series(prices).astype(float)
    y = s.ewm(span=smooth_span, adjust=False).mean().to_numpy()
    n = len(y)
    if n < 60:
        return []

    # Find local extrema via derivative sign changes
    dy = np.diff(y)
    sgn = np.sign(dy)
    # Fill zeros by nearest non-zero sign for stability
    for i in range(1, len(sgn)):
        if sgn[i] == 0:
            sgn[i] = sgn[i-1]
    for i in range(len(sgn)-2, -1, -1):
        if sgn[i] == 0:
            sgn[i] = sgn[i+1]
    transitions = np.diff(sgn)
    peaks = np.where(transitions < 0)[0] + 1
    troughs = np.where(transitions > 0)[0] + 1

    results = []
    if len(peaks) == 0 or len(troughs) == 0:
        return results

    for t in troughs:
        # locate nearest left and right peaks around trough
        li = np.searchsorted(peaks, t) - 1
        ri = li + 1
        if li < 0 or ri >= len(peaks):
            continue
        l = peaks[li]
        r = peaks[ri]
        if r <= l or t <= l or t >= r:
            continue

        cup_width = r - l
        if cup_width < min_cup or cup_width > max_cup:
            continue

        lp, tp, rp = y[l], y[t], y[r]
        # Peak similarity
        if abs(lp - rp) / max(lp, rp) > peak_tolerance:
            continue
        # Cup depth (as a drop from peak level)
        peak_level = min(lp, rp)
        depth = 1.0 - (tp / peak_level)
        if depth < depth_min or depth > depth_max:
            continue

        # Monotonicity: mostly falling on left, rising on right
        left_slopes = np.diff(y[l:t+1])
        right_slopes = np.diff(y[t:r+1])
        if len(left_slopes) == 0 or len(right_slopes) == 0:
            continue
        left_down_ratio = np.mean(left_slopes <= 0)
        right_up_ratio = np.mean(right_slopes >= 0)
        if left_down_ratio < monotonic_min_ratio or right_up_ratio < monotonic_min_ratio:
            continue

        # Width symmetry
        lw = t - l
        rw = r - t
        width_ratio = max(lw, rw) / max(1, min(lw, rw))
        if width_ratio > left_right_width_ratio:
            continue

        # Handle: shallow pullback after the right peak
        h_start = r + 1
        h_end = min(n, r + 1 + handle_max_len)
        if h_end - h_start < handle_min_len:
            continue
        h_window = y[h_start:h_end]
        h_rel = int(np.argmin(h_window))
        h = h_start + h_rel
        drop = (y[r] - y[h]) / y[r]
        if drop < 0 or drop > handle_max_drop or (h - r) < handle_min_len:
            continue

        # Breakout above resistance (near max of the two peaks)
        resistance = max(lp, rp)
        b = None
        search_b_end = min(n, h + 1 + breakout_lookahead)
        for i in range(h + 1, search_b_end):
            if y[i] >= resistance * (1 + breakout_buffer):
                b = i
                break
        if b is None:
            continue

        results.append({
            'left_peak': int(l),
            'trough': int(t),
            'right_peak': int(r),
            'handle_low': int(h),
            'breakout': int(b),
            'cup_width': int(cup_width),
            'depth': float(depth),
            'handle_drop': float(drop),
            'resistance': float(resistance),
        })

    return results

# --- Synthetic example ---
np.random.seed(7)
# Build a cup and handle price path
left = np.linspace(100, 95, 40)                # mild decline
down = np.linspace(95, 80, 20)                 # drop to trough
up = np.linspace(80, 95, 60)                   # rise back to peak
handle = np.r_[95, 94.5, 94.2, 94.0, 94.3, 94.6, 94.8, 95.0]  # shallow pullback
breakout = np.linspace(95.2, 99, 12)           # breakout and follow-through
price = np.r_[left, down[1:], up[1:], handle, breakout]
price += np.random.normal(0, 0.15, size=price.size)  # small noise

patterns = detect_cup_handle(price)
print(f"Detected patterns: {len(patterns)}")
if patterns:
    p = patterns[0]
    print("First pattern indices:", p)
    print("Resistance level:", round(p['resistance'], 2))

Step-by-step algorithm

  1. Smooth prices: apply a short EMA or rolling median to suppress noise.
  2. Find extrema: detect local peaks and troughs using derivative sign changes.
  3. Form a cup candidate: nearest left/right peaks around each trough.
  4. Validate the cup:
    • Peak similarity within tolerance (e.g., ≤3%).
    • Depth in [12%, 50%] of peak level.
    • Mostly decreasing to trough, then increasing (monotonic ratios ≥ 0.6).
    • Left/right widths reasonably symmetric (ratio ≤ 2).
  5. Find the handle: shallow pullback after right peak (≤15% drop, 3–30 bars).
  6. Confirm breakout: price closes above resistance (peak high) within a lookahead window.
  7. Emit pattern with key indices and metrics for downstream signals.

Tuning parameters

  • min_cup, max_cup: bars between left and right peaks; scale to your timeframe.
  • depth_min, depth_max: enforce cup depth realism; tighten to reduce false positives.
  • peak_tolerance: similarity of left/right peaks; smaller is stricter.
  • monotonic_min_ratio: how strictly the cup should be U-shaped.
  • handle_min_len, handle_max_len, handle_max_drop: shape of handle.
  • breakout_lookahead, breakout_buffer: how soon and how far price must break out.
  • smooth_span: higher reduces noise but may miss tighter patterns.

Example defaults are conservative; widen ranges to find more candidates, then filter with backtests.

Using with real OHLCV data

  • Use the close series for detection: detect_cup_handle(df['close'].values).
  • Optional volume check: require average volume in handle < average volume in cup; confirm higher volume on breakout bars.
  • Post-filter by trend: ensure a prior uptrend exists (e.g., price above 100–200 bar MA) before the cup forms.

Pitfalls and validation

  • Trend context: cups in downtrends are weaker. Add a trend filter.
  • Splits/dividends: adjust prices; unadjusted data will distort depths.
  • Over-smoothing: too large smooth_span can flatten the handle and suppress valid breakouts.
  • Timeframe mismatch: parameters must scale with bar interval; intraday cups are usually narrower.
  • Look-ahead bias: only act on a pattern once the breakout is confirmed by a completed bar.
  • Multiple candidates: overlapping cups can occur; deduplicate by taking the earliest breakout or the deepest cup.

Performance notes

  • Complexity: O(n) for extrema plus linear scans around troughs. Efficient on millions of bars.
  • Vectorization: the heavy lifting (EMA and diffs) is vectorized; avoid per-bar Python loops except where necessary for final scans.
  • Windowing: for streaming, keep a rolling buffer of recent bars and incremental extrema to avoid rescanning history.
  • Numba/Polars: for very large universes, JIT the loop or use a faster DataFrame engine.

Small FAQ

  • Q: Do I need high/low prices?
    A: Close is sufficient for detection here; highs can refine resistance, lows help gauge depth more precisely.
  • Q: How do I avoid false positives?
    A: Tighten peak_tolerance, raise monotonic_min_ratio, enforce trend filters, and require volume confirmation.
  • Q: Can I detect multi-timeframe cups?
    A: Run the detector with different parameter sets (e.g., min_cup/max_cup) and merge signals.
  • Q: When is the signal actionable?
    A: Only after the breakout bar closes above resistance (optionally with increased volume).

Next Article: Generating Real-Time Trading Signals with yfinance and Python

Series: Algorithmic Trading with Python

Python