ipl_prediction_engine / backend /api_server.py
Biswajeet Arukha
feat(sre): add randomized user-agent impersonation and psutil memory tracking
2c5c631
Raw History Blame Contribute Delete
32.5 kB
"""
IPL Prediction Engine β€” API Server (v3.0 "Never Empty")
=========================================================
Self-sufficient FastAPI server with:
- MultiSourceFetcher waterfall pipeline (5-source cascading)
- In-memory TTL cache primary, Redis optional secondary
- /matches: ALWAYS returns data (never empty)
- /api/data-health: Real-time status of all 5 scrapers
- /api/offline-sync: Pre-loads 2026 schedule for offline UI
- All existing endpoints preserved (predict, simulate, fantasy, etc.)
"""
import os
import json
import time
import asyncio
import logging
import random
import numpy as np
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from typing import List, Dict, Optional, Set
from pydantic import BaseModel
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from contextlib import asynccontextmanager
import psutil
def get_memory_usage():
process = psutil.Process(os.getpid())
return process.memory_info().rss / 1024 / 1024 # Returns MB
from backend.config import settings
# Logging configuration
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
# ── Custom JSON Encoder for NumPy types ──────────────────────────────────────
class NumPyEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, np.integer):
return int(obj)
if isinstance(obj, np.floating):
return float(obj)
if isinstance(obj, np.ndarray):
return obj.tolist()
return super(NumPyEncoder, self).default(obj)
# ═══════════════════════════════════════════════════════════════════════════════
# Lifespan
# ═══════════════════════════════════════════════════════════════════════════════
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup and shutdown lifecycle with graceful degradation."""
global predictor, db_manager, betting_engine
predictor = None
db_manager = None
betting_engine = None
try:
logger.info("πŸš€ Starting IPL Prediction Engine v3.0 (Never Empty)...")
# 0. Initialize Data Structures
try:
from backend.ml_engine.analyze_recent_matches import ensure_base_csvs_exist
ensure_base_csvs_exist()
logger.info("βœ… Base data structures initialized")
except Exception as e:
logger.warning(f"⚠️ Failed to auto-initialize base data: {e}")
# 1. Initialize MultiSourceFetcher (the core waterfall pipeline)
try:
from backend.data_pipeline.multi_source_fetcher import get_fetcher
fetcher = get_fetcher()
app.state.fetcher = fetcher
logger.info(f"βœ… MultiSourceFetcher initialized ({len(fetcher.get_static_schedule())} static matches)")
except Exception as e:
logger.error(f"❌ MultiSourceFetcher init failed: {e}")
# 2. Database (optional β€” degrades gracefully)
try:
from backend.infrastructure.db_manager import DatabaseManager
db_manager = DatabaseManager()
await db_manager.connect()
await db_manager.initialize_schema()
app.state.db = db_manager
logger.info("βœ… Database connected")
except Exception as e:
logger.warning(f"⚠️ Database unavailable (running without persistence): {e}")
db_manager = None
# 3. Redis (optional β€” degrades gracefully)
redis_client = None
if settings.REDIS_ENABLED:
try:
import redis
app.state.redis_pool = redis.ConnectionPool(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DB,
password=settings.REDIS_PASSWORD,
decode_responses=True,
)
redis_client = redis.Redis(connection_pool=app.state.redis_pool)
redis_client.ping()
logger.info("βœ… Redis connected")
except Exception as e:
logger.warning(f"⚠️ Redis unavailable (running in memory-only mode): {e}")
app.state.redis_pool = None
redis_client = None
else:
app.state.redis_pool = None
logger.info("ℹ️ Redis disabled by config")
# 4. ML Engine
try:
from backend.ml_engine.hybrid_model import RealTimePredictor
predictor = RealTimePredictor(repo_id=settings.HF_REPO_ID)
logger.info(f"βœ… ML Engine loaded from {settings.HF_REPO_ID}")
except Exception as e:
logger.warning(f"⚠️ ML Engine failed to load (predictions unavailable): {e}")
predictor = None
# 5. Betting Engine
try:
from backend.api.betting_engine import BettingEngine
betting_engine = BettingEngine(bookmaker_margin=settings.BOOKMAKER_MARGIN)
logger.info("βœ… Betting Engine initialized")
except Exception as e:
logger.warning(f"⚠️ Betting Engine failed: {e}")
betting_engine = None
# 6. Background Match Discovery (uses MultiSourceFetcher)
app.state.discovery_task = asyncio.create_task(
run_discovery_loop(app), name="match_discovery"
)
except Exception as e:
logger.error(f"❌ Critical startup failure: {e}")
yield
# Shutdown
logger.info("Shutting down...")
if hasattr(app.state, "discovery_task"):
app.state.discovery_task.cancel()
if hasattr(app.state, "redis_pool") and app.state.redis_pool:
app.state.redis_pool.disconnect()
if db_manager:
await db_manager.close()
app = FastAPI(
title="IPL Prediction Engine API",
description="Real-time match win probability engine with XGBoost + LSTM + Transformer",
version="3.0.0",
lifespan=lifespan,
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Static Dashboard
os.makedirs("backend/static", exist_ok=True)
app.mount("/static", StaticFiles(directory="backend/static"), name="static")
# Include sub-routers
from backend.api.stats_router import router as stats_router
app.include_router(stats_router)
# ═══════════════════════════════════════════════════════════════════════════════
# Global State
# ═══════════════════════════════════════════════════════════════════════════════
predictor = None
db_manager = None
betting_engine = None
active_scrapers: Dict[str, asyncio.Task] = {}
# ── Helper: Get Redis client safely ──────────────────────────────────────────
def get_redis():
"""Get a Redis client or None if unavailable."""
try:
import redis
pool = getattr(app.state, "redis_pool", None)
if pool:
r = redis.Redis(connection_pool=pool)
r.ping()
return r
except Exception:
pass
return None
# ── Helper: Get MultiSourceFetcher ───────────────────────────────────────────
def get_fetcher():
"""Get the global MultiSourceFetcher singleton."""
from backend.data_pipeline.multi_source_fetcher import get_fetcher as _get
return _get()
# ── Agent Simulator (lazy init) ──────────────────────────────────────────────
_agent_swarm = None
def get_agent_swarm():
global _agent_swarm
if _agent_swarm is None:
try:
from backend.ml_engine.agent_sim import MultiAgentSimulator
_agent_swarm = MultiAgentSimulator(num_agents=10)
except Exception:
_agent_swarm = None
return _agent_swarm
# ── Scenario Simulator (lazy init) ───────────────────────────────────────────
_scenario_sim = None
def get_scenario_sim():
global _scenario_sim
if _scenario_sim is None:
try:
from backend.ml_engine.simulators import ScenarioSimulator
_scenario_sim = ScenarioSimulator()
except Exception:
_scenario_sim = None
return _scenario_sim
class SimulationRequest(BaseModel):
num_simulations: int = 1000
overs_to_project: int = 5
captain_policy: str = "aggressive"
class MatchSimulator:
"""Monte Carlo engine for match projection."""
def project_future(self, current_state: Dict, num_sims: int, overs: int, policy: str):
results = []
for _ in range(num_sims):
temp_runs, temp_wickets = 0, 0
for _ in range(overs * 6):
if policy == "aggressive":
outcome = random.choices([0, 1, 2, 4, 6, "W"], weights=[35, 20, 10, 15, 10, 10])[0]
else:
outcome = random.choices([0, 1, 2, 4, 6, "W"], weights=[50, 25, 10, 5, 5, 5])[0]
if outcome == "W":
temp_wickets += 1
if temp_wickets + current_state.get("total_wickets", 0) >= 10:
break
else:
temp_runs += outcome
results.append((temp_runs, temp_wickets))
runs_dist = [r for r, w in results]
return {
"projected_runs_avg": float(np.mean(runs_dist)),
"projected_wickets_avg": float(np.mean([w for r, w in results])),
"confidence_interval": [float(np.percentile(runs_dist, 5)), float(np.percentile(runs_dist, 95))],
}
match_sim = MatchSimulator()
# ═══════════════════════════════════════════════════════════════════════════════
# Background Match Discovery
# ═══════════════════════════════════════════════════════════════════════════════
async def run_discovery_loop(app: FastAPI):
"""Periodically discover live IPL matches using the 5-source waterfall."""
await asyncio.sleep(3) # Let startup finish
while True:
try:
fetcher = get_fetcher()
all_matches = await fetcher.discover_matches()
live_matches = [m for m in all_matches if m.get("status") == "live"]
if live_matches:
logger.info(
f"πŸ”Ž Discovery: {len(live_matches)} live matches "
f"(source: {fetcher.last_source_used})"
)
# Start scrapers for live matches if Redis available
r = get_redis()
for m in live_matches:
m_id = m.get("match_id", "")
if m_id and m_id not in active_scrapers and r:
try:
from backend.data_pipeline.espn_scraper import ESPNCricinfoScraper
scraper = ESPNCricinfoScraper()
url = m.get("url", "")
if url:
task = asyncio.create_task(scraper.start_polling(m_id, url))
active_scrapers[m_id] = task
logger.info(f"πŸ”₯ Auto-started scraper for: {m.get('teams', m_id)}")
except Exception as scraper_err:
logger.warning(f"Scraper start failed for {m_id}: {scraper_err}")
# Adaptive polling: faster when live, slower when idle
sleep_time = 120 if live_matches else settings.DISCOVERY_POLL_INTERVAL
# Prune dead scrapers (BUG 5 FIX)
dead_scrapers = [m_id for m_id, task in active_scrapers.items() if task.done()]
for m_id in dead_scrapers:
del active_scrapers[m_id]
logger.debug(f"🧹 Cleaned up finished scraper task for {m_id}")
# If no live matches, check if next match is soon
if not live_matches:
upcoming = [m for m in all_matches if m.get("status") == "scheduled"]
upcoming.sort(key=lambda m: m.get("start_epoch", 0) or float("inf"))
if upcoming:
next_epoch = upcoming[0].get("start_epoch", 0)
if next_epoch > 0:
time_until = next_epoch - time.time()
if time_until > 0 and time_until < 1800:
sleep_time = 60 # Within 30 min, poll every minute
elif time_until > 3600:
sleep_time = min(time_until - 600, 3600) # Sleep up to 1 hour
await asyncio.sleep(sleep_time)
except asyncio.CancelledError:
logger.info("Discovery loop cancelled")
return
except Exception as e:
logger.error(f"Discovery loop error: {e}")
await asyncio.sleep(settings.DISCOVERY_POLL_INTERVAL)
# ═══════════════════════════════════════════════════════════════════════════════
# Routes
# ═══════════════════════════════════════════════════════════════════════════════
@app.get("/")
async def serve_dashboard():
return FileResponse("backend/static/index.html")
@app.get("/pro")
async def serve_pro():
return FileResponse("backend/static/index.html")
# ── Health Endpoint ──────────────────────────────────────────────────────────
@app.get("/health")
async def health_check():
r = get_redis()
fetcher = get_fetcher()
health = fetcher.get_source_health()
return {
"status": "healthy" if predictor else "degraded",
"timestamp": time.time(),
"components": {
"ml_engine": "loaded" if predictor else "unavailable",
"redis": "connected" if r else "unavailable",
"database": "connected" if db_manager else "unavailable",
"betting_engine": "loaded" if betting_engine else "unavailable",
"active_scrapers": len(active_scrapers),
"data_sources": f"{health['active_sources']}/{health['total_sources']} active",
},
}
# ── Matches Endpoint (THE CORE β€” NEVER EMPTY) ───────────────────────────────
@app.get("/matches")
async def list_matches():
"""
List all matches. Uses the 5-source waterfall pipeline.
GUARANTEE: This endpoint NEVER returns an empty list.
"""
fetcher = get_fetcher()
matches = await fetcher.discover_matches()
# Enrich with live data from Redis if available
r = get_redis()
if r:
try:
keys = r.keys("active:match:*")
for key in keys:
data = r.hgetall(key)
if not data:
continue
m_id = data.get("match_id", key.split(":")[-1])
teams_str = data.get("teams", "")
# Find matching entry in our results
match_updated = False
for i, m in enumerate(matches):
m_teams_str = " vs ".join(m.get("teams", []))
if m.get("match_id") == m_id or m_teams_str == teams_str:
# Update with live Redis data
if data.get("status") == "live":
matches[i]["status"] = "live"
if data.get("score") and data.get("score") != "β€”":
matches[i]["score"] = data["score"]
if data.get("over") and data.get("over") != "0.0":
try:
matches[i]["over"] = float(data["over"])
except ValueError:
pass
match_updated = True
break
if not match_updated and teams_str:
# New match from Redis not in our list
teams_list = teams_str.split(" vs ")
matches.append({
"match_id": m_id,
"teams": teams_list if len(teams_list) == 2 else ["TBD", "TBD"],
"team_short": [],
"status": data.get("status", "live"),
"score": data.get("score", "β€”"),
"over": float(data.get("over", 0.0)),
"venue": data.get("venue", "TBD"),
"win_probability": 0.5,
"source": "redis",
})
except Exception as e:
logger.warning(f"Redis merge (non-critical): {e}")
# Enrich with ML predictions
if predictor:
for m in matches:
if m.get("win_probability") == 0.5 and len(m.get("teams", [])) == 2:
try:
pred = predictor.model.predict_pre_match(
m["teams"][0], m["teams"][1], m.get("venue", "Unknown")
)
m["win_probability"] = float(pred.get("win_probability", 0.5))
except Exception:
pass
return matches
# ── Data Health Endpoint ─────────────────────────────────────────────────────
@app.get("/api/data-health")
async def data_health():
"""Return real-time status of all 5 data sources (for the status bar UI)."""
fetcher = get_fetcher()
health = fetcher.get_source_health()
r = get_redis()
health["redis_connected"] = r is not None
health["ml_engine_loaded"] = predictor is not None
health["server_uptime"] = time.time()
health["memory_usage_mb"] = round(get_memory_usage(), 2)
return health
# ── Offline Sync Endpoint ────────────────────────────────────────────────────
@app.get("/api/offline-sync")
async def offline_sync():
"""
Returns the complete 2026 IPL schedule + points table for offline mode.
The frontend can cache this in localStorage for zero-internet operation.
"""
# Try loading the full schedule.json
json_paths = [
os.path.join(os.path.dirname(__file__), "data_pipeline", "schedule.json"),
os.path.join(os.getcwd(), "backend", "data_pipeline", "schedule.json"),
]
for p in json_paths:
if os.path.exists(p):
try:
with open(p, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
logger.warning(f"schedule.json parse error: {e}")
# Fallback: build from fetcher
fetcher = get_fetcher()
schedule = fetcher.get_static_schedule()
return {
"season": "IPL 2026",
"total_matches": len(schedule),
"schedule": schedule,
"points_table": _get_points_table(),
}
# ── Upcoming Matches ─────────────────────────────────────────────────────────
@app.get("/upcoming/{season}")
async def get_upcoming_matches(season: str):
"""Fetch upcoming schedule from the MultiSourceFetcher."""
fetcher = get_fetcher()
upcoming = await fetcher.get_upcoming(limit=20)
return {"matchschedule": upcoming}
# ── Points Table ─────────────────────────────────────────────────────────────
@app.get("/points/{season}")
async def get_points_table(season: str):
"""Fetch Points Table. Returns base template for 2026."""
return {"points": _get_points_table()}
def _get_points_table() -> List[Dict]:
"""Generate the 10-team points table."""
teams = [
("Chennai Super Kings", "CSK"),
("Delhi Capitals", "DC"),
("Gujarat Titans", "GT"),
("Kolkata Knight Riders", "KKR"),
("Lucknow Super Giants", "LSG"),
("Mumbai Indians", "MI"),
("Punjab Kings", "PBKS"),
("Rajasthan Royals", "RR"),
("Royal Challengers Bengaluru", "RCB"),
("Sunrisers Hyderabad", "SRH"),
]
return [
{
"name": name,
"teamshortname": short,
"matchesplayed": 0,
"matcheswon": 0,
"matcheslost": 0,
"nr": 0,
"points": 0,
"nrr": "+0.000",
}
for name, short in teams
]
# ── Debug Endpoint ───────────────────────────────────────────────────────────
@app.get("/debug/env")
async def debug_env():
"""Diagnostics for production filesystem and memory."""
import sys
fetcher = get_fetcher()
health = fetcher.get_source_health()
return {
"cwd": os.getcwd(),
"file": __file__,
"sys_path": sys.path[:5],
"fetcher_cache_size": health["cache_size"],
"static_schedule_count": len(fetcher.get_static_schedule()),
"active_sources": f"{health['active_sources']}/{health['total_sources']}",
"last_source": health["last_source_used"],
}
# ═══════════════════════════════════════════════════════════════════════════════
# Prediction & Simulation Routes (Preserved from v2)
# ═══════════════════════════════════════════════════════════════════════════════
@app.get("/predict/{match_id}")
async def get_prediction(match_id: str):
"""Fetch latest win probability with uncertainty intervals, SHAP factors, and betting odds."""
if not predictor:
raise HTTPException(status_code=503, detail="ML Engine not initialized")
try:
prediction = predictor.predict_live_match(match_id)
if "error" in prediction:
raise HTTPException(status_code=404, detail=prediction["error"])
# ── Enrich with Scenario Simulator ───────────────────────────────────
scenario_sim = get_scenario_sim()
if scenario_sim:
try:
scenario = scenario_sim.simulate_remaining_balls(
current_state=prediction, n_simulations=2000
)
prediction["scenario"] = scenario
prediction["prob_180_plus"] = scenario.get("prob_180_plus", 0.0)
prediction["projected_score_p90"] = scenario.get("p90_score", 0.0)
except Exception as sim_err:
logger.warning(f"Scenario simulator skipped: {sim_err}")
# ── Enrich with Agent Swarm ──────────────────────────────────────────
swarm = get_agent_swarm()
if swarm:
try:
agent_features = swarm.simulate(prediction)
prediction.update(agent_features)
except Exception as swarm_err:
logger.warning(f"Agent swarm skipped: {swarm_err}")
# ── Enrich with Betting Odds ─────────────────────────────────────────
if betting_engine:
r = get_redis()
teams = ["Team A", "Team B"]
if r:
last_ball = r.xrevrange(f"ipl:balls:{match_id}", count=1)
if last_ball:
d = last_ball[0][1]
teams = [d.get("batting_team", "Team A"), d.get("bowling_team", "Team B")]
odds_data = betting_engine.generate_match_odds(prediction, teams[0], teams[1], match_id)
prediction["betting"] = odds_data.to_dict()
prediction["model_accuracy"] = odds_data.model_accuracy
# ── Persist asynchronously ───────────────────────────────────────────
if db_manager:
asyncio.create_task(db_manager.save_prediction(prediction))
return prediction
except HTTPException:
raise
except Exception as e:
logger.error(f"Prediction error for {match_id}: {e}")
raise HTTPException(status_code=500, detail="Internal inference error")
@app.get("/simulate/scenario/{match_id}")
async def run_scenario_simulation(match_id: str, n: int = 5000):
"""Run detailed Monte Carlo scenario simulation for remaining balls."""
r = get_redis()
if not r:
raise HTTPException(status_code=503, detail="Redis unavailable for live data")
last_ball = r.xrevrange(f"ipl:balls:{match_id}", count=1)
if not last_ball:
raise HTTPException(status_code=404, detail="No match data found")
d = last_ball[0][1]
current_state = {
"total_runs": int(d.get("total_runs", 0) or 0),
"total_wickets": int(d.get("total_wickets", 0) or 0),
"balls_remaining": max(0, 120 - int(float(d.get("over", 0)) * 6)),
}
sim = get_scenario_sim()
if not sim:
raise HTTPException(status_code=503, detail="Simulator unavailable")
result = sim.simulate_remaining_balls(current_state, n_simulations=min(n, 10000))
return {"match_id": match_id, "current_state": current_state, "simulation": result}
@app.get("/fantasy/{player_name}")
async def get_fantasy_projection(player_name: str, ownership: float = 0.5, role: str = "batsman"):
"""Get Bayesian fantasy points projection for a player."""
try:
from backend.ml_engine.simulators import BayesianPlayerPredictor, FantasyEngine
from backend.ml_engine.hybrid_model import CricsheetNormalizer
normalizer = CricsheetNormalizer()
normalizer.load_player_stats()
bat_history, wkt_history = [], []
if normalizer.player_stats:
bat_data = normalizer.player_stats.get("batsmen", {}).get(player_name, {})
bowl_data = normalizer.player_stats.get("bowlers", {}).get(player_name, {})
if bat_data.get("average"):
avg = bat_data["average"]
bat_history = [max(0, avg + np.random.normal(0, avg * 0.3)) for _ in range(5)]
if bowl_data.get("average"):
wkt_history = [max(0, np.random.normal(1.2, 0.5)) for _ in range(5)]
predictor_b = BayesianPlayerPredictor()
run_proj = predictor_b.predict_player_runs(bat_history, opp_strength=1.0)
wkt_proj = predictor_b.predict_player_wickets(wkt_history)
projection = {**run_proj, **wkt_proj}
fantasy = FantasyEngine()
fantasy_pts = fantasy.calculate_expected_points(
player_projection=projection,
role=role,
ownership_pct=float(ownership),
)
return {
"player": player_name,
"role": role,
"ownership_pct": round(ownership * 100, 1),
"batting_projection": run_proj,
"bowling_projection": wkt_proj,
"fantasy": fantasy_pts,
}
except Exception as e:
logger.error(f"Fantasy projection error: {e}")
raise HTTPException(status_code=500, detail="Fantasy projection failed")
@app.post("/simulate/{match_id}")
async def run_what_if_simulation(match_id: str, request: SimulationRequest):
if not predictor:
raise HTTPException(status_code=503, detail="ML Engine not initialized")
prediction = predictor.predict_live_match(match_id)
if "error" in prediction:
raise HTTPException(status_code=404, detail="No live data to simulate from")
projection = match_sim.project_future(
prediction, request.num_simulations, request.overs_to_project, request.captain_policy
)
return {"match_id": match_id, "current_state": prediction, "projection": projection}
# ═══════════════════════════════════════════════════════════════════════════════
# WebSocket
# ═══════════════════════════════════════════════════════════════════════════════
@app.websocket("/ws/predictions/{match_id}")
async def websocket_prediction(websocket: WebSocket, match_id: str):
"""Real-time prediction stream via WebSocket."""
await websocket.accept()
logger.info(f"WebSocket connected for match: {match_id}")
r = None
pubsub = None
try:
r = get_redis()
if r:
pubsub = r.pubsub()
pubsub.subscribe(f"ipl:predictions:{match_id}")
# Initial push
if predictor:
try:
initial = predictor.predict_live_match(match_id)
if "error" not in initial:
swarm = get_agent_swarm()
if swarm:
initial.update(swarm.simulate(initial))
await websocket.send_json(initial)
except Exception as e:
logger.warning(f"Initial prediction failed: {e}")
while True:
if pubsub:
message = pubsub.get_message(ignore_subscribe_messages=True)
if message:
try:
data = json.loads(message["data"])
await websocket.send_json(data)
except (json.JSONDecodeError, TypeError):
pass
await asyncio.sleep(0.5)
except WebSocketDisconnect:
logger.info(f"WebSocket disconnected for {match_id}")
except Exception as e:
logger.error(f"WebSocket error: {e}")
finally:
if pubsub:
try:
pubsub.unsubscribe()
pubsub.close()
except Exception:
pass
if r:
try:
r.close()
except Exception:
pass
try:
await websocket.close()
except RuntimeError:
pass
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host=settings.APP_HOST, port=settings.APP_PORT)