chart/app/analysis/alerts.py
Chris Amow 32e25b84aa Number alerts, stamp them locally, and compact the zone list
Alerts get a number, assigned server-side and shown in both the push and the
Events list, so a notification on a phone can be matched to a row on a screen
when several fire together. It could not come from the browser: that counter
restarts on reload and differs between tabs. It is persisted next to the
cooldown state, because numbering restarting after a deploy would collide with a
phone's existing notification history — which changed that file from a list to
an object, with the loader still reading the old shape.

Pushes now carry a timestamp in the configured zone rather than the server's.
ALERT_TIMEZONE defaults to America/Chicago; containers run UTC, and a push
reading 02:14 to someone seeing 21:14 costs a translation every time. The
browser already formats its own times locally and is unchanged.

Confluence zones are one line each, ordered by price rather than by proximity,
so the list reads top to bottom the way the chart does and all of them fit on
screen — sixteen zones in 394px, about 25px each, where each previously took a
four-line block. Ordering is a display concern only: the server still returns
them nearest-first, which is what the alert path wants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:44:57 -05:00

207 lines
8.3 KiB
Python

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