| """ |
| 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" |
| ) |
|
|
| |
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_credentials=True, |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| |
| signal_engine = SignalEngine() |
|
|
| |
| 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 |
|
|
| |
| def verify_api_key(x_api_key: str = Header(...)) -> Subscriber: |
| """Verify API key and return subscriber""" |
| session = get_session(settings.database_url) |
| |
| |
| 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") |
| |
| |
| 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) |
| |
| |
| 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) |
| |
| |
| 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() |
|
|
| |
| @app.get("/health", response_model=HealthResponse) |
| async def health(): |
| """Health check endpoint""" |
| session = get_session(settings.database_url) |
| |
| |
| 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 |
| |
| |
| latest_batch = session.query(PredictionBatch).order_by(PredictionBatch.id.desc()).first() |
| |
| |
| 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) |
| |
| |
| batch = session.query(PredictionBatch).order_by(PredictionBatch.id.desc()).first() |
| |
| if not batch: |
| session.close() |
| raise HTTPException(status_code=404, detail="No predictions available") |
| |
| |
| 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) |
| |
| |
| 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 |
| )) |
| |
| |
| 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) |
| |
| |
| ledger_head = session.query(LedgerEntry).order_by(LedgerEntry.id.desc()).first() |
| |
| |
| recent_entries = session.query(LedgerEntry).order_by( |
| LedgerEntry.id.desc() |
| ).limit(10).all() |
| |
| |
| 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: |
| |
| payload = await request.body() |
| event_data = payload.decode('utf-8') |
| else: |
| |
| 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 |
| |
| |
| session = get_session(settings.database_url) |
| |
| |
| 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 |
| |
| 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) |
|
|