"""SQS computation and scoring API endpoints.""" from __future__ import annotations import threading import uuid from pathlib import Path from typing import Any from fastapi import APIRouter, HTTPException from pydantic import BaseModel from apps.web.dependencies import get_configs_dir, get_journal_dir, get_runs_dir from libs.backtest.tracker import ( _hydrate_split_result, compute_public_sqs_v9, compute_rqs, compute_wfqs_v2, load_journal, refresh_public_scores, ) router = APIRouter(prefix="/sqs", tags=["sqs"]) # --------------------------------------------------------------------------- # In-memory pipeline state # --------------------------------------------------------------------------- _pipelines: dict[str, dict[str, Any]] = {} _pipelines_lock = threading.Lock() class RescoreRequest(BaseModel): target: str class PipelineRequest(BaseModel): target: str _NO_ENTRY_RESPONSE = { "entry_id": None, "sqs_score": None, "source": None, "pending_reason": "no_backtest_data", "pillars": { "rqs_score": None, "wfqs_v2_score": None, "regime_score": None, "regime_source": "none", "core_score": None, }, "gates": { "deployment_gate_factor": None, "robustness_gate_factor": None, "activity_factor": None, }, "breakdown": {}, "stored_sqs_score": None, "scenario_robustness_score": None, "overfit_check_score": None, } def _registry_fallback(journal_dir: Path, experiment_name: str) -> dict[str, Any] | None: """Look up stored SQS from registry when no live journal entry exists.""" from libs.backtest.domain import ExperimentRegistry registry_path = journal_dir / "experiment_registry.json" if not registry_path.exists(): return None try: registry = ExperimentRegistry.model_validate_json(registry_path.read_text()) except Exception: return None matches = [e for e in registry.entries if e.experiment_name == experiment_name] if not matches: return None entry = sorted(matches, key=lambda e: e.timestamp)[-1] return { "entry_id": entry.entry_id, "sqs_score": entry.sqs_score, "source": "registry_stored", "pending_reason": None if entry.sqs_score is not None else "no_backtest_data", "pillars": { "rqs_score": entry.rqs_score, "wfqs_v2_score": entry.wfqs_v2_score, "regime_score": None, "regime_source": "stored", "core_score": None, }, "gates": { "deployment_gate_factor": None, "robustness_gate_factor": None, "activity_factor": None, }, "breakdown": {}, "stored_sqs_score": entry.sqs_score, "scenario_robustness_score": entry.scenario_robustness_score, "overfit_check_score": entry.overfit_check_score, } def _compute_for_entry(entry: Any) -> dict[str, Any]: """Run SQS v9 computation on a journal entry and return structured result.""" train_result = _hydrate_split_result(entry.results.get("train")) valid_result = _hydrate_split_result(entry.results.get("valid")) test_result = _hydrate_split_result(entry.results.get("test")) rqs_score, _ = compute_rqs(train_result, valid_result, test_result) wfqs_v2_score, _ = compute_wfqs_v2(entry.walk_forward_summary) sqs_score, sqs_breakdown, source = compute_public_sqs_v9( train_result, valid_result, test_result, walk_forward_summary=entry.walk_forward_summary, robustness_matrix_summary=entry.robustness_matrix_summary, out_of_time_robustness_summary=entry.out_of_time_robustness_summary, common_window_summary=entry.common_window_summary, reset_common_window_summary=entry.reset_common_window_summary, scenario_robustness_score=entry.scenario_robustness_score, rqs_score=rqs_score, wfqs_v2_score=wfqs_v2_score, ) regime_src = ( "scenario_rrs" if entry.scenario_robustness_score is not None else "oot_quality" if entry.out_of_time_robustness_summary is not None else "neutral_50" ) pending_reason = None if sqs_score is None: pending_key = next((k for k in sqs_breakdown if k.startswith("requires_")), None) pending_reason = ( pending_key.replace("requires_", "").replace("_", " ") if pending_key else "missing data" ) return { "experiment_name": entry.experiment_name, "entry_id": entry.entry_id, "sqs_score": sqs_score, "source": source, "pending_reason": pending_reason, "pillars": { "rqs_score": rqs_score, "wfqs_v2_score": wfqs_v2_score, "regime_score": sqs_breakdown.get("regime_score"), "regime_source": regime_src, "core_score": sqs_breakdown.get("core_score"), }, "gates": { "deployment_gate_factor": sqs_breakdown.get("deployment_gate_factor"), "robustness_gate_factor": sqs_breakdown.get("gate_factor"), "activity_factor": sqs_breakdown.get("activity_factor"), }, "breakdown": sqs_breakdown, "stored_sqs_score": entry.sqs_score, "scenario_robustness_score": entry.scenario_robustness_score, "overfit_check_score": entry.overfit_check_score, } @router.get("/{experiment_name:path}") def get_sqs(experiment_name: str) -> dict[str, Any]: """Compute SQS v9 live for an experiment (read-only, no journal write). Falls back to registry-stored SQS when no journal entry exists. Never returns 404 — always returns a structured response. """ journal_dir = get_journal_dir() journal_path = journal_dir / "improvement_journal.jsonl" base = {"experiment_name": experiment_name} # 1. Try journal — live recompute if journal_path.exists(): entries = load_journal(journal_path) exact = [e for e in entries if e.experiment_name == experiment_name] if exact: entry = sorted(exact, key=lambda e: e.timestamp)[-1] return {**base, **_compute_for_entry(entry)} # 2. Fall back to registry (stored values, no live recompute) reg = _registry_fallback(journal_dir, experiment_name) if reg: return {**base, **reg} # 3. Nothing available return {**base, **_NO_ENTRY_RESPONSE} def _run_pipeline_thread(pipeline_id: str, experiment_name: str) -> None: """Background thread: run train/valid/test + WFV + robustness → record → compute SQS v9.""" import datetime as _dt from libs.backtest.tracker import ( append_journal_entry, build_split_result, get_next_entry_id, journal_lock, rebuild_registry, ) from libs.backtest.domain import JournalEntry def _set(updates: dict) -> None: with _pipelines_lock: _pipelines[pipeline_id].update(updates) try: configs_dir = get_configs_dir() journal_dir = get_journal_dir() runs_dir = get_runs_dir() journal_path = journal_dir / "improvement_journal.jsonl" registry_path = journal_dir / "experiment_registry.json" leaderboard_path = journal_dir / "LEADERBOARD.md" config_path = configs_dir / f"{experiment_name}.json" if not config_path.exists(): _set({"status": "failed", "error": f"Config not found: {experiment_name}.json"}) return from apps.backtester.run import ( BacktestRunner, _resolve_scoring_fn, load_manifest, resolve_config, run_walk_forward, run_robustness_matrix, ) from libs.backtest.snapshots import resolve_snapshot_path from libs.backtest.snapshot_store import SnapshotStore from libs.common.config import get_settings _set({"status": "running", "step": "Loading config…"}) manifest = load_manifest(str(config_path)) config = resolve_config(manifest) snapshot_request_id = config.requested_snapshot_id or config.dataset_snapshot_id snapshot_dir = resolve_snapshot_path(snapshot_request_id) if snapshot_dir is None: _set({"status": "failed", "error": f"Snapshot not found: '{snapshot_request_id}'"}) return s = get_settings() scoring_fn = _resolve_scoring_fn(config) # --- Pre-warm merged cache from per-split caches --- # Many experiments have per-split PKL caches but no merged cache. # _build_merged_snapshot_store (used by run_walk_forward / run_robustness_matrix) # would trigger an Oracle API fetch if the merged cache is missing. Pre-warm it here. _set({"status": "running", "step": "Loading snapshots…"}) per_split_data: dict[str, dict] = {} for split in ("train", "valid", "test"): d = SnapshotStore._try_load_runtime_cache(snapshot_dir, [split], scoring_fn=scoring_fn) if d is not None: per_split_data[split] = d if len(per_split_data) == 3: # Check if merged cache is already present merged_cached = SnapshotStore._try_load_runtime_cache( snapshot_dir, ["train", "valid", "test"], scoring_fn=scoring_fn ) if merged_cached is None: # Build and write merged cache from per-split caches merged_cands: dict = {} merged_bars: dict = {} merged_macro: dict = {} for d in per_split_data.values(): for date, rows in d["candidates_by_exec_date"].items(): merged_cands.setdefault(date, []).extend(rows) for sym, bars in d["bars_by_symbol_date"].items(): merged_bars.setdefault(sym, {}).update(bars) for date, obs in (d.get("macro_by_date") or {}).items(): if date not in merged_macro: merged_macro[date] = obs SnapshotStore._write_runtime_cache( snapshot_dir, ["train", "valid", "test"], scoring_fn=scoring_fn, data={ "candidates_by_exec_date": merged_cands, "bars_by_symbol_date": merged_bars, "macro_by_date": merged_macro, }, ) # --- Run basic backtests, collecting results directly from runner --- results: dict[str, Any] = {} for split in ("train", "valid", "test"): _set({"status": "running", "step": f"Running {split} split…"}) if split in per_split_data: split_store = SnapshotStore(**per_split_data[split]) else: split_store = SnapshotStore.load( snapshot_dir=snapshot_dir, split_name=split, oracle_url=s.stock_oracle_url, db_dsn=s.postgres_dsn, scoring_fn=scoring_fn, ) runner = BacktestRunner( manifest=manifest, config=config, store=split_store, initial_equity=10_000.0, split_name=split, ) exp_result = runner.run(output_root=str(runs_dir)) results[split] = build_split_result(split, exp_result.run_id, exp_result.metrics) # --- Walk-forward validation (merged cache now warm → fast) --- _set({"status": "running", "step": "Running walk-forward validation…"}) wf_summary = run_walk_forward( manifest=manifest, config=config, snapshot_dir_override=None, initial_equity=10_000.0, output_root=str(runs_dir), train_days=252, test_days=63, step_days=63, ) # --- Robustness matrix (merged cache warm → fast, step_days=63 lightweight) --- _set({"status": "running", "step": "Running robustness matrix…"}) rb_summary = run_robustness_matrix( manifest=manifest, config=config, snapshot_dir_override=None, initial_equity=10_000.0, output_root=str(runs_dir), horizons_days=[63, 126, 252], step_days=63, ) # --- Compute scores --- _set({"status": "running", "step": "Computing SQS v9…"}) rqs_score, rqs_breakdown = compute_rqs(results.get("train"), results.get("valid"), results.get("test")) wfqs_v2_score, _ = compute_wfqs_v2(wf_summary) # scenario_robustness_score=50.0 → neutral (run `fithia2 rescore-public` for actual scenario score) sqs_score, sqs_breakdown, _ = compute_public_sqs_v9( results.get("train"), results.get("valid"), results.get("test"), walk_forward_summary=wf_summary, robustness_matrix_summary=rb_summary, scenario_robustness_score=50.0, rqs_score=rqs_score, wfqs_v2_score=wfqs_v2_score, ) # --- Record to journal --- _set({"status": "running", "step": "Recording to journal…"}) tags = [t for t in experiment_name.replace("-", "_").split("_") if t] entry_id = get_next_entry_id(journal_path) timestamp = _dt.datetime.now(_dt.timezone.utc).isoformat() entry = JournalEntry( entry_id=entry_id, timestamp=timestamp, experiment_name=experiment_name, hypothesis="Auto-recorded via web GUI", results=results, walk_forward_summary=wf_summary, robustness_matrix_summary=rb_summary, scenario_robustness_score=50.0, rqs_score=rqs_score, rqs_breakdown=rqs_breakdown, sqs_score=sqs_score, sqs_breakdown=sqs_breakdown, verdict="unknown", tags=tags, ) with journal_lock(journal_path): append_journal_entry(journal_path, entry) rebuild_registry(journal_path, registry_path, leaderboard_path) sqs_result = _compute_for_entry(entry) _set({"status": "completed", "step": "Done", "result": sqs_result}) except Exception as exc: import traceback as _tb _set({"status": "failed", "error": f"{exc}\n{_tb.format_exc()[-800:]}"}) @router.post("/pipeline") def pipeline_start(req: PipelineRequest) -> dict[str, Any]: """Start the auto-backtest pipeline: run train/valid/test → record → compute SQS. Returns a pipeline_id to poll with GET /sqs/pipeline/{pipeline_id}. If a journal entry already exists, computes SQS immediately (no pipeline). """ experiment_name = req.target journal_dir = get_journal_dir() journal_path = journal_dir / "improvement_journal.jsonl" base = {"experiment_name": experiment_name} # Journal entry already exists WITH walk-forward data → compute immediately (no pipeline needed) if journal_path.exists(): entries = load_journal(journal_path) exact = [e for e in entries if e.experiment_name == experiment_name] if exact: entry = sorted(exact, key=lambda e: e.timestamp)[-1] if entry.walk_forward_summary is not None: return {**base, "pipeline_status": "existing_entry", **_compute_for_entry(entry)} # Config must exist before we start config_path = get_configs_dir() / f"{experiment_name}.json" if not config_path.exists(): raise HTTPException(status_code=404, detail=f"Experiment config not found: {experiment_name}") pipeline_id = str(uuid.uuid4())[:8] with _pipelines_lock: _pipelines[pipeline_id] = { "pipeline_id": pipeline_id, "experiment_name": experiment_name, "status": "running", "step": "Starting…", "error": None, "result": None, } thread = threading.Thread( target=_run_pipeline_thread, args=(pipeline_id, experiment_name), daemon=True, ) thread.start() return {**base, "pipeline_id": pipeline_id, "pipeline_status": "running", "step": "Starting…"} @router.get("/pipeline/{pipeline_id}") def pipeline_poll(pipeline_id: str) -> dict[str, Any]: """Poll pipeline status. Returns result when status='completed'.""" with _pipelines_lock: state = _pipelines.get(pipeline_id) if state is None: raise HTTPException(status_code=404, detail=f"Pipeline not found: {pipeline_id}") out = dict(state) if out.get("result"): out.update(out.pop("result")) out["pipeline_status"] = out.pop("status") return out @router.post("/rescore") def rescore_experiment(req: RescoreRequest) -> dict[str, Any]: """Recompute SQS for one experiment, save to journal, rebuild leaderboard.""" from libs.backtest.tracker import journal_lock, rebuild_registry journal_dir = get_journal_dir() journal_path = journal_dir / "improvement_journal.jsonl" registry_path = journal_dir / "experiment_registry.json" leaderboard_path = journal_dir / "LEADERBOARD.md" if not journal_path.exists(): raise HTTPException(status_code=404, detail="Journal not found") entries = load_journal(journal_path) exact = [e for e in entries if e.experiment_name == req.target] if not exact: raise HTTPException( status_code=404, detail=f"No journal entry for '{req.target}'. Run a backtest and record results first (fithia2 rec).", ) entry = sorted(exact, key=lambda e: e.timestamp)[-1] target_name = entry.experiment_name def _only_this(e: Any) -> bool: return e.experiment_name == target_name with journal_lock(journal_path): updated_count = refresh_public_scores(journal_path, selector=_only_this) rebuild_registry(journal_path, registry_path, leaderboard_path) fresh_entries = load_journal(journal_path) fresh_exact = [e for e in fresh_entries if e.experiment_name == target_name] fresh_entry = sorted(fresh_exact, key=lambda e: e.timestamp)[-1] result = { "experiment_name": target_name, **_compute_for_entry(fresh_entry), "entries_updated": updated_count, } return result