Suppression matched on cluster side as well as position, but side is positional: a level sitting at price is resistance when price is a tick below it and support a tick later. Every crossing failed the side match and fired as a brand-new zone — which is exactly when a level is least newsworthy, not most. Found the honest way. A flat line placed at the live price produced four phone pushes in two minutes. Zones are now matched on position alone. Side still determines whether the message reads BULLISH or BEARISH; it just no longer decides whether you are told twice. Over the same six replayed sessions at threshold 28 that is 40 alerts down to 26, and 10 at the configured four-hour cooldown with a worst session of 5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
97 lines
3.9 KiB
Python
97 lines
3.9 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:
|
|
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
|
|
# 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))
|
|
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
|