from dataclasses import dataclass from app.analysis.confluence import Cluster @dataclass(slots=True) class Alert: cluster: Cluster message: str @dataclass(slots=True) class _Fired: side: str center: float at: int class AlertEngine: """Fires once per price zone, then stays quiet until price genuinely leaves. Suppression is by proximity rather than cluster identity. Membership churns constantly — a moving average drifts in and out of a group, changing the cluster's identity while a human still sees one zone sitting at the prior day's close. Keying on identity let every reshuffle through as a fresh alert; keying on where the zone *is* does not. """ def __init__(self, min_score: float, cooldown_seconds: int = 900): self.min_score = min_score self.cooldown_seconds = cooldown_seconds self._fired: list[_Fired] = [] def evaluate( self, clusters: list[Cluster], current_price: float, atr15: float, now: int, symbol: str, ) -> list[Alert]: tolerance = 0.5 * atr15 if tolerance <= 0: return [] # Two zones within an ATR of each other are the same zone as far as # being told about them goes. merge_distance = 2 * tolerance # Re-arming needs both elapsed time and real separation. Time alone lets # price oscillating on a level alert forever. self._fired = [ entry for entry in self._fired if not ( now - entry.at >= self.cooldown_seconds and abs(entry.center - current_price) > merge_distance ) ] alerts: list[Alert] = [] # Strongest first, so when several overlapping zones qualify at once the # one that survives suppression is the most significant. for cluster in sorted(clusters, key=lambda item: item.score, reverse=True): if cluster.score < self.min_score or abs(cluster.center - current_price) > tolerance: continue if any( entry.side == cluster.side.value and abs(entry.center - cluster.center) <= merge_distance for entry in self._fired ): continue self._fired.append(_Fired(cluster.side.value, cluster.center, now)) direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH" timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members)) message = ( f"{direction} ZONE {symbol} {current_price:.2f}\n" f"{cluster.side.value.title()} confluence {cluster.score:g} " f"@ {cluster.low:.2f}-{cluster.high:.2f}\n{timeframes}" ) alerts.append(Alert(cluster, message)) return alerts