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>
95 lines
3.6 KiB
Python
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
|