diff --git a/alembic/versions/005_add_alert_log.py b/alembic/versions/005_add_alert_log.py new file mode 100644 index 0000000..986ffff --- /dev/null +++ b/alembic/versions/005_add_alert_log.py @@ -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") diff --git a/app/config.py b/app/config.py index 98b8864..c0780e4 100644 --- a/app/config.py +++ b/app/config.py @@ -37,11 +37,16 @@ class Settings(BaseSettings): # Fundamentals Provider — Alpha Vantage (optional fallback) 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 data_collector_frequency: str = "daily" sentiment_poll_interval_minutes: int = 30 fundamental_fetch_frequency: str = "daily" rr_scan_frequency: str = "daily" + alerts_frequency: str = "hourly" fundamental_rate_limit_retries: int = 3 fundamental_rate_limit_backoff_seconds: int = 15 diff --git a/app/models/__init__.py b/app/models/__init__.py index 3521e21..d456fc6 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -8,6 +8,7 @@ from app.models.sr_level import SRLevel from app.models.trade_setup import TradeSetup from app.models.watchlist import WatchlistEntry from app.models.settings import SystemSetting, IngestionProgress +from app.models.alert import AlertLog __all__ = [ "Ticker", @@ -22,4 +23,5 @@ __all__ = [ "WatchlistEntry", "SystemSetting", "IngestionProgress", + "AlertLog", ] diff --git a/app/models/alert.py b/app/models/alert.py new file mode 100644 index 0000000..194b12e --- /dev/null +++ b/app/models/alert.py @@ -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 + ) diff --git a/app/routers/admin.py b/app/routers/admin.py index 0080793..600ec1c 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -10,6 +10,7 @@ from app.dependencies import get_db, require_admin from app.models.user import User from app.schemas.admin import ( ActivationConfigUpdate, + AlertConfigUpdate, CreateUserRequest, DataCleanupRequest, JobToggle, @@ -24,6 +25,7 @@ from app.schemas.admin import ( ) from app.schemas.common import APIEnvelope from app.services import admin_service +from app.services import alert_service from app.services import sentiment_provider_service from app.services import ticker_universe_service @@ -210,6 +212,37 @@ async def test_sentiment_settings( 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) async def get_ticker_universe_setting( _admin: User = Depends(require_admin), diff --git a/app/scheduler.py b/app/scheduler.py index ed1fd1a..adda64f 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -34,6 +34,7 @@ from app.providers.alpaca import AlpacaOHLCVProvider from app.providers.fundamentals_chain import build_fundamental_provider_chain from app.providers.protocol import SentimentData 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.rr_scanner_service import scan_all_tickers from app.services.sentiment_provider_service import build_sentiment_provider @@ -121,6 +122,17 @@ _job_runtime: dict[str, dict[str, object]] = { "finished_at": 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 # --------------------------------------------------------------------------- @@ -882,6 +930,17 @@ def configure_scheduler() -> None: 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( json.dumps({ "event": "scheduler_configured", diff --git a/app/schemas/admin.py b/app/schemas/admin.py index 6809e77..f6869c9 100644 --- a/app/schemas/admin.py +++ b/app/schemas/admin.py @@ -77,3 +77,15 @@ class SentimentConfigUpdate(BaseModel): class SentimentTestRequest(BaseModel): 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 diff --git a/app/services/admin_service.py b/app/services/admin_service.py index b49cad4..8c40d37 100644 --- a/app/services/admin_service.py +++ b/app/services/admin_service.py @@ -482,6 +482,7 @@ VALID_JOB_NAMES = { "rr_scanner", "ticker_universe_sync", "outcome_evaluator", + "alerts", } JOB_LABELS = { @@ -491,6 +492,7 @@ JOB_LABELS = { "rr_scanner": "R:R Scanner", "ticker_universe_sync": "Ticker Universe Sync", "outcome_evaluator": "Outcome Evaluator", + "alerts": "Alerts Dispatcher", } diff --git a/app/services/alert_service.py b/app/services/alert_service.py new file mode 100644 index 0000000..225ed04 --- /dev/null +++ b/app/services/alert_service.py @@ -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} {s['symbol']} {s['direction'].upper()} — 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"📍 {symbol} 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"🔻 {symbol} 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"📊 Daily digest — {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"], + "✅ Signal Platform — 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)} diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 692e04b..a892540 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -2,6 +2,8 @@ import apiClient from './client'; import type { ActivationConfig, AdminUser, + AlertConfig, + AlertTestResult, PipelineReadiness, RecommendationConfig, SentimentProviderConfig, @@ -105,6 +107,32 @@ export function testSentimentSettings(ticker: string) { .then((r) => r.data); } +export function getAlertSettings() { + return apiClient + .get('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('admin/settings/alerts', payload) + .then((r) => r.data); +} + +export function testAlertSettings() { + return apiClient + .post('admin/settings/alerts/test') + .then((r) => r.data); +} + export function getTickerUniverseSetting() { return apiClient .get('admin/settings/ticker-universe') diff --git a/frontend/src/components/admin/AlertSettings.tsx b/frontend/src/components/admin/AlertSettings.tsx new file mode 100644 index 0000000..cdc8689 --- /dev/null +++ b/frontend/src/components/admin/AlertSettings.tsx @@ -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 = { + 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 ticker’s 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 ( + + ); +} + +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>({ + 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 ; + if (isError) return

{(error as Error)?.message || 'Failed to load alert settings'}

; + 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 ( +
+
+

Telegram Alerts

+

+ Push actionable signals to Telegram so you don’t have to keep checking the dashboard. + The dispatcher runs hourly; each trigger respects a cooldown so you’re not spammed. +

+
+ + + +
+ + + +
+ +
+

Triggers

+
+ {TRIGGERS.map((t) => ( + setTriggers((prev) => ({ ...prev, [t.key]: v }))} + /> + ))} +
+
+ +
+ + + Save first, then Send Test to verify the bot reaches you. +
+ +
+ Setup: 1) message @BotFather, send /newbot, copy the token. + 2) send your new bot any message. 3) get your chat ID from @userinfobot. Paste both above. +
+
+ ); +} diff --git a/frontend/src/hooks/useAdmin.ts b/frontend/src/hooks/useAdmin.ts index a9a6e1e..acb488d 100644 --- a/frontend/src/hooks/useAdmin.ts +++ b/frontend/src/hooks/useAdmin.ts @@ -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[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() { return useQuery({ queryKey: ['admin', 'ticker-universe'], diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index a4d0804..e19388c 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -179,6 +179,22 @@ export interface SentimentProviderConfig { 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 { ok: boolean; provider: string; diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 7e3f029..7b85cc8 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { ActivationSettings } from '../components/admin/ActivationSettings'; +import { AlertSettings } from '../components/admin/AlertSettings'; import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings'; import { DataCleanup } from '../components/admin/DataCleanup'; import { JobControls } from '../components/admin/JobControls'; @@ -31,6 +32,7 @@ export default function AdminPage() { {activeTab === 'Settings' && (
+ diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 31c3655..ec12067 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/tests/unit/test_alert_service.py b/tests/unit/test_alert_service.py new file mode 100644 index 0000000..c1ac460 --- /dev/null +++ b/tests/unit/test_alert_service.py @@ -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" diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index d43bab1..6bc3edd 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -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",