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 import asyncio
from datetime import date, datetime, timedelta, timezone 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.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import and_, case, func, or_, select from sqlalchemy import and_, case, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app import job_catalog
from app.config import settings from app.config import settings
from app.database import async_session_factory from app.database import async_session_factory
from app.models.ohlcv import OHLCVRecord 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 # Track last successful ticker per job for rate-limit resume
_last_successful: dict[str, str | None] = { _last_successful: dict[str, str | None] = {
"data_collector": None, "data_collector": None,
@@ -91,26 +121,10 @@ _last_successful: dict[str, str | None] = {
"sentiment_collector": None, "sentiment_collector": None,
} }
# Jobs whose per-run progress is surfaced to Admin → Jobs. (outcome_evaluator is # Seeded from the catalog rather than a private list. The old literal held 16 of
# created lazily on first run via _runtime_start.) # the 19 jobs -- benchmark_collector, outcome_evaluator and shadow_book were
_JOB_NAMES = [ # missing, so they had no runtime row (and so no "last run" line in Admin → Jobs)
"data_collector", # until their first run in a given process.
"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",
]
def _idle_runtime() -> dict[str, object]: 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_target_model = PRODUCTION_GTL_TARGET_MODEL
_next_backtest_cadence = DEFAULT_BACKTEST_CADENCE _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). # the intraday partial one (covers a long weekend / holiday gap).
_FINAL_REFETCH_DAYS = 5 _FINAL_REFETCH_DAYS = 5
_DAILY_PIPELINE_STEPS = [ # Step lists live in app.job_catalog so the runner, the admin API's pipeline
("data_collector", "collect_ohlcv"), # membership and the UI's grouping all read one definition. Re-exported here
("benchmark_collector", "collect_benchmark"), # under their original names: _run_pipeline and the scheduler_configured log
("sentiment_collector", "collect_sentiment"), # payload refer to them directly.
("market_regime", "compute_market_regime"), _DAILY_PIPELINE_STEPS = job_catalog._DAILY_PIPELINE_STEPS
# Observational only — display/alerts; not trade selection. _NEAR_CLOSE_PIPELINE_STEPS = job_catalog._NEAR_CLOSE_PIPELINE_STEPS
("regime_monitor", "compute_regime_monitor"), _AFTER_CLOSE_PIPELINE_STEPS = job_catalog._AFTER_CLOSE_PIPELINE_STEPS
# Alerts after regime so quadrant changes reach Telegram in the morning. _INTRADAY_PIPELINE_STEPS = job_catalog._INTRADAY_PIPELINE_STEPS
# 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"),
]
# Warn if near-close fetch+scan+alert drifts past this — entries leave the close # Warn if near-close fetch+scan+alert drifts past this — entries leave the close
# and the stale_close floor quietly becomes the ceiling. # 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", "schedule_after_close_pipeline_cron": "45 16 * * mon-fri",
# Hourly mid-session price + outcome (10:0015:00 ET MonFri). # Hourly mid-session price + outcome (10:0015:00 ET MonFri).
"schedule_intraday_pipeline_cron": "0 10-15 * * mon-fri", "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 # job id -> schedule setting key
@@ -1496,6 +1478,8 @@ _CRON_JOBS: dict[str, str] = {
"near_close_pipeline": "schedule_near_close_pipeline_cron", "near_close_pipeline": "schedule_near_close_pipeline_cron",
"after_close_pipeline": "schedule_after_close_pipeline_cron", "after_close_pipeline": "schedule_after_close_pipeline_cron",
"intraday_pipeline": "schedule_intraday_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, 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( 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, id="ticker_universe_sync", name="Ticker Universe Sync", replace_existing=True,
) )
# Alerts auto-fire only via near_close_pipeline (scan → alert before MOC). # 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, replace_existing=True, next_run_time=None,
) )
scheduler.add_job( 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, id="backtest", name="Backtest", replace_existing=True,
) )
# Deep history backfill: manual only (never auto-fires); triggered from # 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_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_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_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): class PerformanceConfigUpdate(BaseModel):
+88 -69
View File
@@ -7,6 +7,7 @@ from passlib.hash import bcrypt
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app import job_catalog
from app.exceptions import DuplicateError, NotFoundError, ValidationError from app.exceptions import DuplicateError, NotFoundError, ValidationError
from app.models.fundamental import FundamentalData from app.models.fundamental import FundamentalData
from app.models.ohlcv import OHLCVRecord 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) # Job control (placeholder — scheduler is Task 12.1)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
VALID_JOB_NAMES = { # Job identity, labels and pipeline membership now live in app.job_catalog, which
"data_collector", # derives PIPELINE_MEMBERS from the pipeline step lists instead of restating them.
"data_backfill", # Re-exported here because callers (routers, tests) import them from this module.
"benchmark_collector", VALID_JOB_NAMES = job_catalog.VALID_JOB_NAMES
"sentiment_collector", JOB_LABELS = job_catalog.JOB_LABELS
"dolt_earnings_import", PIPELINE_MEMBERS = job_catalog.PIPELINE_MEMBERS
"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_LABELS = { # Anything further out than this is a parked backstop, not a schedule: pipeline
"data_collector": "Data Collector (OHLCV)", # steps and manual jobs are registered on a 520-week interval, and triggering one
"data_backfill": "Data Backfill (deep history)", # re-arms it. Belt-and-braces behind the category rule in _next_run_fields.
"benchmark_collector": "Benchmark Collector", _NEXT_RUN_HORIZON_DAYS = 365
"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)",
}
# Jobs driven by a pipeline (in order) rather than their own auto timer.
PIPELINE_MEMBERS = { def _visible_next_run(next_run: datetime | None) -> datetime | None:
"data_collector", """Drop a next-run that is really the parked backstop."""
"benchmark_collector", if next_run is None:
"sentiment_collector", return None
"rr_scanner", horizon = datetime.now(next_run.tzinfo) + timedelta(days=_NEXT_RUN_HORIZON_DAYS)
"outcome_evaluator", return None if next_run > horizon else next_run
"alerts",
"market_regime",
"regime_monitor", def _own_next_run(scheduler, name: str) -> datetime | None:
"shadow_book", # 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]: 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 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 = [] jobs_out = []
for name in sorted(VALID_JOB_NAMES): for name in visible:
# 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
job = scheduler.get_job(name) 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) runtime = get_job_runtime_snapshot(name)
jobs_out.append({ jobs_out.append({
"name": name, "name": name,
"label": JOB_LABELS.get(name, name), "label": JOB_LABELS.get(name, name),
"enabled": enabled, "enabled": enabled_map.get(name, True),
"next_run_at": next_run, "category": job_catalog.JOB_CATEGORY.get(name),
"via_pipeline": name in PIPELINE_MEMBERS, "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, "registered": job is not None,
"running": bool(runtime.get("running", False)), "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_status": runtime.get("status"),
"runtime_processed": runtime.get("processed"), "runtime_processed": runtime.get("processed"),
"runtime_total": runtime.get("total"), "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_started_at": runtime.get("started_at"),
"runtime_finished_at": runtime.get("finished_at"), "runtime_finished_at": runtime.get("finished_at"),
"runtime_message": runtime.get("message"), "runtime_message": runtime.get("message"),
**_next_run_fields(scheduler, name, enabled_map),
}) })
return jobs_out return jobs_out
@@ -11,6 +11,8 @@ const DEFAULTS: ScheduleConfig = {
schedule_near_close_pipeline_cron: '30 15 * * mon-fri', schedule_near_close_pipeline_cron: '30 15 * * mon-fri',
schedule_after_close_pipeline_cron: '45 16 * * mon-fri', schedule_after_close_pipeline_cron: '45 16 * * mon-fri',
schedule_intraday_pipeline_cron: '0 10-15 * * 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 }[] = [ 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.', hint: 'Refresh prices + resolve outcomes mid-session. Default hourly 10:0015:00 ET weekdays.',
mono: true, 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() { export function ScheduleSettings() {
+2
View File
@@ -196,6 +196,8 @@ export interface ScheduleConfig {
schedule_near_close_pipeline_cron: string; schedule_near_close_pipeline_cron: string;
schedule_after_close_pipeline_cron: string; schedule_after_close_pipeline_cron: string;
schedule_intraday_pipeline_cron: string; schedule_intraday_pipeline_cron: string;
schedule_backtest_cron: string;
schedule_ticker_universe_cron: string;
} }
// Runtime sentiment LLM configuration // 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.""" """Unit tests for app.scheduler module."""
import asyncio import asyncio
from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
from app import job_catalog
from app.scheduler import ( from app.scheduler import (
_DAILY_PIPELINE_STEPS, _DAILY_PIPELINE_STEPS,
_NEAR_CLOSE_PIPELINE_STEPS, _NEAR_CLOSE_PIPELINE_STEPS,
_consume_backtest_options, _consume_backtest_options,
_consume_backtest_target_model, _consume_backtest_target_model,
_parse_frequency, _parse_frequency,
_repause_after_manual_run,
_resume_tickers, _resume_tickers,
_last_successful, _last_successful,
_run_source_import, _run_source_import,
@@ -112,60 +115,119 @@ class TestResumeTickers:
class TestConfigureScheduler: class TestConfigureScheduler:
def test_configure_adds_all_jobs(self): 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() scheduler.remove_all_jobs()
configure_scheduler() configure_scheduler()
jobs = scheduler.get_jobs() assert {j.id for j in scheduler.get_jobs()} == set(job_catalog.VALID_JOB_NAMES)
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",
}
def test_configure_is_idempotent(self): def test_configure_is_idempotent(self):
scheduler.remove_all_jobs() scheduler.remove_all_jobs()
configure_scheduler() configure_scheduler()
configure_scheduler() # Should replace, not duplicate configure_scheduler() # Should replace, not duplicate
job_ids = [j.id for j in scheduler.get_jobs()] job_ids = [j.id for j in scheduler.get_jobs()]
# Each ID should appear exactly once assert sorted(job_ids) == sorted(job_catalog.VALID_JOB_NAMES)
assert sorted(job_ids) == sorted([
"after_close_pipeline", def test_independent_jobs_use_cron_not_interval(self):
"alerts", """Interval countdowns restart on every deploy, so a weekly interval on a
"backtest", frequently-redeployed box can defer forever. Both standalone jobs were
"benchmark_collector", migrated to cron; this pins them there."""
"daily_pipeline", scheduler.remove_all_jobs()
"intraday_pipeline", 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_collector",
"data_backfill", "benchmark_collector",
"dolt_earnings_import",
"sec_fundamentals_import",
"market_regime",
"near_close_pipeline",
"regime_monitor",
"event_study",
"outcome_evaluator",
"rr_scanner",
"sentiment_collector", "sentiment_collector",
"rr_scanner",
"shadow_book", "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: class _SessionContext: