""" FastAPI Backend for Signal Engine REST API with authentication, rate limiting, and all endpoints """ from fastapi import FastAPI, HTTPException, Depends, Header, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from typing import List, Optional, Dict from datetime import datetime, timedelta import secrets import hashlib import hmac import stripe from database import get_session, Symbol, PredictionBatch, Prediction, LedgerEntry, Subscriber, ApiUsage from signal_engine import SignalEngine from config import settings 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=["*"], # Configure appropriately for production allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Initialize signal engine signal_engine = SignalEngine() # 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 # 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("/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: 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: 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: 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: 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: 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: 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"} if __name__ == "__main__": import uvicorn uvicorn.run(app, host=settings.api_host, port=settings.api_port)