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