chart/tests/test_runtime_alerts.py
Chris Amow e9c22f6bbd Move alert evaluation server-side so push works without a browser open
Alerts were evaluated inside the WebSocket handler, with a separate AlertEngine
per connection. Three consequences, all of which defeated the point of phone
push:

- No browser connected meant no alert at all. The notification only existed if
  a tab was open to receive it, which is precisely when you least need it.
- Two tabs meant two notifications, since each connection evaluated
  independently.
- Cooldowns lived and died with the connection, so reloading the page cleared
  them and a zone that had just alerted alerted again at once.

The third also meant the calibration in the README described a system nobody was
running: it models a single engine, which is what this now is.

Evaluation moves into Runtime, once per closed 1m bar, over every level. Layer
preferences are deliberately not consulted — they are a display choice made in
one browser, and a push notification should not depend on which checkboxes that
browser has ticked. Sockets now only relay what the runtime produced.

ntfy dispatch is a detached task with its own error handling. It previously ran
inline in the socket loop and called raise_for_status(), where the only except
clause caught disconnects — so a transient ntfy outage dropped the client's
connection.

Delivery verified end to end against ntfy.sh: title, priority and the multi-line
body all arrive as intended. NTFY_TOPIC still has to be set for anything to send.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:48:03 -05:00

74 lines
2.4 KiB
Python

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