"""RSI (Relative Strength Index) signal evaluator. Computes the standard 14-period RSI using Wilder's smoothing method and produces overbought (RSI > 70 → BEARISH) or oversold (RSI < 30 → BULLISH) signals with strength scaled by distance from the threshold. When RSI is between 30 and 70 (neutral zone), no signal is produced. """ from __future__ import annotations from services.signal_engine.models import OHLCVBar, SignalDirection, SignalResult from services.signal_engine.signals.base import validate_lookback # Default RSI period (standard Wilder 14-period) DEFAULT_RSI_PERIOD: int = 14 # Minimum bars required: period + 1 (for initial price change calculation) DEFAULT_MIN_BARS: int = DEFAULT_RSI_PERIOD + 1 # 15 # Overbought / oversold thresholds OVERBOUGHT_THRESHOLD: float = 70.0 OVERSOLD_THRESHOLD: float = 30.0 # Maximum possible distance from threshold (used for strength scaling) _MAX_DISTANCE_OVERBOUGHT: float = 100.0 - OVERBOUGHT_THRESHOLD # 30 _MAX_DISTANCE_OVERSOLD: float = OVERSOLD_THRESHOLD - 0.0 # 30 # Confidence multiplier _CONFIDENCE_MULTIPLIER: float = 0.85 def compute_rsi(bars: list[OHLCVBar], period: int = DEFAULT_RSI_PERIOD) -> float | None: """Compute RSI using Wilder's smoothing method. Args: bars: OHLCV bar series (oldest-first). period: RSI period (default 14). Returns: RSI value in [0, 100], or ``None`` if insufficient data. """ min_bars = period + 1 if len(bars) < min_bars: return None closes = [bar.close for bar in bars] # Calculate price changes changes = [closes[i] - closes[i - 1] for i in range(1, len(closes))] # Separate gains and losses for the first `period` changes first_gains = [max(0.0, c) for c in changes[:period]] first_losses = [max(0.0, -c) for c in changes[:period]] avg_gain = sum(first_gains) / period avg_loss = sum(first_losses) / period # Apply Wilder smoothing for subsequent changes for c in changes[period:]: gain = max(0.0, c) loss = max(0.0, -c) avg_gain = (avg_gain * (period - 1) + gain) / period avg_loss = (avg_loss * (period - 1) + loss) / period # Avoid division by zero: if avg_loss is 0, RSI is 100 if avg_loss == 0.0: return 100.0 rs = avg_gain / avg_loss rsi = 100.0 - (100.0 / (1.0 + rs)) return rsi class RSIEvaluator: """RSI signal evaluator. Satisfies the :class:`~services.signal_engine.signals.base.SignalEvaluator` protocol. Parameters ---------- period: RSI calculation period. Defaults to ``14``. """ def __init__(self, period: int = DEFAULT_RSI_PERIOD) -> None: self.period = period self.min_bars = period + 1 # ------------------------------------------------------------------ # Public API (SignalEvaluator protocol) # ------------------------------------------------------------------ def evaluate( self, bars: list[OHLCVBar], timeframe: str, ) -> SignalResult | None: """Evaluate RSI on *bars* for *timeframe*. Returns ``None`` when there are fewer than ``period + 1`` bars or when RSI is in the neutral zone (30–70). """ if not validate_lookback(bars, self.min_bars): return None rsi = compute_rsi(bars, self.period) if rsi is None: return None # Overbought: RSI > 70 → BEARISH (potential reversal down) if rsi > OVERBOUGHT_THRESHOLD: distance = rsi - OVERBOUGHT_THRESHOLD strength = min(1.0, max(0.0, distance / _MAX_DISTANCE_OVERBOUGHT)) confidence = strength * _CONFIDENCE_MULTIPLIER return SignalResult( signal_type="rsi", timeframe=timeframe, strength=strength, direction=SignalDirection.BEARISH, confidence=confidence, metadata={ "rsi": rsi, "period": self.period, "zone": "overbought", }, ) # Oversold: RSI < 30 → BULLISH (potential reversal up) if rsi < OVERSOLD_THRESHOLD: distance = OVERSOLD_THRESHOLD - rsi strength = min(1.0, max(0.0, distance / _MAX_DISTANCE_OVERSOLD)) confidence = strength * _CONFIDENCE_MULTIPLIER return SignalResult( signal_type="rsi", timeframe=timeframe, strength=strength, direction=SignalDirection.BULLISH, confidence=confidence, metadata={ "rsi": rsi, "period": self.period, "zone": "oversold", }, ) # Neutral zone (30 ≤ RSI ≤ 70): no signal return None