"""Backtest run artifact endpoints.""" from __future__ import annotations import json from pathlib import Path from typing import Any from fastapi import APIRouter, HTTPException, Query from apps.web.dependencies import get_runs_dir router = APIRouter(prefix="/runs", tags=["runs"]) def _run_path(run_id: str, runs_dir: Path) -> Path: """Find a run directory by run_id.""" # run_id IS the directory name p = runs_dir / run_id if p.is_dir(): return p # Fallback: search nested (some runs may be nested) for d in runs_dir.iterdir(): if d.is_dir() and d.name == run_id: return d raise FileNotFoundError(f"Run not found: {run_id}") def _parquet_to_records(path: Path) -> list[dict[str, Any]]: """Read a Parquet file and return list of dicts.""" import pyarrow.parquet as pq table = pq.read_table(path) return table.to_pydict() def _parquet_to_records_list(path: Path) -> list[dict[str, Any]]: """Read a Parquet file and convert to list of row dicts.""" import pyarrow.parquet as pq table = pq.read_table(path) return [ {col: (table.column(col)[i].as_py()) for col in table.column_names} for i in range(len(table)) ] @router.get("") def list_runs( experiment: str | None = Query(None, description="Filter by experiment name"), limit: int = Query(50, le=200), offset: int = 0, ) -> dict[str, Any]: """List available backtest runs.""" runs_dir = get_runs_dir() if not runs_dir.exists(): return {"runs": [], "total": 0} runs = [] for d in sorted(runs_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True): if not d.is_dir(): continue metadata_file = d / "metadata.json" if not metadata_file.exists(): continue try: meta = json.loads(metadata_file.read_text()) except Exception: continue if experiment and meta.get("experiment_name") != experiment: continue runs.append({ "run_id": d.name, "experiment_name": meta.get("experiment_name"), "split_name": meta.get("split_name"), "started_at": meta.get("started_at"), "finished_at": meta.get("finished_at"), "duration_seconds": meta.get("duration_seconds"), }) total = len(runs) return {"runs": runs[offset : offset + limit], "total": total} @router.get("/{run_id}/metadata") def get_run_metadata(run_id: str) -> dict[str, Any]: """Get run metadata.""" runs_dir = get_runs_dir() try: run_path = _run_path(run_id, runs_dir) except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Run not found: {run_id}") metadata_file = run_path / "metadata.json" if not metadata_file.exists(): raise HTTPException(status_code=404, detail="metadata.json not found") return json.loads(metadata_file.read_text()) @router.get("/{run_id}/metrics") def get_run_metrics(run_id: str) -> dict[str, Any]: """Get metrics summary for a run.""" runs_dir = get_runs_dir() try: run_path = _run_path(run_id, runs_dir) except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Run not found: {run_id}") metrics_file = run_path / "metrics" / "metrics_summary.json" if not metrics_file.exists(): raise HTTPException(status_code=404, detail="metrics_summary.json not found") return json.loads(metrics_file.read_text()) @router.get("/{run_id}/equity-curve") def get_equity_curve(run_id: str) -> dict[str, Any]: """Get equity curve data for a run (from Parquet).""" runs_dir = get_runs_dir() try: run_path = _run_path(run_id, runs_dir) except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Run not found: {run_id}") parquet_file = run_path / "artifacts" / "equity_curve.parquet" csv_file = run_path / "artifacts" / "equity_curve.csv" if parquet_file.exists(): try: records = _parquet_to_records_list(parquet_file) return {"data": records, "format": "parquet"} except Exception: pass if csv_file.exists(): import csv with open(csv_file) as f: reader = csv.DictReader(f) records = list(reader) return {"data": records, "format": "csv"} raise HTTPException(status_code=404, detail="Equity curve data not found") @router.get("/{run_id}/trade-blotter") def get_trade_blotter( run_id: str, limit: int = Query(200, le=2000), offset: int = 0, ) -> dict[str, Any]: """Get trade blotter for a run (paginated).""" runs_dir = get_runs_dir() try: run_path = _run_path(run_id, runs_dir) except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Run not found: {run_id}") parquet_file = run_path / "artifacts" / "trade_blotter.parquet" csv_file = run_path / "artifacts" / "trade_blotter.csv" if parquet_file.exists(): try: records = _parquet_to_records_list(parquet_file) total = len(records) return {"trades": records[offset : offset + limit], "total": total} except Exception: pass if csv_file.exists(): import csv with open(csv_file) as f: reader = csv.DictReader(f) records = list(reader) total = len(records) return {"trades": records[offset : offset + limit], "total": total} raise HTTPException(status_code=404, detail="Trade blotter not found") @router.get("/{run_id}/per-engine-metrics") def get_per_engine_metrics(run_id: str) -> dict[str, Any]: """Get per-engine metrics breakdown.""" runs_dir = get_runs_dir() try: run_path = _run_path(run_id, runs_dir) except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Run not found: {run_id}") pem_file = run_path / "artifacts" / "per_engine_metrics.json" if not pem_file.exists(): return {"engines": {}} return json.loads(pem_file.read_text())