from collections import deque from decimal import Decimal import pandas as pd from shared.models import Candle, Signal, OrderSide from strategies.base import BaseStrategy def _compute_rsi(series: pd.Series, period: int) -> float | None: """Compute RSI using Wilder's smoothing (EMA-based).""" if len(series) < period + 1: return None delta = series.diff() gain = delta.clip(lower=0) loss = -delta.clip(upper=0) avg_gain = gain.ewm(com=period - 1, min_periods=period).mean() avg_loss = loss.ewm(com=period - 1, min_periods=period).mean() rs = avg_gain / avg_loss.replace(0, float("nan")) rsi = 100 - (100 / (1 + rs)) value = rsi.iloc[-1] if pd.isna(value): return None return float(value) class RsiStrategy(BaseStrategy): name: str = "rsi" def __init__(self) -> None: super().__init__() self._closes: deque[float] = deque(maxlen=200) self._period: int = 14 self._oversold: float = 30.0 self._overbought: float = 70.0 self._quantity: Decimal = Decimal("0.01") @property def warmup_period(self) -> int: return self._period + 1 def configure(self, params: dict) -> None: self._period = int(params.get("period", 14)) self._oversold = float(params.get("oversold", 30)) self._overbought = float(params.get("overbought", 70)) self._quantity = Decimal(str(params.get("quantity", "0.01"))) if self._period < 2: raise ValueError(f"RSI period must be >= 2, got {self._period}") if not (0 < self._oversold < self._overbought < 100): raise ValueError( f"RSI thresholds must be 0 < oversold < overbought < 100, " f"got oversold={self._oversold}, overbought={self._overbought}" ) if self._quantity <= 0: raise ValueError(f"Quantity must be positive, got {self._quantity}") def reset(self) -> None: self._closes.clear() def on_candle(self, candle: Candle) -> Signal | None: self._closes.append(float(candle.close)) if len(self._closes) < self._period + 1: return None series = pd.Series(list(self._closes)) rsi_value = _compute_rsi(series, self._period) if rsi_value is None: return None if rsi_value < self._oversold: return Signal( strategy=self.name, symbol=candle.symbol, side=OrderSide.BUY, price=candle.close, quantity=self._quantity, reason=f"RSI {rsi_value:.2f} below oversold threshold {self._oversold}", ) elif rsi_value > self._overbought: return Signal( strategy=self.name, symbol=candle.symbol, side=OrderSide.SELL, price=candle.close, quantity=self._quantity, reason=f"RSI {rsi_value:.2f} above overbought threshold {self._overbought}", ) return None