from __future__ import annotations from dataclasses import dataclass from typing import Iterable, List, Sequence, Tuple, Union from app.schemas.schemas import PatternSignal, PriceData BarLike = Union[PriceData, dict] @dataclass class Candle: time: int open: float high: float low: float close: float @property def hl2(self) -> float: return (self.high + self.low) / 2 class CandlestickPatternDetector: """Translated subset of the TradingView *All Candlestick Patterns* study. The detector focuses on high-signal patterns that are most useful for risk automation and narrative building. The implementation is intentionally modular so additional patterns from the Pine script can be ported quickly. """ BODY_AVG_EMA = 14 SHADOW_PERCENT = 5.0 SHADOW_EQUALS_PERCENT = 100.0 DOJI_BODY_PERCENT = 5.0 LONG_LOWER_PERCENT = 75.0 LONG_UPPER_PERCENT = 75.0 HAMMER_FACTOR = 2.0 TREND_SMA = 50 TREND_SMA_LONG = 200 def analyze(self, rows: Iterable[BarLike]) -> List[PatternSignal]: candles = self._normalize(rows) if len(candles) < 3: return [] opens = [c.open for c in candles] highs = [c.high for c in candles] lows = [c.low for c in candles] closes = [c.close for c in candles] times = [c.time for c in candles] body_hi = [max(o, c) for o, c in zip(opens, closes)] body_lo = [min(o, c) for o, c in zip(opens, closes)] bodies = [hi - lo for hi, lo in zip(body_hi, body_lo)] ranges = [h - l for h, l in zip(highs, lows)] upper_shadows = [h - hi for h, hi in zip(highs, body_hi)] lower_shadows = [lo - l for lo, l in zip(body_lo, lows)] body_avg = self._ema_series(bodies, self.BODY_AVG_EMA) sma50 = self._sma_series(closes, self.TREND_SMA) sma200 = self._sma_series(closes, self.TREND_SMA_LONG) up_trend = [False] * len(candles) down_trend = [False] * len(candles) for idx in range(len(candles)): if sma50[idx] is None: if idx > 0: up_trend[idx] = closes[idx] > closes[idx - 1] down_trend[idx] = closes[idx] < closes[idx - 1] continue close = closes[idx] s50 = sma50[idx] s200 = sma200[idx] up = close > s50 down = close < s50 if s200 is not None: up = up and s50 > s200 down = down and s50 < s200 up_trend[idx] = up down_trend[idx] = down pattern_signals: List[PatternSignal] = [] for i in range(len(candles)): detected = self._detect_at( i, candles, body_hi, body_lo, bodies, body_avg, ranges, upper_shadows, lower_shadows, up_trend, down_trend, ) for pattern, classification in detected: pattern_signals.append( PatternSignal( pattern=pattern, classification=classification, price=closes[i], time=times[i], ) ) return pattern_signals # ------------------------------------------------------------------ # Detection helpers # ------------------------------------------------------------------ def _detect_at( self, i: int, candles: Sequence[Candle], body_hi: Sequence[float], body_lo: Sequence[float], bodies: Sequence[float], body_avg: Sequence[float | None], ranges: Sequence[float], upper_shadows: Sequence[float], lower_shadows: Sequence[float], up_trend: Sequence[bool], down_trend: Sequence[bool], ) -> List[Tuple[str, str]]: signals: List[Tuple[str, str]] = [] if i == 0: return signals body = bodies[i] body_average = body_avg[i] or 0.0 range_ = ranges[i] upper = upper_shadows[i] lower = lower_shadows[i] is_white = candles[i].close > candles[i].open is_black = candles[i].open > candles[i].close prev_white = candles[i - 1].close > candles[i - 1].open prev_black = candles[i - 1].open > candles[i - 1].close small_body = body_average > 0 and body < body_average long_body = body_average > 0 and body > body_average has_upper_shadow = upper > self.SHADOW_PERCENT / 100 * body if body > 0 else False has_lower_shadow = lower > self.SHADOW_PERCENT / 100 * body if body > 0 else False doji = self._is_doji(body, range_) # Single-candle patterns ------------------------------------------------- if doji: signals.append(("Doji", "NEUTRAL")) if upper <= body: signals.append(("Dragonfly Doji", "BULLISH")) if lower <= body: signals.append(("Gravestone Doji", "BEARISH")) if body > 0: if not has_upper_shadow and lower >= self.HAMMER_FACTOR * body and candles[i].hl2 < body_lo[i] and down_trend[i]: signals.append(("Hammer", "BULLISH")) if not has_upper_shadow and lower >= self.HAMMER_FACTOR * body and candles[i].hl2 < body_lo[i] and up_trend[i]: signals.append(("Hanging Man", "BEARISH")) if not has_lower_shadow and upper >= self.HAMMER_FACTOR * body and candles[i].hl2 > body_hi[i] and down_trend[i]: signals.append(("Inverted Hammer", "BULLISH")) if not has_lower_shadow and upper >= self.HAMMER_FACTOR * body and candles[i].hl2 > body_hi[i] and up_trend[i]: signals.append(("Shooting Star", "BEARISH")) if body > 0 and upper <= body * self.SHADOW_PERCENT / 100 and lower <= body * self.SHADOW_PERCENT / 100: if is_white: signals.append(("Marubozu White", "BULLISH")) if is_black: signals.append(("Marubozu Black", "BEARISH")) if lower > range_ * self.LONG_LOWER_PERCENT / 100: signals.append(("Long Lower Shadow", "BULLISH")) if upper > range_ * self.LONG_UPPER_PERCENT / 100: signals.append(("Long Upper Shadow", "BEARISH")) # Multi-candle patterns -------------------------------------------------- signals.extend( self._two_candle_patterns( i, candles, body_hi, body_lo, bodies, body_avg, ranges, up_trend, down_trend, ) ) signals.extend( self._three_candle_patterns( i, candles, body_hi, body_lo, bodies, body_avg, up_trend, down_trend, ) ) signals.extend(self._soldiers_and_crows(i, candles, bodies, body_avg)) return signals def _two_candle_patterns( self, i: int, candles: Sequence[Candle], body_hi: Sequence[float], body_lo: Sequence[float], bodies: Sequence[float], body_avg: Sequence[float | None], ranges: Sequence[float], up_trend: Sequence[bool], down_trend: Sequence[bool], ) -> List[Tuple[str, str]]: if i < 1: return [] signals: List[Tuple[str, str]] = [] body = bodies[i] body_prev = bodies[i - 1] avg = body_avg[i] or 0.0 avg_prev = body_avg[i - 1] or 0.0 white = candles[i].close > candles[i].open black = candles[i].open > candles[i].close prev_white = candles[i - 1].close > candles[i - 1].open prev_black = candles[i - 1].open > candles[i - 1].close tol = (avg + avg_prev) / 2 * 0.05 if (avg + avg_prev) > 0 else 0.0 # Tweezer patterns if abs(candles[i].high - candles[i - 1].high) <= tol and prev_white and black and up_trend[i - 1]: signals.append(("Tweezer Top", "BEARISH")) if abs(candles[i].low - candles[i - 1].low) <= tol and prev_black and white and down_trend[i - 1]: signals.append(("Tweezer Bottom", "BULLISH")) # Engulfing if down_trend[i - 1] and prev_black and (avg_prev == 0 or body_prev <= avg_prev) and white: if candles[i].close >= candles[i - 1].open and candles[i].open <= candles[i - 1].close: signals.append(("Bullish Engulfing", "BULLISH")) if up_trend[i - 1] and prev_white and (avg_prev == 0 or body_prev <= avg_prev) and black: if candles[i].close <= candles[i - 1].open and candles[i].open >= candles[i - 1].close: signals.append(("Bearish Engulfing", "BEARISH")) # Piercing / Dark Cloud Cover mid_prev = (candles[i - 1].open + candles[i - 1].close) / 2 if down_trend[i - 1] and prev_black and white: if candles[i].open <= candles[i - 1].low and candles[i].close > mid_prev and candles[i].close < candles[i - 1].open: signals.append(("Piercing", "BULLISH")) if up_trend[i - 1] and prev_white and black: if candles[i].open >= candles[i - 1].high and candles[i].close < mid_prev and candles[i].close > candles[i - 1].open: signals.append(("Dark Cloud Cover", "BEARISH")) # Doji Star variants if self._is_doji(body, ranges[i]) and up_trend[i - 1] and prev_white: if candles[i].open > candles[i - 1].high: signals.append(("Doji Star", "BEARISH")) if self._is_doji(body, ranges[i]) and down_trend[i - 1] and prev_black: if candles[i].open < candles[i - 1].low: signals.append(("Doji Star", "BULLISH")) return signals def _three_candle_patterns( self, i: int, candles: Sequence[Candle], body_hi: Sequence[float], body_lo: Sequence[float], bodies: Sequence[float], body_avg: Sequence[float | None], up_trend: Sequence[bool], down_trend: Sequence[bool], ) -> List[Tuple[str, str]]: if i < 2: return [] signals: List[Tuple[str, str]] = [] c0, c1, c2 = candles[i - 2], candles[i - 1], candles[i] body0, body1, body2 = bodies[i - 2], bodies[i - 1], bodies[i] avg0 = body_avg[i - 2] or 0.0 avg1 = body_avg[i - 1] or 0.0 avg2 = body_avg[i] or 0.0 white2 = c2.close > c2.open black2 = c2.open > c2.close small1 = avg1 > 0 and body1 < avg1 doji1 = self._is_doji(body1, c1.high - c1.low) mid0 = (c0.open + c0.close) / 2 if down_trend[i - 2] and (c0.open > c0.close) and small1 and white2: if c1.open < c0.low and c2.close >= mid0 and c2.close < c0.high: signals.append(("Morning Star", "BULLISH")) if up_trend[i - 2] and (c0.close > c0.open) and small1 and black2: if c1.open > c0.high and c2.close <= mid0 and c2.close > c0.low: signals.append(("Evening Star", "BEARISH")) if down_trend[i - 2] and (c0.open > c0.close) and doji1 and white2: if c1.open < c0.low and c2.close >= mid0 and c2.close < c0.high: signals.append(("Morning Doji Star", "BULLISH")) if up_trend[i - 2] and (c0.close > c0.open) and doji1 and black2: if c1.open > c0.high and c2.close <= mid0 and c2.close > c0.low: signals.append(("Evening Doji Star", "BEARISH")) return signals def _soldiers_and_crows( self, i: int, candles: Sequence[Candle], bodies: Sequence[float], body_avg: Sequence[float | None], ) -> List[Tuple[str, str]]: if i < 2: return [] signals: List[Tuple[str, str]] = [] c0, c1, c2 = candles[i - 2], candles[i - 1], candles[i] body0, body1, body2 = bodies[i - 2], bodies[i - 1], bodies[i] avg0 = body_avg[i - 2] or 0.0 avg1 = body_avg[i - 1] or 0.0 avg2 = body_avg[i] or 0.0 if all(b > a for b, a in zip((body0, body1, body2), (avg0, avg1, avg2))): if c0.close < c0.open and c1.close > c1.open and c2.close > c2.open: if c1.open > c0.close and c2.open > c1.close and c2.close > c1.close > c0.close: signals.append(("Three White Soldiers", "BULLISH")) if c0.close > c0.open and c1.close < c1.open and c2.close < c2.open: if c1.open < c0.close and c2.open < c1.close and c2.close < c1.close < c0.close: signals.append(("Three Black Crows", "BEARISH")) return signals # ------------------------------------------------------------------ # Utility functions # ------------------------------------------------------------------ def _normalize(self, rows: Iterable[BarLike]) -> List[Candle]: candles: List[Candle] = [] for row in rows: if isinstance(row, PriceData): candles.append(Candle(time=row.time, open=row.open, high=row.high, low=row.low, close=row.close)) else: candles.append( Candle( time=int(row.get("time", len(candles))), open=float(row["open"]), high=float(row["high"]), low=float(row["low"]), close=float(row["close"]), ) ) return candles def _ema_series(self, values: Sequence[float], length: int) -> List[float | None]: ema_series: List[float | None] = [None] * len(values) if len(values) < length: return ema_series k = 2 / (length + 1) ema = sum(values[:length]) / length ema_series[length - 1] = ema for idx in range(length, len(values)): ema = values[idx] * k + ema * (1 - k) ema_series[idx] = ema return ema_series def _sma_series(self, values: Sequence[float], length: int) -> List[float | None]: sma_series: List[float | None] = [None] * len(values) if length <= 0: return sma_series window_sum = 0.0 for idx, value in enumerate(values): window_sum += value if idx >= length: window_sum -= values[idx - length] if idx >= length - 1: sma_series[idx] = window_sum / length return sma_series def _is_doji(self, body: float, candle_range: float) -> bool: if candle_range <= 0: return False return body <= candle_range * self.DOJI_BODY_PERCENT / 100 candlestick_detector = CandlestickPatternDetector()