5d41ccac1c
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>
102 lines
3.2 KiB
Python
102 lines
3.2 KiB
Python
"""Unit tests for app.scheduler module."""
|
|
|
|
import pytest
|
|
|
|
from app.scheduler import (
|
|
_is_job_enabled,
|
|
_parse_frequency,
|
|
_resume_tickers,
|
|
_last_successful,
|
|
configure_scheduler,
|
|
scheduler,
|
|
)
|
|
|
|
|
|
class TestParseFrequency:
|
|
def test_hourly(self):
|
|
assert _parse_frequency("hourly") == {"hours": 1}
|
|
|
|
def test_daily(self):
|
|
assert _parse_frequency("daily") == {"hours": 24}
|
|
|
|
def test_case_insensitive(self):
|
|
assert _parse_frequency("Hourly") == {"hours": 1}
|
|
assert _parse_frequency("DAILY") == {"hours": 24}
|
|
|
|
def test_unknown_defaults_to_daily(self):
|
|
assert _parse_frequency("weekly") == {"hours": 24}
|
|
assert _parse_frequency("") == {"hours": 24}
|
|
|
|
|
|
class TestResumeTickers:
|
|
def test_no_previous_returns_full_list(self):
|
|
symbols = ["AAPL", "GOOG", "MSFT"]
|
|
_last_successful["test_job"] = None
|
|
result = _resume_tickers(symbols, "test_job")
|
|
assert result == ["AAPL", "GOOG", "MSFT"]
|
|
|
|
def test_resume_after_first(self):
|
|
symbols = ["AAPL", "GOOG", "MSFT"]
|
|
_last_successful["test_job"] = "AAPL"
|
|
result = _resume_tickers(symbols, "test_job")
|
|
# Should start from GOOG, then wrap around
|
|
assert result == ["GOOG", "MSFT", "AAPL"]
|
|
|
|
def test_resume_after_middle(self):
|
|
symbols = ["AAPL", "GOOG", "MSFT", "TSLA"]
|
|
_last_successful["test_job"] = "GOOG"
|
|
result = _resume_tickers(symbols, "test_job")
|
|
assert result == ["MSFT", "TSLA", "AAPL", "GOOG"]
|
|
|
|
def test_resume_after_last(self):
|
|
symbols = ["AAPL", "GOOG", "MSFT"]
|
|
_last_successful["test_job"] = "MSFT"
|
|
result = _resume_tickers(symbols, "test_job")
|
|
# All already processed, wraps to full list
|
|
assert result == ["AAPL", "GOOG", "MSFT"]
|
|
|
|
def test_unknown_last_returns_full_list(self):
|
|
symbols = ["AAPL", "GOOG", "MSFT"]
|
|
_last_successful["test_job"] = "NVDA"
|
|
result = _resume_tickers(symbols, "test_job")
|
|
assert result == ["AAPL", "GOOG", "MSFT"]
|
|
|
|
def test_empty_list(self):
|
|
_last_successful["test_job"] = "AAPL"
|
|
result = _resume_tickers([], "test_job")
|
|
assert result == []
|
|
|
|
|
|
class TestConfigureScheduler:
|
|
def test_configure_adds_all_jobs(self):
|
|
# Remove any existing jobs first
|
|
scheduler.remove_all_jobs()
|
|
configure_scheduler()
|
|
jobs = scheduler.get_jobs()
|
|
job_ids = {j.id for j in jobs}
|
|
assert job_ids == {
|
|
"data_collector",
|
|
"sentiment_collector",
|
|
"fundamental_collector",
|
|
"rr_scanner",
|
|
"ticker_universe_sync",
|
|
"outcome_evaluator",
|
|
"alerts",
|
|
}
|
|
|
|
def test_configure_is_idempotent(self):
|
|
scheduler.remove_all_jobs()
|
|
configure_scheduler()
|
|
configure_scheduler() # Should replace, not duplicate
|
|
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",
|
|
"rr_scanner",
|
|
"sentiment_collector",
|
|
"ticker_universe_sync",
|
|
])
|