triangular_arbitrage_bot/executor/rest_api.py

172 lines
5.9 KiB
Python

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