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