from collections import deque from decimal import Decimal import pandas as pd from shared.models import Candle, Signal, OrderSide from strategies.base import BaseStrategy class BollingerStrategy(BaseStrategy): name: str = "bollinger" def __init__(self) -> None: super().__init__() self._closes: deque[float] = deque(maxlen=500) self._period: int = 20 self._num_std: float = 2.0 self._min_bandwidth: float = 0.02 self._quantity: Decimal = Decimal("0.01") self._was_below_lower: bool = False self._was_above_upper: bool = False @property def warmup_period(self) -> int: return self._period def configure(self, params: dict) -> None: self._period = int(params.get("period", 20)) self._num_std = float(params.get("num_std", 2.0)) self._min_bandwidth = float(params.get("min_bandwidth", 0.02)) self._quantity = Decimal(str(params.get("quantity", "0.01"))) if self._period < 2: raise ValueError(f"Bollinger period must be >= 2, got {self._period}") if self._num_std <= 0: raise ValueError(f"Bollinger num_std must be > 0, got {self._num_std}") if self._quantity <= 0: raise ValueError(f"Quantity must be positive, got {self._quantity}") self._init_filters( require_trend=False, adx_threshold=float(params.get("adx_threshold", 25.0)), min_volume_ratio=float(params.get("min_volume_ratio", 0.5)), atr_stop_multiplier=float(params.get("atr_stop_multiplier", 2.0)), atr_tp_multiplier=float(params.get("atr_tp_multiplier", 3.0)), ) def reset(self) -> None: self._closes.clear() self._was_below_lower = False self._was_above_upper = False def _bollinger_conviction(self, price: float, band: float, sma: float) -> float: """Map distance from band to conviction (0.1-1.0). Further from band (relative to band width) = stronger signal. """ if sma == 0: return 0.5 distance = abs(price - band) / sma # Scale: 0% distance -> 0.1, 2%+ distance -> ~1.0 return min(1.0, max(0.1, distance * 50)) def on_candle(self, candle: Candle) -> Signal | None: self._update_filter_data(candle) self._closes.append(float(candle.close)) if len(self._closes) < self._period: return None series = pd.Series(list(self._closes)) sma = series.rolling(window=self._period).mean().iloc[-1] std = series.rolling(window=self._period).std().iloc[-1] upper = sma + self._num_std * std lower = sma - self._num_std * std # Bandwidth filter: skip sideways markets if sma != 0 and (upper - lower) / sma < self._min_bandwidth: return None price = float(candle.close) # Track band penetration if price < lower: self._was_below_lower = True if price > upper: self._was_above_upper = True # BUY: was below lower band and recovered back inside if self._was_below_lower and price >= lower: self._was_below_lower = False conviction = self._bollinger_conviction(price, lower, sma) signal = Signal( strategy=self.name, symbol=candle.symbol, side=OrderSide.BUY, price=candle.close, quantity=self._quantity, conviction=conviction, reason=f"Price recovered above lower Bollinger Band ({lower:.2f})", ) return self._apply_filters(signal) # SELL: was above upper band and recovered back inside if self._was_above_upper and price <= upper: self._was_above_upper = False conviction = self._bollinger_conviction(price, upper, sma) signal = Signal( strategy=self.name, symbol=candle.symbol, side=OrderSide.SELL, price=candle.close, quantity=self._quantity, conviction=conviction, reason=f"Price recovered below upper Bollinger Band ({upper:.2f})", ) return self._apply_filters(signal) return None