"""Cup & Handle pattern signal evaluator. Detects the Cup & Handle chart pattern — a bullish continuation pattern consisting of a U-shaped price recovery (the cup) followed by a small consolidation pullback (the handle). Pattern detection algorithm: 1. Find the left rim (local high in the first third of bars). 2. Find the cup bottom (lowest low between left rim and right rim area). 3. Find the right rim (local high in the last third of bars, near left rim price). 4. Identify the handle as a small pullback after the right rim (last few bars). Pattern completeness scoring: - Cup depth: ``(left_rim - bottom) / left_rim`` — valid range 12–33%. - Symmetry: how close left_rim and right_rim prices are (within 5% = perfect). - Handle: small pullback (< 50% of cup depth) after right rim. The signal is always BULLISH (cup & handle is a bullish continuation pattern). """ from __future__ import annotations from services.signal_engine.models import OHLCVBar, SignalDirection, SignalResult from services.signal_engine.signals.base import validate_lookback # Default minimum number of bars required for cup & handle detection DEFAULT_MIN_BARS: int = 30 # Cup depth valid range (as fraction of left rim price) _CUP_DEPTH_MIN: float = 0.12 # 12% _CUP_DEPTH_MAX: float = 0.33 # 33% # Symmetry: maximum allowed difference between left and right rim prices # as a fraction of left rim price for "perfect" symmetry _SYMMETRY_PERFECT_PCT: float = 0.05 # 5% # Handle: maximum pullback as fraction of cup depth _HANDLE_MAX_RETRACE: float = 0.50 # 50% of cup depth # Handle lookback: number of bars at the end to check for handle _HANDLE_LOOKBACK_FRACTION: float = 0.15 # last 15% of bars # Confidence multiplier _CONFIDENCE_MULTIPLIER: float = 0.90 class CupHandleEvaluator: """Cup & Handle pattern signal evaluator. Satisfies the :class:`~services.signal_engine.signals.base.SignalEvaluator` protocol. Parameters ---------- min_bars: Minimum number of OHLCV bars required before the evaluator will produce a signal. Defaults to ``30``. """ def __init__(self, min_bars: int = DEFAULT_MIN_BARS) -> None: self.min_bars = min_bars # ------------------------------------------------------------------ # Public API (SignalEvaluator protocol) # ------------------------------------------------------------------ def evaluate( self, bars: list[OHLCVBar], timeframe: str, ) -> SignalResult | None: """Evaluate Cup & Handle pattern on *bars* for *timeframe*. Returns ``None`` when there are fewer than :pyattr:`min_bars` bars, or when no valid cup & handle pattern is detected. """ if not validate_lookback(bars, self.min_bars): return None n = len(bars) # --- Step 1: Find the left rim (highest high in first third) --- first_third_end = n // 3 if first_third_end < 1: return None left_rim_idx = 0 left_rim_price = bars[0].high for i in range(1, first_third_end): if bars[i].high > left_rim_price: left_rim_idx = i left_rim_price = bars[i].high if left_rim_price <= 0: return None # --- Step 2: Find the right rim (highest high in last third) --- last_third_start = n - (n // 3) if last_third_start >= n: return None right_rim_idx = last_third_start right_rim_price = bars[last_third_start].high for i in range(last_third_start + 1, n): if bars[i].high > right_rim_price: right_rim_idx = i right_rim_price = bars[i].high # --- Step 3: Find the cup bottom (lowest low between rims) --- search_start = left_rim_idx + 1 search_end = right_rim_idx if search_start >= search_end: return None bottom_idx = search_start bottom_price = bars[search_start].low for i in range(search_start + 1, search_end): if bars[i].low < bottom_price: bottom_idx = i bottom_price = bars[i].low # --- Step 4: Validate cup depth --- cup_depth = left_rim_price - bottom_price if cup_depth <= 0: return None cup_depth_pct = cup_depth / left_rim_price if cup_depth_pct < _CUP_DEPTH_MIN or cup_depth_pct > _CUP_DEPTH_MAX: return None # --- Step 5: Score symmetry (left rim vs right rim) --- rim_diff_pct = abs(left_rim_price - right_rim_price) / left_rim_price if rim_diff_pct <= _SYMMETRY_PERFECT_PCT: symmetry_score = 1.0 else: # Linear decay from 1.0 at 5% to 0.0 at 20% max_diff = 0.20 symmetry_score = max(0.0, 1.0 - (rim_diff_pct - _SYMMETRY_PERFECT_PCT) / (max_diff - _SYMMETRY_PERFECT_PCT)) # Right rim must be at least close to left rim (within 20%) if symmetry_score <= 0.0: return None # --- Step 6: Detect and score the handle --- handle_lookback = max(2, int(n * _HANDLE_LOOKBACK_FRACTION)) handle_bars = bars[-handle_lookback:] # Handle is a small pullback from the right rim handle_low = min(b.low for b in handle_bars) handle_depth = right_rim_price - handle_low if cup_depth <= 0: return None handle_retrace = handle_depth / cup_depth if handle_retrace > _HANDLE_MAX_RETRACE: # Handle is too deep — not a valid cup & handle return None # Handle score: 1.0 when handle is very shallow, decreasing as it deepens if handle_retrace <= 0: handle_score = 1.0 else: handle_score = 1.0 - (handle_retrace / _HANDLE_MAX_RETRACE) # --- Step 7: Cup depth quality score --- # Ideal cup depth is around 20-25% — score peaks in the middle of valid range ideal_depth = (_CUP_DEPTH_MIN + _CUP_DEPTH_MAX) / 2.0 # 0.225 depth_deviation = abs(cup_depth_pct - ideal_depth) / ((_CUP_DEPTH_MAX - _CUP_DEPTH_MIN) / 2.0) depth_score = max(0.0, 1.0 - depth_deviation) # --- Step 8: Compute overall completeness --- completeness = ( 0.35 * symmetry_score + 0.35 * depth_score + 0.30 * handle_score ) completeness = max(0.0, min(1.0, completeness)) # --- Step 9: Build signal result --- strength = completeness confidence = completeness * _CONFIDENCE_MULTIPLIER return SignalResult( signal_type="cup_handle", timeframe=timeframe, strength=strength, direction=SignalDirection.BULLISH, confidence=confidence, metadata={ "left_rim": left_rim_price, "left_rim_idx": left_rim_idx, "right_rim": right_rim_price, "right_rim_idx": right_rim_idx, "bottom": bottom_price, "bottom_idx": bottom_idx, "cup_depth_pct": round(cup_depth_pct, 4), "handle_depth": round(handle_depth, 4), "handle_retrace_pct": round(handle_retrace, 4), "symmetry_score": round(symmetry_score, 4), "depth_score": round(depth_score, 4), "handle_score": round(handle_score, 4), "completeness": round(completeness, 4), }, )