Producing a real alert to check the wiring showed #47 twice in one Events row: once as the badge the browser draws from the `number` field, and again at the start of the message, because the number had been prefixed onto the shared string. ntfy carries plain text and has nowhere else to put a number or a timestamp, so those belong in a push body built for it. The browser already receives `number` and `at` as fields and formats its own local time, so its message stays clean. Alert now carries both: `message` for a screen, `push` for a phone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
212 lines
8.6 KiB
Python
212 lines
8.6 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
|
|
# 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
|
|
|
|
|
|
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.
|
|
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)}",
|
|
)
|
|
)
|
|
if changed:
|
|
self._save()
|
|
return alerts
|