diff --git a/README.md b/README.md index f0df4a8..b28fd84 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/app/analysis/alerts.py b/app/analysis/alerts.py index dc4dc4c..3004301 100644 --- a/app/analysis/alerts.py +++ b/app/analysis/alerts.py @@ -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 diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 8c74640..2f61fd4 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -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)