from dataclasses import dataclass from app.analysis.confluence import Cluster from app.analysis.levels import LevelKind @dataclass(slots=True) class Alert: cluster: Cluster message: str @dataclass(slots=True) class _Fired: 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): drawn = [member for member in cluster.members if member.kind is LevelKind.MANUAL] # A drawn line bypasses the score threshold entirely. Weights run # from 1 (5m) to 4 (1h) against a threshold of 28, so gating on # score would mean a line you deliberately drew could never alert. if not drawn and cluster.score < self.min_score: continue if abs(cluster.center - current_price) > tolerance: continue # Deliberately not matched on side. A level sitting at price flips # between support and resistance every time price ticks across it, # because the side is positional. Matching on it meant a zone price # was oscillating on re-alerted on every crossing — which is exactly # when a level is least newsworthy, not most. if any( abs(entry.center - cluster.center) <= merge_distance for entry in self._fired ): continue self._fired.append(_Fired(cluster.center, now)) direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH" timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members)) # Naming the line matters: "your line" is actionable in a way that # "confluence 4" is not, and it says which drawing to go look at. headline = "LINE" if drawn and len(cluster.members) == len(drawn) else "ZONE" detail = ( f"{cluster.side.value.title()} confluence {cluster.score:g} " f"@ {cluster.low:.2f}-{cluster.high:.2f}" ) if drawn: detail += "\n" + ", ".join(member.label for member in drawn) message = ( f"{direction} {headline} {symbol} {current_price:.2f}\n{detail}\n{timeframes}" ) alerts.append(Alert(cluster, message)) return alerts