triangular_arbitrage_bot/fh_ob/rest_server.py

110 lines
3.9 KiB
Python

from typing import Callable, Optional
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from fh_ob.book_store import BookStore, OrderBookTop5
class BookLevelResponse(BaseModel):
price: str
size: str
class OrderBookResponse(BaseModel):
symbol: str
bids: list[BookLevelResponse]
asks: list[BookLevelResponse]
ts_ms: int
class HealthResponse(BaseModel):
status: str
books_tracked: int
socket_clients: int
subscribed_symbols: int
connected: bool
last_update_ms: Optional[int] = None
reconnect_count: int = 0
last_reconnect_ms: Optional[int] = None
class SymbolOpRequest(BaseModel):
symbol: str
class SymbolsResponse(BaseModel):
symbols: list[str]
def create_app(
book_store: BookStore,
get_socket_clients: Optional[Callable[[], int]] = None,
get_subscribed_count: Optional[Callable[[], int]] = None,
is_connected: Optional[Callable[[], bool]] = None,
add_symbol: Optional[Callable[[str], bool]] = None,
remove_symbol: Optional[Callable[[str], bool]] = None,
get_symbols: Optional[Callable[[], list[str]]] = None,
get_reconnect_stats: Optional[Callable[[], tuple[int, int]]] = None,
) -> FastAPI:
app = FastAPI(title="FH+OB Debug API", description="Dev-only debug endpoint")
@app.get("/book/{symbol}", response_model=OrderBookResponse)
async def get_book(symbol: str) -> OrderBookResponse:
book = book_store.get(symbol)
if book is None:
raise HTTPException(status_code=404, detail=f"No book data for {symbol}")
return OrderBookResponse(
symbol=book.symbol,
bids=[BookLevelResponse(price=str(b.price), size=str(b.size)) for b in book.bids],
asks=[BookLevelResponse(price=str(a.price), size=str(a.size)) for a in book.asks],
ts_ms=book.ts_ms,
)
@app.get("/books", response_model=dict[str, OrderBookResponse])
async def get_all_books() -> dict[str, OrderBookResponse]:
books = book_store.get_all()
return {
symbol: OrderBookResponse(
symbol=book.symbol,
bids=[BookLevelResponse(price=str(b.price), size=str(b.size)) for b in book.bids],
asks=[BookLevelResponse(price=str(a.price), size=str(a.size)) for a in book.asks],
ts_ms=book.ts_ms,
)
for symbol, book in books.items()
}
@app.get("/symbols")
async def list_symbols():
return SymbolsResponse(symbols=get_symbols() if get_symbols else [])
@app.post("/symbols")
async def add_sym(req: SymbolOpRequest):
if add_symbol and add_symbol(req.symbol):
return SymbolsResponse(symbols=get_symbols() if get_symbols else [])
raise HTTPException(status_code=400, detail="Symbol not found or already subscribed")
@app.delete("/symbols/{symbol}")
async def rm_sym(symbol: str):
if remove_symbol and remove_symbol(symbol):
return SymbolsResponse(symbols=get_symbols() if get_symbols else [])
raise HTTPException(status_code=404, detail="Symbol not found or not subscribed")
@app.get("/health", response_model=HealthResponse)
async def health() -> HealthResponse:
books = book_store.get_all()
latest_ts = max((b.ts_ms for b in books.values()), default=None)
reconnects, last_reconnect_ms = get_reconnect_stats() if get_reconnect_stats else (0, None)
return HealthResponse(
status="ok" if (is_connected and is_connected()) else "degraded",
books_tracked=len(books),
socket_clients=get_socket_clients() if get_socket_clients else 0,
subscribed_symbols=get_subscribed_count() if get_subscribed_count else 0,
connected=is_connected() if is_connected else False,
last_update_ms=latest_ts,
reconnect_count=reconnects,
last_reconnect_ms=last_reconnect_ms,
)
return app