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
- Smooth prices: apply a short EMA or rolling median to suppress noise.
- Find extrema: detect local peaks and troughs using derivative sign changes.
- Form a cup candidate: nearest left/right peaks around each trough.
- 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).
- Find the handle: shallow pullback after right peak (≤15% drop, 3–30 bars).
- Confirm breakout: price closes above resistance (peak high) within a lookahead window.
- 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).