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
+207
View File
@@ -0,0 +1,207 @@
"""Job topology: names, labels, pipeline membership, categories, ordering.
The single source of truth for *what the jobs are*, as opposed to how they run.
It deliberately imports nothing from ``app`` so both ``app.scheduler`` and
``app.services.admin_service`` can import it at module level -- admin_service
otherwise has to do ``from app.scheduler import ...`` inside functions to dodge a
cycle.
The pipeline step lists live here rather than in the scheduler because three
separate things need them and used to keep private copies: the runner, the
``PIPELINE_MEMBERS`` set the admin API reports, and the UI's grouping. Steps are
``(step_name, coroutine_name)``; ``_run_pipeline`` resolves the coroutine late
out of the scheduler's own globals, so nothing here depends on those functions
existing.
"""
from __future__ import annotations
# ---------------------------------------------------------------------------
# Pipelines
# ---------------------------------------------------------------------------
_DAILY_PIPELINE_STEPS = [
("data_collector", "collect_ohlcv"),
("benchmark_collector", "collect_benchmark"),
("sentiment_collector", "collect_sentiment"),
("market_regime", "compute_market_regime"),
# Observational only — display/alerts; not trade selection.
("regime_monitor", "compute_regime_monitor"),
# Alerts after regime so quadrant changes reach Telegram in the morning.
# Dispatcher is change-driven; quiet days stay quiet. Setup alerts still
# fire on the near-close pipeline after the qualifying scan.
("alerts", "dispatch_alerts_job"),
]
# Near-close (~15:30 ET MonFri): refresh in-progress day-t bars (incremental
# ingestion overlaps the latest stored session), then the only daily
# qualifying R:R scan, then Telegram immediately so manual fills can still hit
# MOC cutoffs (~15:50/15:55). Under a 15-minute delayed SIP feed a 15:30 scan
# may see ~15:15 prices — immaterial for a 12-1 momentum signal.
#
# US early-close days (~3/year, 13:00 ET close): this job runs post-close and
# entries behave like stale_close (still acceptable per execution-recovery matrix).
# No exchange calendar dependency.
_NEAR_CLOSE_PIPELINE_STEPS = [
# Must land today's in-progress bar (~20 min behind live), or the scan falls
# back to the previous close and execution degrades to the stale_close floor.
("data_collector", "collect_ohlcv_for_scan"),
("rr_scanner", "scan_rr"),
# Straight after the scan so shadow entries mark at the same near-close
# prices the discretionary book is looking at.
("shadow_book", "run_shadow_book"),
("alerts", "dispatch_alerts_job"),
]
# After close (~16:45 ET MonFri): fresh OHLCV fetch so outcomes resolve on the
# final bar, not the near-close partial bar, then outcome/paper close.
_AFTER_CLOSE_PIPELINE_STEPS = [
("data_collector", "collect_ohlcv_final"),
("outcome_evaluator", "evaluate_outcomes"),
]
# Intraday (light): keep prices current and resolve outcomes through the day,
# without the expensive scan/sentiment. The dashboard recomputes live R:R from
# the latest price, so refreshing OHLCV is enough to stop prices lagging; the
# outcome step also closes paper trades that hit their stop/target intraday.
_INTRADAY_PIPELINE_STEPS = [
("data_collector", "collect_ohlcv"),
("outcome_evaluator", "evaluate_outcomes"),
]
# Ordered by trading day, not alphabetically: this is the sequence an operator
# reads down the page, and it drives the UI's ordering too.
PIPELINE_STEPS: dict[str, list[tuple[str, str]]] = {
"daily_pipeline": _DAILY_PIPELINE_STEPS,
"intraday_pipeline": _INTRADAY_PIPELINE_STEPS,
"near_close_pipeline": _NEAR_CLOSE_PIPELINE_STEPS,
"after_close_pipeline": _AFTER_CLOSE_PIPELINE_STEPS,
}
# Derived, never hand-maintained: this used to be a literal set in admin_service
# duplicating the four lists above from another module, with nothing asserting
# the two agreed.
PIPELINE_MEMBERS: frozenset[str] = frozenset(
step for steps in PIPELINE_STEPS.values() for step, _ in steps
)
def _pipelines_by_member() -> dict[str, tuple[str, ...]]:
"""Member -> the orchestrators that run it, in trading-day order.
Membership is many-to-many: data_collector runs in all four pipelines (via
three different coroutines), alerts and outcome_evaluator in two each.
"""
out: dict[str, list[str]] = {}
for pipeline, steps in PIPELINE_STEPS.items():
for step, _ in steps:
bucket = out.setdefault(step, [])
if pipeline not in bucket:
bucket.append(pipeline)
return {member: tuple(pipelines) for member, pipelines in out.items()}
PIPELINES_BY_MEMBER: dict[str, tuple[str, ...]] = _pipelines_by_member()
# ---------------------------------------------------------------------------
# Job identity
# ---------------------------------------------------------------------------
# Orchestrators, in trading-day order.
PIPELINE_JOBS: tuple[str, ...] = tuple(PIPELINE_STEPS)
# Own timer, independent of any pipeline.
SCHEDULED_JOBS: tuple[str, ...] = (
"dolt_earnings_import",
"sec_fundamentals_import",
"ticker_universe_sync",
"backtest",
)
# Registered but never auto-fired; run only when a human asks.
MANUAL_JOBS: tuple[str, ...] = ("event_study", "data_backfill")
# Steps in the order an operator meets them across the trading day, so the UI
# reads as a sequence rather than an alphabetical jumble.
PIPELINE_STEP_JOBS: tuple[str, ...] = tuple(
dict.fromkeys(step for steps in PIPELINE_STEPS.values() for step, _ in steps)
)
VALID_JOB_NAMES: frozenset[str] = frozenset(
PIPELINE_JOBS + PIPELINE_STEP_JOBS + SCHEDULED_JOBS + MANUAL_JOBS
)
JOB_LABELS: dict[str, str] = {
"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)",
}
CATEGORY_PIPELINE = "pipeline"
CATEGORY_STEP = "pipeline_step"
CATEGORY_SCHEDULED = "scheduled"
CATEGORY_MANUAL = "manual"
# Order the sections appear in.
CATEGORY_ORDER: tuple[str, ...] = (
CATEGORY_PIPELINE,
CATEGORY_STEP,
CATEGORY_SCHEDULED,
CATEGORY_MANUAL,
)
CATEGORY_LABELS: dict[str, str] = {
CATEGORY_PIPELINE: "Pipelines",
CATEGORY_STEP: "Pipeline steps",
CATEGORY_SCHEDULED: "Standalone scheduled",
CATEGORY_MANUAL: "Manual only",
}
_CATEGORY_MEMBERS: dict[str, tuple[str, ...]] = {
CATEGORY_PIPELINE: PIPELINE_JOBS,
CATEGORY_STEP: PIPELINE_STEP_JOBS,
CATEGORY_SCHEDULED: SCHEDULED_JOBS,
CATEGORY_MANUAL: MANUAL_JOBS,
}
JOB_CATEGORY: dict[str, str] = {
name: category
for category, names in _CATEGORY_MEMBERS.items()
for name in names
}
# Registered and triggerable through the API, but kept out of Admin → Jobs.
# data_backfill's only capability beyond collect_ohlcv (which already backfills
# full history for *new* tickers) is re-deepening *existing* ones after
# ohlcv_history_days is raised -- a rare one-off, not something to scan past
# every time you open the page.
HIDDEN_JOBS: frozenset[str] = frozenset({"data_backfill"})
_SORT_INDEX: dict[str, tuple[int, int]] = {
name: (CATEGORY_ORDER.index(category), position)
for category, names in _CATEGORY_MEMBERS.items()
for position, name in enumerate(names)
}
def sort_order(job_name: str) -> tuple[int, int]:
"""(category rank, position within category). Unknown jobs sort last."""
return _SORT_INDEX.get(job_name, (len(CATEGORY_ORDER), 0))
+60 -72
View File
@@ -18,11 +18,13 @@ import logging
import asyncio
from datetime import date, datetime, timedelta, timezone
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import and_, case, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app import job_catalog
from app.config import settings
from app.database import async_session_factory
from app.models.ohlcv import OHLCVRecord
@@ -84,6 +86,34 @@ scheduler = AsyncIOScheduler(
}
)
def _repause_after_manual_run(event: object) -> None:
"""Re-pause a job that only ever runs on demand, once its run finishes.
Pipeline steps and manual jobs are registered with a 520-week interval and
``next_run_time=None`` as a backstop. Triggering one sets next_run_time=now,
and APScheduler then re-arms that backstop -- so Admin → Jobs would show a
"next run" ten years out. Guarding on category means the six cron jobs and
the real interval jobs are never touched.
Registered at module level, not inside ``configure_scheduler``: that function
is called more than once (idempotency test) and ``add_listener`` does not
deduplicate.
"""
job_id = getattr(event, "job_id", None)
if job_catalog.JOB_CATEGORY.get(job_id) not in (
job_catalog.CATEGORY_STEP,
job_catalog.CATEGORY_MANUAL,
):
return
try:
scheduler.modify_job(job_id, next_run_time=None)
except Exception: # job gone, scheduler stopped — nothing to re-pause
logger.debug("Could not re-pause %s after its run", job_id, exc_info=True)
scheduler.add_listener(_repause_after_manual_run, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR)
# Track last successful ticker per job for rate-limit resume
_last_successful: dict[str, str | None] = {
"data_collector": None,
@@ -91,26 +121,10 @@ _last_successful: dict[str, str | None] = {
"sentiment_collector": None,
}
# Jobs whose per-run progress is surfaced to Admin → Jobs. (outcome_evaluator is
# created lazily on first run via _runtime_start.)
_JOB_NAMES = [
"data_collector",
"data_backfill",
"sentiment_collector",
"dolt_earnings_import",
"sec_fundamentals_import",
"rr_scanner",
"ticker_universe_sync",
"alerts",
"market_regime",
"regime_monitor",
"event_study",
"backtest",
"daily_pipeline", # morning: OHLCV/sentiment/regime — no qualifying scan
"near_close_pipeline", # OHLCV fetch → R:R scan → Telegram alerts
"after_close_pipeline", # OHLCV fetch → outcome eval (final bar)
"intraday_pipeline",
]
# Seeded from the catalog rather than a private list. The old literal held 16 of
# the 19 jobs -- benchmark_collector, outcome_evaluator and shadow_book were
# missing, so they had no runtime row (and so no "last run" line in Admin → Jobs)
# until their first run in a given process.
def _idle_runtime() -> dict[str, object]:
@@ -127,7 +141,9 @@ def _idle_runtime() -> dict[str, object]:
}
_job_runtime: dict[str, dict[str, object]] = {name: _idle_runtime() for name in _JOB_NAMES}
_job_runtime: dict[str, dict[str, object]] = {
name: _idle_runtime() for name in sorted(job_catalog.VALID_JOB_NAMES)
}
_next_backtest_target_model = PRODUCTION_GTL_TARGET_MODEL
_next_backtest_cadence = DEFAULT_BACKTEST_CADENCE
@@ -1300,54 +1316,14 @@ async def sync_ticker_universe() -> None:
# the intraday partial one (covers a long weekend / holiday gap).
_FINAL_REFETCH_DAYS = 5
_DAILY_PIPELINE_STEPS = [
("data_collector", "collect_ohlcv"),
("benchmark_collector", "collect_benchmark"),
("sentiment_collector", "collect_sentiment"),
("market_regime", "compute_market_regime"),
# Observational only — display/alerts; not trade selection.
("regime_monitor", "compute_regime_monitor"),
# Alerts after regime so quadrant changes reach Telegram in the morning.
# Dispatcher is change-driven; quiet days stay quiet. Setup alerts still
# fire on the near-close pipeline after the qualifying scan.
("alerts", "dispatch_alerts_job"),
]
# Near-close (~15:30 ET MonFri): refresh in-progress day-t bars (incremental
# ingestion overlaps the latest stored session), then the only daily
# qualifying R:R scan, then Telegram immediately so manual fills can still hit
# MOC cutoffs (~15:50/15:55). Under a 15-minute delayed SIP feed a 15:30 scan
# may see ~15:15 prices — immaterial for a 12-1 momentum signal.
#
# US early-close days (~3/year, 13:00 ET close): this job runs post-close and
# entries behave like stale_close (still acceptable per execution-recovery matrix).
# No exchange calendar dependency.
_NEAR_CLOSE_PIPELINE_STEPS = [
# Must land today's in-progress bar (~20 min behind live), or the scan falls
# back to the previous close and execution degrades to the stale_close floor.
("data_collector", "collect_ohlcv_for_scan"),
("rr_scanner", "scan_rr"),
# Straight after the scan so shadow entries mark at the same near-close
# prices the discretionary book is looking at.
("shadow_book", "run_shadow_book"),
("alerts", "dispatch_alerts_job"),
]
# After close (~16:45 ET MonFri): fresh OHLCV fetch so outcomes resolve on the
# final bar, not the near-close partial bar, then outcome/paper close.
_AFTER_CLOSE_PIPELINE_STEPS = [
("data_collector", "collect_ohlcv_final"),
("outcome_evaluator", "evaluate_outcomes"),
]
# Intraday (light): keep prices current and resolve outcomes through the day,
# without the expensive scan/sentiment. The dashboard recomputes live R:R from
# the latest price, so refreshing OHLCV is enough to stop prices lagging; the
# outcome step also closes paper trades that hit their stop/target intraday.
_INTRADAY_PIPELINE_STEPS = [
("data_collector", "collect_ohlcv"),
("outcome_evaluator", "evaluate_outcomes"),
]
# Step lists live in app.job_catalog so the runner, the admin API's pipeline
# membership and the UI's grouping all read one definition. Re-exported here
# under their original names: _run_pipeline and the scheduler_configured log
# payload refer to them directly.
_DAILY_PIPELINE_STEPS = job_catalog._DAILY_PIPELINE_STEPS
_NEAR_CLOSE_PIPELINE_STEPS = job_catalog._NEAR_CLOSE_PIPELINE_STEPS
_AFTER_CLOSE_PIPELINE_STEPS = job_catalog._AFTER_CLOSE_PIPELINE_STEPS
_INTRADAY_PIPELINE_STEPS = job_catalog._INTRADAY_PIPELINE_STEPS
# Warn if near-close fetch+scan+alert drifts past this — entries leave the close
# and the stale_close floor quietly becomes the ceiling.
@@ -1486,6 +1462,12 @@ SCHEDULE_DEFAULTS: dict[str, str] = {
"schedule_after_close_pipeline_cron": "45 16 * * mon-fri",
# Hourly mid-session price + outcome (10:0015:00 ET MonFri).
"schedule_intraday_pipeline_cron": "0 10-15 * * mon-fri",
# Both were interval jobs until 2026-08-08 and hit exactly the pitfall
# described above: configure_scheduler calls remove_all_jobs() on every
# startup, so an interval countdown restarts from zero each deploy. A 168h
# backtest needed a week of uninterrupted uptime to fire even once.
"schedule_backtest_cron": "0 3 * * sun",
"schedule_ticker_universe_cron": "0 1 * * *",
}
# job id -> schedule setting key
@@ -1496,6 +1478,8 @@ _CRON_JOBS: dict[str, str] = {
"near_close_pipeline": "schedule_near_close_pipeline_cron",
"after_close_pipeline": "schedule_after_close_pipeline_cron",
"intraday_pipeline": "schedule_intraday_pipeline_cron",
"backtest": "schedule_backtest_cron",
"ticker_universe_sync": "schedule_ticker_universe_cron",
}
@@ -1630,9 +1614,12 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
id="intraday_pipeline", name="Intraday Pipeline", replace_existing=True,
)
# Independent interval jobs (own cadence, no ordering dependency)
# Independent jobs (own cadence, no ordering dependency). Cron, not interval,
# for the reason documented at SCHEDULE_DEFAULTS: an interval countdown
# restarts on every deploy, so these could be deferred indefinitely.
scheduler.add_job(
sync_ticker_universe, "interval", hours=24,
sync_ticker_universe,
_cron_trigger(cfg["schedule_ticker_universe_cron"], tz, "schedule_ticker_universe_cron"),
id="ticker_universe_sync", name="Ticker Universe Sync", replace_existing=True,
)
# Alerts auto-fire only via near_close_pipeline (scan → alert before MOC).
@@ -1643,7 +1630,8 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
replace_existing=True, next_run_time=None,
)
scheduler.add_job(
run_backtest_job, "interval", hours=168,
run_backtest_job,
_cron_trigger(cfg["schedule_backtest_cron"], tz, "schedule_backtest_cron"),
id="backtest", name="Backtest", replace_existing=True,
)
# Deep history backfill: manual only (never auto-fires); triggered from
+2
View File
@@ -83,6 +83,8 @@ class ScheduleConfigUpdate(BaseModel):
schedule_near_close_pipeline_cron: str | None = Field(default=None, max_length=120)
schedule_after_close_pipeline_cron: str | None = Field(default=None, max_length=120)
schedule_intraday_pipeline_cron: str | None = Field(default=None, max_length=120)
schedule_backtest_cron: str | None = Field(default=None, max_length=120)
schedule_ticker_universe_cron: str | None = Field(default=None, max_length=120)
class PerformanceConfigUpdate(BaseModel):
+87 -68
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
@@ -11,6 +11,8 @@ const DEFAULTS: ScheduleConfig = {
schedule_near_close_pipeline_cron: '30 15 * * mon-fri',
schedule_after_close_pipeline_cron: '45 16 * * mon-fri',
schedule_intraday_pipeline_cron: '0 10-15 * * mon-fri',
schedule_backtest_cron: '0 3 * * sun',
schedule_ticker_universe_cron: '0 1 * * *',
};
const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: boolean }[] = [
@@ -55,6 +57,18 @@ const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: b
hint: 'Refresh prices + resolve outcomes mid-session. Default hourly 10:0015:00 ET weekdays.',
mono: true,
},
{
key: 'schedule_backtest_cron',
label: 'Backtest',
hint: 'Replay history and refresh the Track Record report. Default Sunday 03:00 ET. Was a 168h interval, which restarted on every deploy and so could defer indefinitely.',
mono: true,
},
{
key: 'schedule_ticker_universe_cron',
label: 'Ticker universe sync',
hint: 'Refresh the tracked-symbol universe. Default 01:00 ET daily, before the morning pipeline.',
mono: true,
},
];
export function ScheduleSettings() {
+2
View File
@@ -196,6 +196,8 @@ export interface ScheduleConfig {
schedule_near_close_pipeline_cron: string;
schedule_after_close_pipeline_cron: string;
schedule_intraday_pipeline_cron: string;
schedule_backtest_cron: string;
schedule_ticker_universe_cron: string;
}
// Runtime sentiment LLM configuration
+110
View File
@@ -0,0 +1,110 @@
"""Admin → Jobs listing: categories, ordering, and next-run coherence.
The panel used to render 19 jobs as one alphabetical list in which a pipeline
step, a cron job and a manual job were indistinguishable, and a triggered job
could advertise a next run ten years out.
"""
from datetime import datetime, timedelta, timezone
import pytest
from app import job_catalog
from app.scheduler import configure_scheduler, scheduler
from app.services.admin_service import _visible_next_run, list_jobs
@pytest.fixture(autouse=True)
def _configured_scheduler():
scheduler.remove_all_jobs()
configure_scheduler()
yield
scheduler.remove_all_jobs()
def _by_name(jobs: list[dict]) -> dict[str, dict]:
return {job["name"]: job for job in jobs}
class TestVisibleNextRun:
def test_parked_backstop_is_not_a_schedule(self):
"""Paused jobs carry a 520-week interval; triggering one re-arms it."""
backstop = datetime.now(timezone.utc) + timedelta(weeks=520)
assert _visible_next_run(backstop) is None
def test_a_real_upcoming_run_passes_through(self):
soon = datetime.now(timezone.utc) + timedelta(hours=6)
assert _visible_next_run(soon) == soon
def test_none_stays_none(self):
assert _visible_next_run(None) is None
class TestListJobs:
async def test_hidden_jobs_are_not_listed_but_stay_valid(self, db_session):
jobs = _by_name(await list_jobs(db_session))
assert "data_backfill" not in jobs
# Still triggerable through the API, and still registered.
assert "data_backfill" in job_catalog.VALID_JOB_NAMES
assert scheduler.get_job("data_backfill") is not None
async def test_every_visible_job_has_a_category(self, db_session):
jobs = await list_jobs(db_session)
assert {j["name"] for j in jobs} == set(
job_catalog.VALID_JOB_NAMES - job_catalog.HIDDEN_JOBS
)
assert all(j["category"] in job_catalog.CATEGORY_ORDER for j in jobs)
async def test_jobs_arrive_grouped_by_category(self, db_session):
"""The frontend renders sections in payload order, so ordering is the
API's job — not something each client re-derives."""
categories = [j["category"] for j in await list_jobs(db_session)]
ranks = [job_catalog.CATEGORY_ORDER.index(c) for c in categories]
assert ranks == sorted(ranks)
async def test_pipeline_steps_defer_their_schedule_to_the_parent(self, db_session):
jobs = _by_name(await list_jobs(db_session))
step = jobs["rr_scanner"]
assert step["category"] == job_catalog.CATEGORY_STEP
assert step["next_run_at"] is None
assert step["next_run_source"] == "via_pipeline"
assert step["pipelines"] == ["near_close_pipeline"]
async def test_step_reports_the_soonest_enabled_parent(self, db_session):
due = datetime.now(timezone.utc) + timedelta(hours=3)
scheduler.modify_job("daily_pipeline", next_run_time=due)
collector = _by_name(await list_jobs(db_session))["data_collector"]
assert collector["via_next_run_job"] == "daily_pipeline"
assert collector["via_next_run_at"] == due.isoformat()
# Runs in all four pipelines — the reason steps are not nested under one.
assert set(collector["pipelines"]) == set(job_catalog.PIPELINE_JOBS)
async def test_manual_jobs_say_so_instead_of_showing_a_date(self, db_session):
study = _by_name(await list_jobs(db_session))["event_study"]
assert study["category"] == job_catalog.CATEGORY_MANUAL
assert study["next_run_source"] == "manual_only"
assert study["next_run_at"] is None
async def test_a_triggered_manual_job_still_shows_no_next_run(self, db_session):
"""Regression: triggering re-armed the 520-week backstop, which the panel
rendered as a real 'next run in ~87600h'."""
scheduler.modify_job("event_study", next_run_time=datetime.now(timezone.utc))
scheduler.modify_job("event_study", next_run_time=None)
study = _by_name(await list_jobs(db_session))["event_study"]
assert study["next_run_at"] is None
async def test_pipelines_report_their_own_schedule_and_steps(self, db_session):
pipeline = _by_name(await list_jobs(db_session))["daily_pipeline"]
assert pipeline["category"] == job_catalog.CATEGORY_PIPELINE
assert pipeline["next_run_source"] == "own_schedule"
assert pipeline["steps"] == [
step for step, _ in job_catalog.PIPELINE_STEPS["daily_pipeline"]
]
async def test_standalone_jobs_keep_their_own_schedule(self, db_session):
backtest = _by_name(await list_jobs(db_session))["backtest"]
assert backtest["category"] == job_catalog.CATEGORY_SCHEDULED
assert backtest["next_run_source"] == "own_schedule"
assert backtest["pipelines"] == []
+105 -43
View File
@@ -1,16 +1,19 @@
"""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,
@@ -112,60 +115,119 @@ class TestResumeTickers:
class TestConfigureScheduler:
def test_configure_adds_all_jobs(self):
# Remove any existing jobs first
# 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()
jobs = scheduler.get_jobs()
job_ids = {j.id for j in jobs}
assert job_ids == {
"data_collector",
"data_backfill",
"benchmark_collector",
"sentiment_collector",
"dolt_earnings_import",
"sec_fundamentals_import",
"rr_scanner",
"shadow_book",
"ticker_universe_sync",
"outcome_evaluator",
"alerts",
"market_regime",
"regime_monitor",
"event_study",
"backtest",
"daily_pipeline",
"near_close_pipeline",
"after_close_pipeline",
"intraday_pipeline",
}
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()]
# Each ID should appear exactly once
assert sorted(job_ids) == sorted([
"after_close_pipeline",
"alerts",
"backtest",
"benchmark_collector",
"daily_pipeline",
"intraday_pipeline",
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",
"data_backfill",
"dolt_earnings_import",
"sec_fundamentals_import",
"market_regime",
"near_close_pipeline",
"regime_monitor",
"event_study",
"outcome_evaluator",
"rr_scanner",
"benchmark_collector",
"sentiment_collector",
"rr_scanner",
"shadow_book",
"ticker_universe_sync",
])
"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: