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
+40
View File
@@ -0,0 +1,40 @@
"""add alert_log table
Revision ID: 005
Revises: 004
Create Date: 2026-06-14 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "005"
down_revision: Union[str, None] = "004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"alert_log",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("alert_type", sa.String(length=30), nullable=False),
sa.Column("dedup_key", sa.String(length=200), nullable=False),
sa.Column("value", sa.Float(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_alert_log_type_key_created",
"alert_log",
["alert_type", "dedup_key", "created_at"],
)
def downgrade() -> None:
op.drop_index("ix_alert_log_type_key_created", table_name="alert_log")
op.drop_table("alert_log")
+5
View File
@@ -37,11 +37,16 @@ class Settings(BaseSettings):
# Fundamentals Provider — Alpha Vantage (optional fallback) # Fundamentals Provider — Alpha Vantage (optional fallback)
alpha_vantage_api_key: str = "" alpha_vantage_api_key: str = ""
# Alerts — Telegram (optional env fallback; can also be set in Admin)
telegram_bot_token: str = ""
telegram_chat_id: str = ""
# Scheduled Jobs # Scheduled Jobs
data_collector_frequency: str = "daily" data_collector_frequency: str = "daily"
sentiment_poll_interval_minutes: int = 30 sentiment_poll_interval_minutes: int = 30
fundamental_fetch_frequency: str = "daily" fundamental_fetch_frequency: str = "daily"
rr_scan_frequency: str = "daily" rr_scan_frequency: str = "daily"
alerts_frequency: str = "hourly"
fundamental_rate_limit_retries: int = 3 fundamental_rate_limit_retries: int = 3
fundamental_rate_limit_backoff_seconds: int = 15 fundamental_rate_limit_backoff_seconds: int = 15
+2
View File
@@ -8,6 +8,7 @@ from app.models.sr_level import SRLevel
from app.models.trade_setup import TradeSetup from app.models.trade_setup import TradeSetup
from app.models.watchlist import WatchlistEntry from app.models.watchlist import WatchlistEntry
from app.models.settings import SystemSetting, IngestionProgress from app.models.settings import SystemSetting, IngestionProgress
from app.models.alert import AlertLog
__all__ = [ __all__ = [
"Ticker", "Ticker",
@@ -22,4 +23,5 @@ __all__ = [
"WatchlistEntry", "WatchlistEntry",
"SystemSetting", "SystemSetting",
"IngestionProgress", "IngestionProgress",
"AlertLog",
] ]
+32
View File
@@ -0,0 +1,32 @@
from datetime import datetime
from sqlalchemy import DateTime, Float, Index, String
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class AlertLog(Base):
"""Append-only log of alerts sent (and score watermarks).
Two uses, distinguished by ``alert_type``:
- notification dedup: a row records that ``dedup_key`` was alerted at
``created_at``; the dispatcher suppresses re-sending the same key within
a cooldown window.
- score watermark: rows of type ``score_watermark`` carry the last
observed composite in ``value``; the latest row per key is the baseline
for score-deterioration alerts.
"""
__tablename__ = "alert_log"
__table_args__ = (
Index("ix_alert_log_type_key_created", "alert_type", "dedup_key", "created_at"),
)
id: Mapped[int] = mapped_column(primary_key=True)
alert_type: Mapped[str] = mapped_column(String(30), nullable=False)
dedup_key: Mapped[str] = mapped_column(String(200), nullable=False)
value: Mapped[float | None] = mapped_column(Float, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)
+33
View File
@@ -10,6 +10,7 @@ from app.dependencies import get_db, require_admin
from app.models.user import User from app.models.user import User
from app.schemas.admin import ( from app.schemas.admin import (
ActivationConfigUpdate, ActivationConfigUpdate,
AlertConfigUpdate,
CreateUserRequest, CreateUserRequest,
DataCleanupRequest, DataCleanupRequest,
JobToggle, JobToggle,
@@ -24,6 +25,7 @@ from app.schemas.admin import (
) )
from app.schemas.common import APIEnvelope from app.schemas.common import APIEnvelope
from app.services import admin_service from app.services import admin_service
from app.services import alert_service
from app.services import sentiment_provider_service from app.services import sentiment_provider_service
from app.services import ticker_universe_service from app.services import ticker_universe_service
@@ -210,6 +212,37 @@ async def test_sentiment_settings(
return APIEnvelope(status="success", data=result) return APIEnvelope(status="success", data=result)
@router.get("/admin/settings/alerts", response_model=APIEnvelope)
async def get_alert_settings(
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
config = await alert_service.get_alert_config(db)
return APIEnvelope(status="success", data=config)
@router.put("/admin/settings/alerts", response_model=APIEnvelope)
async def update_alert_settings(
body: AlertConfigUpdate,
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
config = await alert_service.update_alert_config(
db, **body.model_dump(exclude_unset=True)
)
return APIEnvelope(status="success", data=config)
@router.post("/admin/settings/alerts/test", response_model=APIEnvelope)
async def test_alert_settings(
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Send a test Telegram message with the current config."""
result = await alert_service.send_test_alert(db)
return APIEnvelope(status="success", data=result)
@router.get("/admin/settings/ticker-universe", response_model=APIEnvelope) @router.get("/admin/settings/ticker-universe", response_model=APIEnvelope)
async def get_ticker_universe_setting( async def get_ticker_universe_setting(
_admin: User = Depends(require_admin), _admin: User = Depends(require_admin),
+59
View File
@@ -34,6 +34,7 @@ from app.providers.alpaca import AlpacaOHLCVProvider
from app.providers.fundamentals_chain import build_fundamental_provider_chain from app.providers.fundamentals_chain import build_fundamental_provider_chain
from app.providers.protocol import SentimentData from app.providers.protocol import SentimentData
from app.services import fundamental_service, ingestion_service, sentiment_service from app.services import fundamental_service, ingestion_service, sentiment_service
from app.services.alert_service import dispatch_alerts
from app.services.outcome_service import evaluate_pending_setups from app.services.outcome_service import evaluate_pending_setups
from app.services.rr_scanner_service import scan_all_tickers from app.services.rr_scanner_service import scan_all_tickers
from app.services.sentiment_provider_service import build_sentiment_provider from app.services.sentiment_provider_service import build_sentiment_provider
@@ -121,6 +122,17 @@ _job_runtime: dict[str, dict[str, object]] = {
"finished_at": None, "finished_at": None,
"message": None, "message": None,
}, },
"alerts": {
"running": False,
"status": "idle",
"processed": 0,
"total": None,
"progress_pct": None,
"current_ticker": None,
"started_at": None,
"finished_at": None,
"message": None,
},
} }
@@ -744,6 +756,42 @@ async def evaluate_outcomes() -> None:
})) }))
# ---------------------------------------------------------------------------
# Job: Alerts Dispatcher
# ---------------------------------------------------------------------------
async def dispatch_alerts_job() -> None:
"""Push Telegram alerts for qualified setups, S/R proximity, score drops, digest."""
job_name = "alerts"
logger.info(json.dumps({"event": "job_start", "job": job_name}))
_runtime_start(job_name, total=1)
try:
async with async_session_factory() as db:
if not await _is_job_enabled(db, job_name):
logger.info(json.dumps({"event": "job_skipped", "job": job_name, "reason": "disabled"}))
_runtime_finish(job_name, "skipped", processed=0, total=1, message="Disabled")
return
result = await dispatch_alerts(db)
_runtime_progress(job_name, processed=1, total=1)
_runtime_finish(
job_name, "completed", processed=1, total=1,
message=f"{result.get('status')}, sent {result.get('sent', 0)}",
)
logger.info(json.dumps({"event": "job_complete", "job": job_name, "result": result}))
except Exception as exc:
_runtime_finish(job_name, "error", processed=0, total=1, message=str(exc))
logger.error(json.dumps({
"event": "job_error",
"job": job_name,
"error_type": type(exc).__name__,
"message": str(exc),
}))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Job: Ticker Universe Sync # Job: Ticker Universe Sync
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -882,6 +930,17 @@ def configure_scheduler() -> None:
replace_existing=True, replace_existing=True,
) )
# Alerts Dispatcher — configurable frequency (default: hourly)
alerts_interval = _parse_frequency(settings.alerts_frequency)
scheduler.add_job(
dispatch_alerts_job,
"interval",
**alerts_interval,
id="alerts",
name="Alerts Dispatcher",
replace_existing=True,
)
logger.info( logger.info(
json.dumps({ json.dumps({
"event": "scheduler_configured", "event": "scheduler_configured",
+12
View File
@@ -77,3 +77,15 @@ class SentimentConfigUpdate(BaseModel):
class SentimentTestRequest(BaseModel): class SentimentTestRequest(BaseModel):
ticker: str = Field(default="AAPL", max_length=10) ticker: str = Field(default="AAPL", max_length=10)
class AlertConfigUpdate(BaseModel):
"""Telegram alert config. bot_token is write-only; omit/empty to keep the
stored token."""
enabled: bool | None = None
bot_token: str | None = Field(default=None, max_length=200)
telegram_chat_id: str | None = Field(default=None, max_length=64)
qualified_enabled: bool | None = None
sr_proximity_enabled: bool | None = None
score_drop_enabled: bool | None = None
digest_enabled: bool | None = None
+2
View File
@@ -482,6 +482,7 @@ VALID_JOB_NAMES = {
"rr_scanner", "rr_scanner",
"ticker_universe_sync", "ticker_universe_sync",
"outcome_evaluator", "outcome_evaluator",
"alerts",
} }
JOB_LABELS = { JOB_LABELS = {
@@ -491,6 +492,7 @@ JOB_LABELS = {
"rr_scanner": "R:R Scanner", "rr_scanner": "R:R Scanner",
"ticker_universe_sync": "Ticker Universe Sync", "ticker_universe_sync": "Ticker Universe Sync",
"outcome_evaluator": "Outcome Evaluator", "outcome_evaluator": "Outcome Evaluator",
"alerts": "Alerts Dispatcher",
} }
+404
View File
@@ -0,0 +1,404 @@
"""Telegram alerts: notify on actionable signals so the dashboard isn't a
poll-only tool.
Triggers (each toggleable):
- qualified setups: a (symbol, direction) setup that clears the activation gate
- watchlist S/R proximity: a watched ticker's price entering a strong S/R zone
- score deterioration: a watched ticker's composite dropping sharply vs a
running watermark
- daily digest: one end-of-day summary
Dedup is via the AlertLog table: cooldown-based for the first two and the digest,
watermark-based for score drops. Telegram credentials follow the usual
precedence DB > env; the bot token is write-only (never returned on read).
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.alert import AlertLog
from app.models.ohlcv import OHLCVRecord
from app.models.score import CompositeScore
from app.models.settings import SystemSetting
from app.models.sr_level import SRLevel
from app.models.ticker import Ticker
from app.models.watchlist import WatchlistEntry
from app.services.admin_service import get_activation_config, update_setting
from app.services.qualification import best_target_probability, setup_qualifies
from app.services.rr_scanner_service import get_trade_setups
logger = logging.getLogger(__name__)
# SystemSetting keys
KEY_ENABLED = "alerts_enabled"
KEY_TOKEN = "alerts_telegram_bot_token"
KEY_CHAT_ID = "alerts_telegram_chat_id"
KEY_QUALIFIED = "alerts_qualified_enabled"
KEY_SR = "alerts_sr_proximity_enabled"
KEY_SCORE_DROP = "alerts_score_drop_enabled"
KEY_DIGEST = "alerts_digest_enabled"
_BOOL_DEFAULTS = {
KEY_ENABLED: False,
KEY_QUALIFIED: True,
KEY_SR: True,
KEY_SCORE_DROP: True,
KEY_DIGEST: True,
}
# Tunables (kept as constants for now; promote to settings if needed)
SR_PROXIMITY_PCT = 2.0 # within this % of a strong level → alert
SR_MIN_STRENGTH = 60 # only strong levels are alert-worthy
SCORE_DROP_POINTS = 15.0 # composite drop vs watermark that triggers an alert
COOLDOWN_HOURS = 72 # don't re-send the same key within this window
DIGEST_HOUR_UTC = 22 # send the daily digest on the first run at/after this hour
WATERMARK_TYPE = "score_watermark"
def _as_bool(value: str | None, default: bool) -> bool:
if value is None:
return default
return value.strip().lower() == "true"
async def _settings_map(db: AsyncSession) -> dict[str, str]:
keys = [KEY_ENABLED, KEY_TOKEN, KEY_CHAT_ID, KEY_QUALIFIED, KEY_SR, KEY_SCORE_DROP, KEY_DIGEST]
result = await db.execute(select(SystemSetting).where(SystemSetting.key.in_(keys)))
return {s.key: s.value for s in result.scalars().all()}
async def _resolve(db: AsyncSession) -> dict:
stored = await _settings_map(db)
db_token = (stored.get(KEY_TOKEN) or "").strip()
if db_token:
token, token_source = db_token, "database"
elif settings.telegram_bot_token:
token, token_source = settings.telegram_bot_token, "environment"
else:
token, token_source = "", "none"
chat_id = (stored.get(KEY_CHAT_ID) or "").strip() or (settings.telegram_chat_id or "").strip()
return {
"enabled": _as_bool(stored.get(KEY_ENABLED), _BOOL_DEFAULTS[KEY_ENABLED]),
"token": token,
"token_source": token_source,
"chat_id": chat_id,
"qualified": _as_bool(stored.get(KEY_QUALIFIED), _BOOL_DEFAULTS[KEY_QUALIFIED]),
"sr": _as_bool(stored.get(KEY_SR), _BOOL_DEFAULTS[KEY_SR]),
"score_drop": _as_bool(stored.get(KEY_SCORE_DROP), _BOOL_DEFAULTS[KEY_SCORE_DROP]),
"digest": _as_bool(stored.get(KEY_DIGEST), _BOOL_DEFAULTS[KEY_DIGEST]),
}
async def get_alert_config(db: AsyncSession) -> dict:
"""Public config — never includes the raw bot token."""
r = await _resolve(db)
return {
"enabled": r["enabled"],
"telegram_chat_id": r["chat_id"],
"bot_token_configured": bool(r["token"]),
"bot_token_source": r["token_source"],
"qualified_enabled": r["qualified"],
"sr_proximity_enabled": r["sr"],
"score_drop_enabled": r["score_drop"],
"digest_enabled": r["digest"],
}
async def update_alert_config(
db: AsyncSession,
*,
enabled: bool | None = None,
bot_token: str | None = None,
telegram_chat_id: str | None = None,
qualified_enabled: bool | None = None,
sr_proximity_enabled: bool | None = None,
score_drop_enabled: bool | None = None,
digest_enabled: bool | None = None,
) -> dict:
"""Persist config. An empty/omitted bot_token leaves the stored token intact."""
bool_updates = {
KEY_ENABLED: enabled,
KEY_QUALIFIED: qualified_enabled,
KEY_SR: sr_proximity_enabled,
KEY_SCORE_DROP: score_drop_enabled,
KEY_DIGEST: digest_enabled,
}
for key, val in bool_updates.items():
if val is not None:
await update_setting(db, key, "true" if val else "false")
if telegram_chat_id is not None:
await update_setting(db, KEY_CHAT_ID, telegram_chat_id.strip())
if bot_token: # only overwrite when a non-empty token is supplied
await update_setting(db, KEY_TOKEN, bot_token.strip())
return await get_alert_config(db)
# ---------------------------------------------------------------------------
# Telegram transport
# ---------------------------------------------------------------------------
async def _send(client: httpx.AsyncClient, token: str, chat_id: str, text: str) -> None:
resp = await client.post(
f"https://api.telegram.org/bot{token}/sendMessage",
json={
"chat_id": chat_id,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": True,
},
)
resp.raise_for_status()
# ---------------------------------------------------------------------------
# Dedup helpers
# ---------------------------------------------------------------------------
async def _recently_alerted(
db: AsyncSession, alert_type: str, key: str, cooldown_hours: int = COOLDOWN_HOURS
) -> bool:
cutoff = datetime.now(timezone.utc) - timedelta(hours=cooldown_hours)
result = await db.execute(
select(AlertLog.id)
.where(
AlertLog.alert_type == alert_type,
AlertLog.dedup_key == key,
AlertLog.created_at > cutoff,
)
.limit(1)
)
return result.first() is not None
def _log_alert(db: AsyncSession, alert_type: str, key: str, value: float | None = None) -> None:
db.add(
AlertLog(
alert_type=alert_type,
dedup_key=key,
value=value,
created_at=datetime.now(timezone.utc),
)
)
async def _watermark(db: AsyncSession, symbol: str) -> float | None:
result = await db.execute(
select(AlertLog.value)
.where(AlertLog.alert_type == WATERMARK_TYPE, AlertLog.dedup_key == symbol)
.order_by(AlertLog.created_at.desc())
.limit(1)
)
row = result.first()
return row[0] if row else None
# ---------------------------------------------------------------------------
# Trigger collectors
# ---------------------------------------------------------------------------
async def _watchlist_tickers(db: AsyncSession) -> list[tuple[int, str]]:
"""Distinct tickers across all watchlists (single-user app → one chat)."""
result = await db.execute(
select(WatchlistEntry.ticker_id, Ticker.symbol)
.join(Ticker, WatchlistEntry.ticker_id == Ticker.id)
.where(WatchlistEntry.entry_type != "dismissed")
.distinct()
)
return [(tid, sym) for tid, sym in result.all()]
async def _qualified_setups(db: AsyncSession) -> list[dict]:
setups = await get_trade_setups(db)
config = await get_activation_config(db)
return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)]
def _format_qualified(s: dict) -> str:
prob = best_target_probability(SimpleNamespace(**s))
arrow = "🟢" if s["direction"] == "long" else "🔴"
return (
f"{arrow} <b>{s['symbol']} {s['direction'].upper()}</b> — qualified setup\n"
f"entry {s['entry_price']:.2f} → target {s['target']:.2f} "
f"(R:R {s['rr_ratio']:.1f}:1)\n"
f"confidence {(s.get('confidence_score') or 0):.0f}% · P(target) {prob:.0f}%"
)
async def _collect_qualified(db: AsyncSession) -> list[tuple[str, str]]:
out: list[tuple[str, str]] = []
for s in await _qualified_setups(db):
key = f"qualified:{s['symbol']}:{s['direction']}"
out.append((key, _format_qualified(s)))
return out
async def _latest_close(db: AsyncSession, ticker_id: int) -> float | None:
result = await db.execute(
select(OHLCVRecord.close)
.where(OHLCVRecord.ticker_id == ticker_id)
.order_by(OHLCVRecord.date.desc())
.limit(1)
)
row = result.first()
return float(row[0]) if row else None
async def _collect_sr_proximity(db: AsyncSession) -> list[tuple[str, str]]:
out: list[tuple[str, str]] = []
for tid, symbol in await _watchlist_tickers(db):
price = await _latest_close(db, tid)
if not price:
continue
levels_result = await db.execute(
select(SRLevel).where(
SRLevel.ticker_id == tid,
SRLevel.strength >= SR_MIN_STRENGTH,
)
)
for lv in levels_result.scalars().all():
dist_pct = abs(price - lv.price_level) / price * 100
if dist_pct <= SR_PROXIMITY_PCT:
key = f"sr:{symbol}:{lv.price_level:.2f}"
out.append((
key,
f"📍 <b>{symbol}</b> approaching {lv.type} at {lv.price_level:.2f} "
f"(now {price:.2f}, {dist_pct:.1f}% away)",
))
return out
async def _collect_score_drops(db: AsyncSession) -> list[tuple[str, str]]:
"""Returns drop messages and (as a side effect) advances watermarks.
Watermark = the reference composite. Alert when current drops
SCORE_DROP_POINTS below it, then rebaseline to current so a single slide
doesn't re-fire; let the watermark rise with the score so the next drop is
measured from the new high.
"""
out: list[tuple[str, str]] = []
for tid, symbol in await _watchlist_tickers(db):
comp_result = await db.execute(
select(CompositeScore.score).where(CompositeScore.ticker_id == tid)
)
row = comp_result.first()
if row is None or row[0] is None:
continue
current = float(row[0])
base = await _watermark(db, symbol)
if base is None:
_log_alert(db, WATERMARK_TYPE, symbol, value=current) # seed, no alert
continue
if current <= base - SCORE_DROP_POINTS:
out.append((
f"scoredrop:{symbol}",
f"🔻 <b>{symbol}</b> composite score fell to {current:.0f} (from {base:.0f})",
))
_log_alert(db, WATERMARK_TYPE, symbol, value=current) # rebaseline
elif current > base:
_log_alert(db, WATERMARK_TYPE, symbol, value=current) # track the rise
return out
async def _collect_digest(db: AsyncSession) -> tuple[str, str] | None:
now = datetime.now(timezone.utc)
if now.hour < DIGEST_HOUR_UTC:
return None
key = f"digest:{now.date().isoformat()}"
if await _recently_alerted(db, "digest", key, cooldown_hours=20):
return None
qualified = await _qualified_setups(db)
lines = [f"📊 <b>Daily digest</b> — {now.date().isoformat()}"]
if qualified:
top = sorted(qualified, key=lambda s: s["rr_ratio"], reverse=True)[:5]
lines.append(f"{len(qualified)} qualified setup(s):")
for s in top:
lines.append(
f"{s['symbol']} {s['direction'].upper()} "
f"R:R {s['rr_ratio']:.1f}:1, conf {(s.get('confidence_score') or 0):.0f}%"
)
else:
lines.append("No qualified setups today.")
return key, "\n".join(lines)
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
async def dispatch_alerts(db: AsyncSession) -> dict:
"""Gather all enabled triggers, dedup, and push to Telegram. Job entrypoint."""
cfg = await _resolve(db)
if not cfg["enabled"]:
return {"status": "disabled", "sent": 0}
if not cfg["token"] or not cfg["chat_id"]:
return {"status": "no_credentials", "sent": 0}
outgoing: list[tuple[str, str, str]] = [] # (alert_type, key, text)
if cfg["qualified"]:
for key, text in await _collect_qualified(db):
if not await _recently_alerted(db, "qualified", key):
outgoing.append(("qualified", key, text))
if cfg["sr"]:
for key, text in await _collect_sr_proximity(db):
if not await _recently_alerted(db, "sr_proximity", key):
outgoing.append(("sr_proximity", key, text))
if cfg["score_drop"]:
# also seeds/advances watermarks as a side effect
for key, text in await _collect_score_drops(db):
outgoing.append(("score_drop", key, text))
if cfg["digest"]:
digest = await _collect_digest(db)
if digest is not None:
outgoing.append(("digest", digest[0], digest[1]))
sent = 0
if outgoing:
async with httpx.AsyncClient(timeout=15) as client:
for alert_type, key, text in outgoing:
try:
await _send(client, cfg["token"], cfg["chat_id"], text)
_log_alert(db, alert_type, key)
sent += 1
except Exception:
logger.exception("Failed to send alert %s", key)
await db.commit() # persist watermark seeds/advances and sent-logs
return {"status": "ok", "sent": sent, "candidates": len(outgoing)}
async def send_test_alert(db: AsyncSession) -> dict:
"""Send a fixed message to verify Telegram credentials."""
cfg = await _resolve(db)
if not cfg["token"] or not cfg["chat_id"]:
return {"ok": False, "error": "Bot token and chat ID must both be configured."}
try:
async with httpx.AsyncClient(timeout=15) as client:
await _send(
client, cfg["token"], cfg["chat_id"],
"✅ <b>Signal Platform</b> — test alert. Notifications are wired up correctly.",
)
return {"ok": True}
except Exception as exc:
logger.warning("Test alert failed: %s", exc)
return {"ok": False, "error": str(exc)}
+28
View File
@@ -2,6 +2,8 @@ import apiClient from './client';
import type { import type {
ActivationConfig, ActivationConfig,
AdminUser, AdminUser,
AlertConfig,
AlertTestResult,
PipelineReadiness, PipelineReadiness,
RecommendationConfig, RecommendationConfig,
SentimentProviderConfig, SentimentProviderConfig,
@@ -105,6 +107,32 @@ export function testSentimentSettings(ticker: string) {
.then((r) => r.data); .then((r) => r.data);
} }
export function getAlertSettings() {
return apiClient
.get<AlertConfig>('admin/settings/alerts')
.then((r) => r.data);
}
export function updateAlertSettings(payload: {
enabled?: boolean;
bot_token?: string;
telegram_chat_id?: string;
qualified_enabled?: boolean;
sr_proximity_enabled?: boolean;
score_drop_enabled?: boolean;
digest_enabled?: boolean;
}) {
return apiClient
.put<AlertConfig>('admin/settings/alerts', payload)
.then((r) => r.data);
}
export function testAlertSettings() {
return apiClient
.post<AlertTestResult>('admin/settings/alerts/test')
.then((r) => r.data);
}
export function getTickerUniverseSetting() { export function getTickerUniverseSetting() {
return apiClient return apiClient
.get<TickerUniverseSetting>('admin/settings/ticker-universe') .get<TickerUniverseSetting>('admin/settings/ticker-universe')
@@ -0,0 +1,178 @@
import { useEffect, useState } from 'react';
import { useAlertSettings, useUpdateAlertSettings, useTestAlert } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton';
const SOURCE_LABEL: Record<string, string> = {
database: 'configured here',
environment: 'from environment (.env)',
none: 'not configured',
};
type TriggerKey =
| 'qualified_enabled'
| 'sr_proximity_enabled'
| 'score_drop_enabled'
| 'digest_enabled';
const TRIGGERS: { key: TriggerKey; label: string; hint: string }[] = [
{ key: 'qualified_enabled', label: 'Qualified setups', hint: 'a setup newly clears the activation gate' },
{ key: 'sr_proximity_enabled', label: 'Watchlist S/R proximity', hint: 'a watched ticker nears a strong support/resistance' },
{ key: 'score_drop_enabled', label: 'Score deterioration', hint: 'a watched tickers composite drops sharply' },
{ key: 'digest_enabled', label: 'Daily digest', hint: 'one end-of-day summary of qualified setups' },
];
function Toggle({ checked, onChange, label, hint }: {
checked: boolean;
onChange: (v: boolean) => void;
label: string;
hint: string;
}) {
return (
<label className="flex items-start gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="mt-0.5 h-4 w-4 cursor-pointer accent-blue-400"
/>
<span>
<span className="text-sm text-gray-200">{label}</span>
<span className="block text-[11px] text-gray-500">{hint}</span>
</span>
</label>
);
}
export function AlertSettings() {
const { data, isLoading, isError, error } = useAlertSettings();
const update = useUpdateAlertSettings();
const test = useTestAlert();
const [enabled, setEnabled] = useState(false);
const [chatId, setChatId] = useState('');
const [botToken, setBotToken] = useState('');
const [triggers, setTriggers] = useState<Record<TriggerKey, boolean>>({
qualified_enabled: true,
sr_proximity_enabled: true,
score_drop_enabled: true,
digest_enabled: true,
});
useEffect(() => {
if (data) {
setEnabled(data.enabled);
setChatId(data.telegram_chat_id ?? '');
setTriggers({
qualified_enabled: data.qualified_enabled,
sr_proximity_enabled: data.sr_proximity_enabled,
score_drop_enabled: data.score_drop_enabled,
digest_enabled: data.digest_enabled,
});
}
}, [data]);
if (isLoading) return <SkeletonTable rows={4} cols={2} />;
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load alert settings'}</p>;
if (!data) return null;
const onSave = () => {
update.mutate({
enabled,
telegram_chat_id: chatId,
...triggers,
...(botToken ? { bot_token: botToken } : {}),
});
setBotToken('');
};
const tokenConfigured = data.bot_token_configured;
return (
<div className="glass p-5 space-y-4">
<div>
<h3 className="text-sm font-semibold text-gray-200">Telegram Alerts</h3>
<p className="mt-1 text-xs text-gray-500">
Push actionable signals to Telegram so you dont have to keep checking the dashboard.
The dispatcher runs hourly; each trigger respects a cooldown so youre not spammed.
</p>
</div>
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="h-4 w-4 cursor-pointer accent-blue-400"
/>
<span className="text-sm text-gray-200">Alerts enabled</span>
</label>
<div className="grid gap-4 md:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-gray-400">Bot Token</span>
<input
type="password"
value={botToken}
onChange={(e) => setBotToken(e.target.value)}
autoComplete="new-password"
placeholder={tokenConfigured ? '•••••••••• (leave blank to keep current)' : 'Paste bot token from @BotFather…'}
className="w-full input-glass px-3 py-2 text-sm"
/>
<span className="text-[11px] text-gray-500">
Status:{' '}
<span className={tokenConfigured ? 'text-emerald-400' : 'text-amber-400'}>
{SOURCE_LABEL[data.bot_token_source] ?? data.bot_token_source}
</span>
{' '}· write-only, never displayed
</span>
</label>
<label className="block space-y-1">
<span className="text-xs text-gray-400">Chat ID</span>
<input
type="text"
value={chatId}
onChange={(e) => setChatId(e.target.value)}
placeholder="e.g. 123456789"
className="w-full input-glass px-3 py-2 text-sm"
/>
<span className="text-[11px] text-gray-600">Your numeric chat ID (message @userinfobot to find it).</span>
</label>
</div>
<div className="space-y-2">
<p className="text-xs text-gray-400">Triggers</p>
<div className="grid gap-3 md:grid-cols-2">
{TRIGGERS.map((t) => (
<Toggle
key={t.key}
label={t.label}
hint={t.hint}
checked={triggers[t.key]}
onChange={(v) => setTriggers((prev) => ({ ...prev, [t.key]: v }))}
/>
))}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button className="btn-primary px-4 py-2 text-sm" onClick={onSave} disabled={update.isPending}>
{update.isPending ? 'Saving…' : 'Save Alerts'}
</button>
<button
className="px-4 py-2 text-sm rounded border border-white/[0.1] text-gray-300 hover:text-white disabled:opacity-50"
onClick={() => test.mutate()}
disabled={test.isPending}
>
{test.isPending ? 'Sending…' : 'Send Test'}
</button>
<span className="text-[11px] text-gray-500">Save first, then Send Test to verify the bot reaches you.</span>
</div>
<div className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-4 py-2.5 text-[11px] text-gray-500">
Setup: 1) message <span className="text-gray-300">@BotFather</span>, send <span className="text-gray-300">/newbot</span>, copy the token.
2) send your new bot any message. 3) get your chat ID from <span className="text-gray-300">@userinfobot</span>. Paste both above.
</div>
</div>
);
}
+39
View File
@@ -175,6 +175,45 @@ export function useTestSentimentProvider() {
}); });
} }
export function useAlertSettings() {
return useQuery({
queryKey: ['admin', 'alert-settings'],
queryFn: () => adminApi.getAlertSettings(),
});
}
export function useUpdateAlertSettings() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (payload: Parameters<typeof adminApi.updateAlertSettings>[0]) =>
adminApi.updateAlertSettings(payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'alert-settings'] });
addToast('success', 'Alert settings updated');
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to update alert settings');
},
});
}
export function useTestAlert() {
const { addToast } = useToast();
return useMutation({
mutationFn: () => adminApi.testAlertSettings(),
onSuccess: (result) => {
if (result.ok) addToast('success', 'Test alert sent — check Telegram.');
else addToast('error', result.error || 'Test alert failed');
},
onError: (error: Error) => {
addToast('error', error.message || 'Test alert failed');
},
});
}
export function useTickerUniverseSetting() { export function useTickerUniverseSetting() {
return useQuery({ return useQuery({
queryKey: ['admin', 'ticker-universe'], queryKey: ['admin', 'ticker-universe'],
+16
View File
@@ -179,6 +179,22 @@ export interface SentimentProviderConfig {
custom_base_url_providers: string[]; custom_base_url_providers: string[];
} }
export interface AlertConfig {
enabled: boolean;
telegram_chat_id: string;
bot_token_configured: boolean;
bot_token_source: 'database' | 'environment' | 'none';
qualified_enabled: boolean;
sr_proximity_enabled: boolean;
score_drop_enabled: boolean;
digest_enabled: boolean;
}
export interface AlertTestResult {
ok: boolean;
error?: string;
}
export interface SentimentTestResult { export interface SentimentTestResult {
ok: boolean; ok: boolean;
provider: string; provider: string;
+2
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { ActivationSettings } from '../components/admin/ActivationSettings'; import { ActivationSettings } from '../components/admin/ActivationSettings';
import { AlertSettings } from '../components/admin/AlertSettings';
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings'; import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
import { DataCleanup } from '../components/admin/DataCleanup'; import { DataCleanup } from '../components/admin/DataCleanup';
import { JobControls } from '../components/admin/JobControls'; import { JobControls } from '../components/admin/JobControls';
@@ -31,6 +32,7 @@ export default function AdminPage() {
{activeTab === 'Settings' && ( {activeTab === 'Settings' && (
<div className="space-y-4"> <div className="space-y-4">
<ActivationSettings /> <ActivationSettings />
<AlertSettings />
<SentimentProviderSettings /> <SentimentProviderSettings />
<TickerUniverseBootstrap /> <TickerUniverseBootstrap />
<RecommendationSettings /> <RecommendationSettings />
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/ohlcv.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/useperformance.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"} {"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/ohlcv.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/alertsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/useperformance.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
+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: class TestConfigureScheduler:
def test_configure_adds_six_jobs(self): def test_configure_adds_all_jobs(self):
# Remove any existing jobs first # Remove any existing jobs first
scheduler.remove_all_jobs() scheduler.remove_all_jobs()
configure_scheduler() configure_scheduler()
@@ -81,6 +81,7 @@ class TestConfigureScheduler:
"rr_scanner", "rr_scanner",
"ticker_universe_sync", "ticker_universe_sync",
"outcome_evaluator", "outcome_evaluator",
"alerts",
} }
def test_configure_is_idempotent(self): def test_configure_is_idempotent(self):
@@ -90,6 +91,7 @@ class TestConfigureScheduler:
job_ids = [j.id for j in scheduler.get_jobs()] job_ids = [j.id for j in scheduler.get_jobs()]
# Each ID should appear exactly once # Each ID should appear exactly once
assert sorted(job_ids) == sorted([ assert sorted(job_ids) == sorted([
"alerts",
"data_collector", "data_collector",
"fundamental_collector", "fundamental_collector",
"outcome_evaluator", "outcome_evaluator",