Alerts get a number, assigned server-side and shown in both the push and the Events list, so a notification on a phone can be matched to a row on a screen when several fire together. It could not come from the browser: that counter restarts on reload and differs between tabs. It is persisted next to the cooldown state, because numbering restarting after a deploy would collide with a phone's existing notification history — which changed that file from a list to an object, with the loader still reading the old shape. Pushes now carry a timestamp in the configured zone rather than the server's. ALERT_TIMEZONE defaults to America/Chicago; containers run UTC, and a push reading 02:14 to someone seeing 21:14 costs a translation every time. The browser already formats its own times locally and is unchanged. Confluence zones are one line each, ordered by price rather than by proximity, so the list reads top to bottom the way the chart does and all of them fit on screen — sixteen zones in 394px, about 25px each, where each previously took a four-line block. Ordering is a display concern only: the server still returns them nearest-first, which is what the alert path wants. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
68 lines
2.6 KiB
Python
68 lines
2.6 KiB
Python
"""Suppression has to survive a restart, or every deploy re-alerts."""
|
|
import json
|
|
|
|
from app.analysis.alerts import AlertEngine
|
|
from app.analysis.confluence import cluster_levels
|
|
from app.analysis.levels import Level, LevelKind, Side
|
|
from app.bars.models import Timeframe
|
|
|
|
|
|
def level(id_: str, price: float, weight: float):
|
|
return Level(
|
|
id_, LevelKind.MA, Timeframe.D1, Side.RESISTANCE, weight, 1, id_,
|
|
100, price, 0, None, 0, 100, 100, False, False,
|
|
)
|
|
|
|
|
|
def zone(price: float = 100.0):
|
|
return cluster_levels([level("a", price, 3), level("b", price + 0.1, 4)], 100, price, 1)
|
|
|
|
|
|
def engine(tmp_path, cooldown=14400):
|
|
return AlertEngine(6, cooldown, tmp_path / "alert_state.json")
|
|
|
|
|
|
def test_fires_once_then_suppresses_within_the_process(tmp_path):
|
|
one = engine(tmp_path)
|
|
assert len(one.evaluate(zone(), 100, 1, 0, "/ES")) == 1
|
|
assert one.evaluate(zone(), 100, 1, 60, "/ES") == []
|
|
|
|
|
|
def test_suppression_survives_a_restart(tmp_path):
|
|
one = engine(tmp_path)
|
|
assert len(one.evaluate(zone(), 100, 1, 0, "/ES")) == 1
|
|
|
|
# A second engine over the same state file stands in for a redeploy.
|
|
two = engine(tmp_path)
|
|
assert two.evaluate(zone(), 100, 1, 60, "/ES") == []
|
|
|
|
|
|
def test_without_a_state_path_a_restart_still_refires(tmp_path):
|
|
"""Unchanged behaviour for local runs, which should not write files."""
|
|
assert len(AlertEngine(6, 14400).evaluate(zone(), 100, 1, 0, "/ES")) == 1
|
|
assert len(AlertEngine(6, 14400).evaluate(zone(), 100, 1, 60, "/ES")) == 1
|
|
|
|
|
|
def test_rearms_across_a_restart_after_cooldown_and_separation(tmp_path):
|
|
one = engine(tmp_path, cooldown=900)
|
|
assert len(one.evaluate(zone(), 100, 1, 0, "/ES")) == 1
|
|
|
|
two = engine(tmp_path, cooldown=900)
|
|
# Price genuinely left the zone, and the cooldown has elapsed.
|
|
assert two.evaluate(cluster_levels([level("a", 100, 3)], 100, 103, 1), 103, 1, 902, "/ES") == []
|
|
assert len(two.evaluate(zone(), 100, 1, 903, "/ES")) == 1
|
|
|
|
|
|
def test_corrupt_state_does_not_prevent_alerting(tmp_path):
|
|
(tmp_path / "alert_state.json").write_text("{not json", encoding="utf-8")
|
|
assert len(engine(tmp_path).evaluate(zone(), 100, 1, 0, "/ES")) == 1
|
|
|
|
|
|
def test_state_file_records_centre_and_time(tmp_path):
|
|
engine(tmp_path).evaluate(zone(), 100, 1, 42, "/ES")
|
|
payload = json.loads((tmp_path / "alert_state.json").read_text(encoding="utf-8"))
|
|
# The file carries the alert counter as well as the fired zones, so numbers
|
|
# do not restart from 1 after a deploy and collide with a phone's history.
|
|
assert len(payload["fired"]) == 1
|
|
assert payload["fired"][0]["at"] == 42
|
|
assert payload["next_number"] == 2
|