import json from datetime import datetime, timezone from zoneinfo import ZoneInfo import logging from dataclasses import dataclass from pathlib import Path from app.analysis.confluence import Cluster from app.analysis.levels import LevelKind logger = logging.getLogger(__name__) @dataclass(slots=True) class Alert: cluster: Cluster message: str # Hand-placed levels that caused this alert; the caller disarms them so a # one-shot alert stays one-shot. tripped: tuple[str, ...] = () # Assigned by the engine, monotonic and persisted, so the same alert carries # the same number on a phone and on a screen. A browser cannot number these: # its counter restarts on reload and differs between tabs. Declared after # `tripped` so existing positional callers keep working. number: int = 0 at: int = 0 @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. Suppression is persisted when given a ``state_path``. Without it the list lives only in memory, so every restart re-fires every zone that currently qualifies — with a four-hour cooldown that turned each deploy into a burst of pushes for zones that had already had their say. """ def __init__( self, min_score: float, cooldown_seconds: int = 900, state_path: Path | None = None, timezone_name: str = "UTC", ): self.min_score = min_score self.cooldown_seconds = cooldown_seconds self.timezone = timezone_name self.state_path = Path(state_path) if state_path else None self._next_number = 1 self._fired: list[_Fired] = self._load() def _load(self) -> list[_Fired]: if not self.state_path or not self.state_path.exists(): return [] try: payload = json.loads(self.state_path.read_text(encoding="utf-8")) # The file used to be a bare list, before alerts were numbered. if isinstance(payload, dict): self._next_number = int(payload.get("next_number", 1)) payload = payload.get("fired", []) return [_Fired(float(item["center"]), int(item["at"])) for item in payload] except Exception: # Corrupt state costs one burst of duplicate alerts, which is a far # better failure than refusing to start the stream. logger.warning("Could not read alert state; starting empty", exc_info=True) return [] def _stamp(self, when: int) -> str: """The alert's time, in the configured zone rather than the server's. Containers run in UTC. A push that says 02:14 when the person reading it sees 21:14 on their wall costs a moment of translation every time. """ moment = datetime.fromtimestamp(when, timezone.utc) try: moment = moment.astimezone(ZoneInfo(self.timezone)) except Exception: # An unknown zone must not cost an alert; UTC is still readable. logger.warning("Unknown alert timezone %r; using UTC", self.timezone) return moment.strftime("%a %H:%M:%S %Z") def _save(self) -> None: if not self.state_path: return try: self.state_path.parent.mkdir(parents=True, exist_ok=True) temporary = self.state_path.with_suffix(self.state_path.suffix + ".tmp") temporary.write_text( json.dumps( { "next_number": self._next_number, "fired": [ {"center": entry.center, "at": entry.at} for entry in self._fired ], }, indent=2, sort_keys=True, ) + "\n", encoding="utf-8", ) temporary.replace(self.state_path) except Exception: # Losing a write means duplicate alerts later, never a missed one. logger.warning("Could not persist alert state", exc_info=True) 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. before = len(self._fired) self._fired = [ entry for entry in self._fired if not ( now - entry.at >= self.cooldown_seconds and abs(entry.center - current_price) > merge_distance ) ] changed = len(self._fired) != before 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 and member.armed ] # 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. # A disarmed one has already had its say and no longer qualifies. 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)) changed = True 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}" ) number = self._next_number self._next_number += 1 fired_at = int(now) # The number leads the message so it survives truncation in a # notification shade, and the time is local because a push read on a # phone is read by a person, not by a machine. message = f"#{number} {message}\n{self._stamp(fired_at)}" alerts.append( Alert( cluster, message, tuple(member.id for member in drawn), number=number, at=fired_at, ) ) if changed: self._save() return alerts