Files
QuantumTerminal/backend/pulse_room/routes.py
T

124 lines
3.6 KiB
Python
Raw Normal View History

# version: v2
"""Pulse Room REST endpoints.
Wire into data_server.py:
from pulse_room.routes import create_pulse_router
app.include_router(create_pulse_router())
Endpoints:
GET /api/pulse/state — full state (scanner, watchlist, snapshot, meta)
POST /api/pulse/calculate — rebuild snapshot (sync; 409 if in-flight)
PUT /api/pulse/watchlist — replace watchlist
PUT /api/pulse/scanner — set scanner enabled flag
"""
import asyncio
import json
import logging
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from . import builder, state
# Concurrency: only one Calculate at a time. Lock lives in state.py so
# sync_hook.py shares the same instance and the two paths can't race.
from .state import calc_lock as _calc_lock
log = logging.getLogger("mk.pulse_room.routes")
# ---------- request models ----------
class WatchlistAssetIn(BaseModel):
symbol: str
families: list[str]
class WatchlistIn(BaseModel):
assets: list[WatchlistAssetIn]
class ScannerIn(BaseModel):
enabled: bool
# ---------- helpers ----------
def _read_snapshot() -> tuple[dict | None, dict]:
"""Return (snapshot, meta). Snapshot may be None if not yet built."""
path = state._pulse_dir() / "pulse_snapshot.json"
if not path.exists():
return None, {"exists": False}
try:
with open(path, "r", encoding="utf-8") as f:
snap = json.load(f)
except (OSError, json.JSONDecodeError):
return None, {"exists": False}
meta = {
"exists": True,
"computed_at": snap.get("computed_at"),
"source_sync_at": snap.get("source_sync_at"),
"watchlist_hash": snap.get("watchlist_hash"),
"asset_count": len(snap.get("assets", {})),
"size_bytes": path.stat().st_size,
}
return snap, meta
def _build_state_response() -> dict:
snap, meta = _read_snapshot()
return {
"scanner_enabled": state.load_scanner().get("enabled", False),
"watchlist": state.load_watchlist(),
"snapshot_meta": meta,
"snapshot": snap,
}
# ---------- router ----------
def create_pulse_router() -> APIRouter:
router = APIRouter(prefix="/api/pulse", tags=["pulse"])
@router.get("/state")
async def get_state():
return _build_state_response()
@router.put("/scanner")
async def put_scanner(body: ScannerIn):
state.save_scanner({"enabled": body.enabled})
return _build_state_response()
@router.put("/watchlist")
async def put_watchlist(body: WatchlistIn):
wl = {"assets": [a.model_dump() for a in body.assets]}
state.save_watchlist(wl)
return _build_state_response()
@router.post("/calculate")
async def post_calculate():
wl = state.load_watchlist()
if not wl.get("assets"):
raise HTTPException(400, detail="Watchlist is empty")
if not _calc_lock.acquire(blocking=False):
raise HTTPException(409, detail="A calculate is already in progress")
try:
try:
# build_snapshot does sync file I/O; offload to a worker
# thread so we don't block the FastAPI event loop (which
# would freeze /api/ticks, /ws/prices, etc. for 0.53 s).
await asyncio.to_thread(builder.build_snapshot, wl, write=True)
except Exception as e:
log.exception("Calculate failed")
raise HTTPException(500, detail=f"Calculate failed: {e}") from e
finally:
_calc_lock.release()
return _build_state_response()
return router