84 lines
2.6 KiB
Python
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
|