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,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")
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)}
|
||||||
@@ -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 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 (
|
||||||
|
<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 don’t have to keep checking the dashboard.
|
||||||
|
The dispatcher runs hourly; each trigger respects a cooldown so you’re 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
{"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"}
|
||||||
@@ -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"
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user