diff --git a/pwa/api.py b/pwa/api.py new file mode 100644 index 0000000..3fc405f --- /dev/null +++ b/pwa/api.py @@ -0,0 +1,331 @@ +"""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") diff --git a/pwa/static/app.js b/pwa/static/app.js new file mode 100644 index 0000000..e4761e8 --- /dev/null +++ b/pwa/static/app.js @@ -0,0 +1,369 @@ +// Fantabeto PWA — Auction Assistant +const API = "/api"; +const state = { + tab: "auction", + players: [], + roster: [], + budgetSpent: 0, + budgetTotal: 500, + selectedPlayer: null, + search: "", + roleFilter: "", + bidAmount: 0, + online: true, +}; + +// ── Init ──────────────────────────────────────────────────────── +async function init() { + registerSW(); + await loadRoster(); + await searchPlayers(); + await checkHealth(); + setInterval(checkHealth, 30000); +} + +function registerSW() { + if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("/sw.js"); + } +} + +async function api(url, opts = {}) { + try { + const res = await fetch(url, opts); + state.online = true; + updatePing(); + return await res.json(); + } catch (e) { + state.online = false; + updatePing(); + return null; + } +} + +async function checkHealth() { + const r = await api(API + "/stats"); + document.getElementById("ping").className = state.online ? "ping" : "ping offline"; + document.getElementById("ping").textContent = state.online ? "● connected" : "○ offline"; +} + +function updatePing() { + const el = document.getElementById("ping"); + if (!el) return; + el.className = state.online ? "ping" : "ping offline"; + el.textContent = state.online ? "● connected" : "○ offline"; +} + +// ── Search ────────────────────────────────────────────────────── +async function searchPlayers() { + const params = new URLSearchParams({ limit: 60, sort: "projected_points" }); + if (state.search) params.set("search", state.search); + if (state.roleFilter) params.set("role", state.roleFilter); + + const data = await api(API + "/players?" + params); + state.players = data?.players || []; + renderPlayerList(); +} + +// ── Roster ────────────────────────────────────────────────────── +async function loadRoster() { + const data = await api(API + "/roster"); + if (data) { + state.roster = data.roster || []; + state.budgetSpent = data.budget_spent || 0; + } + renderBudget(); + renderRoster(); +} + +async function addToRoster(name, price) { + const data = await api(API + "/roster/add", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, price }), + }); + if (data?.error) { + toast(data.error, "error"); + return false; + } + if (data?.ok) { + state.roster = data.roster.roster; + state.budgetSpent = data.roster.budget_spent; + renderBudget(); + renderRoster(); + renderPlayerList(); + toast(`${name} added · ${price} cr`); + return true; + } + return false; +} + +async function removeFromRoster(name) { + const data = await api(API + "/roster/remove", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + if (data?.ok) { + state.roster = data.roster.roster; + state.budgetSpent = data.roster.budget_spent; + renderBudget(); + renderRoster(); + renderPlayerList(); + toast(`Removed ${name}`); + } +} + +async function resetRoster() { + if (!confirm("Reset entire roster?")) return; + await api(API + "/roster/reset", { method: "POST" }); + state.roster = []; + state.budgetSpent = 0; + renderBudget(); + renderRoster(); + renderPlayerList(); +} + +// ── Player detail ─────────────────────────────────────────────── +async function showPlayer(name) { + const data = await api(API + "/player/" + encodeURIComponent(name)); + if (!data) return; + state.selectedPlayer = data; + state.bidAmount = data.recommended_bid || 0; + renderDetail(); +} + +function closeDetail() { + state.selectedPlayer = null; + renderDetail(); +} + +function setBid(amount) { + state.bidAmount = amount; + document.getElementById("bidInput").value = amount; + document.querySelectorAll(".bid-quick button").forEach((b) => { + const v = parseInt(b.dataset.bid); + b.className = v === amount ? "active" : ""; + }); +} + +function formatBidButtons(player) { + const bids = []; + const rec = player.recommended_bid || 0; + const max = player.max_bid || 0; + const mkt = player.market_value || 0; + if (mkt > 0) bids.push(mkt); + if (rec > 0 && rec !== mkt) bids.push(rec); + if (max > 0 && max !== rec && max !== mkt) bids.push(max); + // Ensure at least 3 options + if (bids.length < 3 && mkt > 10) bids.push(Math.round(mkt * 1.3)); + return [...new Set(bids)].sort((a, b) => a - b); +} + +// ── Render ────────────────────────────────────────────────────── +function renderBudget() { + const spent = state.budgetSpent; + const total = state.budgetTotal; + const pct = Math.min((spent / total) * 100, 100); + + document.getElementById("budgetSpent").textContent = spent; + document.getElementById("budgetRemaining").textContent = total - spent; + document.getElementById("budgetFill").style.width = pct + "%"; + document.getElementById("budgetFill").style.background = + pct > 90 ? "var(--red)" : pct > 70 ? "var(--gold)" : "var(--green)"; +} + +function renderPlayerList() { + const el = document.getElementById("playerList"); + const inRoster = new Set(state.roster.map((p) => p.name)); + + if (!state.players.length) { + el.innerHTML = '