"""Bollinger Band breakout strategy — trade on price breaching Bollinger Bands.""" from datetime import datetime, timezone from shared.schemas.trading import MarketSnapshot, SentimentContext, SignalDirection, TradeSignal from shared.strategies.base import BaseStrategy class BollingerBreakoutStrategy(BaseStrategy): """Generate signals when price breaks through Bollinger Bands. **Buy signal** (LONG): - Price > upper band AND volume > 1.5x average volume (momentum breakout), or - Price < lower band (mean reversion bounce). **Sell signal**: None — this strategy only generates LONG signals. Signal strength = distance from the relevant band / band_width, clamped to [0, 1]. """ name: str = "bollinger_breakout" async def evaluate( self, ticker: str, market: MarketSnapshot, sentiment: SentimentContext | None = None, ) -> TradeSignal | None: if ( market.bollinger_upper is None or market.bollinger_mid is None or market.bollinger_lower is None ): return None price = market.current_price upper = market.bollinger_upper lower = market.bollinger_lower band_width = upper - lower if band_width <= 0: return None avg_volume = self._avg_volume(market.bars) direction: SignalDirection | None = None distance: float = 0.0 if price > upper and market.volume > 1.5 * avg_volume: # Momentum breakout above upper band on high volume. direction = SignalDirection.LONG distance = price - upper elif price < lower: # Mean reversion bounce off lower band. direction = SignalDirection.LONG distance = lower - price else: return None raw_strength = distance / band_width strength = max(0.0, min(1.0, raw_strength)) return TradeSignal( ticker=ticker, direction=direction, strength=strength, strategy_sources=[self.name], timestamp=datetime.now(tz=timezone.utc), ) @staticmethod def _avg_volume(bars: list[dict]) -> float: """Compute average volume from historical bars. Returns a fallback of 0.0 if no bars are available so the volume comparison always works (volume > 1.5 * 0 is always true when volume > 0). """ if not bars: return 0.0 volumes = [b.get("volume", 0) for b in bars if "volume" in b] return sum(volumes) / len(volumes) if volumes else 0.0