"""Base protocol and common helpers for signal evaluators. Defines the ``SignalEvaluator`` protocol that every signal in the Signal Library must satisfy, plus shared utility functions for swing detection, lookback validation, and simple moving average computation. """ from __future__ import annotations from typing import Protocol from services.signal_engine.models import OHLCVBar, SignalResult # --------------------------------------------------------------------------- # Signal evaluator protocol # --------------------------------------------------------------------------- class SignalEvaluator(Protocol): """Protocol for all signal evaluators in the Signal Library. Each evaluator receives a list of OHLCV bars for a single timeframe and returns a ``SignalResult`` when the signal triggers, or ``None`` when insufficient data is available or the signal does not fire. """ def evaluate( self, bars: list[OHLCVBar], timeframe: str, ) -> SignalResult | None: """Evaluate a signal on a single timeframe's bar data. Returns ``None`` when insufficient data is available. """ ... # --------------------------------------------------------------------------- # Common helper functions # --------------------------------------------------------------------------- def find_swing_high( bars: list[OHLCVBar], lookback: int, ) -> tuple[int, float] | None: """Find the highest high in the last *lookback* bars. Args: bars: OHLCV bar series (oldest-first). lookback: Number of recent bars to search. Returns: ``(index, price)`` of the bar with the highest high within the lookback window, or ``None`` if *bars* has fewer than *lookback* entries. """ if len(bars) < lookback or lookback <= 0: return None window = bars[-lookback:] offset = len(bars) - lookback best_idx = 0 best_price = window[0].high for i, bar in enumerate(window): if bar.high >= best_price: best_idx = i best_price = bar.high return (offset + best_idx, best_price) def find_swing_low( bars: list[OHLCVBar], lookback: int, ) -> tuple[int, float] | None: """Find the lowest low in the last *lookback* bars. Args: bars: OHLCV bar series (oldest-first). lookback: Number of recent bars to search. Returns: ``(index, price)`` of the bar with the lowest low within the lookback window, or ``None`` if *bars* has fewer than *lookback* entries. """ if len(bars) < lookback or lookback <= 0: return None window = bars[-lookback:] offset = len(bars) - lookback best_idx = 0 best_price = window[0].low for i, bar in enumerate(window): if bar.low <= best_price: best_idx = i best_price = bar.low return (offset + best_idx, best_price) def validate_lookback(bars: list[OHLCVBar], min_bars: int) -> bool: """Return ``True`` if *bars* contains at least *min_bars* entries.""" return len(bars) >= min_bars def compute_sma(bars: list[OHLCVBar], period: int) -> float | None: """Compute the simple moving average of close prices over the last *period* bars. Args: bars: OHLCV bar series (oldest-first). period: Number of recent bars to average. Returns: The arithmetic mean of the last *period* close prices, or ``None`` if *bars* has fewer than *period* entries or *period* is not positive. """ if period <= 0 or len(bars) < period: return None total = sum(bar.close for bar in bars[-period:]) return total / period