""" FastAPI Backend for Signal Engine Complete trading signals platform with ML predictions, user auth, and real-time data """ from fastapi import FastAPI, HTTPException, Depends, Header, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials import websockets import json import asyncio from pydantic import BaseModel, Field from typing import List, Optional, Dict from datetime import datetime, timedelta import secrets import hashlib import hmac import stripe import aiohttp import numpy as np from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier from sklearn.svm import SVC from sklearn.linear_model import LogisticRegression from eth_account import Account from eth_account.messages import encode_defunct from eth_utils import to_checksum_address from web3 import Web3 from web3.middleware import geth_poa_middleware from database import get_session, Symbol, PredictionBatch, Prediction, LedgerEntry, Subscriber, ApiUsage from signal_engine import SignalEngine from config import settings from ml_engine import MLEngine, ReinforcementLearningAgent, GeneticAlgorithmOptimizer, SelfLearningSystem app = FastAPI( title="Signal Engine API", description="Gate.io crypto-perpetual signal engine with audit-grade ledger", version="1.0.0" ) # CORS middleware app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Mount static files for landing page app.mount("/static", StaticFiles(directory="static"), name="static") # Initialize signal engine signal_engine = SignalEngine() # Initialize ML/RL Engine ml_engine = MLEngine() rl_agent = ReinforcementLearningAgent(state_size=10, action_size=3) ga_optimizer = GeneticAlgorithmOptimizer(population_size=20, generations=50) self_learning = SelfLearningSystem(ml_engine) # Try to load pre-trained models try: ml_engine.load_models() print("Loaded pre-trained ML models") except: print("No pre-trained models found, will train on first request") # Pydantic models class HealthResponse(BaseModel): status: str ledger_valid: bool latest_batch: Optional[str] total_predictions: int class PredictionResponse(BaseModel): symbol: str target_hour: str entry_price: float direction: str probability_up: float confidence: float suggested_position: float class CurrentPredictionsResponse(BaseModel): batch_id: str target_hour: str batch_hash: str num_predictions: int avg_confidence: float predictions: List[PredictionResponse] class MetricsResponse(BaseModel): total_predictions: int scored_predictions: int overall_accuracy: float avg_brier_score: float avg_hypothetical_pnl_bps: float class AccuracyResponse(BaseModel): window_hours: int accuracy: float total_predictions: int correct_predictions: int class LeaderboardEntry(BaseModel): symbol: str hit_rate: float total_predictions: int avg_hypothetical_pnl_bps: float class TrackRecordResponse(BaseModel): ledger_head_hash: str recent_entries: List[Dict] total_scored_predictions: int overall_accuracy: float overall_hypothetical_pnl_bps: float class WebhookEvent(BaseModel): type: str data: Dict class WalletConnectRequest(BaseModel): address: str signature: str message: str class ZKLoginRequest(BaseModel): challenge: str proof: Dict public_key: str class PredictionRequest(BaseModel): symbol: str timeframe: str = "1h" class ContractSubscription(BaseModel): tier: str # FREE, PRO, ENTERPRISE duration: int # months # Authentication def verify_api_key(x_api_key: str = Header(...)) -> Subscriber: """Verify API key and return subscriber""" session = get_session(settings.database_url) # Hash the provided key for comparison key_hash = hashlib.sha256(x_api_key.encode()).hexdigest() subscriber = session.query(Subscriber).filter_by( api_key_hash=key_hash, active=True ).first() if not subscriber: session.close() raise HTTPException(status_code=401, detail="Invalid API key") # Check subscription expiry if subscriber.expires_at and subscriber.expires_at < datetime.utcnow(): session.close() raise HTTPException(status_code=403, detail="Subscription expired") session.close() return subscriber def check_rate_limit(subscriber: Subscriber): """Check rate limit for subscriber""" session = get_session(settings.database_url) # Get rate limit based on tier rate_limits = { 'free': settings.free_tier_rate_limit, 'pro': settings.pro_tier_rate_limit, 'enterprise': settings.enterprise_tier_rate_limit } limit = rate_limits.get(subscriber.tier, settings.free_tier_rate_limit) # Count requests in last hour one_hour_ago = datetime.utcnow() - timedelta(hours=1) usage_count = session.query(ApiUsage).filter( ApiUsage.subscriber_id == subscriber.id, ApiUsage.timestamp >= one_hour_ago ).count() session.close() if usage_count >= limit: raise HTTPException(status_code=429, detail="Rate limit exceeded") def log_api_usage(subscriber: Subscriber, endpoint: str, status_code: int): """Log API usage for rate limiting""" session = get_session(settings.database_url) usage = ApiUsage( subscriber_id=subscriber.id, endpoint=endpoint, status_code=status_code ) session.add(usage) session.commit() session.close() # Endpoints @app.get("/") async def root(): """Root endpoint - serve landing page""" from fastapi.responses import FileResponse return FileResponse("static/landing.html") @app.get("/app") async def app_root(): """Serve the main application""" from fastapi.responses import FileResponse return FileResponse("static/index.html") @app.get("/health", response_model=HealthResponse) async def health(): """Health check endpoint""" session = get_session(settings.database_url) # Check ledger validity ledger_entries = session.query(LedgerEntry).order_by(LedgerEntry.id).all() ledger_valid = True for i, entry in enumerate(ledger_entries): if i > 0 and entry.prev_hash != ledger_entries[i-1].entry_hash: ledger_valid = False break # Get latest batch latest_batch = session.query(PredictionBatch).order_by(PredictionBatch.id.desc()).first() # Count total predictions total_predictions = session.query(Prediction).count() session.close() return HealthResponse( status="healthy", ledger_valid=ledger_valid, latest_batch=latest_batch.batch_id if latest_batch else None, total_predictions=total_predictions ) @app.get("/v1/predictions/current", response_model=CurrentPredictionsResponse) async def get_current_predictions(subscriber: Subscriber = Depends(verify_api_key)): """Get current prediction batch""" check_rate_limit(subscriber) session = get_session(settings.database_url) # Get latest batch batch = session.query(PredictionBatch).order_by(PredictionBatch.id.desc()).first() if not batch: session.close() raise HTTPException(status_code=404, detail="No predictions available") # Get predictions predictions = session.query(Prediction).filter_by(batch_id=batch.batch_id).all() session.close() log_api_usage(subscriber, "/v1/predictions/current", 200) return CurrentPredictionsResponse( batch_id=batch.batch_id, target_hour=batch.target_hour.isoformat(), batch_hash=batch.batch_hash, num_predictions=batch.num_predictions, avg_confidence=batch.avg_confidence, predictions=[ PredictionResponse( symbol=p.symbol, target_hour=p.target_hour.isoformat(), entry_price=p.entry_price, direction=p.direction, probability_up=p.probability_up, confidence=p.confidence, suggested_position=p.suggested_position ) for p in predictions ] ) @app.get("/v1/predictions/history") async def get_prediction_history( subscriber: Subscriber = Depends(verify_api_key), limit: int = 100, offset: int = 0 ): """Get historical predictions""" check_rate_limit(subscriber) session = get_session(settings.database_url) batches = session.query(PredictionBatch).order_by( PredictionBatch.target_hour.desc() ).limit(limit).offset(offset).all() result = [] for batch in batches: predictions = session.query(Prediction).filter_by(batch_id=batch.batch_id).all() result.append({ 'batch_id': batch.batch_id, 'target_hour': batch.target_hour.isoformat(), 'batch_hash': batch.batch_hash, 'num_predictions': batch.num_predictions, 'avg_confidence': batch.avg_confidence, 'accuracy': batch.accuracy, 'scored_at': batch.scored_at.isoformat() if batch.scored_at else None, 'predictions': [ { 'symbol': p.symbol, 'direction': p.direction, 'probability_up': p.probability_up, 'confidence': p.confidence, 'entry_price': p.entry_price, 'exit_price': p.exit_price, 'actual_return': p.actual_return, 'correct': p.correct } for p in predictions ] }) session.close() log_api_usage(subscriber, "/v1/predictions/history", 200) return result @app.get("/v1/metrics", response_model=MetricsResponse) async def get_metrics(subscriber: Subscriber = Depends(verify_api_key)): """Get global metrics""" check_rate_limit(subscriber) session = get_session(settings.database_url) total_predictions = session.query(Prediction).count() scored_predictions = session.query(Prediction).filter( Prediction.scored_at.isnot(None) ).count() batches = session.query(PredictionBatch).filter( PredictionBatch.scored_at.isnot(None) ).all() if batches: avg_accuracy = sum(b.accuracy or 0 for b in batches) / len(batches) avg_brier = sum(b.brier_score or 0 for b in batches) / len(batches) avg_pnl = sum(b.hypothetical_pnl_bps or 0 for b in batches) / len(batches) else: avg_accuracy = 0.0 avg_brier = 0.0 avg_pnl = 0.0 session.close() log_api_usage(subscriber, "/v1/metrics", 200) return MetricsResponse( total_predictions=total_predictions, scored_predictions=scored_predictions, overall_accuracy=avg_accuracy, avg_brier_score=avg_brier, avg_hypothetical_pnl_bps=avg_pnl ) @app.get("/v1/accuracy", response_model=AccuracyResponse) async def get_accuracy( subscriber: Subscriber = Depends(verify_api_key), window_hours: int = 24 ): """Get accuracy over rolling window""" check_rate_limit(subscriber) session = get_session(settings.database_url) cutoff = datetime.utcnow() - timedelta(hours=window_hours) predictions = session.query(Prediction).filter( Prediction.scored_at >= cutoff, Prediction.scored_at.isnot(None) ).all() total = len(predictions) correct = sum(1 for p in predictions if p.correct) session.close() log_api_usage(subscriber, "/v1/accuracy", 200) return AccuracyResponse( window_hours=window_hours, accuracy=correct / total if total > 0 else 0.0, total_predictions=total, correct_predictions=correct ) @app.get("/v1/leaderboard", response_model=List[LeaderboardEntry]) async def get_leaderboard(subscriber: Subscriber = Depends(verify_api_key)): """Get symbol leaderboard (pro tier only)""" if subscriber.tier != 'pro' and subscriber.tier != 'enterprise': raise HTTPException(status_code=403, detail="Pro tier required") check_rate_limit(subscriber) session = get_session(settings.database_url) # Calculate per-symbol metrics symbols = session.query(Prediction.symbol).distinct().all() leaderboard = [] for (symbol,) in symbols: predictions = session.query(Prediction).filter( Prediction.symbol == symbol, Prediction.scored_at.isnot(None) ).all() if predictions: hit_rate = sum(1 for p in predictions if p.correct) / len(predictions) avg_pnl = sum( (p.actual_return or 0) * abs(p.suggested_position) * 10000 for p in predictions ) / len(predictions) leaderboard.append(LeaderboardEntry( symbol=symbol, hit_rate=hit_rate, total_predictions=len(predictions), avg_hypothetical_pnl_bps=avg_pnl - settings.maker_fee_bps )) # Sort by hit rate leaderboard.sort(key=lambda x: x.hit_rate, reverse=True) session.close() log_api_usage(subscriber, "/v1/leaderboard", 200) return leaderboard[:20] @app.get("/v1/track-record", response_model=TrackRecordResponse) async def get_track_record(subscriber: Subscriber = Depends(verify_api_key)): """Get track record with ledger info""" check_rate_limit(subscriber) session = get_session(settings.database_url) # Get ledger head ledger_head = session.query(LedgerEntry).order_by(LedgerEntry.id.desc()).first() # Get recent ledger entries recent_entries = session.query(LedgerEntry).order_by( LedgerEntry.id.desc() ).limit(10).all() # Calculate overall metrics scored_predictions = session.query(Prediction).filter( Prediction.scored_at.isnot(None) ).all() total_scored = len(scored_predictions) overall_accuracy = sum(1 for p in scored_predictions if p.correct) / total_scored if total_scored > 0 else 0 overall_pnl = sum( (p.actual_return or 0) * abs(p.suggested_position) * 10000 for p in scored_predictions ) / total_scored if total_scored > 0 else 0 session.close() log_api_usage(subscriber, "/v1/track-record", 200) return TrackRecordResponse( ledger_head_hash=ledger_head.entry_hash if ledger_head else "", recent_entries=[ { 'entry_hash': e.entry_hash, 'entry_type': e.entry_type, 'timestamp': e.timestamp.isoformat() } for e in recent_entries ], total_scored_predictions=total_scored, overall_accuracy=overall_accuracy, overall_hypothetical_pnl_bps=overall_pnl - settings.maker_fee_bps ) @app.post("/v1/stripe/webhook") async def stripe_webhook(request: Request): """Handle Stripe webhooks""" if not settings.stripe_webhook_secret: # In development, accept without verification payload = await request.body() event_data = payload.decode('utf-8') else: # Production: verify signature payload = await request.body() sig_header = request.headers.get('stripe-signature') if not sig_header: raise HTTPException(status_code=400, detail="No signature header") try: event = stripe.Webhook.construct_event( payload, sig_header, settings.stripe_webhook_secret ) except ValueError: raise HTTPException(status_code=400, detail="Invalid payload") except stripe.error.SignatureVerificationError: raise HTTPException(status_code=400, detail="Invalid signature") event_data = event # Process webhook session = get_session(settings.database_url) # Handle subscription events if event_data['type'] in ['customer.subscription.created', 'customer.subscription.updated']: customer_id = event_data['data']['object']['customer'] subscriber = session.query(Subscriber).filter_by( stripe_customer_id=customer_id ).first() if subscriber: subscriber.active = True # Set expiry based on subscription session.commit() elif event_data['type'] == 'customer.subscription.deleted': customer_id = event_data['data']['object']['customer'] subscriber = session.query(Subscriber).filter_by( stripe_customer_id=customer_id ).first() if subscriber: subscriber.active = False session.commit() session.close() return {"status": "success"} @app.post("/v1/auth/zk") async def zk_login(request: ZKLoginRequest): """ZK login with zero-knowledge proof verification (demo mode)""" try: # Demo mode: Generate API key without database api_key = secrets.token_urlsafe(32) return { "api_key": api_key, "tier": "free", "identity": request.public_key, "message": "ZK login successful (demo mode)" } except Exception as e: import traceback error_detail = f"ZK login failed: {str(e)}\n{traceback.format_exc()}" print(error_detail) return JSONResponse( status_code=500, content={"detail": error_detail} ) @app.get("/v1/predictions/public") async def get_public_predictions(): """Get public predictions (no auth required, limited data)""" session = get_session(settings.database_url) # Get latest batch batch = session.query(PredictionBatch).order_by(PredictionBatch.id.desc()).first() if not batch: session.close() return {"predictions": [], "message": "No predictions available"} # Get predictions (limited fields for public view) predictions = session.query(Prediction).filter_by(batch_id=batch.batch_id).limit(5).all() session.close() return { "batch_id": batch.batch_id, "target_hour": batch.target_hour.isoformat(), "num_predictions": batch.num_predictions, "predictions": [ { "symbol": p.symbol, "direction": p.direction, "confidence": round(p.confidence, 2) } for p in predictions ] } @app.get("/v1/market/tickers") async def get_market_tickers(): """Get real market tickers from Gate.io REST API""" from gateio_client import GateIOClient try: async with GateIOClient() as client: symbols = ["BTC_USDT", "ETH_USDT", "SOL_USDT", "DOGE_USDT", "XRP_USDT"] tickers = [] for symbol in symbols: ticker = await client.get_ticker(symbol) if ticker: tickers.append({ "symbol": ticker["symbol"].replace("_", "/"), "price": ticker["last_price"], "change_24h": 0, # Gate.io doesn't provide this directly in ticker "volume_24h": ticker["volume_24h"] }) return {"tickers": tickers} except Exception as e: return {"tickers": [], "error": str(e)} @app.get("/v1/market/orderbook/{symbol}") async def get_orderbook(symbol: str): """Get order book from Gate.io REST API""" import aiohttp gate_symbol = symbol.replace("/", "_") url = f"https://api.gateio.ws/api/v4/futures/usdt/order_book" try: async with aiohttp.ClientSession() as session: params = {"contract": gate_symbol, "limit": 20} async with session.get(url, params=params) as response: data = await response.json() return { "asks": data.get("asks", [])[:10], "bids": data.get("bids", [])[:10] } except Exception as e: return {"asks": [], "bids": [], "error": str(e)} # ML Prediction Generation @app.post("/v1/predictions/generate") async def generate_prediction(request: PredictionRequest, subscriber: Subscriber = Depends(verify_api_key)): """Generate ML prediction using real trained models""" try: # Fetch recent market data from gateio_client import GateIOClient async with GateIOClient() as client: candles = await client.get_candles(request.symbol, interval='1h', limit=100) if len(candles) < 50: raise HTTPException(status_code=400, detail="Insufficient data for prediction") # Extract features df = pd.DataFrame(candles, columns=['timestamp', 'volume', 'close', 'high', 'low', 'open']) df['timestamp'] = pd.to_datetime(df['timestamp'], unit='s') df.set_index('timestamp', inplace=True) features_dict = ml_engine.extract_features(df).iloc[-1].to_dict() # Generate prediction using real ML engine if not ml_engine.is_trained: # Train on the fly if not trained await ml_engine.train(request.symbol) prediction = ml_engine.predict(features_dict) # Get RL signal rl_signal = rl_agent.get_signal(features_dict) # Store in database session = get_session(settings.database_url) batch_id = secrets.token_hex(16) target_hour = datetime.utcnow() + timedelta(hours=1) pred = Prediction( batch_id=batch_id, symbol=request.symbol, target_hour=target_hour, entry_price=float(df.iloc[-1]['close']), direction=prediction['direction'], probability_up=prediction['probability_up'], confidence=prediction['confidence'], suggested_position=prediction['probability_up'] if prediction['direction'] == 'LONG' else -prediction['probability_up'], feature_hash=hashlib.sha256(str(features_dict).encode()).hexdigest() ) session.add(pred) session.commit() session.close() return { "symbol": request.symbol, "direction": prediction['direction'], "rl_signal": rl_signal, "confidence": prediction['confidence'], "entry_price": float(df.iloc[-1]['close']), "probability_up": prediction['probability_up'], "probability_down": prediction['probability_down'], "target_hour": target_hour.isoformat(), "ensemble_weights": { "random_forest": 0.3, "gradient_boosting": 0.3, "svm": 0.2, "logistic_regression": 0.2 } } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/v1/ml/train") async def train_models(symbol: str = "BTC_USDT", subscriber: Subscriber = Depends(verify_api_key)): """Train ML models on historical data""" try: results = await ml_engine.train(symbol) # Update self-learning system await self_learning.update(symbol) return { "status": "success", "symbol": symbol, "training_results": results, "performance_metrics": self_learning.get_performance_metrics() } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.get("/v1/ml/performance") async def get_ml_performance(subscriber: Subscriber = Depends(verify_api_key)): """Get ML performance metrics""" return self_learning.get_performance_metrics() def extract_features(df): """Extract technical features from candle data""" features = {} # Price features features['close'] = df.iloc[-1]['close'] features['high'] = df.iloc[-1]['high'] features['low'] = df.iloc[-1]['low'] features['volume'] = df.iloc[-1]['volume'] # Returns df['returns'] = df['close'].pct_change() features['return_1h'] = df['returns'].iloc[-1] features['return_4h'] = df['returns'].iloc[-4:].sum() features['return_24h'] = df['returns'].iloc[-24:].sum() # Volatility features['volatility'] = df['returns'].iloc[-24:].std() # Moving averages features['sma_7'] = df['close'].iloc[-7:].mean() features['sma_24'] = df['close'].iloc[-24:].mean() features['ema_12'] = df['close'].ewm(span=12).mean().iloc[-1] # RSI delta = df['close'].diff() gain = (delta.where(delta > 0, 0)).rolling(window=14).mean() loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean() rs = gain / loss features['rsi'] = 100 - (100 / (1 + rs.iloc[-1])) return features # Portfolio Management @app.get("/v1/portfolio") async def get_portfolio(subscriber: Subscriber = Depends(verify_api_key)): """Get user portfolio""" session = get_session(settings.database_url) # Get user's recent predictions predictions = session.query(Prediction).filter_by( symbol='BTC_USDT' # Filter by user's symbols ).order_by(Prediction.target_hour.desc()).limit(20).all() session.close() # Calculate portfolio value (mock for now) portfolio = { "total_value": 0, "positions": [], "recent_predictions": [ { "symbol": p.symbol, "direction": p.direction, "confidence": p.confidence, "entry_price": p.entry_price, "target_hour": p.target_hour.isoformat() } for p in predictions ] } return portfolio # Analytics @app.get("/v1/analytics/performance") async def get_performance(subscriber: Subscriber = Depends(verify_api_key)): """Get performance analytics""" session = get_session(settings.database_url) # Get scored predictions predictions = session.query(Prediction).filter( Prediction.scored_at.isnot(None) ).all() if not predictions: session.close() return {"accuracy": 0, "total_predictions": 0, "profitable": 0} correct = sum(1 for p in predictions if p.correct) accuracy = correct / len(predictions) session.close() return { "accuracy": round(accuracy * 100, 2), "total_predictions": len(predictions), "profitable": correct } # Smart Contract Endpoints @app.get("/v1/contract/subscription/{address}") async def get_contract_subscription(address: str): """Get subscription status from smart contract""" try: # For demo, return mock data # In production, connect to actual deployed contract return { "subscriber": address, "tier": "FREE", "expiresAt": int(datetime.utcnow().timestamp()) + 86400 * 30, "active": True } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/v1/contract/purchase") async def purchase_subscription(sub: ContractSubscription, subscriber: Subscriber = Depends(verify_api_key)): """Purchase subscription via smart contract""" try: # For demo, just update database tier session = get_session(settings.database_url) db_subscriber = session.query(Subscriber).filter_by( wallet_address=subscriber.wallet_address ).first() if db_subscriber: db_subscriber.tier = sub.tier.lower() session.commit() session.close() return { "status": "success", "tier": sub.tier, "message": "Subscription purchased (demo mode)" } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.get("/v1/contract/predictions/{batch_id}") async def get_contract_prediction(batch_id: str): """Get prediction verification from smart contract""" try: # For demo, return mock data return { "batchId": batch_id, "verified": True, "timestamp": int(datetime.utcnow().timestamp()) } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.websocket("/ws/market") async def websocket_market(websocket: WebSocket): """WebSocket endpoint for real-time Gate.io market data""" await websocket.accept() # Connect to Gate.io WebSocket gate_ws_url = "wss://api.gateio.ws/ws/v4/" try: async with websockets.connect(gate_ws_url) as gate_ws: # Subscribe to BTC/USDT ticker subscribe_msg = { "method": "ticker.subscribe", "params": ["BTC_USDT"], "id": 1 } await gate_ws.send(json.dumps(subscribe_msg)) # Subscribe to order book orderbook_msg = { "method": "order_book.subscribe", "params": ["BTC_USDT", "5", "0"], "id": 2 } await gate_ws.send(json.dumps(orderbook_msg)) # Forward messages from Gate.io to client while True: try: message = await gate_ws.recv() data = json.loads(message) # Forward relevant data to client if 'result' in data or 'params' in data: await websocket.send_json(data) except websockets.exceptions.ConnectionClosed: break except Exception as e: print(f"WebSocket error: {e}") finally: await websocket.close() @app.websocket("/ws/multi") async def websocket_multi(websocket: WebSocket): """WebSocket endpoint for multiple symbols market data""" await websocket.accept() gate_ws_url = "wss://api.gateio.ws/ws/v4/" symbols = ["BTC_USDT", "ETH_USDT", "SOL_USDT", "DOGE_USDT", "XRP_USDT"] try: async with websockets.connect(gate_ws_url) as gate_ws: # Subscribe to multiple tickers for i, symbol in enumerate(symbols): subscribe_msg = { "method": "ticker.subscribe", "params": [symbol], "id": i + 1 } await gate_ws.send(json.dumps(subscribe_msg)) # Forward messages while True: try: message = await gate_ws.recv() data = json.loads(message) await websocket.send_json(data) except websockets.exceptions.ConnectionClosed: break except Exception as e: print(f"WebSocket error: {e}") finally: await websocket.close() if __name__ == "__main__": import uvicorn uvicorn.run(app, host=settings.api_host, port=settings.api_port)