Files
signal-platform/tests/unit/test_schedule_config.py
T
dennisthiessenandClaude Opus 5 3e83d63b05 chore: decommission FMP, Finnhub and Alpha Vantage (A6)
The A5 cutover has been on and observed in production, so SEC Company Facts +
DoltHub earnings are already the live source for `fundamental_data`. This
removes everything the legacy path still occupied.

Gone: the three providers and their config/env keys; the weekly
`fundamental_collector` job; the cutover toggle (SEC + Dolt is now the
unconditional path, so `off` can no longer silently freeze scoring inputs); the
A5 parity report, whose deltas became structurally zero once the candidate
builder started writing the table it compared against; and the FMP tier of
universe bootstrap.

Two behavioral notes:

- Disabling **SEC Fundamentals Import** now stops the SEC network fetch only.
  The local cache refresh moved outside the job-enable check, because candidates
  also derive from daily closes and earnings events — freezing those on an
  ingestion pause would stale scoring with no fallback left to recover from.
- `/ingestion/fetch?sources=fundamentals` still accepts the key and reports
  `skipped`; there is no per-ticker fetch any more.

Migration 029 does not blanket-delete the leftover settings rows. Migrations run
before the service restart, and pre-A6 code reads an absent `job_*_enabled` row
as *enabled* — so the two behavior-bearing keys become tombstones pinned to safe
values (hidden in Admin) and only the inert three are deleted. Removing the
provider keys from the production `.env` is the matching rollout step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:19:28 +02:00

119 lines
4.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit tests for the cron pipeline schedule config."""
from __future__ import annotations
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import ValidationError
from app.scheduler import SCHEDULE_DEFAULTS, validate_cron
from app.services.admin_service import get_schedule_config, update_schedule_config
@pytest.fixture
async def session() -> AsyncSession:
from tests.conftest import _test_session_factory
async with _test_session_factory() as session:
yield session
class TestValidateCron:
def test_accepts_valid(self):
validate_cron("0 7 * * *", "Europe/Berlin")
validate_cron("0 14-22 * * 1-5", "UTC")
def test_rejects_bad_cron(self):
with pytest.raises(Exception):
validate_cron("not a cron", "UTC")
def test_rejects_bad_timezone(self):
with pytest.raises(Exception):
validate_cron("0 7 * * *", "Mars/Phobos")
class TestTradingDayCrons:
"""APScheduler's from_crontab() uses 0=Monday, so numeric "1-5" means
TueSat: it skips every Monday and fires on Saturdays. Weekday schedules
must therefore be spelled with day *names*.
"""
_WEEKDAY_KEYS = (
"schedule_near_close_pipeline_cron",
"schedule_after_close_pipeline_cron",
"schedule_intraday_pipeline_cron",
)
@pytest.mark.parametrize("key", _WEEKDAY_KEYS)
def test_fires_monday_and_never_saturday(self, key: str):
from datetime import datetime, timedelta
from apscheduler.triggers.cron import CronTrigger
trigger = CronTrigger.from_crontab(
SCHEDULE_DEFAULTS[key], timezone=SCHEDULE_DEFAULTS["schedule_timezone"]
)
# Walk a full week of fire times from a known Sunday.
cursor = datetime(2026, 7, 19, tzinfo=trigger.timezone)
weekdays = set()
previous = None
for _ in range(12):
fire = trigger.get_next_fire_time(previous, cursor)
weekdays.add(fire.strftime("%a"))
previous = fire
cursor = fire + timedelta(seconds=1)
assert "Mon" in weekdays, f"{key} skips Mondays — numeric day-of-week?"
assert {"Sat", "Sun"}.isdisjoint(weekdays), f"{key} fires on a weekend"
@pytest.mark.parametrize(
("key", "hour", "minute"),
(
("schedule_dolt_earnings_cron", 2, 30),
("schedule_sec_fundamentals_cron", 4, 0),
),
)
def test_shadow_imports_run_daily_at_expected_et_time(
self, key: str, hour: int, minute: int
):
from datetime import datetime
from apscheduler.triggers.cron import CronTrigger
trigger = CronTrigger.from_crontab(
SCHEDULE_DEFAULTS[key], timezone=SCHEDULE_DEFAULTS["schedule_timezone"]
)
fire = trigger.get_next_fire_time(
None, datetime(2026, 7, 19, tzinfo=trigger.timezone)
)
assert (fire.hour, fire.minute) == (hour, minute)
class TestScheduleConfig:
async def test_defaults_when_unset(self, session: AsyncSession):
config = await get_schedule_config(session)
assert config == SCHEDULE_DEFAULTS
async def test_update_and_read_back(self, session: AsyncSession):
updated = await update_schedule_config(
session, {"schedule_daily_pipeline_cron": "30 6 * * *"}
)
assert updated["schedule_daily_pipeline_cron"] == "30 6 * * *"
# untouched keys keep their defaults
assert updated["schedule_intraday_pipeline_cron"] == SCHEDULE_DEFAULTS["schedule_intraday_pipeline_cron"]
config = await get_schedule_config(session)
assert config["schedule_daily_pipeline_cron"] == "30 6 * * *"
async def test_rejects_bad_cron(self, session: AsyncSession):
with pytest.raises(ValidationError):
await update_schedule_config(session, {"schedule_daily_pipeline_cron": "every monday"})
async def test_rejects_bad_timezone(self, session: AsyncSession):
with pytest.raises(ValidationError):
await update_schedule_config(session, {"schedule_timezone": "Nowhere/Void"})
async def test_rejects_unknown_key(self, session: AsyncSession):
with pytest.raises(ValidationError):
await update_schedule_config(session, {"schedule_bogus": "0 0 * * *"})