Groundwork for the Admin -> Jobs cleanup. Three sources of truth collapse into
app/job_catalog.py, which imports nothing from app so both the scheduler and
admin_service can import it at module level (admin_service otherwise has to
import the scheduler inside functions to dodge a cycle).
PIPELINE_MEMBERS is now DERIVED from the four pipeline step lists instead of
being a literal set in admin_service duplicating four lists in scheduler.py with
nothing asserting they agreed. A test pins that the derivation reproduces the
previous hand-maintained 9 names exactly, so this is behaviour-preserving.
Deletes the private _JOB_NAMES list, which held 16 of the 19 jobs:
benchmark_collector, outcome_evaluator and shadow_book had no runtime row, and
so no "last run" line in the panel, until their first run in a given process.
_job_runtime is now seeded from the catalog, and a test pins the invariant.
Next-run is decided by category rather than by reading a timestamp. A pipeline
step has no schedule of its own, so it reports its parent's ("next via Morning
Pipeline in 3h") instead of nothing; a manual job says manual_only rather than
rendering a date. This also fixes a real bug: triggering a paused job set
next_run_time=now, APScheduler re-armed the 520-week backstop behind it, and the
panel displayed "next run in ~87600h". Two independent guards -- the category
rule, plus _visible_next_run dropping anything past a year -- and an APScheduler
listener that re-pauses steps and manual jobs once their run finishes. The
listener is registered at module level because configure_scheduler is called
more than once and add_listener does not deduplicate.
Migrates backtest and ticker_universe_sync from interval to cron (Sun 03:00 ET
and 01:00 ET). configure_scheduler calls remove_all_jobs() on every startup, so
an interval countdown restarts each deploy -- a 168h backtest needed a week of
uninterrupted uptime to fire even once. The codebase already documented this
pitfall as the reason cron was adopted; these two were never migrated. Both are
now editable in Admin -> Schedule.
Also: list_jobs went from one settings query per job (19) to one for all of
them, and data_backfill is hidden from the listing while staying registered and
API-triggerable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
497 lines
19 KiB
Python
497 lines
19 KiB
Python
"""Unit tests for app.scheduler module."""
|
|
|
|
import asyncio
|
|
from datetime import datetime, timezone
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from app import job_catalog
|
|
from app.scheduler import (
|
|
_DAILY_PIPELINE_STEPS,
|
|
_NEAR_CLOSE_PIPELINE_STEPS,
|
|
_consume_backtest_options,
|
|
_consume_backtest_target_model,
|
|
_parse_frequency,
|
|
_repause_after_manual_run,
|
|
_resume_tickers,
|
|
_last_successful,
|
|
_run_source_import,
|
|
run_sec_fundamentals_import,
|
|
configure_scheduler,
|
|
get_job_runtime_snapshot,
|
|
queue_backtest_options,
|
|
queue_backtest_target_model,
|
|
scheduler,
|
|
)
|
|
from app.services.data_import import STATUS_DEFERRED
|
|
|
|
|
|
def test_manual_backtest_target_model_is_one_shot():
|
|
assert queue_backtest_target_model("structural_sr") == "structural_sr"
|
|
assert _consume_backtest_target_model() == "structural_sr"
|
|
assert _consume_backtest_target_model() == "production_gtl"
|
|
|
|
|
|
def test_manual_backtest_target_model_rejects_removed_research_arms():
|
|
with pytest.raises(ValueError, match="Unknown backtest target model"):
|
|
queue_backtest_target_model("production_control")
|
|
|
|
|
|
def test_manual_backtest_options_are_one_shot_and_default_back_to_weekly():
|
|
assert queue_backtest_options("structural_sr", "daily") == (
|
|
"structural_sr",
|
|
"daily",
|
|
)
|
|
assert _consume_backtest_options() == ("structural_sr", "daily")
|
|
assert _consume_backtest_options() == ("production_gtl", "weekly")
|
|
|
|
|
|
def test_only_near_close_fetch_skips_redundant_sr_refresh():
|
|
assert dict(_DAILY_PIPELINE_STEPS)["data_collector"] == "collect_ohlcv"
|
|
assert (
|
|
dict(_NEAR_CLOSE_PIPELINE_STEPS)["data_collector"]
|
|
== "collect_ohlcv_for_scan"
|
|
)
|
|
|
|
|
|
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_weekly_maps_to_one_week(self):
|
|
assert _parse_frequency("weekly") == {"weeks": 1}
|
|
|
|
def test_unknown_defaults_to_daily(self):
|
|
assert _parse_frequency("monthly") == {"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):
|
|
# Derived from the catalog, not a fourth hand-maintained copy of the
|
|
# job list: a job added to the catalog but never registered now fails
|
|
# here instead of silently rendering "Not registered" in the admin UI.
|
|
scheduler.remove_all_jobs()
|
|
configure_scheduler()
|
|
assert {j.id for j in scheduler.get_jobs()} == set(job_catalog.VALID_JOB_NAMES)
|
|
|
|
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()]
|
|
assert sorted(job_ids) == sorted(job_catalog.VALID_JOB_NAMES)
|
|
|
|
def test_independent_jobs_use_cron_not_interval(self):
|
|
"""Interval countdowns restart on every deploy, so a weekly interval on a
|
|
frequently-redeployed box can defer forever. Both standalone jobs were
|
|
migrated to cron; this pins them there."""
|
|
scheduler.remove_all_jobs()
|
|
configure_scheduler()
|
|
for job_id in ("backtest", "ticker_universe_sync"):
|
|
trigger = type(scheduler.get_job(job_id).trigger).__name__
|
|
assert trigger == "CronTrigger", f"{job_id} regressed to {trigger}"
|
|
|
|
|
|
class TestJobCatalog:
|
|
def test_pipeline_members_are_derived_from_step_lists(self):
|
|
derived = {
|
|
step
|
|
for steps in job_catalog.PIPELINE_STEPS.values()
|
|
for step, _ in steps
|
|
}
|
|
assert job_catalog.PIPELINE_MEMBERS == derived
|
|
# ...and reproduces the set that used to be maintained by hand, so the
|
|
# derivation is behaviour-preserving rather than merely self-consistent.
|
|
assert job_catalog.PIPELINE_MEMBERS == {
|
|
"data_collector",
|
|
"benchmark_collector",
|
|
"sentiment_collector",
|
|
"rr_scanner",
|
|
"shadow_book",
|
|
"outcome_evaluator",
|
|
"alerts",
|
|
"market_regime",
|
|
"regime_monitor",
|
|
}
|
|
|
|
def test_categories_partition_every_job_exactly_once(self):
|
|
buckets = [
|
|
job_catalog.PIPELINE_JOBS,
|
|
job_catalog.PIPELINE_STEP_JOBS,
|
|
job_catalog.SCHEDULED_JOBS,
|
|
job_catalog.MANUAL_JOBS,
|
|
]
|
|
flat = [name for bucket in buckets for name in bucket]
|
|
assert len(flat) == len(set(flat)), "a job is in two categories"
|
|
assert set(flat) == set(job_catalog.VALID_JOB_NAMES)
|
|
assert all(name in job_catalog.JOB_CATEGORY for name in flat)
|
|
|
|
def test_every_job_has_a_label_and_a_unique_sort_order(self):
|
|
names = job_catalog.VALID_JOB_NAMES
|
|
assert set(job_catalog.JOB_LABELS) == set(names)
|
|
assert len({job_catalog.sort_order(n) for n in names}) == len(names)
|
|
|
|
def test_multi_pipeline_members_report_every_parent(self):
|
|
"""Membership is many-to-many — the reason the UI groups into sections
|
|
rather than nesting steps under one parent."""
|
|
by_member = job_catalog.PIPELINES_BY_MEMBER
|
|
assert set(by_member["data_collector"]) == set(job_catalog.PIPELINE_JOBS)
|
|
assert set(by_member["alerts"]) == {"daily_pipeline", "near_close_pipeline"}
|
|
assert set(by_member["outcome_evaluator"]) == {
|
|
"intraday_pipeline",
|
|
"after_close_pipeline",
|
|
}
|
|
assert "backtest" not in by_member
|
|
|
|
def test_every_job_has_a_runtime_row_before_it_first_runs(self):
|
|
"""The old private _JOB_NAMES list held 16 of 19, so three jobs showed no
|
|
last-run line until their first run in a given process."""
|
|
assert set(get_job_runtime_snapshot()) == set(job_catalog.VALID_JOB_NAMES)
|
|
|
|
|
|
class TestRepauseListener:
|
|
def _configured(self):
|
|
scheduler.remove_all_jobs()
|
|
configure_scheduler()
|
|
|
|
def test_manual_job_is_repaused_after_running(self):
|
|
"""Triggering a paused job re-arms its 520-week backstop, which used to
|
|
surface as a "next run in ~87600h"."""
|
|
self._configured()
|
|
scheduler.modify_job("event_study", next_run_time=datetime.now(timezone.utc))
|
|
_repause_after_manual_run(SimpleNamespace(job_id="event_study"))
|
|
assert scheduler.get_job("event_study").next_run_time is None
|
|
|
|
def test_pipeline_step_is_repaused_after_running(self):
|
|
self._configured()
|
|
scheduler.modify_job("rr_scanner", next_run_time=datetime.now(timezone.utc))
|
|
_repause_after_manual_run(SimpleNamespace(job_id="rr_scanner"))
|
|
assert scheduler.get_job("rr_scanner").next_run_time is None
|
|
|
|
def test_cron_jobs_are_left_alone(self):
|
|
# Set an explicit next run first: an unstarted scheduler leaves the
|
|
# attribute unset, so comparing None to None would prove nothing.
|
|
self._configured()
|
|
due = datetime.now(timezone.utc)
|
|
scheduler.modify_job("daily_pipeline", next_run_time=due)
|
|
_repause_after_manual_run(SimpleNamespace(job_id="daily_pipeline"))
|
|
assert scheduler.get_job("daily_pipeline").next_run_time == due
|
|
|
|
def test_unknown_job_is_ignored(self):
|
|
self._configured()
|
|
_repause_after_manual_run(SimpleNamespace(job_id="not_a_job"))
|
|
|
|
|
|
class _SessionContext:
|
|
async def __aenter__(self):
|
|
return object()
|
|
|
|
async def __aexit__(self, *exc):
|
|
return None
|
|
|
|
|
|
class TestSourceImportJobs:
|
|
@staticmethod
|
|
def _session_factory():
|
|
return _SessionContext()
|
|
|
|
async def test_promoted_run_surfaces_completion(self, monkeypatch):
|
|
async def enabled(db, job_name):
|
|
return True
|
|
|
|
async def imported(importer):
|
|
return SimpleNamespace(
|
|
status="promoted", revision="abcdef1234567890", error_details=None
|
|
)
|
|
|
|
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
|
|
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
|
|
monkeypatch.setattr("app.scheduler.run_import", imported)
|
|
|
|
await _run_source_import("dolt_earnings_import", object())
|
|
|
|
runtime = get_job_runtime_snapshot("dolt_earnings_import")
|
|
assert runtime["status"] == "completed"
|
|
assert runtime["processed"] == 1
|
|
assert runtime["message"] == "promoted · abcdef123456"
|
|
|
|
async def test_failed_run_surfaces_error(self, monkeypatch):
|
|
async def enabled(db, job_name):
|
|
return True
|
|
|
|
async def imported(importer):
|
|
return SimpleNamespace(
|
|
status="failed", revision=None, error_details="validation failed"
|
|
)
|
|
|
|
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
|
|
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
|
|
monkeypatch.setattr("app.scheduler.run_import", imported)
|
|
|
|
await _run_source_import("sec_fundamentals_import", object())
|
|
|
|
runtime = get_job_runtime_snapshot("sec_fundamentals_import")
|
|
assert runtime["status"] == "error"
|
|
assert runtime["processed"] == 0
|
|
assert runtime["message"] == "validation failed"
|
|
|
|
async def test_deferred_run_is_visible_without_error_status(self, monkeypatch):
|
|
async def enabled(db, job_name):
|
|
return True
|
|
|
|
async def imported(importer):
|
|
return SimpleNamespace(
|
|
status=STATUS_DEFERRED,
|
|
revision="abcdef1234567890",
|
|
error_details="Company Facts publication lag; retrying",
|
|
)
|
|
|
|
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
|
|
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
|
|
monkeypatch.setattr("app.scheduler.run_import", imported)
|
|
|
|
await _run_source_import("sec_fundamentals_import", object())
|
|
|
|
runtime = get_job_runtime_snapshot("sec_fundamentals_import")
|
|
assert runtime["status"] == STATUS_DEFERRED
|
|
assert runtime["processed"] == 0
|
|
assert runtime["message"] == "Company Facts publication lag; retrying"
|
|
|
|
async def test_source_lock_surfaces_skipped(self, monkeypatch):
|
|
async def enabled(db, job_name):
|
|
return True
|
|
|
|
async def imported(importer):
|
|
return None
|
|
|
|
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
|
|
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
|
|
monkeypatch.setattr("app.scheduler.run_import", imported)
|
|
|
|
await _run_source_import("dolt_earnings_import", object())
|
|
|
|
runtime = get_job_runtime_snapshot("dolt_earnings_import")
|
|
assert runtime["status"] == "skipped"
|
|
assert "already running" in runtime["message"]
|
|
|
|
async def test_disabled_job_never_runs_importer(self, monkeypatch):
|
|
async def disabled(db, job_name):
|
|
return False
|
|
|
|
async def should_not_run(importer):
|
|
raise AssertionError("disabled job ran importer")
|
|
|
|
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
|
|
monkeypatch.setattr("app.scheduler._is_job_enabled", disabled)
|
|
monkeypatch.setattr("app.scheduler.run_import", should_not_run)
|
|
|
|
await _run_source_import("sec_fundamentals_import", object())
|
|
|
|
runtime = get_job_runtime_snapshot("sec_fundamentals_import")
|
|
assert runtime["status"] == "skipped"
|
|
assert runtime["message"] == "Disabled"
|
|
|
|
async def test_sec_failure_still_runs_local_cache_refresh(self, monkeypatch):
|
|
calls = []
|
|
events = []
|
|
|
|
async def enabled(db, job_name):
|
|
return True
|
|
|
|
async def unavailable(importer):
|
|
raise RuntimeError("SEC unavailable")
|
|
|
|
async def refreshed(db):
|
|
calls.append(db)
|
|
return {
|
|
"refreshed": 511,
|
|
"score_inputs_changed": 2,
|
|
"dimension_scores_staled": 2,
|
|
"composite_scores_staled": 2,
|
|
}
|
|
|
|
async def record(**kwargs):
|
|
events.append(kwargs)
|
|
|
|
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
|
|
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
|
|
monkeypatch.setattr("app.scheduler.run_import", unavailable)
|
|
monkeypatch.setattr("app.scheduler._record_system_event", record)
|
|
monkeypatch.setattr(
|
|
"app.scheduler.fundamental_data_refresh_service.refresh",
|
|
refreshed,
|
|
)
|
|
|
|
await run_sec_fundamentals_import()
|
|
await asyncio.sleep(0) # let the fire-and-forget event task run
|
|
|
|
assert len(calls) == 1
|
|
runtime = get_job_runtime_snapshot("sec_fundamentals_import")
|
|
assert runtime["status"] == "error"
|
|
# the failure stays the headline, but the cache result is still visible
|
|
assert runtime["message"] == (
|
|
"SEC unavailable · cache 511 · 2 score inputs changed"
|
|
)
|
|
# Rewording the outcome must not duplicate the durable event: the dedup
|
|
# key includes the message, so a second finish would show up twice in
|
|
# Admin → System Events.
|
|
assert len(events) == 1, events
|
|
|
|
async def test_sec_success_surfaces_cache_refresh_summary(self, monkeypatch):
|
|
async def enabled(db, job_name):
|
|
return True
|
|
|
|
async def imported(importer):
|
|
return SimpleNamespace(
|
|
status="no_op", revision="abcdef1234567890", error_details=None
|
|
)
|
|
|
|
async def refreshed(db):
|
|
return {
|
|
"refreshed": 511,
|
|
"score_inputs_changed": 2,
|
|
"dimension_scores_staled": 2,
|
|
"composite_scores_staled": 2,
|
|
}
|
|
|
|
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
|
|
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
|
|
monkeypatch.setattr("app.scheduler.run_import", imported)
|
|
monkeypatch.setattr(
|
|
"app.scheduler.fundamental_data_refresh_service.refresh",
|
|
refreshed,
|
|
)
|
|
|
|
await run_sec_fundamentals_import()
|
|
|
|
runtime = get_job_runtime_snapshot("sec_fundamentals_import")
|
|
assert runtime["status"] == "completed"
|
|
assert runtime["message"] == (
|
|
"no_op · abcdef123456 · cache 511 · 2 score inputs changed"
|
|
)
|
|
|
|
async def test_source_locked_sec_run_still_reports_the_cache_refresh(
|
|
self, monkeypatch
|
|
):
|
|
"""A skipped import keeps its skip status but shows the cache advanced."""
|
|
|
|
async def enabled(db, job_name):
|
|
return True
|
|
|
|
async def locked(importer):
|
|
return None # another import owns the source lock
|
|
|
|
async def refreshed(db):
|
|
return {
|
|
"refreshed": 511,
|
|
"score_inputs_changed": 0,
|
|
"dimension_scores_staled": 0,
|
|
"composite_scores_staled": 0,
|
|
}
|
|
|
|
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
|
|
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
|
|
monkeypatch.setattr("app.scheduler.run_import", locked)
|
|
monkeypatch.setattr(
|
|
"app.scheduler.fundamental_data_refresh_service.refresh",
|
|
refreshed,
|
|
)
|
|
|
|
await run_sec_fundamentals_import()
|
|
|
|
runtime = get_job_runtime_snapshot("sec_fundamentals_import")
|
|
assert runtime["status"] == "skipped"
|
|
assert runtime["message"] == (
|
|
"Another import for this source is already running · "
|
|
"cache 511 · 0 score inputs changed"
|
|
)
|
|
|
|
async def test_disabled_sec_job_still_refreshes_local_cache(self, monkeypatch):
|
|
"""Disabling the job stops the SEC fetch, not the local cache.
|
|
|
|
The cache is derived from stored snapshots, earnings events and closes.
|
|
Prices and earnings move daily even when no filing does, and there is no
|
|
provider fallback since A6 — freezing it would silently stale scoring.
|
|
"""
|
|
calls = []
|
|
|
|
async def disabled(db, job_name):
|
|
return False
|
|
|
|
async def should_not_run(*args, **kwargs):
|
|
raise AssertionError("disabled SEC job hit the network")
|
|
|
|
async def refreshed(db):
|
|
calls.append(db)
|
|
return {
|
|
"refreshed": 511,
|
|
"score_inputs_changed": 2,
|
|
"dimension_scores_staled": 2,
|
|
"composite_scores_staled": 2,
|
|
}
|
|
|
|
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
|
|
monkeypatch.setattr("app.scheduler._is_job_enabled", disabled)
|
|
monkeypatch.setattr("app.scheduler.run_import", should_not_run)
|
|
monkeypatch.setattr(
|
|
"app.scheduler.fundamental_data_refresh_service.refresh",
|
|
refreshed,
|
|
)
|
|
|
|
await run_sec_fundamentals_import()
|
|
|
|
assert len(calls) == 1
|
|
runtime = get_job_runtime_snapshot("sec_fundamentals_import")
|
|
assert runtime["status"] == "completed"
|
|
assert runtime["message"] == (
|
|
"Import disabled · cache 511 · 2 score inputs changed"
|
|
)
|