from collections import deque from decimal import Decimal import pandas as pd from shared.models import Candle, OrderSide, Signal 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 self._squeeze_threshold: float = 0.01 # Bandwidth below this = squeeze self._in_squeeze: bool = False self._squeeze_bars: int = 0 # How many bars in squeeze @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._squeeze_threshold = float(params.get("squeeze_threshold", 0.01)) 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: super().reset() self._closes.clear() self._was_below_lower = False self._was_above_upper = False self._in_squeeze = False self._squeeze_bars = 0 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 price = float(candle.close) # %B calculation bandwidth = (upper - lower) / sma if sma > 0 else 0 pct_b = (price - lower) / (upper - lower) if (upper - lower) > 0 else 0.5 # Squeeze detection if bandwidth < self._squeeze_threshold: self._in_squeeze = True self._squeeze_bars += 1 return None # Don't trade during squeeze, wait for breakout elif self._in_squeeze: # Squeeze just ended — breakout! self._in_squeeze = False squeeze_duration = self._squeeze_bars self._squeeze_bars = 0 if price > sma: # Breakout upward conv = min(0.5 + squeeze_duration * 0.1, 1.0) return self._apply_filters( Signal( strategy=self.name, symbol=candle.symbol, side=OrderSide.BUY, price=candle.close, quantity=self._quantity, conviction=conv, reason=f"Bollinger squeeze breakout UP after {squeeze_duration} bars", ) ) else: # Breakout downward conv = min(0.5 + squeeze_duration * 0.1, 1.0) return self._apply_filters( Signal( strategy=self.name, symbol=candle.symbol, side=OrderSide.SELL, price=candle.close, quantity=self._quantity, conviction=conv, reason=f"Bollinger squeeze breakout DOWN after {squeeze_duration} bars", ) ) # Bandwidth filter: skip sideways markets if sma != 0 and bandwidth < self._min_bandwidth: return None # 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 conv = max(1.0 - pct_b, 0.3) # Closer to lower band = higher conviction signal = Signal( strategy=self.name, symbol=candle.symbol, side=OrderSide.BUY, price=candle.close, quantity=self._quantity, conviction=conv, 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 conv = max(pct_b, 0.3) # Closer to upper band = higher conviction signal = Signal( strategy=self.name, symbol=candle.symbol, side=OrderSide.SELL, price=candle.close, quantity=self._quantity, conviction=conv, reason=f"Price recovered below upper Bollinger Band ({upper:.2f})", ) return self._apply_filters(signal) return None