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>
207 lines
8.3 KiB
Python
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
|