298 lines
12 KiB
Python
298 lines
12 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 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
|