""" FastAPI control interface for the executor. Exposes endpoints for status inspection, configuration changes, execution cancellation, and pause/resume. Intended for operator use; not used in the normal signal flow. """ from typing import Optional from fastapi import FastAPI, HTTPException from pydantic import BaseModel from executor.executor import Executor class ConfigResponse(BaseModel): """Current executor configuration.""" live_mode: bool concurrent_slots: int enforce_same_base_isolation: bool socket_path: str class ConfigPatchRequest(BaseModel): """Request body for configuration update endpoints.""" live_mode: Optional[bool] = None concurrent_slots: Optional[int] = None enforce_same_base_isolation: Optional[bool] = None class ConfigPatchResponse(BaseModel): """Response after a configuration update, echoing the new values.""" ok: bool live_mode: Optional[bool] = None concurrent_slots: Optional[int] = None enforce_same_base_isolation: Optional[bool] = None class PauseResponse(BaseModel): """Response after a pause request.""" ok: bool class ResumeResponse(BaseModel): """Response after a resume request.""" ok: bool class CancelResponse(BaseModel): """Response after a cancellation request.""" ok: bool message: str class StatusResponse(BaseModel): """Runtime status of the executor.""" status: str version: str live_mode: bool slots_used: int slots_total: int uptime_seconds: float class ShutdownResponse(BaseModel): """Response after a shutdown request.""" ok: bool def create_app(executor: Executor, shutdown_callback) -> FastAPI: """ Build the FastAPI application and wire all routes to the executor. Parameters ---------- executor : Executor The live Executor instance to control. shutdown_callback : callable Called when /api/v1/shutdown is hit to begin clean shutdown. Returns ------- FastAPI Configured app ready to be passed to uvicorn. """ app = FastAPI(title="Executor API", description="Control interface for the triangular arbitrage executor") @app.get("/api/v1/status", response_model=StatusResponse) async def get_status() -> StatusResponse: config = executor.get_config() in_flight = executor.get_in_flight() return StatusResponse( status="ok", version="0.1.0", live_mode=config["live_mode"], slots_used=len(in_flight), slots_total=config["concurrent_slots"], uptime_seconds=executor.get_uptime_seconds(), ) @app.get("/api/v1/config", response_model=ConfigResponse) async def get_config() -> ConfigResponse: cfg = executor.get_config() return ConfigResponse( paper_mode=cfg["paper_mode"], concurrent_slots=cfg["concurrent_slots"], enforce_same_base_isolation=cfg["enforce_same_base_isolation"], socket_path=cfg["socket_path"], ) @app.patch("/api/v1/config/live_mode", response_model=ConfigPatchResponse) async def patch_live_mode(req: ConfigPatchRequest) -> ConfigPatchResponse: if req.live_mode is None: raise HTTPException(status_code=400, detail="live_mode required") executor._live_mode = req.live_mode return ConfigPatchResponse(ok=True, live_mode=req.live_mode) @app.patch("/api/v1/config/concurrent_slots", response_model=ConfigPatchResponse) async def patch_concurrent_slots(req: ConfigPatchRequest) -> ConfigPatchResponse: if req.concurrent_slots is None or req.concurrent_slots < 1: raise HTTPException(status_code=400, detail="concurrent_slots must be >= 1") executor.set_concurrent_slots(req.concurrent_slots) return ConfigPatchResponse(ok=True, concurrent_slots=req.concurrent_slots) @app.patch("/api/v1/config/enforce_same_base_isolation", response_model=ConfigPatchResponse) async def patch_isolation(req: ConfigPatchRequest) -> ConfigPatchResponse: if req.enforce_same_base_isolation is None: raise HTTPException(status_code=400, detail="enforce_same_base_isolation required") executor._settings.enforce_same_base_isolation = req.enforce_same_base_isolation return ConfigPatchResponse(ok=True, enforce_same_base_isolation=req.enforce_same_base_isolation) @app.get("/api/v1/executions") async def get_executions() -> list[dict]: return executor.get_in_flight() @app.get("/api/v1/executions/{correlation_id}") async def get_execution(correlation_id: str) -> dict: in_flight = executor.get_in_flight() for inf in in_flight: if inf["correlation_id"] == correlation_id: return inf raise HTTPException(status_code=404, detail="Execution not found") @app.post("/api/v1/cancel/{correlation_id}", response_model=CancelResponse) async def cancel_execution(correlation_id: str) -> CancelResponse: ok = await executor.cancel_execution(correlation_id) if ok: return CancelResponse(ok=True, message=f"Cancellation requested for {correlation_id}") raise HTTPException(status_code=404, detail="Execution not found") @app.post("/api/v1/pause", response_model=PauseResponse) async def pause() -> PauseResponse: await executor.pause() return PauseResponse(ok=True) @app.post("/api/v1/resume", response_model=ResumeResponse) async def resume() -> ResumeResponse: await executor.resume() return ResumeResponse(ok=True) @app.post("/api/v1/shutdown", response_model=ShutdownResponse) async def shutdown() -> ShutdownResponse: await executor.pause() shutdown_callback() return ShutdownResponse(ok=True) @app.get("/api/v1/reports") async def get_reports(limit: int = 50) -> list[dict]: return executor.get_reports(limit=limit) return app