Marchelo23/mempool
0
1"""2Bitcoin Mempool Fee Prediction API — Hugging Face Spaces Edition3================================================================4Self-contained FastAPI application with XGBoost + LightGBM ensemble.5No Redis dependency. Open access for public demo.6 7Models: XGBoost 2.1+ / LightGBM 4.5+8Port: 7860 (HF Spaces requirement)9"""10 11from fastapi import FastAPI, HTTPException, BackgroundTasks, Request12from fastapi.middleware.cors import CORSMiddleware13from fastapi.responses import JSONResponse14from contextlib import asynccontextmanager15from datetime import datetime, timezone16import logging17import os18import sys19from pathlib import Path20 21# Ensure app root is in path22sys.path.insert(0, str(Path(__file__).parent))23 24from src.ingestion import MempoolDataIngestion25from src.inference import FeeModelInference26 27# ---------------------------------------------------------------------------28# Logging29# ---------------------------------------------------------------------------30logging.basicConfig(31 level=logging.INFO,32 format="%(asctime)s | %(name)s | %(levelname)s | %(message)s",33)34logger = logging.getLogger("mempool-api")35 36# ---------------------------------------------------------------------------37# Global state38# ---------------------------------------------------------------------------39ingestion: MempoolDataIngestion = None40inference: FeeModelInference = None41cached_prediction = None42cache_timestamp = None43CACHE_TTL = 30 # seconds — fast refresh for live demo44 45 46# ---------------------------------------------------------------------------47# Lifespan48# ---------------------------------------------------------------------------49@asynccontextmanager50async def lifespan(app: FastAPI):51 global ingestion, inference52 53 logger.info("⚡ Starting Bitcoin Mempool Fee Prediction API (HF Spaces)")54 ingestion = MempoolDataIngestion()55 inference = FeeModelInference()56 inference.load_all_models()57 58 model_info = inference.get_loaded_models_info()59 logger.info(f"✓ Models loaded: {model_info}")60 61 yield62 63 logger.info("🔌 Shutting down API")64 65 66# ---------------------------------------------------------------------------67# App68# ---------------------------------------------------------------------------69app = FastAPI(70 title="Bitcoin Mempool Fee Predictor",71 description=(72 "ML-powered fee prediction for Bitcoin block inclusion. "73 "Predicts the optimal fee rate (sats/vByte) using an XGBoost + LightGBM ensemble "74 "trained on real-time mempool data from mempool.space."75 ),76 version="3.0.0",77 lifespan=lifespan,78 docs_url="/docs",79 redoc_url="/redoc",80)81 82# CORS — wide open for HF Spaces public demo83app.add_middleware(84 CORSMiddleware,85 allow_origins=["*"],86 allow_credentials=False,87 allow_methods=["GET"],88 allow_headers=["*"],89 max_age=600,90)91 92 93# ============================================================================94# ENDPOINTS95# ============================================================================96 97@app.get("/", tags=["General"])98async def root():99 """Root endpoint — API overview"""100 return {101 "service": "Bitcoin Mempool Fee Predictor",102 "version": "3.0.0",103 "status": "operational",104 "models": "XGBoost 2.1 + LightGBM 4.5 Ensemble",105 "network": "Bitcoin Mainnet",106 "docs": "/docs",107 "endpoints": {108 "predict": "/fees/predict",109 "current": "/fees/current",110 "health": "/health",111 "models": "/models",112 "metadata": "/model-metadata",113 "mempool_blocks": "/mempool/blocks",114 },115 }116 117 118@app.get("/health", tags=["General"])119async def health_check():120 """Health check"""121 model_info = inference.get_loaded_models_info() if inference else {}122 return {123 "status": "healthy",124 "timestamp": datetime.now(timezone.utc).isoformat(),125 "models_loaded": model_info.get("total_models", 0),126 "xgb_horizons": model_info.get("xgb_models", []),127 "lgb_horizons": model_info.get("lgb_models", []),128 "version": "3.0.0",129 }130 131 132@app.get("/fees/predict", tags=["Fee Prediction"])133async def predict_fees(134 request: Request,135 background_tasks: BackgroundTasks,136 use_ensemble: bool = True,137):138 """139 🔮 Predict optimal fee rates for Bitcoin block inclusion.140 141 Returns ML predictions for 1-block, 3-block, and 6-block horizons142 with confidence intervals and a recommendation.143 144 **No API key needed** — this is a public demo.145 """146 global cached_prediction, cache_timestamp147 148 # Serve from cache if fresh149 if (150 cached_prediction is not None151 and cache_timestamp is not None152 and (datetime.now() - cache_timestamp).total_seconds() < CACHE_TTL153 ):154 return cached_prediction155 156 try:157 # Fetch live mempool snapshot158 snapshot = ingestion.fetch_full_snapshot()159 if snapshot is None:160 raise HTTPException(161 status_code=503,162 detail="Could not fetch mempool data from mempool.space",163 )164 165 # Load historical data for rolling features166 snapshots_df = ingestion.load_snapshots()167 import pandas as pd168 169 if snapshots_df is None or len(snapshots_df) < 10:170 snapshots_df = pd.DataFrame([snapshot])171 else:172 snapshots_df = pd.concat(173 [snapshots_df, pd.DataFrame([snapshot])],174 ignore_index=True,175 )176 177 # Make predictions178 response = inference.predict_from_snapshot(179 snapshots_df, use_ensemble=use_ensemble180 )181 182 # Cache183 cached_prediction = response184 cache_timestamp = datetime.now()185 186 # Save snapshot in background187 background_tasks.add_task(ingestion.save_snapshot, snapshot)188 189 return response190 191 except HTTPException:192 raise193 except Exception as e:194 logger.error(f"Prediction error: {e}", exc_info=True)195 raise HTTPException(status_code=500, detail="Prediction failed")196 197 198@app.get("/fees/current", tags=["Fee Prediction"])199async def get_current_fees():200 """201 📊 Current mempool fees from mempool.space (no ML — raw network data).202 """203 try:204 fees = ingestion.fetch_recommended_fees()205 mempool = ingestion.fetch_mempool_state()206 207 if fees is None:208 raise HTTPException(status_code=503, detail="Could not fetch fee data")209 210 return {211 "timestamp": datetime.now(timezone.utc).isoformat(),212 "fees": {213 "fastest": fees.get("fastestFee", 0),214 "half_hour": fees.get("halfHourFee", 0),215 "hour": fees.get("hourFee", 0),216 "economy": fees.get("economyFee", 0),217 "minimum": fees.get("minimumFee", 0),218 },219 "mempool": {220 "tx_count": mempool.get("count", 0) if mempool else 0,221 "vsize_mb": round(mempool.get("vsize", 0) / 1e6, 1)222 if mempool223 else 0,224 "total_fee_btc": round(mempool.get("total_fee", 0) / 1e8, 4)225 if mempool226 else 0,227 },228 "source": "mempool.space",229 }230 231 except HTTPException:232 raise233 except Exception as e:234 logger.error(f"Current fees error: {e}")235 raise HTTPException(status_code=500, detail="Error fetching current fees")236 237 238@app.get("/mempool/blocks", tags=["Mempool"])239async def get_mempool_blocks():240 """📦 Projected mempool blocks with fee ranges"""241 try:242 blocks = ingestion.fetch_mempool_blocks()243 if blocks is None:244 raise HTTPException(245 status_code=503, detail="Could not fetch mempool blocks"246 )247 248 return {249 "timestamp": datetime.now(timezone.utc).isoformat(),250 "projected_blocks": blocks,251 "n_blocks": len(blocks),252 }253 except HTTPException:254 raise255 except Exception as e:256 raise HTTPException(status_code=500, detail="Error fetching mempool blocks")257 258 259@app.get("/models", tags=["Models"])260async def list_models():261 """🧠 List loaded model information"""262 if inference:263 return inference.get_loaded_models_info()264 return {"error": "Models not loaded"}265 266 267@app.get("/model-metadata", tags=["Models"])268async def get_model_metadata():269 """270 📋 Comprehensive model metadata: version, training info, performance metrics.271 """272 try:273 import json274 275 metadata = {276 "model_version": "3.0.0",277 "api_version": "3.0.0",278 "framework": "XGBoost 2.1 + LightGBM 4.5 Ensemble",279 "horizons_supported": [1, 3, 6],280 "timestamp": datetime.now(timezone.utc).isoformat(),281 "deployment": "Hugging Face Spaces (Docker)",282 "models": {},283 }284 285 for horizon in [1, 3, 6]:286 model_info = {287 "horizon_blocks": horizon,288 "loaded": False,289 "xgboost": None,290 "lightgbm": None,291 "metrics": {},292 }293 294 # XGBoost model295 xgb_path = Path(f"models/production/xgb_fee_{horizon}block.json")296 if xgb_path.exists():297 model_info["xgboost"] = {298 "loaded": True,299 "file_size_kb": round(xgb_path.stat().st_size / 1024, 2),300 "last_modified": datetime.fromtimestamp(301 xgb_path.stat().st_mtime302 ).isoformat(),303 }304 model_info["loaded"] = True305 306 # LightGBM model307 lgb_path = Path(f"models/production/lgbm_fee_{horizon}block.txt")308 if lgb_path.exists():309 model_info["lightgbm"] = {310 "loaded": True,311 "file_size_kb": round(lgb_path.stat().st_size / 1024, 2),312 "last_modified": datetime.fromtimestamp(313 lgb_path.stat().st_mtime314 ).isoformat(),315 }316 model_info["loaded"] = True317 318 # Production meta metrics319 meta_path = Path(f"models/production/meta_fee_{horizon}block.json")320 if meta_path.exists():321 try:322 with open(meta_path) as f:323 meta = json.load(f)324 model_info["metrics"] = {325 "xgb": meta.get("xgb_metrics", {}),326 "lgb": meta.get("lgb_metrics", {}),327 }328 except Exception:329 pass330 331 metadata["models"][f"{horizon}_block"] = model_info332 333 # System status334 loaded = inference.get_loaded_models_info() if inference else {}335 metadata["system_status"] = {336 "total_models_loaded": loaded.get("total_models", 0),337 "xgb_models": loaded.get("xgb_models", []),338 "lgb_models": loaded.get("lgb_models", []),339 }340 341 return metadata342 343 except Exception as e:344 logger.error(f"Metadata error: {e}")345 raise HTTPException(status_code=500, detail="Error fetching model metadata")346 347 348# ============================================================================349# ERROR HANDLERS350# ============================================================================351 352@app.exception_handler(HTTPException)353async def http_exception_handler(request, exc):354 return JSONResponse(355 status_code=exc.status_code,356 content={357 "error": exc.detail,358 "timestamp": datetime.now(timezone.utc).isoformat(),359 },360 )361 362 363@app.exception_handler(Exception)364async def general_exception_handler(request, exc):365 logger.error(f"Unhandled exception: {exc}", exc_info=True)366 return JSONResponse(367 status_code=500,368 content={369 "error": "Internal server error",370 "timestamp": datetime.now(timezone.utc).isoformat(),371 },372 )373 374 375# ============================================================================376# RUN377# ============================================================================378 379if __name__ == "__main__":380 import uvicorn381 382 uvicorn.run(383 "app:app",384 host="0.0.0.0",385 port=int(os.getenv("PORT", "7860")),386 log_level="info",387 )388 