Slice B of 3 (A → B → C) — Forecast Champion Selector
Converts the synchronous champion comparison into a DB-backed long-running operation and builds the results-visualization UI. Builds on merged Slice A (#356/#359) and the model_selection backend (#353). Mirrors the proven async LRO pattern in app/features/batch/ (slice-locally — no cross-slice import).
PRP: PRPs/forecast-champion-selector-slice-b-async-comparison-results.md
Backend
POST /model-selection/runs — 202 immediate, fire-and-forget (detached asyncio.create_task, returns status=running + Location/Retry-After + monitor_url/cancel_url before any backtest finishes).
DELETE /model-selection/{selection_id} — cooperative cancel + drain (200 settled / 404 / 409 terminal / 504 drain timeout).
GET /model-selection/{selection_id} — additive live progress (GROUP BY children) + candidate_progress; terminal output byte-compatible with the sync /run.
- New
model_selection_candidate child table + additive model_selection_run columns (started_at, count columns) + cancelled status (enum + CheckConstraint) — one Alembic migration.
- Slice-local
runner.py (TaskGroup + Semaphore + CancelHandle registry, mirror of batch/runner.py); new Settings.model_selection_global_max_parallel (4) + model_selection_cancel_drain_timeout_seconds (30).
- Ranking/chart/business computed once at settle, reusing
rank_candidates/build_chart_data/explain_winner unchanged.
Frontend (extends Slice A page)
useSubmitSelectionRun / useSelectionRun (poll) / useCancelSelectionRun hooks.
components/champion-selector/results/: progress panel, ranking table, winner card, comparison charts, model-detail drawer (Sheet), cancel dialog (AlertDialog).
- Wire the previously-disabled "Run comparison" CTA → submit → live progress → ranking/winner/charts; partial/all-failed/cancelled states; failed candidates stay visible.
- Additive types in
types/api.ts (SubmitRunResponse, SelectionProgress, CandidateProgress, 'cancelled').
Explicitly NOT in Slice B (Slice C)
- No train-selected, no predict decision, no promotion/alias, no safety stock, no forecast-decision panel.
auto_train_winner/auto_predict are no-ops in the async worker. Legacy sync POST /run kept unchanged (no frontend caller).
Acceptance
- Backend Level-1..4 (incl. migration up/down + integration: no candidate left
running after drain) + frontend tsc/lint/test green.
Slice C (decision layer) follows as a separate issue.
Slice B of 3 (A → B → C) — Forecast Champion Selector
Converts the synchronous champion comparison into a DB-backed long-running operation and builds the results-visualization UI. Builds on merged Slice A (#356/#359) and the
model_selectionbackend (#353). Mirrors the proven async LRO pattern inapp/features/batch/(slice-locally — no cross-slice import).PRP:
PRPs/forecast-champion-selector-slice-b-async-comparison-results.mdBackend
POST /model-selection/runs— 202 immediate, fire-and-forget (detachedasyncio.create_task, returnsstatus=running+Location/Retry-After+monitor_url/cancel_urlbefore any backtest finishes).DELETE /model-selection/{selection_id}— cooperative cancel + drain (200 settled / 404 / 409 terminal / 504 drain timeout).GET /model-selection/{selection_id}— additive liveprogress(GROUP BY children) +candidate_progress; terminal output byte-compatible with the sync/run.model_selection_candidatechild table + additivemodel_selection_runcolumns (started_at, count columns) +cancelledstatus (enum + CheckConstraint) — one Alembic migration.runner.py(TaskGroup + Semaphore + CancelHandle registry, mirror ofbatch/runner.py); newSettings.model_selection_global_max_parallel(4) +model_selection_cancel_drain_timeout_seconds(30).rank_candidates/build_chart_data/explain_winnerunchanged.Frontend (extends Slice A page)
useSubmitSelectionRun/useSelectionRun(poll) /useCancelSelectionRunhooks.components/champion-selector/results/: progress panel, ranking table, winner card, comparison charts, model-detail drawer (Sheet), cancel dialog (AlertDialog).types/api.ts(SubmitRunResponse,SelectionProgress,CandidateProgress,'cancelled').Explicitly NOT in Slice B (Slice C)
auto_train_winner/auto_predictare no-ops in the async worker. Legacy syncPOST /runkept unchanged (no frontend caller).Acceptance
runningafter drain) + frontendtsc/lint/testgreen.Slice C (decision layer) follows as a separate issue.