Armed drawings and DMA bells still fire. The pref lives in user_prefs so pushes follow it, not a display checkbox.
230 lines
9.6 KiB
Python
230 lines
9.6 KiB
Python
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 drawn_line(id_: str, price: float, label: str = "swing high", weight: float = 1):
|
|
return Level(id_, LevelKind.MANUAL, Timeframe.M5, Side.RESISTANCE, weight, 1, label, 100, price, 0, None, 0, 100, 100, False, False)
|
|
|
|
|
|
def test_oscillation_fires_once_until_separation_and_cooldown():
|
|
engine = AlertEngine(min_score=6, cooldown_seconds=900)
|
|
levels = [level("a", 100, 3), level("b", 100.1, 4)]
|
|
cluster = cluster_levels(levels, 100, 100, 1)
|
|
|
|
assert len(engine.evaluate(cluster, 100, 1, 0, "/ES")) == 1
|
|
assert engine.evaluate(cluster, 100.2, 1, 60, "/ES") == []
|
|
assert engine.evaluate(cluster, 100, 1, 901, "/ES") == []
|
|
|
|
far_cluster = cluster_levels(levels, 100, 103, 1)
|
|
assert engine.evaluate(far_cluster, 103, 1, 902, "/ES") == []
|
|
assert len(engine.evaluate(cluster, 100, 1, 903, "/ES")) == 1
|
|
|
|
|
|
def test_confluence_off_skips_auto_zones_but_keeps_drawn_lines():
|
|
engine = AlertEngine(min_score=6, cooldown_seconds=900)
|
|
auto = cluster_levels([level("a", 100, 3), level("b", 100.1, 4)], 100, 100, 1)
|
|
assert engine.evaluate(auto, 100, 1, 0, "/ES", confluence=False) == []
|
|
drawn = cluster_levels([drawn_line("ml_1", 100)], 100, 100, 1)
|
|
assert len(engine.evaluate(drawn, 100, 1, 0, "/ES", confluence=False)) == 1
|
|
|
|
|
|
def test_score_threshold_blocks_two_daily_mas_at_default_calibration():
|
|
engine = AlertEngine(min_score=28)
|
|
cluster = cluster_levels([level("a", 100, 12), level("b", 100.1, 12)], 100, 100, 1)
|
|
assert engine.evaluate(cluster, 100, 1, 0, "/ES") == []
|
|
|
|
|
|
def test_a_third_level_joining_the_zone_does_not_re_alert():
|
|
# Membership churns constantly as levels drift in and out of tolerance.
|
|
# Suppression is by proximity precisely so this reads as one zone.
|
|
engine = AlertEngine(min_score=6, cooldown_seconds=900)
|
|
two = cluster_levels([level("a", 100, 3), level("b", 100.1, 4)], 100, 100, 1)
|
|
assert len(engine.evaluate(two, 100, 1, 0, "/ES")) == 1
|
|
|
|
three = cluster_levels(
|
|
[level("a", 100, 3), level("b", 100.1, 4), level("c", 100.2, 5)], 100, 100, 1
|
|
)
|
|
assert engine.evaluate(three, 100, 1, 60, "/ES") == []
|
|
|
|
|
|
def test_a_numbered_drawn_line_is_named_by_its_drawing_not_the_score():
|
|
engine = AlertEngine(min_score=28, cooldown_seconds=900)
|
|
line = drawn_line("ml_1", 100, label="up1h")
|
|
line.number = 27
|
|
alerts = engine.evaluate(cluster_levels([line], 100, 100, 1), 100, 1, 0, "/ES")
|
|
assert len(alerts) == 1
|
|
assert "#27" in alerts[0].message
|
|
assert "up1h" in alerts[0].message
|
|
assert "confluence" not in alerts[0].message
|
|
|
|
|
|
def test_a_lone_drawn_line_alerts_despite_the_score_threshold():
|
|
# A 5m line weighs 1 against a threshold of 28. Gating drawn lines on score
|
|
# would mean a line you deliberately drew could never alert.
|
|
engine = AlertEngine(min_score=28, cooldown_seconds=900)
|
|
clusters = cluster_levels([drawn_line("ml_1", 100)], 100, 100, 1)
|
|
|
|
assert len(clusters) == 1, "a lone drawn line must survive clustering"
|
|
alerts = engine.evaluate(clusters, 100, 1, 0, "/ES")
|
|
assert len(alerts) == 1
|
|
assert "LINE" in alerts[0].message
|
|
assert "swing high" in alerts[0].message
|
|
|
|
|
|
def test_a_lone_weak_non_drawn_level_still_does_not_alert():
|
|
# The bypass is for drawn lines only; a lone 5m average stays quiet.
|
|
weak = level("ma", 100, 1)
|
|
weak.tf = Timeframe.M5
|
|
assert cluster_levels([weak], 100, 100, 1) == []
|
|
|
|
|
|
def test_drawn_line_clustering_with_levels_reports_as_a_zone():
|
|
engine = AlertEngine(min_score=28, cooldown_seconds=900)
|
|
clusters = cluster_levels([drawn_line("ml_1", 100), level("pd", 100.1, 16)], 100, 100, 1)
|
|
|
|
alerts = engine.evaluate(clusters, 100, 1, 0, "/ES")
|
|
assert len(alerts) == 1
|
|
assert "ZONE" in alerts[0].message
|
|
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)
|
|
near = cluster_levels([level("a", 100, 3), level("b", 100.1, 4)], 100, 100, 1)
|
|
assert len(engine.evaluate(near, 100, 1, 0, "/ES")) == 1
|
|
|
|
far = cluster_levels([level("c", 120, 3), level("d", 120.1, 4)], 100, 120, 1)
|
|
assert len(engine.evaluate(far, 120, 1, 60, "/ES")) == 1
|
|
|
|
|
|
def test_alerts_are_numbered_and_stamped_in_local_time(tmp_path):
|
|
# A push and a screen have to agree on which alert is which. The browser
|
|
# cannot supply that: its counter restarts on reload and differs per tab.
|
|
from app.analysis.alerts import AlertEngine
|
|
|
|
engine = AlertEngine(
|
|
1.0, cooldown_seconds=0, state_path=tmp_path / "alerts.json",
|
|
timezone_name="America/Chicago",
|
|
)
|
|
engine._next_number = 41
|
|
stamp = engine._stamp(1786360000) # Mon 2026-08-10 11:06:40 UTC
|
|
|
|
assert "06:06:40" in stamp and "CDT" in stamp, stamp
|
|
assert "11:06" not in stamp, "stamped in UTC rather than the configured zone"
|
|
|
|
|
|
def test_alert_numbers_survive_a_restart(tmp_path):
|
|
# Numbers restarting from 1 after a deploy would collide with the ones
|
|
# already sitting in a phone's notification history.
|
|
from app.analysis.alerts import AlertEngine
|
|
|
|
path = tmp_path / "alerts.json"
|
|
first = AlertEngine(1.0, cooldown_seconds=0, state_path=path)
|
|
first._next_number = 87
|
|
first._save()
|
|
|
|
assert AlertEngine(1.0, cooldown_seconds=0, state_path=path)._next_number == 87
|
|
|
|
|
|
def test_an_old_bare_list_state_file_still_loads(tmp_path):
|
|
# The file predates numbering and was a plain list of fired zones.
|
|
import json
|
|
from app.analysis.alerts import AlertEngine
|
|
|
|
path = tmp_path / "alerts.json"
|
|
path.write_text(json.dumps([{"center": 5000.0, "at": 1786360000}]), encoding="utf-8")
|
|
|
|
engine = AlertEngine(1.0, cooldown_seconds=0, state_path=path)
|
|
assert len(engine._fired) == 1
|
|
assert engine._next_number == 1
|
|
|
|
|
|
def test_a_watched_daily_ma_alerts_despite_the_score_threshold():
|
|
engine = AlertEngine(min_score=28, cooldown_seconds=900)
|
|
watched = level("ma:1d:sma:200", 100, 12)
|
|
watched.period = 200
|
|
|
|
alerts = engine.evaluate([], 100, 1, 0, "/ES", [watched])
|
|
assert len(alerts) == 1
|
|
assert alerts[0].tripped == ()
|
|
assert "200 DMA" in alerts[0].message
|
|
assert "ZONE" not in alerts[0].message
|
|
|
|
|
|
def test_an_unwatched_daily_ma_does_not_alert_alone():
|
|
engine = AlertEngine(min_score=28, cooldown_seconds=900)
|
|
lone = level("ma:1d:sma:200", 100, 12)
|
|
lone.period = 200
|
|
clusters = cluster_levels([lone], 100, 100, 1)
|
|
|
|
assert engine.evaluate(clusters, 100, 1, 0, "/ES") == []
|
|
|
|
|
|
def test_a_watched_daily_ma_stays_quiet_until_price_leaves():
|
|
engine = AlertEngine(min_score=28, cooldown_seconds=900)
|
|
watched = level("ma:1d:sma:50", 100, 12)
|
|
watched.period = 50
|
|
|
|
assert len(engine.evaluate([], 100, 1, 0, "/ES", [watched])) == 1
|
|
assert engine.evaluate([], 100.2, 1, 60, "/ES", [watched]) == []
|
|
assert engine.evaluate([], 103, 1, 901, "/ES", [watched]) == []
|
|
assert len(engine.evaluate([], 100, 1, 902, "/ES", [watched])) == 1
|
|
|
|
|
|
def test_a_zone_at_the_same_price_suppresses_a_dma_alert():
|
|
engine = AlertEngine(min_score=6, cooldown_seconds=900)
|
|
members = [level("a", 100, 3), level("b", 100.1, 4)]
|
|
clusters = cluster_levels(members, 100, 100, 1)
|
|
watched = level("ma:1d:sma:200", 100, 12)
|
|
watched.period = 200
|
|
|
|
alerts = engine.evaluate(clusters, 100, 1, 0, "/ES", [watched])
|
|
assert len(alerts) == 1
|
|
assert "ZONE" in alerts[0].message
|
|
assert "DMA" not in alerts[0].message
|
|
|
|
|
|
def test_the_push_carries_the_number_and_time_but_the_screen_message_does_not(tmp_path):
|
|
# ntfy is plain text, so the number and the local time have to live in the
|
|
# body. The browser gets `number` as a field and draws a badge, so printing
|
|
# them in the message too would show the same number twice in one row.
|
|
from app.analysis.alerts import AlertEngine
|
|
from app.analysis.confluence import Cluster
|
|
from app.analysis.levels import Level, LevelKind, Side
|
|
from app.bars.models import Timeframe
|
|
|
|
level = Level("pd:high", LevelKind.HORIZONTAL, Timeframe.D1, Side.RESISTANCE, 16, 1,
|
|
"PDH", 7784, 5000, 0, None, 0, 7784, 7784, False, False)
|
|
cluster = Cluster("cl_x", Side.RESISTANCE, 7784.0, 7783.0, 7785.5, 21, [level], 0.25)
|
|
engine = AlertEngine(1.0, cooldown_seconds=0, timezone_name="America/Chicago")
|
|
engine._next_number = 47
|
|
|
|
alert = engine.evaluate([cluster], 7784.25, 4.0, 1786430800, "/ES")[0]
|
|
|
|
assert alert.number == 47
|
|
assert alert.push.startswith("#47 ")
|
|
assert "CDT" in alert.push
|
|
assert not alert.message.startswith("#")
|
|
assert "CDT" not in alert.message
|