trading/shared/strategies/bollinger_breakout.py

84 lines
2.6 KiB
Python

"""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