87 lines
3.3 KiB
Python
87 lines
3.3 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_an_es_zone_does_not_suppress_the_same_price_on_gold(tmp_path):
|
|
one = engine(tmp_path)
|
|
assert len(one.evaluate(zone(), 100, 1, 0, "/ES")) == 1
|
|
assert len(one.evaluate(zone(), 100, 1, 60, "/GC")) == 1
|
|
assert one.evaluate(zone(), 100, 1, 90, "ES=F") == []
|
|
|
|
|
|
def test_legacy_fired_rows_without_symbol_are_es(tmp_path):
|
|
path = tmp_path / "alert_state.json"
|
|
path.write_text(
|
|
'{"next_number": 2, "fired": [{"center": 100.0, "at": 0}]}\n',
|
|
encoding="utf-8",
|
|
)
|
|
two = engine(tmp_path)
|
|
assert two.evaluate(zone(), 100, 1, 60, "/ES") == []
|
|
assert len(two.evaluate(zone(), 100, 1, 60, "/GC")) == 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["fired"][0]["symbol"] == "/ES"
|
|
assert payload["next_number"] == 2
|