You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

189 lines
6.0 KiB
Python

"""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())