add Telegram alerts: qualified setups, S/R proximity, score drops, daily digest
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 23s

Closes the action loop — instead of polling the dashboard, the platform pushes
actionable signals to Telegram. New hourly 'alerts' job dispatches four
toggleable triggers, deduped via a new alert_log table (cooldown-based for
qualified/S-R/digest, watermark-based for score deterioration). Admin → Settings
gains a Telegram panel (write-only bot token, chat ID, per-trigger toggles, Send
Test). Credentials follow DB > env precedence (TELEGRAM_BOT_TOKEN / _CHAT_ID).

Backend: alert_service + AlertLog model + migration 005, scheduler job, admin
endpoints/schema. Frontend: AlertSettings panel, hooks, api, types.

Deploy: run alembic upgrade (new alert_log table).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 19:42:18 +02:00
parent 9d0bef369f
commit 5d41ccac1c
17 changed files with 976 additions and 2 deletions
+120
View File
@@ -0,0 +1,120 @@
"""Tests for the Telegram alert service: config, dedup, watermark, dispatch."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from app.models.alert import AlertLog
from app.models.score import CompositeScore
from app.models.ticker import Ticker
from app.models.user import User
from app.models.watchlist import WatchlistEntry
from app.services import alert_service as svc
from tests.conftest import _test_session_factory # type: ignore
@pytest.fixture
async def session():
async with _test_session_factory() as s:
yield s
async def test_config_defaults(session):
cfg = await svc.get_alert_config(session)
assert cfg["enabled"] is False
assert cfg["bot_token_configured"] is False
assert cfg["bot_token_source"] == "none"
# trigger toggles default on
assert cfg["qualified_enabled"] is True
assert cfg["digest_enabled"] is True
async def test_update_config_token_write_only(session):
cfg = await svc.update_alert_config(
session, enabled=True, bot_token="secret123", telegram_chat_id="42",
)
assert cfg["enabled"] is True
assert cfg["telegram_chat_id"] == "42"
assert cfg["bot_token_configured"] is True
assert cfg["bot_token_source"] == "database"
# raw token never surfaced
assert "bot_token" not in cfg
assert "secret123" not in str(cfg)
async def test_update_empty_token_keeps_existing(session):
await svc.update_alert_config(session, bot_token="keepme", telegram_chat_id="1")
cfg = await svc.update_alert_config(session, bot_token="") # empty → keep
assert cfg["bot_token_configured"] is True
async def test_recently_alerted_cooldown(session):
assert await svc._recently_alerted(session, "qualified", "AAA:long") is False
svc._log_alert(session, "qualified", "AAA:long")
await session.commit()
assert await svc._recently_alerted(session, "qualified", "AAA:long") is True
# different key is independent
assert await svc._recently_alerted(session, "qualified", "BBB:short") is False
async def test_recently_alerted_expires(session):
old = datetime.now(timezone.utc) - timedelta(hours=100)
session.add(AlertLog(alert_type="qualified", dedup_key="old", created_at=old))
await session.commit()
# default cooldown 72h → the 100h-old entry no longer suppresses
assert await svc._recently_alerted(session, "qualified", "old") is False
async def _seed_watchlisted_ticker(session, symbol: str, score: float) -> None:
user = await session.get(User, 1)
if user is None:
user = User(id=1, username="u", password_hash="x", role="user", has_access=True)
session.add(user)
await session.flush()
t = Ticker(symbol=symbol)
session.add(t)
await session.flush()
session.add(WatchlistEntry(user_id=1, ticker_id=t.id, entry_type="manual",
added_at=datetime.now(timezone.utc)))
session.add(CompositeScore(ticker_id=t.id, score=score, is_stale=False,
weights_json="{}", computed_at=datetime.now(timezone.utc)))
await session.commit()
async def test_score_drop_seeds_then_alerts(session):
await _seed_watchlisted_ticker(session, "AAA", 80.0)
# First pass seeds the watermark, no alert
msgs = await svc._collect_score_drops(session)
await session.commit()
assert msgs == []
assert await svc._watermark(session, "AAA") == 80.0
# Drop the composite well past the threshold
row = (await session.execute(
CompositeScore.__table__.update().values(score=60.0)
))
await session.commit()
assert row.rowcount == 1
msgs = await svc._collect_score_drops(session)
await session.commit()
assert len(msgs) == 1
key, text = msgs[0]
assert key == "scoredrop:AAA"
assert "AAA" in text
# rebaselined to the new (lower) level
assert await svc._watermark(session, "AAA") == 60.0
async def test_dispatch_disabled_short_circuits(session):
res = await svc.dispatch_alerts(session)
assert res["status"] == "disabled"
async def test_dispatch_no_credentials(session):
await svc.update_alert_config(session, enabled=True) # enabled but no token/chat
res = await svc.dispatch_alerts(session)
assert res["status"] == "no_credentials"
+3 -1
View File
@@ -68,7 +68,7 @@ class TestResumeTickers:
class TestConfigureScheduler:
def test_configure_adds_six_jobs(self):
def test_configure_adds_all_jobs(self):
# Remove any existing jobs first
scheduler.remove_all_jobs()
configure_scheduler()
@@ -81,6 +81,7 @@ class TestConfigureScheduler:
"rr_scanner",
"ticker_universe_sync",
"outcome_evaluator",
"alerts",
}
def test_configure_is_idempotent(self):
@@ -90,6 +91,7 @@ class TestConfigureScheduler:
job_ids = [j.id for j in scheduler.get_jobs()]
# Each ID should appear exactly once
assert sorted(job_ids) == sorted([
"alerts",
"data_collector",
"fundamental_collector",
"outcome_evaluator",