add Telegram alerts: qualified setups, S/R proximity, score drops, daily digest
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:
@@ -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"
|
||||
Reference in New Issue
Block a user