Files
set50-system/backend/app/backtest_store.py

89 lines
2.9 KiB
Python

"""Durable JSON store for event-driven backtest runs (Task 6).
The previous backtest run history lived only in process-local memory
(``app.extensions.setdefault("backtest_runs", [])``) and was lost on restart.
This store persists every event-driven run (with its input coverage, event
timeline, and scorer provenance) to an atomic JSON file at ``data/backtest/runs.json``
so history survives restarts and can be audited.
"""
from __future__ import annotations
import datetime as dt
import json
import os
import threading
import uuid
from pathlib import Path
from typing import Any, Optional
def _utcnow_iso() -> str:
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
class BacktestStoreError(ValueError):
pass
class BacktestStore:
"""Append-only, thread-safe, atomic JSON store of backtest runs."""
def __init__(self, path: Optional[Path | str] = None) -> None:
self.path = Path(path) if path else None
self._lock = threading.Lock()
self._runs: list[dict[str, Any]] = []
if self.path and self.path.is_file():
self._load()
# -- persistence ------------------------------------------------------
def _load(self) -> None:
p = self.path
if p is None:
return
try:
payload = json.loads(p.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise BacktestStoreError(f"cannot load backtest store: {exc}") from exc
if isinstance(payload, list):
self._runs = payload
elif isinstance(payload, dict) and isinstance(payload.get("runs"), list):
self._runs = payload["runs"]
else:
self._runs = []
def _save(self) -> None:
if not self.path:
return
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".tmp")
tmp.write_text(
json.dumps({"runs": self._runs}, ensure_ascii=False, default=str),
encoding="utf-8",
)
tmp.replace(self.path)
# -- API --------------------------------------------------------------
def add(self, record: dict[str, Any]) -> dict[str, Any]:
"""Persist a completed run record; returns it with an id/timestamp."""
with self._lock:
rec = dict(record)
rec.setdefault("id", str(uuid.uuid4())[:12])
rec.setdefault("ran_at", _utcnow_iso())
self._runs.append(rec)
self._save()
return rec
def all(self) -> list[dict[str, Any]]:
import copy
with self._lock:
return copy.deepcopy(self._runs)
def get(self, run_id: str) -> Optional[dict[str, Any]]:
import copy
with self._lock:
for r in self._runs:
if r.get("id") == run_id:
return copy.deepcopy(r)
return None