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 Level, LevelKind, Side from app.instrument import DEFAULT_SYMBOL, instrument_for_symbol logger = logging.getLogger(__name__) def _drawn_name(member: Level) -> str: number = f"#{member.number}" if member.number is not None else "" label = (member.label or "").strip() if number and label and label not in (number, f"#{member.number}"): return f"{number} {label}" return number or label or member.id @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 # The push body: the same alert with its number and local time folded in, # because ntfy carries plain text and has nowhere else to put them. The # browser gets `number` as a field and renders it as a badge, so `message` # stays clean and the number is not printed twice. push: str = "" @dataclass(slots=True) class _Fired: center: float at: int symbol: str = DEFAULT_SYMBOL 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"]), str(item.get("symbol") or DEFAULT_SYMBOL), ) 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, "symbol": entry.symbol, } 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, watched: list[Level] | None = None, confluence: bool = True, ) -> list[Alert]: tolerance = 0.5 * atr15 if tolerance <= 0: return [] root = instrument_for_symbol(symbol).schwab_symbol # 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 ] early_drawn = [ member for member in drawn if member.alert_early_points is not None and ( -tolerance <= ( member.anchor_p - current_price if member.anchor_p >= current_price else current_price - member.anchor_p ) <= member.alert_early_points ) ] # 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 (not confluence or cluster.score < self.min_score): continue if abs(cluster.center - current_price) > tolerance and not early_drawn: 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( entry.symbol == root and abs(entry.center - cluster.center) <= merge_distance for entry in self._fired ): continue self._fired.append(_Fired(cluster.center, now, root)) changed = True direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH" timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members)) # A drawing number is what you look up. "confluence 28" is the # score — easy to read as the line id when the drawing is #27. names = ", ".join(_drawn_name(member) for member in drawn) lone_line = bool(drawn) and len(cluster.members) == len(drawn) headline = "LINE" if lone_line else "ZONE" if lone_line: detail = f"{cluster.side.value.title()} {names} @ {cluster.center:.2f}" else: detail = ( f"{cluster.side.value.title()} confluence {cluster.score:g} " f"@ {cluster.low:.2f}-{cluster.high:.2f}" ) if names: detail = f"{cluster.side.value.title()} {names}\n" + detail 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. alerts.append( Alert( cluster, message, tuple(member.id for member in drawn), number=number, at=fired_at, push=f"#{number} {message}\n{self._stamp(fired_at)}", ) ) for level in watched or []: price = level.current_p if level.current_p is not None else level.anchor_p if abs(price - current_price) > tolerance: continue if any( entry.symbol == root and abs(entry.center - price) <= merge_distance for entry in self._fired ): continue self._fired.append(_Fired(price, now, root)) changed = True direction = "BEARISH" if level.side is Side.RESISTANCE else "BULLISH" name = f"{level.period} DMA" if level.period else level.label message = ( f"{direction} DMA {symbol} {current_price:.2f}\n" f"{name} @ {price:.2f}\n{level.tf.value}" ) number = self._next_number self._next_number += 1 fired_at = int(now) cluster = Cluster( f"dma:{level.id}", level.side, price, price, price, level.weight, [level], abs(price - current_price), ) alerts.append( Alert( cluster, message, (), number=number, at=fired_at, push=f"#{number} {message}\n{self._stamp(fired_at)}", ) ) if changed: self._save() return alerts