import asyncio import pytest from app.analysis.alerts import Alert from app.analysis.confluence import Cluster from app.analysis.levels import Level, LevelKind, Side from app.bars.models import Timeframe from app.config import Settings from app.runtime import Runtime def runtime(tmp_path, **overrides) -> Runtime: settings = Settings( manual_lines_path=tmp_path / "manual_lines.json", ntfy_topic=overrides.pop("ntfy_topic", ""), **overrides, ) return Runtime(settings) def alert() -> Alert: level = Level( "pd:high", LevelKind.HORIZONTAL, Timeframe.D1, Side.RESISTANCE, 16, 1, "PDH", 100, 5000, 0, None, 0, 100, 100, False, False, ) cluster = Cluster("cl_x", Side.RESISTANCE, 5000, 5000, 5000, 16, [level], 1.0) return Alert(cluster, "BEARISH ZONE /ES") def test_one_engine_serves_every_connection(tmp_path): # Previously each WebSocket built its own engine, so reloading the page # cleared the cooldown and the same zone alerted again immediately. instance = runtime(tmp_path) assert instance.alert_engine is not None assert instance.alert_engine.min_score == instance.settings.confluence_min_score def test_alerts_reach_subscribers(tmp_path): instance = runtime(tmp_path) queue: asyncio.Queue = asyncio.Queue(maxsize=10) instance.subscribers.add(queue) async def scenario(): instance.dispatch_alerts([alert()]) return queue.get_nowait() event = asyncio.run(scenario()) assert event["type"] == "alert" assert event["message"] == "BEARISH ZONE /ES" def test_ntfy_failure_does_not_propagate(tmp_path, monkeypatch): # send_ntfy used to be awaited inside the WebSocket loop, whose except # clause only caught disconnects — so a push outage killed the connection. instance = runtime(tmp_path, ntfy_topic="chart-test") async def explode(*args, **kwargs): raise RuntimeError("ntfy is down") monkeypatch.setattr("app.runtime.send_ntfy", explode) asyncio.run(instance.notify("anything")) def test_blank_topic_sends_nothing(tmp_path, monkeypatch): instance = runtime(tmp_path) calls = [] async def record(server, topic, message): calls.append(topic) monkeypatch.setattr("app.runtime.send_ntfy", record) asyncio.run(instance.notify("anything")) assert calls == [""] # send_ntfy itself is the one that short-circuits def test_tick_bars_update_higher_timeframes_without_doubling_volume(tmp_path): # The aggregator accumulates with `current.v += incoming.v`, so a forming # minute re-sent on every tick would add its volume to each higher # timeframe again and again. The provisional path must combine, not # accumulate: five ticks in one minute leave the hour's volume equal to the # closed minutes plus the live one, exactly once. from app.bars.models import Bar instance = runtime(tmp_path) def minute(t, close, volume, closed=True): return Bar(tf=Timeframe.M1, t=t, o=close, h=close, l=close, c=close, v=volume, closed=closed, symbol="/ES", source="test") base = 1786356000 # top of an hour asyncio.run(instance.on_bar(minute(base, 100.0, 10))) asyncio.run(instance.on_bar(minute(base + 60, 101.0, 20))) settled = [b for b in instance.store.get(Timeframe.H1) if b.t == base][-1].v assert settled == 30 for _ in range(5): asyncio.run(instance.on_bar(minute(base + 120, 102.0, 7, closed=False))) hour = [b for b in instance.store.get(Timeframe.H1) if b.t == base][-1] assert hour.v == 37, "the live minute's volume must be added once, not per tick" assert hour.c == 102.0 assert hour.closed is False