Stop re-alerting when price crosses a level

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>
This commit is contained in:
Chris Amow 2026-08-10 01:51:54 -05:00
parent dd1d6b7a38
commit 51a9636ea7
3 changed files with 30 additions and 9 deletions

View file

@ -59,15 +59,19 @@ Two fixes brought it back, in this order:
- **Alert suppression** keyed on cluster identity, so a level drifting in or out of a
group counted as a new zone. It now suppresses by *proximity*: two zones within one
ATR are the same zone. 54 → 40.
- **Suppression also matched on side**, and side is positional — a level sitting at
price flips between support and resistance every time price ticks across it. Each
flip read as a new zone. Found by putting a real line at the live price and getting
four pushes in two minutes. 40 → 26.
Only then does the cooldown do anything useful. At threshold `28` the sweep reads:
| cooldown | total | max/session |
|---|---|---|
| 900s | 40 | 30 |
| 3600s | 29 | 20 |
| 7200s | 22 | 14 |
| **14400s (selected)** | **17** | **9** |
| 900s | 26 | 19 |
| 3600s | 18 | 12 |
| 7200s | 12 | 7 |
| **14400s (selected)** | **10** | **5** |
Note the threshold itself is a blunt control: scores are sums of 12s (moving averages,
VWAP) and 16s (prior-day levels), so `20`, `24` and `28` behave identically and `32`

View file

@ -12,7 +12,6 @@ class Alert:
@dataclass(slots=True)
class _Fired:
side: str
center: float
at: int
@ -70,13 +69,16 @@ class AlertEngine:
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(
entry.side == cluster.side.value
and abs(entry.center - cluster.center) <= merge_distance
for entry in self._fired
abs(entry.center - cluster.center) <= merge_distance for entry in self._fired
):
continue
self._fired.append(_Fired(cluster.side.value, cluster.center, now))
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

View file

@ -75,6 +75,21 @@ def test_drawn_line_clustering_with_levels_reports_as_a_zone():
assert "swing high" in alerts[0].message # the line is still named
def test_price_crossing_a_level_does_not_re_alert_on_the_side_flip():
# Side is positional, so a level sitting at price flips between support and
# resistance on every tick across it. This produced a fresh alert per
# crossing — four in two minutes when first tried against a live line.
engine = AlertEngine(min_score=6, cooldown_seconds=900)
line = [drawn_line("ml_1", 100, weight=8)]
below = cluster_levels(line, 100, 99.9, 1) # level above price -> resistance
assert len(engine.evaluate(below, 99.9, 1, 0, "/ES")) == 1
above = cluster_levels(line, 100, 100.1, 1) # price crossed -> now support
assert above and above[0].side is not below[0].side, "the flip must actually occur"
assert engine.evaluate(above, 100.1, 1, 30, "/ES") == []
def test_a_genuinely_separate_zone_still_alerts_during_cooldown():
# The cooldown is per zone, not global: a level far away is new information.
engine = AlertEngine(min_score=6, cooldown_seconds=900)