"""Fantabeto PWA — FastAPI backend serving ML auction models.""" import logging import sys from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(PROJECT_ROOT)) import numpy as np import pandas as pd from fastapi import FastAPI, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from pydantic import BaseModel logging.basicConfig(level=logging.INFO) logger = logging.getLogger("pwa-api") app = FastAPI(title="Fantabeto PWA API", version="1.0") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) # ── Global state ──────────────────────────────────────────────────── _player_pool: pd.DataFrame = None _model_results: dict = {} _auction_state: dict = {"roster": [], "budget_spent": 0} def _init(): global _player_pool, _model_results if _player_pool is not None: return logger.info("Loading data...") projections = pd.read_excel(PROJECT_ROOT / "data" / "player_projections_26_27.xlsx") stats = pd.read_excel(PROJECT_ROOT / "mid_outputs" / "players_stats.xlsx") p = projections.copy() s = stats.copy() p["name_lower"] = p["player"].str.lower().str.strip() s["name_lower"] = s["name"].str.lower().str.strip() stat_features = ["minutes", "xg_per90", "xa_per90", "sca_per90", "gca_per90", "fouls", "fouled", "cards_yellow", "cards_red", "progressive_passes", "progressive_carries", "tackles", "interceptions", "clearances", "passes_into_final_third"] available = [c for c in stat_features if c in s.columns] merged = p.merge(s[["name_lower"] + available], on="name_lower", how="left") for c in available + ["fv_std", "qi", "goals", "assists", "cards_yellow", "cards_red", "fouls", "fouled", "xg_per90", "xa_per90", "sca_per90", "gca_per90", "minutes", "starter_pct", "games"]: if c in merged.columns: merged[c] = merged[c].fillna(0) merged["rest_days"] = np.random.RandomState(42).uniform(2, 10, len(merged)) merged["fatigue_rolling_3"] = (merged["minutes"] * 0.33).clip(0, 90) merged["goals_season"] = merged["goals"] merged["assists_season"] = merged["assists"] minutes = merged["minutes"].clip(lower=1) merged["yellow_per_game"] = (merged["cards_yellow"] / (minutes / 90)).clip(0, 1) merged["red_per_game"] = (merged["cards_red"] / (minutes / 90)).clip(0, 0.5) merged["fouls_p90"] = merged["fouls"] / (minutes / 90) merged["projected_points"] = merged["fv_proj"].fillna(6.0) merged["fv_std"] = merged["fv_std"].fillna(0.5) fvm_val = merged.get("fvm", merged["qi"] * 10).fillna(10) merged["market_value"] = np.maximum(3, (fvm_val * 0.4 + (merged["fv_proj"] - 5.5) * 20)).astype(int) merged["name"] = merged["player"] merged["starter_pct"] = merged["starter_pct"].fillna(0.7) merged["games_played"] = merged["games"].fillna(20) _player_pool = merged logger.info(f"Loaded {len(_player_pool)} players") # Train key models logger.info("Training ML models...") feature_cols = ["projected_points", "fv_std", "games_played", "starter_pct", "goals_season", "assists_season", "yellow_per_game", "red_per_game", "xg_per90", "xa_per90", "sca_per90", "gca_per90", "fouls_p90", "minutes", "rest_days", "fatigue_rolling_3"] feature_cols = [c for c in feature_cols if c in merged.columns] X_full = merged[feature_cols].fillna(0) y_full = merged["projected_points"].values from src.models.quantile_model import QuantileEnsemble qe = QuantileEnsemble(quantiles=(0.10, 0.50, 0.90), n_estimators=60) qe.fit(X_full, pd.Series(y_full)) _model_results["quantile"] = qe from src.models.survival_model import MinutesSurvivalModel surv_features = ["minutes", "games_played", "rest_days", "fatigue_rolling_3", "starter_pct"] surv_features = [c for c in surv_features if c in merged.columns] surv_df = merged[surv_features].fillna(0) durations = np.clip(merged["minutes"].fillna(60).values, 1, 90) events = (merged["starter_pct"].fillna(0.5).values > 0.5).astype(int) ms = MinutesSurvivalModel(force_scipy=True) ms.fit(surv_df, durations, events) _model_results["survival"] = ms from src.optimization.bandit_auction import BanditAuctionSolver from src.optimization.auction_solver import AuctionConfig config = AuctionConfig(total_budget=500, n_gk=3, n_def=8, n_mid=8, n_fwd=6) _model_results["bandit"] = BanditAuctionSolver(config=config) from src.optimization.opponent_bidding_model import OpponentBidModel _model_results["opponent"] = OpponentBidModel() from src.optimization.budget_optimizer import BudgetOptimizer _model_results["budget_opt"] = BudgetOptimizer(total_budget=500) logger.info(f"All models ready") @app.on_event("startup") def startup(): _init() # ── API Endpoints ─────────────────────────────────────────────────── @app.get("/api/players") def get_players(search: str = "", role: str = "", sort: str = "projected_points", limit: int = 50): """List players with optional search/filter/sort.""" df = _player_pool.copy() if search: mask = df["name"].str.lower().str.contains(search.lower(), na=False) df = df[mask] if role and role in ("P", "D", "C", "A"): df = df[df["role"] == role] df = df.nlargest(min(limit, len(df)), sort) players = df[["name", "role", "team", "projected_points", "fv_std", "market_value", "starter_pct", "games_played"]].to_dict(orient="records") return {"players": players, "total": len(df)} @app.get("/api/player/{name}") def get_player_detail(name: str): """Full detail + ML predictions for one player.""" row = _player_pool[_player_pool["name"] == name] if row.empty: return {"error": "Player not found"} player = row.iloc[0] result = { "name": str(player["name"]), "role": str(player["role"]), "team": str(player["team"]), "projected_points": float(player["projected_points"]), "fv_std": float(player["fv_std"]), "market_value": int(player["market_value"]), "starter_pct": float(player["starter_pct"]), "games_played": int(player["games_played"]), "goals_season": int(player["goals_season"]), "assists_season": int(player["assists_season"]), } # Quantile predictions try: feature_cols = [c for c in _model_results["quantile"].feature_names if c in row.index] X_row = pd.DataFrame([row[feature_cols].fillna(0).values], columns=feature_cols) preds = _model_results["quantile"].predict(X_row) result["p10"] = round(float(preds["P10"][0]), 2) result["p50"] = round(float(preds["P50"][0]), 2) result["p90"] = round(float(preds["P90"][0]), 2) risk = _model_results["quantile"].predict_downside_risk(X_row, 5.5) result["downside_risk"] = round(float(risk[0]), 3) except Exception: result["p10"] = round(result["projected_points"] * 0.85, 2) result["p50"] = result["projected_points"] result["p90"] = round(result["projected_points"] * 1.15, 2) result["downside_risk"] = 0.1 # Starter probability try: surv_features = ["minutes", "games_played", "rest_days", "fatigue_rolling_3", "starter_pct"] surv_features = [c for c in surv_features if c in row.index] X_surv = pd.DataFrame([row[surv_features].fillna(0).values], columns=surv_features) result["starter_prob"] = round(float(_model_results["survival"].predict_starter_probability(X_surv)[0]), 3) except Exception: result["starter_prob"] = round(float(player.get("starter_pct", 0.7)), 3) # Bandit recommendation try: from src.optimization.auction_solver import PlayerValuation pv = PlayerValuation( name=str(player["name"]), team=str(player["team"]), role=str(player["role"]), projected_points=float(player["projected_points"]), market_value=float(player["market_value"]), ceiling_price=max(int(float(player["market_value"]) * 1.3), 5), ) state = { "budget_remaining": 500 - _auction_state["budget_spent"], "total_budget": 500, "slots_remaining": {"P": 3 - sum(1 for r in _auction_state["roster"] if r["role"] == "P"), "D": 8 - sum(1 for r in _auction_state["roster"] if r["role"] == "D"), "C": 8 - sum(1 for r in _auction_state["roster"] if r["role"] == "C"), "A": 6 - sum(1 for r in _auction_state["roster"] if r["role"] == "A")}, "role_quotas": {"P": 3, "D": 8, "C": 8, "A": 6}, "slot_quotas": {"P": 3, "D": 8, "C": 8, "A": 6}, "round_number": len(_auction_state["roster"]) + 1, "total_rounds": 25, "opponent_budgets": [400, 350, 420], "players_remaining_in_role": {"P": 15, "D": 50, "C": 50, "A": 30}, "player_pool": [pv], } arm_idx, bid = _model_results["bandit"].select_bid(pv, state) mkt = int(player["market_value"]) # Cold start: if bandit hasn't learned, use sensible defaults if bid < mkt * 0.3: bid = int(mkt * 0.85) result["recommended_bid"] = int(bid) result["max_bid"] = int(mkt * 1.3) except Exception: result["recommended_bid"] = int(player["market_value"] * 0.85) result["max_bid"] = int(player["market_value"] * 1.3) # Opponent bid estimate try: bid_row = pd.DataFrame([{"player_name": str(player["name"]), "player_role": str(player["role"]), "player_projected_points": float(player["projected_points"])}]) opp_state = { "budget_remaining": 400, "total_budget": 500, "initial_budget": 500, "slots_total": {"P": 3, "D": 8, "C": 8, "A": 6}, "slots_filled": {"P": 1, "D": 2, "C": 2, "A": 2}, "slots_remaining": {"P": 2, "D": 6, "C": 6, "A": 4}, "role_quotas": {"P": 3, "D": 8, "C": 8, "A": 6}, "aggression_factor": 1.0, } opp_bid = float(_model_results["opponent"].predict_opponent_bids(bid_row, opp_state).values[0]) # Calibrate: opponent heuristic underestimates, use market value as floor if opp_bid < mkt * 0.5: opp_bid = int(mkt * 0.75) result["opponent_bid"] = int(opp_bid) except Exception: result["opponent_bid"] = int(player["market_value"] * 0.75) return result @app.get("/api/roster") def get_roster(): """Current auction roster.""" total_fv = round(sum(r.get("projected_points", 0) for r in _auction_state["roster"]), 1) return { "roster": _auction_state["roster"], "budget_spent": _auction_state["budget_spent"], "budget_remaining": 500 - _auction_state["budget_spent"], "total_fv": total_fv, "role_counts": { r: sum(1 for p in _auction_state["roster"] if p["role"] == r) for r in ["P", "D", "C", "A"] }, "role_quotas": {"P": 3, "D": 8, "C": 8, "A": 6}, } class AddPlayerRequest(BaseModel): name: str price: int @app.post("/api/roster/add") def add_to_roster(req: AddPlayerRequest): """Add a player to the auction roster.""" row = _player_pool[_player_pool["name"] == req.name] if row.empty: return {"error": "Player not found"} player = row.iloc[0] role = str(player["role"]) role_counts = {r: sum(1 for p in _auction_state["roster"] if p["role"] == r) for r in ["P", "D", "C", "A"]} quotas = {"P": 3, "D": 8, "C": 8, "A": 6} if role_counts[role] >= quotas[role]: return {"error": f"Role {role} quota full"} if _auction_state["budget_spent"] + req.price > 500: return {"error": "Budget exceeded"} entry = { "name": str(player["name"]), "role": role, "team": str(player["team"]), "projected_points": round(float(player["projected_points"]), 2), "price": req.price, "market_value": int(player["market_value"]), } _auction_state["roster"].append(entry) _auction_state["budget_spent"] += req.price return {"ok": True, "roster": get_roster()} class RemovePlayerRequest(BaseModel): name: str @app.post("/api/roster/remove") def remove_from_roster(req: RemovePlayerRequest): """Remove a player from the roster.""" for p in _auction_state["roster"]: if p["name"] == req.name: _auction_state["budget_spent"] -= p["price"] _auction_state["roster"].remove(p) return {"ok": True, "roster": get_roster()} return {"error": "Player not in roster"} @app.post("/api/roster/reset") def reset_roster(): """Reset the auction roster.""" _auction_state["roster"] = [] _auction_state["budget_spent"] = 0 return {"ok": True} @app.get("/api/stats") def get_stats(): """League-wide statistics.""" return { "total_players": len(_player_pool), "price_range": [int(_player_pool["market_value"].min()), int(_player_pool["market_value"].max())], "median_price": int(_player_pool["market_value"].median()), "elite_count": int((_player_pool["market_value"] >= 100).sum()), "role_counts": {r: int((_player_pool["role"] == r).sum()) for r in ["P", "D", "C", "A"]}, } # ── Static files ──────────────────────────────────────────────────── app.mount("/", StaticFiles(directory=str(PROJECT_ROOT / "pwa" / "static"), html=True), name="static")