refactor(jobs): derive job topology from one catalog, make next-run coherent

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>
This commit is contained in:
2026-08-08 12:08:01 +02:00
co-authored by Claude Opus 5
parent 7fdcac3b55
commit 083c9dbf7c
8 changed files with 588 additions and 184 deletions
+88 -69
View File
@@ -7,6 +7,7 @@ from passlib.hash import bcrypt
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app import job_catalog
from app.exceptions import DuplicateError, NotFoundError, ValidationError
from app.models.fundamental import FundamentalData
from app.models.ohlcv import OHLCVRecord
@@ -606,91 +607,108 @@ async def get_pipeline_readiness(db: AsyncSession) -> list[dict]:
# Job control (placeholder — scheduler is Task 12.1)
# ---------------------------------------------------------------------------
VALID_JOB_NAMES = {
"data_collector",
"data_backfill",
"benchmark_collector",
"sentiment_collector",
"dolt_earnings_import",
"sec_fundamentals_import",
"rr_scanner",
"ticker_universe_sync",
"outcome_evaluator",
"alerts",
"market_regime",
"regime_monitor",
"event_study",
"backtest",
"daily_pipeline",
"near_close_pipeline",
"after_close_pipeline",
"intraday_pipeline",
"shadow_book",
}
# Job identity, labels and pipeline membership now live in app.job_catalog, which
# derives PIPELINE_MEMBERS from the pipeline step lists instead of restating them.
# Re-exported here because callers (routers, tests) import them from this module.
VALID_JOB_NAMES = job_catalog.VALID_JOB_NAMES
JOB_LABELS = job_catalog.JOB_LABELS
PIPELINE_MEMBERS = job_catalog.PIPELINE_MEMBERS
JOB_LABELS = {
"data_collector": "Data Collector (OHLCV)",
"data_backfill": "Data Backfill (deep history)",
"benchmark_collector": "Benchmark Collector",
"sentiment_collector": "Sentiment Collector",
"dolt_earnings_import": "Dolt Earnings Import",
"sec_fundamentals_import": "SEC Fundamentals Import",
"rr_scanner": "R:R Scanner",
"ticker_universe_sync": "Ticker Universe Sync",
"outcome_evaluator": "Outcome Evaluator",
"alerts": "Alerts Dispatcher",
# Keys are persisted job ids and must not change; these are display only.
"market_regime": "Market Trend (SPY)",
"regime_monitor": "AI/Tech Risk Monitor",
"event_study": "Event Study",
"backtest": "Backtest",
"daily_pipeline": "Morning Pipeline",
"near_close_pipeline": "Near-Close Pipeline (scan+alert)",
"after_close_pipeline": "After-Close Pipeline (outcome)",
"intraday_pipeline": "Intraday Pipeline",
"shadow_book": "Shadow Book (auto-traded strategy)",
}
# Anything further out than this is a parked backstop, not a schedule: pipeline
# steps and manual jobs are registered on a 520-week interval, and triggering one
# re-arms it. Belt-and-braces behind the category rule in _next_run_fields.
_NEXT_RUN_HORIZON_DAYS = 365
# Jobs driven by a pipeline (in order) rather than their own auto timer.
PIPELINE_MEMBERS = {
"data_collector",
"benchmark_collector",
"sentiment_collector",
"rr_scanner",
"outcome_evaluator",
"alerts",
"market_regime",
"regime_monitor",
"shadow_book",
}
def _visible_next_run(next_run: datetime | None) -> datetime | None:
"""Drop a next-run that is really the parked backstop."""
if next_run is None:
return None
horizon = datetime.now(next_run.tzinfo) + timedelta(days=_NEXT_RUN_HORIZON_DAYS)
return None if next_run > horizon else next_run
def _own_next_run(scheduler, name: str) -> datetime | None:
# getattr: APScheduler only sets next_run_time once the scheduler is running,
# so a job registered but not yet started has no such attribute at all.
job = scheduler.get_job(name)
return _visible_next_run(getattr(job, "next_run_time", None)) if job else None
def _next_run_fields(scheduler, name: str, enabled_map: dict[str, bool]) -> dict:
"""Where this job's next run comes from, decided by category not by clock.
A pipeline step has no meaningful schedule of its own, so reporting one is
the bug: its parent's timer is the answer. Manual jobs have no answer at all,
and saying so beats rendering a parked backstop as a date.
"""
category = job_catalog.JOB_CATEGORY.get(name)
if category == job_catalog.CATEGORY_STEP:
parents = job_catalog.PIPELINES_BY_MEMBER.get(name, ())
soonest: datetime | None = None
via: str | None = None
for parent in parents:
if not enabled_map.get(parent, True):
continue
candidate = _own_next_run(scheduler, parent)
if candidate is not None and (soonest is None or candidate < soonest):
soonest, via = candidate, parent
return {
"next_run_at": None,
"next_run_source": "via_pipeline",
"via_next_run_at": soonest.isoformat() if soonest else None,
"via_next_run_job": via,
}
if category == job_catalog.CATEGORY_MANUAL:
return {
"next_run_at": None,
"next_run_source": "manual_only",
"via_next_run_at": None,
"via_next_run_job": None,
}
own = _own_next_run(scheduler, name)
return {
"next_run_at": own.isoformat() if own else None,
"next_run_source": "own_schedule",
"via_next_run_at": None,
"via_next_run_job": None,
}
async def list_jobs(db: AsyncSession) -> list[dict]:
"""Return status of all scheduled jobs."""
"""Return status of all scheduled jobs, grouped and ordered by category."""
from app.scheduler import get_job_runtime_snapshot, scheduler
visible = sorted(VALID_JOB_NAMES - job_catalog.HIDDEN_JOBS, key=job_catalog.sort_order)
# One query for every flag instead of one per job. Parents are read too, since
# a step reports its parent's next run only while that parent is enabled.
flags = await settings_store.get_map(
db, [f"job_{name}_enabled" for name in VALID_JOB_NAMES]
)
enabled_map = {
name: flags.get(f"job_{name}_enabled", "true") == "true"
for name in VALID_JOB_NAMES
}
jobs_out = []
for name in sorted(VALID_JOB_NAMES):
# Check enabled setting
setting = await settings_store.get_setting(db, f"job_{name}_enabled")
enabled = setting.value == "true" if setting else True # default enabled
# Get scheduler job info
for name in visible:
job = scheduler.get_job(name)
next_run = None
if job and job.next_run_time:
next_run = job.next_run_time.isoformat()
runtime = get_job_runtime_snapshot(name)
jobs_out.append({
"name": name,
"label": JOB_LABELS.get(name, name),
"enabled": enabled,
"next_run_at": next_run,
"via_pipeline": name in PIPELINE_MEMBERS,
"enabled": enabled_map.get(name, True),
"category": job_catalog.JOB_CATEGORY.get(name),
"sort_order": job_catalog.sort_order(name),
# Parent pipelines for a step; the steps themselves for a pipeline.
"pipelines": list(job_catalog.PIPELINES_BY_MEMBER.get(name, ())),
"steps": [step for step, _ in job_catalog.PIPELINE_STEPS.get(name, ())],
"registered": job is not None,
"running": bool(runtime.get("running", False)),
# runtime_* are strictly live in-memory state. Persisted history is
# reported separately as last_run_*, so a stale error cannot pin the
# status chip or the rate-limit banner.
"runtime_status": runtime.get("status"),
"runtime_processed": runtime.get("processed"),
"runtime_total": runtime.get("total"),
@@ -699,6 +717,7 @@ async def list_jobs(db: AsyncSession) -> list[dict]:
"runtime_started_at": runtime.get("started_at"),
"runtime_finished_at": runtime.get("finished_at"),
"runtime_message": runtime.get("message"),
**_next_run_fields(scheduler, name, enabled_map),
})
return jobs_out