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:
parent
dd1d6b7a38
commit
51a9636ea7
3 changed files with 30 additions and 9 deletions
12
README.md
12
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
|
- **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
|
group counted as a new zone. It now suppresses by *proximity*: two zones within one
|
||||||
ATR are the same zone. 54 → 40.
|
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:
|
Only then does the cooldown do anything useful. At threshold `28` the sweep reads:
|
||||||
|
|
||||||
| cooldown | total | max/session |
|
| cooldown | total | max/session |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 900s | 40 | 30 |
|
| 900s | 26 | 19 |
|
||||||
| 3600s | 29 | 20 |
|
| 3600s | 18 | 12 |
|
||||||
| 7200s | 22 | 14 |
|
| 7200s | 12 | 7 |
|
||||||
| **14400s (selected)** | **17** | **9** |
|
| **14400s (selected)** | **10** | **5** |
|
||||||
|
|
||||||
Note the threshold itself is a blunt control: scores are sums of 12s (moving averages,
|
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`
|
VWAP) and 16s (prior-day levels), so `20`, `24` and `28` behave identically and `32`
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ class Alert:
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class _Fired:
|
class _Fired:
|
||||||
side: str
|
|
||||||
center: float
|
center: float
|
||||||
at: int
|
at: int
|
||||||
|
|
||||||
|
|
@ -70,13 +69,16 @@ class AlertEngine:
|
||||||
continue
|
continue
|
||||||
if abs(cluster.center - current_price) > tolerance:
|
if abs(cluster.center - current_price) > tolerance:
|
||||||
continue
|
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(
|
if any(
|
||||||
entry.side == cluster.side.value
|
abs(entry.center - cluster.center) <= merge_distance for entry in self._fired
|
||||||
and abs(entry.center - cluster.center) <= merge_distance
|
|
||||||
for entry in self._fired
|
|
||||||
):
|
):
|
||||||
continue
|
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"
|
direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH"
|
||||||
timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members))
|
timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members))
|
||||||
# Naming the line matters: "your line" is actionable in a way that
|
# Naming the line matters: "your line" is actionable in a way that
|
||||||
|
|
|
||||||
|
|
@ -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
|
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():
|
def test_a_genuinely_separate_zone_still_alerts_during_cooldown():
|
||||||
# The cooldown is per zone, not global: a level far away is new information.
|
# The cooldown is per zone, not global: a level far away is new information.
|
||||||
engine = AlertEngine(min_score=6, cooldown_seconds=900)
|
engine = AlertEngine(min_score=6, cooldown_seconds=900)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue