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>
136 lines
5.1 KiB
TypeScript
136 lines
5.1 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import type { ScheduleConfig } from '../../lib/types';
|
||
import { useScheduleSettings, useUpdateScheduleSettings } from '../../hooks/useAdmin';
|
||
import { SkeletonTable } from '../ui/Skeleton';
|
||
|
||
const DEFAULTS: ScheduleConfig = {
|
||
schedule_timezone: 'America/New_York',
|
||
schedule_daily_pipeline_cron: '0 2 * * *',
|
||
schedule_dolt_earnings_cron: '30 2 * * *',
|
||
schedule_sec_fundamentals_cron: '0 4 * * *',
|
||
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 }[] = [
|
||
{
|
||
key: 'schedule_timezone',
|
||
label: 'Timezone',
|
||
hint: 'IANA name. Prefer America/New_York so the near-close scan tracks the US cash close through DST.',
|
||
},
|
||
{
|
||
key: 'schedule_daily_pipeline_cron',
|
||
label: 'Morning pipeline',
|
||
hint: 'OHLCV → benchmark → sentiment → trend/risk → alerts (no R:R scan). Default 02:00 ET so risk-quadrant changes hit Telegram in the morning.',
|
||
mono: true,
|
||
},
|
||
{
|
||
key: 'schedule_dolt_earnings_cron',
|
||
label: 'Dolt earnings',
|
||
hint: 'Pull and import earnings dates/results daily at 02:30 ET. The fundamentals cache refresh uses these local events.',
|
||
mono: true,
|
||
},
|
||
{
|
||
key: 'schedule_sec_fundamentals_cron',
|
||
label: 'SEC fundamentals',
|
||
hint: 'Import tracked-universe SEC facts daily at 04:00 ET, then refresh the fundamentals cache scoring reads. Disabling the job stops the SEC fetch only — the local cache refresh still runs.',
|
||
mono: true,
|
||
},
|
||
{
|
||
key: 'schedule_near_close_pipeline_cron',
|
||
label: 'Near-close pipeline (scan + alert)',
|
||
hint: 'OHLCV fetch → R:R scan → Telegram. Default 15:30 ET Mon–Fri so manual MOC fills can still hit ~15:50/15:55.',
|
||
mono: true,
|
||
},
|
||
{
|
||
key: 'schedule_after_close_pipeline_cron',
|
||
label: 'After-close pipeline (outcome)',
|
||
hint: 'OHLCV fetch (final bar) → outcome eval. Default 16:45 ET Mon–Fri — not chained to the partial near-close bar.',
|
||
mono: true,
|
||
},
|
||
{
|
||
key: 'schedule_intraday_pipeline_cron',
|
||
label: 'Intraday pipeline (light)',
|
||
hint: 'Refresh prices + resolve outcomes mid-session. Default hourly 10:00–15: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() {
|
||
const { data, isLoading, isError, error } = useScheduleSettings();
|
||
const update = useUpdateScheduleSettings();
|
||
|
||
const [form, setForm] = useState<ScheduleConfig>(DEFAULTS);
|
||
|
||
useEffect(() => {
|
||
if (data) setForm({ ...DEFAULTS, ...data });
|
||
}, [data]);
|
||
|
||
if (isLoading) return <SkeletonTable rows={2} cols={2} />;
|
||
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load schedule'}</p>;
|
||
|
||
return (
|
||
<div className="glass p-5 space-y-4">
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-gray-200">Pipeline Schedule</h3>
|
||
<p className="mt-1 text-xs text-gray-500">
|
||
When the jobs run, as 5-field cron (<span className="num">min hour day month weekday</span>).
|
||
Saved changes apply to the running scheduler immediately — no redeploy.
|
||
One qualifying R:R scan per day is the near-close job; alerts fire immediately after that scan.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
{FIELDS.map((f) => (
|
||
<label key={f.key} className="block space-y-1">
|
||
<span className="text-xs text-gray-400">{f.label}</span>
|
||
<input
|
||
type="text"
|
||
value={form[f.key] ?? ''}
|
||
spellCheck={false}
|
||
onChange={(e) => setForm((prev) => ({ ...prev, [f.key]: e.target.value }))}
|
||
className={`w-full input-glass px-3 py-2 text-sm ${f.mono ? 'num' : ''}`}
|
||
/>
|
||
<span className="block text-[11px] text-gray-600">{f.hint}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
className="btn-primary px-4 py-2 text-sm"
|
||
onClick={() => update.mutate(form)}
|
||
disabled={update.isPending}
|
||
>
|
||
{update.isPending ? 'Saving…' : 'Save Schedule'}
|
||
</button>
|
||
<button
|
||
className="px-4 py-2 text-sm rounded border border-white/[0.1] text-gray-300 hover:text-white"
|
||
onClick={() => {
|
||
setForm(DEFAULTS);
|
||
update.mutate(DEFAULTS);
|
||
}}
|
||
disabled={update.isPending}
|
||
>
|
||
Reset to Defaults
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|