chart/app/analysis/alerts.py
Chris Amow 9da7d43434 Make drawn trendlines able to alert at all
A hand-drawn line could never produce an alert, for two independent reasons:

- cluster_levels dropped any single-member group scoring under 8, and a manual
  line weighs 1 on 5m rising to 4 on 1h. A lone drawn line was discarded before
  it ever reached the alert engine.
- Even had it survived, the engine gates on confluence score, and the threshold
  is 28.

Both are wrong for a drawn line specifically. Weight exists to rank levels
nobody asked for; a line you drew by hand is an explicit statement that this
price matters, so it survives clustering on its own and bypasses the score
threshold. Everything else still has to earn its place.

Alerts naming a drawn line say so — "BEARISH LINE" with the line's label rather
than "BEARISH ZONE ... confluence 1", since knowing which drawing to go look at
is the actionable part. A line that happens to coincide with other levels still
reports as a zone, with the line named alongside.

Verified against the running app: a line placed at the current price now forms a
cluster, where before it was discarded outright.

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

95 lines
3.6 KiB
Python

from dataclasses import dataclass
from app.analysis.confluence import Cluster
from app.analysis.levels import LevelKind
@dataclass(slots=True)
class Alert:
cluster: Cluster
message: str
@dataclass(slots=True)
class _Fired:
side: str
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.
"""
def __init__(self, min_score: float, cooldown_seconds: int = 900):
self.min_score = min_score
self.cooldown_seconds = cooldown_seconds
self._fired: list[_Fired] = []
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.
self._fired = [
entry
for entry in self._fired
if not (
now - entry.at >= self.cooldown_seconds
and abs(entry.center - current_price) > merge_distance
)
]
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]
# 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.
if not drawn and cluster.score < self.min_score:
continue
if abs(cluster.center - current_price) > tolerance:
continue
if any(
entry.side == cluster.side.value
and abs(entry.center - cluster.center) <= merge_distance
for entry in self._fired
):
continue
self._fired.append(_Fired(cluster.side.value, cluster.center, now))
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}"
)
alerts.append(Alert(cluster, message))
return alerts