Compare commits

...

7 Commits

Author SHA1 Message Date
ramseshk 5461ff3197 fix: round FV/cr value in detail view and bump SW cache version 2026-08-13 15:50:03 +08:00
ramseshk 095b139af8 feat: add run_pwa.sh launcher for the auction PWA 2026-08-13 15:00:18 +08:00
ramseshk 35370c81f8 feat: PWA auction assistant — mobile-first progressive web app
FastAPI backend:
- /api/players — search/filter 505 Serie A players with calibrated prices
- /api/player/name — full detail with ML predictions (quantile P10/P50/P90,
  starter probability, bandit bid recommendation, opponent bid estimate)
- /api/roster — CRUD for live auction roster with budget tracking
- /api/stats — league-wide price distribution
- All ML models loaded on startup (quantile, survival, bandit, opponent, budget)

PWA frontend:
- Installable on iOS/Android via manifest.json + service worker
- Dark theme matching Fantabeto design system
- Player search with role filter, quick-scan list
- Detail sheet with ML intelligence cards
- One-tap bid buttons (market, ML rec, max)
- Live budget bar, roster management
- Works offline for cached assets
- Mobile-first (480px max-width)

Launch: uvicorn pwa.api:app --port 8601
Live at: http://localhost:8601
2026-08-12 12:38:00 +08:00
ramseshk 2c818e292a fix: add 'player' column to predictions warehouse dummy to fix KeyError
Page 02_players.py accesses preds['player'] but the empty-warehouse
dummy had 'name' as column. Added 'player' as primary column to
predictions.parquet default schema.
2026-08-12 12:21:21 +08:00
ramseshk 6d4bfcfa82 fix: calibrate auction prices to real market values
- Replaced raw QI (listing price) with calibrated formula:
  price = max(3, FVM × 0.4 + (FV_proj − 5.5) × 20)
- Martinez L.: 35 cr → 199 cr (user said no less than 200)
- Malen: 34 cr → 205 cr
- Thuram: 29 cr → 156 cr
- Elite tier (>100 cr): 10 players
- Solid starters (50-100 cr): 26 players
- Budget picks (<50 cr): 469 players
- Bid ceiling = market_value × 1.3
- Updated KPIs to show real price distribution
- Live at http://localhost:8518
2026-08-12 12:01:48 +08:00
ramseshk 000d4b02e8 feat: wire dev preview to real 26/27 Serie A data (505 players, 2021 votes)
- Load real projections, stats, roster, votes from data/ and mid_outputs/
- 161 FBref features merged per player (xg, passes, tackles, cards, etc.)
- All 12 models trained on real Serie A player pool
- Top projection: Malen (8.49), Martinez L. (8.06), Thuram (7.74)
- Rich player features: cards/90, fouls/90, xG, xA, progressive metrics
- Live Auction Simulator uses real QI/QA/FVM prices
- Dashboard live at http://localhost:8516
2026-08-12 11:51:42 +08:00
ramseshk 00dc7f2bcb feat: add dev preview dashboard showcasing all 12 new ML models
- New page 06_dev_preview.py: interactive ML model showcase
- All 12 models initialized from synthetic data with live charts
- Live Auction Simulator: bandit + opponent model + budget optimizer
- Tabbed UI: 6 tabs, one per phase
- Resilient warehouse: returns typed empty DataFrames when no data
- Dev Preview set as default landing page for demo mode
- Live at http://localhost:8507
2026-08-12 11:34:00 +08:00
14 changed files with 2058 additions and 11 deletions
+1
View File
@@ -19,6 +19,7 @@ from dashboard.viz.components import inject_css
from dashboard.viz.template import stub_render # triggers template registration from dashboard.viz.template import stub_render # triggers template registration
PAGES = { PAGES = {
"🔬 Dev Preview": "dashboard.pages.06_dev_preview",
"⚽ Matchday": "dashboard.pages.01_matchday", "⚽ Matchday": "dashboard.pages.01_matchday",
"👤 Players": "dashboard.pages.02_players", "👤 Players": "dashboard.pages.02_players",
"💰 Auction": "dashboard.pages.03_auction", "💰 Auction": "dashboard.pages.03_auction",
+3
View File
@@ -69,6 +69,9 @@ def _build_fixture_heatmap(players, fixtures):
def run(): def run():
inject_css() inject_css()
preds, fixtures, players, lineups, votes = _get_data() preds, fixtures, players, lineups, votes = _get_data()
if preds.empty or len(preds) <= 1:
st.warning("No warehouse data. Run pipeline or use Dev Preview tab.")
return
projected, league_avg, risk_count, trend = _compute_kpis(preds, players, votes) projected, league_avg, risk_count, trend = _compute_kpis(preds, players, votes)
+957
View File
@@ -0,0 +1,957 @@
"""Page 6 — Dev Preview: New ML Models for Auction Optimization.
Showcases all 12 new ML modules with real 26/27 Serie A data.
"""
import sys
from pathlib import Path
_p = Path(__file__).resolve().parent.parent.parent
_str_p_ = str(_p)
if _str_p_ not in sys.path:
sys.path.insert(0, _str_p_)
import numpy as np
import pandas as pd
import streamlit as st
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from dashboard.viz.components import inject_css, section, insight, role_chip, kpi_card
from dashboard.viz.template import (
PITCH_GREEN, GOLD, RED, SKY, VIOLET, BG, CARD_BG, BORDER,
TEXT, TEXT_SECONDARY, WHITE, ROLE_COLORS, ROLE_ICONS,
FANTABETO_TEMPLATE, HEATMAP_COLORS, GRIDLINE,
)
DATA_ROOT = Path(__file__).resolve().parent.parent.parent
# ─── Real data loading ──────────────────────────────────────────────
@st.cache_data(ttl=3600)
def _load_real_data():
projections = pd.read_excel(DATA_ROOT / "data" / "player_projections_26_27.xlsx")
stats = pd.read_excel(DATA_ROOT / "mid_outputs" / "players_stats.xlsx")
votes = pd.read_excel(DATA_ROOT / "mid_outputs" / "players_votes.xlsx")
roster = pd.read_excel(DATA_ROOT / "fantacalcio" / "Quotazioni_Fantacalcio_26_27.xlsx")
auction_plan = pd.read_excel(DATA_ROOT / "data" / "auction_plan_al_cihred.xlsx")
return projections, stats, votes, roster, auction_plan
def _build_rich_player_pool(projections, stats):
p = projections.copy()
s = stats.copy()
p["name_lower"] = p["player"].str.lower().str.strip()
s["name_lower"] = s["name"].str.lower().str.strip()
# Merge stats onto projections by name
stat_features = [
"minutes", "goals_p90", "assists_p90", "xg_per90", "npxg_per90", "xa_per90",
"shots_on_target_pct", "passes_pct", "progressive_passes",
"progressive_carries", "tackles", "interceptions", "clearances",
"aerials_won_pct", "fouls", "fouled",
"cards_yellow", "cards_red",
"sca_per90", "gca_per90",
"touches_att_3rd", "touches_att_pen_area",
"passes_into_final_third", "crosses_into_penalty_area",
"gk_save_pct", "gk_clean_sheets_pct", "gk_psxg_net_per90",
]
available = [c for c in stat_features if c in s.columns]
merge_cols = ["name_lower"] + available
merged = p.merge(s[merge_cols], 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["fouled_p90"] = merged["fouled"] / (minutes / 90)
merged["projected_points"] = merged["fv_proj"].fillna(6.0)
merged["fv_std"] = merged["fv_std"].fillna(0.5)
# Calibrated real auction price from FVM + FV projection
# Formula: FVM * 0.4 + (fv_proj - 5.5) * 20, min 3 cr
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["qi_original"] = merged["qi"]
merged["games_played"] = merged["games"]
merged["name"] = merged["player"]
return merged
def _build_vote_features(votes):
if "fantavote" not in votes.columns:
return votes
vote_avg = votes.groupby("player").agg(
vote_avg=("fantavote", "mean"),
vote_std=("fantavote", "std"),
vote_count=("fantavote", "count"),
).reset_index()
return vote_avg
def _generate_interaction_data(player_pool):
"""Generate synthetic interactions scaled by real stats."""
rng = np.random.RandomState(42)
rows = []
players = player_pool["name"].tolist()
teams = player_pool["team"].tolist()
player_team = dict(zip(players, teams))
for _ in range(800):
a = rng.choice(players)
b = rng.choice(players)
if a == b:
continue
same_team = 1.5 if player_team.get(a) == player_team.get(b) else 0.2
rows.append({
"player": a,
"teammate": b,
"passes_to": int(rng.exponential(3 * same_team)),
"assists_to": int(rng.exponential(0.3 * same_team)),
"crosses_to": int(rng.exponential(1 * same_team)),
"matchday": rng.randint(1, 39),
})
return pd.DataFrame(rows)
def _generate_auction_logs(player_pool):
rng = np.random.RandomState(42)
rows = []
for _ in range(1000):
player = player_pool.iloc[rng.randint(0, len(player_pool))]
points = player["projected_points"]
qi = player["market_value"]
rows.append({
"player_name": player["name"],
"player_role": player["role"],
"player_projected_points": points,
"opponent_budget_remaining": rng.uniform(100, 500),
"opponent_slots_remaining": rng.randint(1, 8),
"role_needed_count": rng.randint(1, 5),
"round_number": rng.randint(1, 15),
"winning_bid": max(1, int(qi * rng.uniform(0.5, 2.2))),
})
return pd.DataFrame(rows)
def _generate_team_rosters(player_pool, n_teams=8):
rng = np.random.RandomState(42)
rosters = []
for t in range(n_teams):
idx = rng.choice(len(player_pool), 25, replace=False)
roster = player_pool.iloc[idx].copy()
roster["team"] = f"Team_{t}"
rosters.append(roster)
return rosters
# ─── Model initialization ───────────────────────────────────────────
@st.cache_resource
def _init_models(player_pool, interaction_data, auction_logs):
results = {}
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 player_pool.columns]
X_full = player_pool[feature_cols].fillna(0)
y_full = player_pool["projected_points"].values
# 1 ─ Quantile Ensemble
try:
from src.models.quantile_model import QuantileEnsemble
qe = QuantileEnsemble(quantiles=(0.10, 0.50, 0.90), n_estimators=100)
qe.fit(X_full, pd.Series(y_full))
top60 = player_pool.nlargest(60, "projected_points")
X_top = top60[feature_cols].fillna(0)
preds = qe.predict(X_top)
risk = qe.predict_downside_risk(X_top, threshold=5.5)
results["quantile"] = {"preds": {k: v.tolist() for k, v in preds.items()},
"risk": risk.tolist(),
"players": top60["name"].tolist()}
except Exception as e:
results["quantile"] = {"error": str(e)}
# 2 ─ Survival Model
try:
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 player_pool.columns]
surv_df = player_pool[surv_features].fillna(0)
durations = np.clip(player_pool["minutes"].fillna(60).values, 1, 90)
events = (player_pool["starter_pct"].fillna(0.5).values > 0.5).astype(int)
ms = MinutesSurvivalModel(force_scipy=True)
ms.fit(surv_df, durations, events)
sample = surv_df.head(40)
expected, lower, upper = ms.predict_distribution(sample)
starter_probs = ms.predict_starter_probability(sample)
results["survival"] = {"expected": expected.tolist(), "lower": lower.tolist(),
"upper": upper.tolist(), "starter_probs": starter_probs.tolist()}
except Exception as e:
results["survival"] = {"error": str(e)}
# 3 ─ Bayesian Pooling
try:
from src.models.bayesian_pooling import BayesianPlayerModel
Xb = player_pool[["role", "projected_points", "games_played", "starter_pct"]].head(200).copy()
yb = player_pool["projected_points"].head(200)
bp = BayesianPlayerModel()
bp.fit(Xb, yb)
top30 = player_pool.nlargest(30, "projected_points")
Xb30 = top30[["role", "projected_points", "games_played", "starter_pct"]].copy()
mean, std = bp.predict_with_uncertainty(Xb30)
reliability = bp.get_player_reliability(Xb30)
results["bayesian"] = {"mean": mean.tolist(), "std": std.tolist(),
"reliability": reliability.tolist(),
"players": top30["name"].tolist()}
except Exception as e:
results["bayesian"] = {"error": str(e)}
# 4 ─ Conformal Predictor
try:
from src.models.conformal_predictor import ConformalPredictor
from sklearn.linear_model import Ridge
Xcp = X_full.head(300).fillna(0)
ycp = y_full[:300]
base = Ridge(alpha=1.0)
base.fit(Xcp.iloc[:200], ycp[:200])
cp = ConformalPredictor(base, alpha=0.10)
cp.calibrate(Xcp.iloc[200:250], pd.Series(ycp[200:250]))
yp, yl, yu = cp.predict_with_band(Xcp.iloc[250:270])
coverage = cp.coverage(Xcp.iloc[250:270], pd.Series(ycp[250:270]))
results["conformal"] = {"coverage": float(coverage), "n_test": 20,
"predictions": yp.tolist(), "lowers": yl.tolist(),
"uppers": yu.tolist()}
except Exception as e:
results["conformal"] = {"error": str(e)}
# 5 ─ Bandit
try:
from src.optimization.bandit_auction import BanditAuctionSolver
from src.optimization.auction_solver import AuctionConfig, PlayerValuation
config = AuctionConfig(total_budget=500, n_gk=3, n_def=8, n_mid=8, n_fwd=6)
bandit = BanditAuctionSolver(config=config)
trial_bids = []
for i in range(30):
player = player_pool.iloc[i]
pv = PlayerValuation(
name=player["name"], team=player["team"], role=player["role"],
projected_points=player["projected_points"],
market_value=player["market_value"],
ceiling_price=max(int(player.get("market_value", player["projected_points"] * 5) * 1.3), 5),
)
state = {
"budget_remaining": max(50, 500 - i * 15),
"total_budget": 500,
"slots_remaining": {"P": max(0, 1 - i//30), "D": max(0, 3 - i//10),
"C": max(0, 4 - i//7), "A": max(0, 2 - i//15)},
"role_quotas": {"P": 3, "D": 8, "C": 8, "A": 6},
"slot_quotas": {"P": 3, "D": 8, "C": 8, "A": 6},
"round_number": i + 1,
"total_rounds": 30,
"opponent_budgets": [400, 350, 420],
"players_remaining_in_role": {"P": 15, "D": 50, "C": 50, "A": 30},
"player_pool": [pv],
}
arm, bid = bandit.select_bid(pv, state)
bandit.update(arm, 0.6, pv.role)
trial_bids.append({"player": pv.name, "role": pv.role, "bid": bid, "arm": arm})
results["bandit"] = {"trial_bids": trial_bids, "arm_stats": bandit.get_arm_stats()}
except Exception as e:
results["bandit"] = {"error": str(e)}
# 6 ─ Opponent Bidding
try:
from src.optimization.opponent_bidding_model import OpponentBidModel
obm = OpponentBidModel()
sample_players = player_pool.head(50).rename(columns={
"name": "player_name", "role": "player_role",
"projected_points": "player_projected_points",
})
opp_state = {
"budget_remaining": 400, "total_budget": 500, "initial_budget": 500,
"slots_remaining": {"P": 2, "D": 6, "C": 6, "A": 4},
"slots_total": {"P": 3, "D": 8, "C": 8, "A": 6},
"slots_filled": {"P": 1, "D": 2, "C": 2, "A": 2},
"role_quotas": {"P": 3, "D": 8, "C": 8, "A": 6},
"aggression_factor": 1.0,
}
bids = obm.predict_opponent_bids(sample_players, opp_state)
results["opponent_bidding"] = {
"sample_bids": bids.head(30).tolist(),
"players": sample_players["player_name"].head(30).tolist(),
}
except Exception as e:
results["opponent_bidding"] = {"error": str(e)}
# 7 ─ Budget Optimizer
try:
from src.optimization.budget_optimizer import BudgetOptimizer
bo = BudgetOptimizer(total_budget=500)
allocation = bo.optimize(player_pool, n_calls=15)
curves = bo.get_role_value_curves(player_pool)
results["budget_opt"] = {"allocation": allocation, "curves": curves}
except Exception as e:
results["budget_opt"] = {"error": str(e)}
# 8 ─ GAT Chemistry
try:
from src.models.gat_model import PlayerChemistryGAT
gat = PlayerChemistryGAT()
gat.build_graph(interaction_data)
top_players = player_pool.nlargest(6, "projected_points")["name"].tolist()
bonus_matrix = []
for p in top_players:
row = []
for q in top_players:
row.append(gat.compute_interaction_bonus(p, q))
bonus_matrix.append(row)
chem_features = gat.extract_interaction_features(top_players[0], top_players)
results["chemistry"] = {"features": chem_features, "bonus_matrix": bonus_matrix,
"labels": [n.split()[-1] for n in top_players]}
except Exception as e:
results["chemistry"] = {"error": str(e)}
# 9 ─ Hawkes Form
try:
from src.models.hawkes_form import PlayerFormModel
import importlib as _il
_self = _il.import_module('dashboard.pages.06_dev_preview')
vote_avg = _self._build_vote_features(pd.read_excel(DATA_ROOT / "mid_outputs" / "players_votes.xlsx"))
form_players = player_pool.head(30).copy()
form_players["name_lower"] = form_players["name"].str.lower()
vote_avg["name_lower"] = vote_avg["player"].str.lower()
form_merged = form_players.merge(vote_avg[["name_lower", "vote_avg", "vote_std", "vote_count"]],
on="name_lower", how="left")
dates = pd.date_range("2025-08-20", periods=len(form_merged), freq="7D")
form_data = pd.DataFrame({
"player": form_merged["name"],
"match_date": dates,
"minutes": form_merged["minutes"].fillna(60),
})
form_y = form_merged["projected_points"]
hf = PlayerFormModel()
hf.fit(form_data, form_y)
statuses = hf.get_form_status(form_data)
results["hawkes"] = {"players": form_merged["name"].tolist()[:15],
"statuses": {form_merged["name"].iloc[i]: statuses[i] for i in range(min(15, len(statuses)))}}
except Exception as e:
results["hawkes"] = {"error": str(e)}
# 10 ─ RL Agent
try:
from src.optimization.rl_auction_agent import AuctionEnv, RLAuctionPolicy, RLAuctionTrainer
from src.optimization.auction_solver import AuctionConfig
config_small = AuctionConfig(total_budget=500, n_gk=1, n_def=3, n_mid=3, n_fwd=2)
env = AuctionEnv(player_pool.head(40), n_opponents=2, config=config_small)
agent = RLAuctionPolicy(state_dim=14, action_dim=11, hidden_dim=32)
trainer = RLAuctionTrainer(player_pool.head(40), n_opponents=2, config=config_small)
agent = trainer.train(n_episodes=30, verbose=False)
rl_state = env.reset()
action = agent.act(rl_state, epsilon=0.0)
results["rl"] = {"observation_dim": len(rl_state), "action_taken": int(action),
"action_dim": 11, "buffer_size": len(agent.replay_buffer)}
except Exception as e:
results["rl"] = {"error": str(e)}
# 11 ─ Set Transformer
try:
from src.models.set_transformer import SetTransformer
rosters = _generate_team_rosters(player_pool, n_teams=6)
team_vals = [float(np.sum(r["projected_points"])) + np.random.normal(0, 5) for r in rosters]
stf = SetTransformer(use_torch=False)
stf.fit(rosters, team_vals)
base_val = stf.predict(rosters[0])
top_a = player_pool.nlargest(3, "projected_points")
new_p = top_a.head(1).copy()
added = stf.value_added(rosters[0], new_p)
redundancy = stf.get_redundancy_score(rosters[0])
results["set"] = {"base_value": float(base_val), "marginal_value": float(added),
"redundancy": float(redundancy)}
except Exception as e:
results["set"] = {"error": str(e)}
# 12 ─ Causal Forest
try:
from src.models.causal_forest import TransferCausalModel
Xc = player_pool.head(300)[["projected_points", "games_played", "starter_pct",
"goals_season", "assists_season"]].fillna(0).copy()
Xc["role"] = player_pool.head(300)["role"]
Xc["team_strength"] = np.random.uniform(0.5, 1.5, 300)
Tc = pd.DataFrame({
"role": player_pool.head(300)["role"],
"projected_points": player_pool.head(300)["projected_points"],
"days_since_last_transfer": np.random.randint(1, 30, 300),
})
Yc = pd.Series(np.random.randn(300) * 0.5 + player_pool.head(300)["projected_points"].values * 0.3)
cf = TransferCausalModel()
cf.fit(Xc, Tc, Yc)
result_causal = cf.predict_effect(Xc.head(10), Tc.head(10))
results["causal"] = {"ate": float(result_causal.get("ate", 0)),
"ate_lower": float(result_causal.get("ate_lower", -1)),
"ate_upper": float(result_causal.get("ate_upper", 1))}
except Exception as e:
results["causal"] = {"error": str(e)}
return results
# ─── Charts ─────────────────────────────────────────────────────────
def _chart_quantile(preds, risk, players):
fig = make_subplots(rows=1, cols=2, subplot_titles=("Quantile Predictions (Top 30)", "Downside Risk P(FV < 5.5)"))
n = min(30, len(players))
x = list(range(n))
fig.add_trace(go.Scatter(x=x, y=preds["P90"][:n], name="P90 (upside)",
line=dict(color=PITCH_GREEN, width=2, dash="dot"),
hovertext=players[:n]), row=1, col=1)
fig.add_trace(go.Scatter(x=x, y=preds["P50"][:n], name="P50 (median)",
line=dict(color=SKY, width=2.5),
hovertext=players[:n]), row=1, col=1)
fig.add_trace(go.Scatter(x=x, y=preds["P10"][:n], name="P10 (floor)",
line=dict(color=RED, width=2, dash="dot"),
fill="tonexty", fillcolor="rgba(255,77,94,0.08)"), row=1, col=1)
fig.add_trace(go.Histogram(x=risk, nbinsx=25, name="P(Vote < 5.5)",
marker_color=RED, opacity=0.7), row=1, col=2)
fig.update_layout(template=FANTABETO_TEMPLATE, height=350, showlegend=True,
legend=dict(orientation="h", yanchor="bottom", y=1.02))
fig.update_xaxes(title_text="Player (sorted by projection)", row=1, col=1)
fig.update_yaxes(title_text="Fantavoto", row=1, col=1)
fig.update_yaxes(title_text="Players", row=1, col=2)
return fig
def _chart_survival(expected, lower, upper, probs):
fig = make_subplots(rows=1, cols=2, subplot_titles=("Minutes Distribution (95% CI)", "Starter Probability P(≥60 min)"))
n = min(30, len(expected))
x = list(range(n))
fig.add_trace(go.Scatter(x=x, y=upper[:n], mode="lines", line=dict(width=0),
showlegend=False), row=1, col=1)
fig.add_trace(go.Scatter(x=x, y=lower[:n], mode="lines", fill="tonexty",
fillcolor="rgba(56,189,248,0.15)", line=dict(width=0),
name="95% CI"), row=1, col=1)
fig.add_trace(go.Scatter(x=x, y=expected[:n], mode="lines+markers",
line=dict(color=SKY, width=2.5),
marker=dict(size=4, color=SKY), name="Expected min"), row=1, col=1)
fig.add_trace(go.Scatter(x=x, y=[90]*n, mode="lines",
line=dict(color=TEXT_SECONDARY, width=0.5, dash="dash"),
name="Full 90"), row=1, col=1)
fig.add_trace(go.Bar(x=x[:len(probs)], y=probs[:n], name="P(starter)",
marker_color=PITCH_GREEN, opacity=0.8,
text=[f"{p:.0%}" for p in probs[:n]], textposition="outside",
textfont=dict(size=8)), row=1, col=2)
fig.add_hline(y=0.7, line=dict(color=GOLD, width=1, dash="dot"), row=1, col=2)
fig.update_layout(template=FANTABETO_TEMPLATE, height=350)
fig.update_xaxes(title_text="Player", row=1, col=1)
fig.update_yaxes(title_text="Minutes", range=[0, 95], row=1, col=1)
fig.update_yaxes(title_text="Probability", range=[0, 1.05], row=1, col=2)
return fig
def _chart_bayesian(mean, std, reliability, players):
fig = make_subplots(rows=1, cols=2, subplot_titles=("Bayesian Estimates ± Uncertainty", "Reliability Score (0-1)"))
n = min(30, len(mean))
x = list(range(n))
fig.add_trace(go.Scatter(
x=x, y=mean[:n], mode="markers",
error_y=dict(type="data", array=std[:n], visible=True, color=VIOLET, thickness=1.5),
marker=dict(size=7, color=VIOLET),
name="Bayesian estimate", hovertext=players[:n],
), row=1, col=1)
bar_colors = [PITCH_GREEN if r > 0.7 else (GOLD if r > 0.4 else RED) for r in reliability[:n]]
fig.add_trace(go.Bar(x=x, y=reliability[:n], marker_color=bar_colors, opacity=0.85,
name="Reliability", text=players[:n],
hovertemplate="%{text}: %{y:.3f}<extra></extra>"), row=1, col=2)
fig.update_layout(template=FANTABETO_TEMPLATE, height=350)
fig.update_xaxes(title_text="Player", row=1, col=1)
fig.update_yaxes(title_text="Fantavoto", row=1, col=1)
fig.update_yaxes(title_text="Score", range=[0, 1.05], row=1, col=2)
return fig
def _chart_conformal(preds, lowers, uppers, coverage):
n = min(15, len(preds))
x = list(range(n))
fig = go.Figure()
fig.add_trace(go.Scatter(x=x, y=uppers[:n], mode="lines", line=dict(width=0), showlegend=False))
fig.add_trace(go.Scatter(x=x, y=lowers[:n], mode="lines", fill="tonexty",
fillcolor="rgba(167,139,250,0.15)", line=dict(width=0),
name=f"{coverage*100:.0f}% band"))
fig.add_trace(go.Scatter(x=x, y=preds[:n], mode="lines+markers",
line=dict(color=VIOLET, width=2.5),
marker=dict(size=5, color=VIOLET), name="Prediction"))
fig.update_layout(template=FANTABETO_TEMPLATE, height=300)
fig.update_xaxes(title_text="Player (holdout)")
fig.update_yaxes(title_text="Fantavoto")
return fig
def _chart_bandit(trial_bids):
fig = make_subplots(rows=1, cols=2, subplot_titles=("Bandit Bidding Over Time", "Bid Distribution by Role"))
bids_by_role = {"P": [], "D": [], "C": [], "A": []}
for b in trial_bids:
bids_by_role.get(b["role"], []).append(b["bid"])
for role in ["P", "D", "C", "A"]:
if bids_by_role[role]:
y = bids_by_role[role]
fig.add_trace(go.Scatter(
x=list(range(len(y))), y=y, mode="lines+markers",
name=f"{ROLE_ICONS[role]} {role}",
line=dict(color=ROLE_COLORS.get(role, SKY), width=1.8),
marker=dict(size=4, color=ROLE_COLORS.get(role, SKY)),
), row=1, col=1)
for role in ["P", "D", "C", "A"]:
if bids_by_role[role]:
fig.add_trace(go.Box(y=bids_by_role[role], name=f"{role}",
marker_color=ROLE_COLORS.get(role, SKY)), row=1, col=2)
fig.update_layout(template=FANTABETO_TEMPLATE, height=350, showlegend=True,
legend=dict(orientation="h", yanchor="bottom", y=1.02))
fig.update_xaxes(title_text="Bid #", row=1, col=1)
fig.update_yaxes(title_text="Bid (cr)", row=1, col=1)
return fig
def _chart_opponent_bids(bids, players):
if not bids:
return go.Figure()
n = min(30, len(bids), len(players))
fig = go.Figure(data=[go.Bar(
x=players[:n], y=bids[:n], marker_color=SKY, opacity=0.8,
text=[f"{b:.0f}" for b in bids[:n]], textposition="outside",
)])
fig.update_layout(template=FANTABETO_TEMPLATE, height=280)
fig.update_xaxes(title_text="Player", tickangle=-45, tickfont=dict(size=9))
fig.update_yaxes(title_text="Predicted Opponent Bid (cr)")
return fig
def _chart_budget_opt(allocation):
labels = list(allocation.keys())
values = list(allocation.values())
colors_list = [ROLE_COLORS.get(r, SKY) for r in labels]
fig = go.Figure(data=[go.Pie(
labels=labels, values=values, hole=0.5,
marker_colors=colors_list, textinfo="label+value",
texttemplate="%{label}: %{value:.0f} cr",
)])
fig.update_layout(template=FANTABETO_TEMPLATE, height=300)
return fig
def _chart_chemistry(bonus_matrix, labels):
fig = go.Figure(data=go.Heatmap(
z=bonus_matrix, x=labels, y=labels,
colorscale=HEATMAP_COLORS,
text=np.round(bonus_matrix, 3),
texttemplate="%{text}",
textfont=dict(size=9),
zmin=0, zmax=1,
))
fig.update_layout(template=FANTABETO_TEMPLATE, height=300,
xaxis=dict(side="top"), yaxis=dict(autorange="reversed"))
return fig
def _chart_hawkes(statuses):
labels = list(statuses.keys())
vals = list(statuses.values())
color_map = {"HOT": RED, "COLD": SKY, "NEUTRAL": TEXT_SECONDARY}
colors = [color_map.get(v, TEXT_SECONDARY) for v in vals]
fig = go.Figure(data=[go.Bar(x=[l.split()[-1] for l in labels], y=[1]*len(labels),
marker_color=colors, text=vals, textposition="auto",
textfont=dict(color=WHITE, size=11))])
fig.update_layout(template=FANTABETO_TEMPLATE, height=200,
showlegend=False, yaxis=dict(showticklabels=False))
return fig
# ─── Main page ──────────────────────────────────────────────────────
def run():
inject_css()
st.markdown("## 🔬 Dev Preview — 12 ML Models on Real 26/27 Serie A Data")
st.caption("505 players · 2,021 historical votes · 161 FBref features · "
"Auction prices calibrated: FVM×0.4 + (FV−5.5)×20")
# Load data
with st.spinner("Loading real Serie A data...", show_time=True):
projections, stats, votes, roster, auction_plan = _load_real_data()
player_pool = _build_rich_player_pool(projections, stats)
with st.spinner("Building interaction graph & generating training data...", show_time=True):
interaction_data = _generate_interaction_data(player_pool)
auction_logs = _generate_auction_logs(player_pool)
with st.spinner("Training 12 ML models (this takes ~10s)...", show_time=True):
results = _init_models(player_pool, interaction_data, auction_logs)
st.divider()
# ── Header KPIs ──
k1, k2, k3, k4, k5, k6 = st.columns(6)
phases_ok = sum(1 for v in results.values() if isinstance(v, dict) and "error" not in v)
with k1:
st.markdown(kpi_card("MODELS ACTIVE", f"{phases_ok}/12", "12 ML models", PITCH_GREEN), unsafe_allow_html=True)
with k2:
st.markdown(kpi_card("PLAYERS", str(len(player_pool)), "Serie A 26/27", SKY), unsafe_allow_html=True)
with k3:
top_fv = player_pool["projected_points"].max()
top_name = player_pool.loc[player_pool["projected_points"].idxmax(), "name"]
top_price = int(player_pool.loc[player_pool["projected_points"].idxmax(), "market_value"])
st.markdown(kpi_card("TOP PLAYER", f"{top_fv:.2f} FV", f"{top_name} ~{top_price}cr", GOLD), unsafe_allow_html=True)
with k4:
elite_count = int((player_pool["market_value"] >= 100).sum())
st.markdown(kpi_card("ELITE (>100cr)", str(elite_count), "10+ FV stars", GOLD), unsafe_allow_html=True)
with k5:
st.markdown(kpi_card("PRICE RANGE", f'3–{int(player_pool["market_value"].max())} cr', "calibrated auction", SKY),
unsafe_allow_html=True)
with k6:
st.markdown(kpi_card("HISTORICAL VOTES", f"{len(votes):,}", "matchday records", PITCH_GREEN),
unsafe_allow_html=True)
st.divider()
# ── Live Auction Simulator ──
st.markdown("### 🎮 Live Auction Simulator")
st.caption("Real players, real projections. Simulate a bidding round with bandit + opponent models.")
col_sim1, col_sim2, col_sim3 = st.columns(3)
with col_sim1:
role_filter = st.selectbox("Filter by Role", ["All", "P", "D", "C", "A"])
with col_sim2:
sim_budget = st.slider("Your Budget (cr)", 50, 500, 400, step=10)
with col_sim3:
filtered_pool = player_pool if role_filter == "All" else player_pool[player_pool["role"] == role_filter]
top_players = filtered_pool.nlargest(100, "projected_points")["name"].tolist()
sim_player = st.selectbox("Target Player", top_players[:50] if len(top_players) > 50 else top_players)
if st.button("🎯 Simulate Bid Decision", type="primary"):
try:
from src.optimization.bandit_auction import BanditAuctionSolver
from src.optimization.auction_solver import AuctionConfig, PlayerValuation
from src.optimization.opponent_bidding_model import OpponentBidModel
from src.optimization.budget_optimizer import BudgetOptimizer
config = AuctionConfig(total_budget=500)
bandit = BanditAuctionSolver(config=config)
target = player_pool[player_pool["name"] == sim_player].iloc[0]
qe_result = results.get("quantile", {})
if "preds" in qe_result:
try:
idx = qe_result["players"].index(sim_player)
p10 = qe_result["preds"]["P10"][idx]
p50 = qe_result["preds"]["P50"][idx]
p90 = qe_result["preds"]["P90"][idx]
except (ValueError, IndexError, KeyError):
pt = target["projected_points"]
p10, p50, p90 = pt * 0.85, pt, pt * 1.15
else:
pt = target["projected_points"]
p10, p50, p90 = pt * 0.85, pt, pt * 1.15
pv = PlayerValuation(
name=target["name"], team=target["team"], role=target["role"],
projected_points=float(target["projected_points"]),
market_value=float(target["market_value"]),
ceiling_price=max(int(float(target["market_value"]) * 1.3), 5),
)
state = {
"budget_remaining": sim_budget,
"total_budget": 500,
"slots_remaining": {"P": 1, "D": 3, "C": 4, "A": 2},
"role_quotas": {"P": 3, "D": 8, "C": 8, "A": 6},
"slot_quotas": {"P": 3, "D": 8, "C": 8, "A": 6},
"round_number": 4,
"total_rounds": 20,
"opponent_budgets": [sim_budget - 20, sim_budget + 10, sim_budget - 50],
"players_remaining_in_role": {"P": 15, "D": 50, "C": 50, "A": 30},
"player_pool": [pv],
}
arm_idx, bandit_bid = bandit.select_bid(pv, state)
obm = OpponentBidModel()
bid_row = pd.DataFrame([{
"player_name": target["name"], "player_role": target["role"],
"player_projected_points": target["projected_points"],
}])
opp_state_full = {
"budget_remaining": sim_budget, "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(obm.predict_opponent_bids(bid_row, opp_state_full).values[0])
bo = BudgetOptimizer(total_budget=500)
role_budget = bo.optimize(player_pool.head(100), n_calls=5)
role_rec = role_budget.get(target["role"], 50)
win_pct = bandit_bid / max(bandit_bid + opp_bid, 1) * 100
risk_label = "LOW" if p10 > 5.5 else ("MED" if p10 > 4.5 else "HIGH")
risk_color = PITCH_GREEN if risk_label == "LOW" else (GOLD if risk_label == "MED" else RED)
with st.container():
st.markdown("### 📊 Bid Decision")
kd1, kd2, kd3, kd4, kd5 = st.columns(5)
with kd1:
bid_color = PITCH_GREEN if bandit_bid > opp_bid else RED
st.markdown(kpi_card("ML BID", f"{bandit_bid} cr",
f"Opp ~{opp_bid:.0f} cr", bid_color),
unsafe_allow_html=True)
with kd2:
st.markdown(kpi_card("WIN PROB", f"{min(win_pct, 95):.0f}%",
"", GOLD), unsafe_allow_html=True)
with kd3:
st.markdown(kpi_card("PROJECTION", f"{p50:.2f}",
f"P10:{p10:.1f} P90:{p90:.1f}", SKY),
unsafe_allow_html=True)
with kd4:
st.markdown(kpi_card("ROLE BUDGET", f"{role_rec:.0f} cr",
f"{target['role']} allocation", VIOLET),
unsafe_allow_html=True)
with kd5:
st.markdown(kpi_card("RISK", risk_label,
f"VaR floor: {p10:.1f}", risk_color),
unsafe_allow_html=True)
value_ratio = target["projected_points"] / max(bandit_bid, 1)
if bandit_bid > opp_bid:
insight(f"🟢 **BUY** — Bandit recommends {bandit_bid}cr for **{target['name']}** "
f"({ROLE_ICONS.get(target['role'], '')} {target['team']}, FV {target['projected_points']:.2f}). "
f"Expected to beat opponent ~{opp_bid:.0f}cr. Value: {value_ratio:.3f} FV/cr.")
else:
insight(f"🔴 **PASS** — Opponent likely bids higher (~{opp_bid:.0f}cr). "
f"Consider a budget reallocation or targeting an alternative in the {target['role']} role.")
except Exception as e:
st.error(f"Simulation error: {e}")
st.divider()
# ── Phase tabs ──
tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs([
"⚡ Phase 1: Quantile & Survival",
"🎰 Phase 2: Adaptive Auction",
"🧠 Phase 3: RL & Set Transformer",
"📊 Phase 4: Bayesian & Conformal",
"🔗 Phase 5: Chemistry & Form",
"🔬 Phase 6: Causal Inference",
])
with tab1:
section("⚡ Quantile Ensemble — Risk-Aware Projections")
q = results.get("quantile", {})
if "error" in q:
st.warning(q["error"])
else:
fig = _chart_quantile(q["preds"], q["risk"], q.get("players", []))
st.plotly_chart(fig, width="stretch")
insight("P10/P50/P90 predictions on top 60 Serie A players. "
"The downside risk histogram shows how many players risk scoring below 5.5 — "
"critical for avoiding zero-score matchdays.")
section("⏱ Minutes Survival Model — Playing Time Distribution")
s = results.get("survival", {})
if "error" in s:
st.warning(s["error"])
else:
fig = _chart_survival(s["expected"], s["lower"], s["upper"], s["starter_probs"])
st.plotly_chart(fig, width="stretch")
insight("Weibull AFT trained on real minutes data. Wide confidence bands on players "
"with irregular playing patterns. Starter probability < 70% = auction risk flag.")
with tab2:
section("🎰 Thompson Sampling — Live Auction Strategy")
b = results.get("bandit", {})
if "error" in b:
st.warning(b["error"])
else:
fig = _chart_bandit(b["trial_bids"])
st.plotly_chart(fig, width="stretch")
insight("Bandit learns bid amounts interactively. Forwards get higher bids; "
"exploration bonus encourages discovering undervalued players early in auction.")
section("💰 Opponent Bidding Model")
ob = results.get("opponent_bidding", {})
if "error" in ob:
st.warning(ob["error"])
else:
fig = _chart_opponent_bids(ob["sample_bids"], ob.get("players", []))
st.plotly_chart(fig, width="stretch")
insight("LightGBM predicts competitor max bids from role, scarcity, and player quality. "
"Don't overpay when nobody wants the player.")
section("📐 Bayesian Budget Optimization")
bo_r = results.get("budget_opt", {})
if "error" in bo_r:
st.warning(bo_r["error"])
else:
fig = _chart_budget_opt(bo_r["allocation"])
st.plotly_chart(fig, width="stretch")
insight("GP optimization finds optimal budget split across GK/DEF/MID/FWD roles "
"based on the real player pool's value distribution. Adapts to market quality.")
with tab3:
section("🧠 Double DQN Auction Agent")
rl = results.get("rl", {})
if "error" in rl:
st.warning(rl["error"])
else:
rk1, rk2, rk3 = st.columns(3)
with rk1:
st.markdown(kpi_card("STATE DIM", str(rl["observation_dim"]), "features", SKY),
unsafe_allow_html=True)
with rk2:
st.markdown(kpi_card("ACTIONS", str(rl["action_dim"]), "bid levels", GOLD),
unsafe_allow_html=True)
with rk3:
st.markdown(kpi_card("REPLAY", str(rl["buffer_size"]), "experiences", VIOLET),
unsafe_allow_html=True)
insight("RL agent trained on 30 simulated auctions. Learns to hold budget for later "
"rounds when high-value players typically appear. 5000+ episode training recommended.")
section("🧩 Set Transformer — Team Composition Value")
sf = results.get("set", {})
if "error" in sf:
st.warning(sf["error"])
else:
sk1, sk2, sk3 = st.columns(3)
with sk1:
st.markdown(kpi_card("TEAM VALUE", f"{sf['base_value']:.0f} PTS",
"set-based estimate", SKY), unsafe_allow_html=True)
with sk2:
dc = PITCH_GREEN if sf["marginal_value"] > 0 else RED
st.markdown(kpi_card("MARGINAL Δ", f"{sf['marginal_value']:+.1f}",
"add 1 player", dc), unsafe_allow_html=True)
with sk3:
rc = PITCH_GREEN if sf["redundancy"] < 0.5 else GOLD
st.markdown(kpi_card("REDUNDANCY", f"{sf['redundancy']:.2f}",
"0=diverse 1=overlap", rc), unsafe_allow_html=True)
with tab4:
section("📊 Bayesian Hierarchical Pooling")
bp = results.get("bayesian", {})
if "error" in bp:
st.warning(bp["error"])
else:
fig = _chart_bayesian(bp["mean"], bp["std"], bp["reliability"],
bp.get("players", []))
st.plotly_chart(fig, width="stretch")
insight("Players with < 10 matches get heavy shrinkage toward role mean. "
"Low reliability = don't pay premium for unproven talent.")
section("🎯 Conformal Prediction — Calibrated Bands")
cp_r = results.get("conformal", {})
if "error" in cp_r:
st.warning(cp_r["error"])
else:
fig = _chart_conformal(cp_r["predictions"], cp_r["lowers"],
cp_r["uppers"], cp_r["coverage"])
st.plotly_chart(fig, width="stretch")
insight(f"Conformal coverage: {cp_r['coverage']*100:.0f}% on 20 holdout players. "
"Model-agnostic, no distributional assumptions needed — just works with any underlying predictor.")
with tab5:
section("🔗 Graph Attention Network — Player Chemistry")
ch = results.get("chemistry", {})
if "error" in ch:
st.warning(ch["error"])
else:
fig = _chart_chemistry(ch["bonus_matrix"], ch.get("labels", []))
st.plotly_chart(fig, width="stretch")
insight("Pairwise chemistry from synthetic pass/assist/cross networks. Higher values = "
"stronger link between players on the same team. Target linked pairs in auction.")
section("🔥 Hawkes Process — Form Momentum")
hf_r = results.get("hawkes", {})
if "error" in hf_r:
st.warning(hf_r["error"])
else:
fig = _chart_hawkes(hf_r["statuses"])
st.plotly_chart(fig, width="stretch")
insight("HOT = positive momentum (buy window open), COLD = negative drift (wait), "
"NEUTRAL = baseline. Self-exciting process captures temporary scoring bursts.")
with tab6:
section("🔬 Causal Forest — Transfer Effects")
cf = results.get("causal", {})
if "error" in cf:
st.warning(cf["error"])
else:
ck1, ck2, ck3 = st.columns(3)
with ck1:
c = PITCH_GREEN if cf["ate"] > 0 else RED
st.markdown(kpi_card("ATE", f"{cf['ate']:+.4f}", "avg tx effect", c),
unsafe_allow_html=True)
with ck2:
st.markdown(kpi_card("CI LOWER", f"{cf['ate_lower']:+.4f}", "95%", TEXT_SECONDARY),
unsafe_allow_html=True)
with ck3:
st.markdown(kpi_card("CI UPPER", f"{cf['ate_upper']:+.4f}", "95%", TEXT_SECONDARY),
unsafe_allow_html=True)
insight("Causal inference separates true player impact from confounding (team, schedule, luck). "
"Adding a highly-projected player doesn't always improve team score when controlling for roster fit.")
# ── Auction Plan reference ──
st.divider()
section("📋 Reference: Existing Al-Cihred Auction Plan (25 players)")
st.dataframe(
auction_plan[["player", "team", "role", "bid_cap", "fv_proj", "games", "starter%"]]
.rename(columns={"starter%": "start_pct"})
.style.background_gradient(subset=["fv_proj", "bid_cap"], cmap="viridis"),
height=600, width="stretch",
)
insight("25-player squad from the MILP solver. Use new ML models above to refine bids and alternatives.")
st.divider()
with st.expander("⚙️ Methodology — 12 Models on Real Data"):
st.markdown("""
| # | Model | Data Used |
|---|-------|----------|
| 1 | QuantileEnsemble | 505 player projections, 161 FBref features |
| 2 | MinutesSurvivalModel | Real minutes played, starter percentages |
| 3 | BanditAuctionSolver | Real QI/QA/FVM prices, projected FV |
| 4 | OpponentBidModel | QI-based pricing, role scarcity from roster |
| 5 | BudgetOptimizer | Real pool value distribution |
| 6 | PlayerChemistryGAT | 800 synthetic pass/assist/cross edges |
| 7 | PlayerFormModel | 2,021 historical matchday votes |
| 8 | BayesianPlayerModel | Per-role shrinkage on 200 players |
| 9 | ConformalPredictor | 300-player holdout calibration |
| 10 | SetTransformer | 6 synthetic teams of 25 real players each |
| 11 | RLAuctionPolicy | 30-episode training on real player pool |
| 12 | TransferCausalModel | 300-player causal forest |
""", unsafe_allow_html=False)
st.divider()
st.caption(f"Real data: {len(player_pool)} Serie A 26/27 players · {len(votes):,} historical votes · "
f"FBref stats · Fantacalcio.it QI/QA/FVM · Project Al-Cihred auction plan")
if __name__ == "__main__":
run()
+30 -11
View File
@@ -2,40 +2,59 @@
Cached with @st.cache_data. No imports from ML code. Cached with @st.cache_data. No imports from ML code.
""" """
import logging
from pathlib import Path from pathlib import Path
import pandas as pd import pandas as pd
logger = logging.getLogger(__name__)
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
WAREHOUSE = ROOT / "data" / "warehouse" WAREHOUSE = ROOT / "data" / "warehouse"
_EMPTY_DEFAULTS = {
"players.parquet": ["player", "role", "team", "fv_avg", "qi", "games_season",
"fv_proj", "goals", "assists", "stability", "starter_pct",
"fvm", "mv_proj", "bid_cap"],
"fixtures.parquet": ["team", "matchday", "opp_strength", "home", "away"],
"predictions.parquet": ["player", "name", "role", "team", "fv_mean", "fv_std", "mv_mean",
"mv_std", "starter_prob", "cs_prob", "oppteam", "home"],
"lineups.parquet": ["player", "role", "team", "starter_pct"],
"votes.parquet": ["player", "vote", "matchday"],
"model_metrics.parquet": ["metric", "value"],
}
def _cache_key():
"""Bust cache when parquet files change.""" def _read_parquet_or_empty(name):
files = sorted(WAREHOUSE.glob("*.parquet")) path = WAREHOUSE / name
mtimes = tuple(f.stat().st_mtime for f in files) if path.exists():
return (len(files), mtimes) return pd.read_parquet(path)
cols = _EMPTY_DEFAULTS.get(name, [])
df = pd.DataFrame([{c: (0.0 if c not in ("player", "role", "team", "name", "oppteam", "metric")
else ("—" if c in ("player", "name") else ""))
for c in cols}])
return df
def load_players() -> pd.DataFrame: def load_players() -> pd.DataFrame:
return pd.read_parquet(WAREHOUSE / "players.parquet") return _read_parquet_or_empty("players.parquet")
def load_fixtures() -> pd.DataFrame: def load_fixtures() -> pd.DataFrame:
return pd.read_parquet(WAREHOUSE / "fixtures.parquet") return _read_parquet_or_empty("fixtures.parquet")
def load_predictions() -> pd.DataFrame: def load_predictions() -> pd.DataFrame:
return pd.read_parquet(WAREHOUSE / "predictions.parquet") return _read_parquet_or_empty("predictions.parquet")
def load_lineups() -> pd.DataFrame: def load_lineups() -> pd.DataFrame:
return pd.read_parquet(WAREHOUSE / "lineups.parquet") return _read_parquet_or_empty("lineups.parquet")
def load_votes() -> pd.DataFrame: def load_votes() -> pd.DataFrame:
return pd.read_parquet(WAREHOUSE / "votes.parquet") return _read_parquet_or_empty("votes.parquet")
def load_model_metrics() -> pd.DataFrame: def load_model_metrics() -> pd.DataFrame:
return pd.read_parquet(WAREHOUSE / "model_metrics.parquet") return _read_parquet_or_empty("model_metrics.parquet")
+331
View File
@@ -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")
+369
View File
@@ -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 = '<div class="empty">No players found</div>';
return;
}
el.innerHTML = state.players
.map((p) => {
const added = inRoster.has(p.name);
return `
<div class="player-card ${added ? "selected" : ""}" onclick="showPlayer('${p.name}')">
<div class="role-badge ${p.role}">${p.role}</div>
<div class="player-info">
<div class="player-name">${p.name}</div>
<div class="player-team">${p.team || ""}</div>
<div class="player-stats">
<span>FV <strong>${p.projected_points?.toFixed(1) || "—"}</strong></span>
<span>Start <strong>${Math.round((p.starter_pct || 0) * 100)}%</strong></span>
<span>Games <strong>${p.games_played || "—"}</strong></span>
</div>
</div>
<div class="player-price">
<div class="amt">${p.market_value} cr</div>
<div class="label">${added ? "OWNED" : "market"}</div>
</div>
</div>`;
})
.join("");
}
function renderRoster() {
const el = document.getElementById("rosterList");
if (!state.roster.length) {
el.innerHTML = '<div class="empty">No players drafted yet. Search and add players.</div>';
return;
}
const roleOrder = { P: "🧤 Goalkeepers", D: "🛡 Defenders", C: "⚙ Midfielders", A: "⚡ Forwards" };
let html = "";
for (const [role, label] of Object.entries(roleOrder)) {
const players = state.roster.filter((p) => p.role === role);
if (!players.length) continue;
html += `<div class="roster-section"><h3>${label} (${players.length})</h3>`;
html += players
.map(
(p) => `
<div class="roster-player">
<div class="role-badge ${p.role}" style="width:24px;height:24px;font-size:10px;">${p.role}</div>
<div class="name">${p.name}</div>
<div class="name" style="font-size:11px;color:var(--muted);">${p.team}</div>
<div class="price">${p.price} cr</div>
<button class="remove" onclick="removeFromRoster('${p.name}')">✕</button>
</div>`
)
.join("");
html += "</div>";
}
el.innerHTML = html;
}
function renderDetail() {
const el = document.getElementById("detailOverlay");
if (!state.selectedPlayer) {
el.style.display = "none";
return;
}
const p = state.selectedPlayer;
const bidButtons = formatBidButtons(p);
const remaining = state.budgetTotal - state.budgetSpent;
el.style.display = "flex";
el.innerHTML = `
<div class="detail-sheet" onclick="event.stopPropagation()">
<div class="detail-header">
<div>
<div style="display:flex;align-items:center;gap:8px;">
<div class="role-badge ${p.role}">${p.role}</div>
<div>
<div style="font-weight:700;font-size:18px;">${p.name}</div>
<div style="font-size:12px;color:var(--muted);">${p.team}</div>
</div>
</div>
</div>
<button class="detail-close" onclick="closeDetail()">✕</button>
</div>
<div class="ml-card">
<div class="title">Projected Fantavoto</div>
<div class="row"><span>Median (P50)</span><span class="val" style="color:var(--sky);">${p.p50}</span></div>
<div class="row"><span>Floor (P10)</span><span class="val" style="color:var(--red);">${p.p10}</span></div>
<div class="row"><span>Ceiling (P90)</span><span class="val" style="color:var(--green);">${p.p90}</span></div>
</div>
<div class="ml-card">
<div class="title">Risk Assessment</div>
<div class="row"><span>Downside Risk P(FV &lt; 5.5)</span><span class="val" style="color:${p.downside_risk > 0.2 ? 'var(--red)' : 'var(--green)'};">${(p.downside_risk * 100).toFixed(0)}%</span></div>
<div class="row"><span>Starter Probability</span><span class="val">${(p.starter_prob * 100).toFixed(0)}%</span></div>
<div class="row"><span>Games Played</span><span class="val">${p.games_played}</span></div>
</div>
<div class="ml-card" style="border-color:var(--gold);">
<div class="title" style="color:var(--gold);">Auction Intelligence</div>
<div class="row"><span>Market Value</span><span class="val" style="color:var(--gold);">${p.market_value} cr</span></div>
<div class="row"><span>ML Recommended Bid</span><span class="val" style="color:var(--green);">${p.recommended_bid} cr</span></div>
<div class="row"><span>Estimated Opponent Bid</span><span class="val" style="color:var(--red);">${p.opponent_bid} cr</span></div>
<div class="row"><span>Max Rational Bid</span><span class="val">${p.max_bid} cr</span></div>
<div class="row" style="margin-top:4px;font-size:11px;color:var(--muted);">
<span>Value: ${(p.p50 / Math.max(p.recommended_bid || 1, 1)).toFixed(3)} FV/cr</span>
<span>Budget left: ${remaining} cr</span>
</div>
</div>
<div class="bid-quick">
${bidButtons.map((b) => {
const over = b > remaining;
return `<button data-bid="${b}" class="${b === state.bidAmount ? 'active' : ''} ${over ? 'over' : ''}"
onclick="setBid(${b})">${b} cr</button>`;
}).join("")}
</div>
<div class="bid-row">
<input id="bidInput" type="number" value="${state.bidAmount}" min="1" max="${remaining}"
onchange="state.bidAmount=parseInt(this.value)||0">
<button onclick="doBid('${p.name}')" ${state.bidAmount > remaining ? "disabled" : ""}>
BUY
</button>
</div>
${state.roster.some((r) => r.name === p.name)
? `<div class="bid-row"><button class="danger" onclick="removeFromRoster('${p.name}');closeDetail();">REMOVE FROM ROSTER</button></div>`
: ""}
</div>
`;
// Focus bid input
setTimeout(() => {
const inp = document.getElementById("bidInput");
if (inp) { inp.focus(); inp.select(); }
}, 100);
}
// ── Actions ─────────────────────────────────────────────────────
async function doBid(name) {
const success = await addToRoster(name, state.bidAmount);
if (success) {
state.bidAmount = 0;
closeDetail();
}
}
function toast(msg, type = "") {
const el = document.createElement("div");
el.className = "toast" + (type === "error" ? " error" : "");
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => el.remove(), 2000);
}
// ── Tab switching ───────────────────────────────────────────────
function switchTab(tab) {
state.tab = tab;
document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t.dataset.tab === tab));
document.getElementById("auctionView").style.display = tab === "auction" ? "block" : "none";
document.getElementById("rosterView").style.display = tab === "roster" ? "block" : "none";
document.getElementById("statsView").style.display = tab === "stats" ? "block" : "none";
if (tab === "stats") loadStats();
if (tab === "roster") loadRoster();
}
async function loadStats() {
const data = await api(API + "/stats");
if (!data) return;
document.getElementById("statsView").innerHTML = `
<div class="stat-grid">
<div class="stat-card"><div class="num" style="color:var(--sky);">${data.total_players}</div><div class="lbl">Players</div></div>
<div class="stat-card"><div class="num" style="color:var(--gold);">${data.elite_count}</div><div class="lbl">Elite (&gt;100cr)</div></div>
<div class="stat-card"><div class="num" style="color:var(--green);">${data.median_price}</div><div class="lbl">Median Price (cr)</div></div>
<div class="stat-card"><div class="num">${data.price_range[0]}–${data.price_range[1]}</div><div class="lbl">Price Range (cr)</div></div>
</div>
<div class="stat-grid" style="margin-top:8px;">
${Object.entries(data.role_counts).map(([r, c]) => `
<div class="stat-card"><div class="num" style="color:var(--${r === 'P' ? 'gk' : r === 'D' ? 'def' : r === 'C' ? 'mid' : 'fwd'});">${c}</div><div class="lbl">${r === 'P' ? 'Goalkeepers' : r === 'D' ? 'Defenders' : r === 'C' ? 'Midfielders' : 'Forwards'}</div></div>
`).join("")}
</div>
`;
}
// ── Event bindings ──────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", init);
Binary file not shown.

After

Width:  |  Height:  |  Size: 662 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+88
View File
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#00D084">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="/manifest.json">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚽</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
<title>Fantabeto — Auction</title>
</head>
<body>
<div class="app">
<!-- Header -->
<header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<h1>⚽ <span>Fantabeto</span> Auction</h1>
<div id="ping" class="ping">● connected</div>
</div>
<div class="budget-bar">
<div class="label">SPENT</div>
<div class="value" id="budgetSpent">0</div>
<div class="progress">
<div class="progress-fill" id="budgetFill" style="width:0%;"></div>
</div>
<div class="label">LEFT</div>
<div class="value" id="budgetRemaining" style="color:var(--green);">500</div>
<div class="label">cr</div>
</div>
</header>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" data-tab="auction" onclick="switchTab('auction')">🔍 Players</button>
<button class="tab" data-tab="roster" onclick="switchTab('roster')">📋 Roster <span id="rosterCount" style="font-size:10px;color:var(--muted);"></span></button>
<button class="tab" data-tab="stats" onclick="switchTab('stats')">📊 Stats</button>
</div>
<!-- Auction View -->
<div id="auctionView">
<div class="search-bar">
<input type="text" placeholder="Search players..." id="searchInput"
oninput="state.search=this.value;searchPlayers()">
<select onchange="state.roleFilter=this.value;searchPlayers()">
<option value="">All</option>
<option value="P">GK</option>
<option value="D">DEF</option>
<option value="C">MID</option>
<option value="A">FWD</option>
</select>
</div>
<div class="player-list" id="playerList">
<div class="empty">Loading players...</div>
</div>
</div>
<!-- Roster View -->
<div id="rosterView" style="display:none;">
<div style="padding:12px 16px;">
<button onclick="resetRoster()" style="background:var(--card);border:1px solid var(--red);color:var(--red);padding:8px 16px;border-radius:8px;cursor:pointer;font-size:12px;">
Reset Roster
</button>
<span style="font-size:11px;color:var(--muted);margin-left:8px;">
Quotas: P3 D8 C8 A6
</span>
</div>
<div class="player-list" id="rosterList">
<div class="empty">No players drafted yet.</div>
</div>
</div>
<!-- Stats View -->
<div id="statsView" style="display:none;">
<div class="empty">Loading stats...</div>
</div>
</div>
<!-- Detail overlay -->
<div class="detail-overlay" id="detailOverlay" style="display:none;" onclick="closeDetail()">
</div>
<script src="/app.js"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
{
"name": "Fantabeto Auction",
"short_name": "Fantabeto",
"description": "Serie A Fantasy Football Auction Assistant",
"start_url": "/",
"display": "standalone",
"background_color": "#0B0F17",
"theme_color": "#00D084",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+208
View File
@@ -0,0 +1,208 @@
:root {
--bg: #0B0F17;
--card: #111827;
--border: #1F2937;
--text: #E5E7EB;
--muted: #9CA3AF;
--green: #00D084;
--gold: #FFC94D;
--red: #FF4D5E;
--sky: #38BDF8;
--violet: #A78BFA;
--white: #FFF;
--gk: #E74C3C;
--def: #3498DB;
--mid: #2ECC71;
--fwd: #F39C12;
--radius: 12px;
--font: 'Inter', -apple-system, sans-serif;
--mono: 'JetBrains Mono', monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* ── Layout ──────────────────────────────────────────────── */
.app { max-width: 480px; margin: 0 auto; min-height: 100dvh; display: flex; flex-direction: column; }
/* ── Header ──────────────────────────────────────────────── */
header {
background: var(--card);
border-bottom: 1px solid var(--border);
padding: 12px 16px;
position: sticky; top: 0; z-index: 10;
backdrop-filter: blur(12px);
}
header h1 { font-size: 18px; font-weight: 700; }
header h1 span { color: var(--green); }
/* ── Budget bar ───────────────────────────────────────────── */
.budget-bar {
margin: 8px 0;
display: flex; gap: 8px; align-items: center;
}
.budget-bar .label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
.budget-bar .value { font-family: var(--mono); font-size: 20px; font-weight: 700; }
.budget-bar .progress {
flex: 1; height: 6px; background: var(--border); border-radius: 3px; overflow: hidden;
}
.budget-bar .progress-fill {
height: 100%; background: var(--green); border-radius: 3px; transition: width 0.3s;
}
/* ── Tabs ─────────────────────────────────────────────────── */
.tabs {
display: flex; gap: 0; border-bottom: 1px solid var(--border);
padding: 0 16px; background: var(--card); position: sticky; top: 60px; z-index: 9;
}
.tab {
flex: 1; text-align: center; padding: 10px 4px;
border: none; background: none; color: var(--muted);
font-family: var(--font); font-size: 12px; font-weight: 600;
border-bottom: 2px solid transparent; cursor: pointer; transition: all 0.15s;
}
.tab.active { color: var(--green); border-bottom-color: var(--green); }
/* ── Search ───────────────────────────────────────────────── */
.search-bar {
margin: 8px 16px; display: flex; gap: 6px;
}
.search-bar input {
flex: 1; padding: 10px 14px; border-radius: var(--radius); border: 1px solid var(--border);
background: var(--card); color: var(--text); font-size: 14px; outline: none;
}
.search-bar input:focus { border-color: var(--sky); }
.search-bar select {
padding: 10px 8px; border-radius: var(--radius); border: 1px solid var(--border);
background: var(--card); color: var(--text); font-size: 13px; cursor: pointer;
}
/* ── Player cards ──────────────────────────────────────────── */
.player-list { flex: 1; overflow-y: auto; padding: 0 16px 80px; }
.player-card {
background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 12px 14px; margin-bottom: 8px;
cursor: pointer; transition: border-color 0.15s; display: flex; gap: 10px; align-items: center;
}
.player-card:hover { border-color: var(--sky); }
.player-card.selected { border-color: var(--green); background: rgba(0,208,132,0.05); }
.role-badge {
width: 36px; height: 36px; border-radius: 8px; display: flex;
align-items: center; justify-content: center; font-weight: 700; font-size: 13px;
color: white; flex-shrink: 0;
}
.role-badge.P { background: var(--gk); }
.role-badge.D { background: var(--def); }
.role-badge.C { background: var(--mid); }
.role-badge.A { background: var(--fwd); }
.player-info { flex: 1; min-width: 0; }
.player-name { font-weight: 600; font-size: 14px; }
.player-team { font-size: 11px; color: var(--muted); }
.player-stats { display: flex; gap: 12px; margin-top: 2px; font-size: 11px; color: var(--muted); }
.player-stats strong { color: var(--text); font-family: var(--mono); }
.player-price { text-align: right; flex-shrink: 0; }
.player-price .amt { font-family: var(--mono); font-size: 16px; font-weight: 700; color: var(--gold); }
.player-price .label { font-size: 10px; color: var(--muted); }
/* ── Player detail sheet ───────────────────────────────────── */
.detail-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 20;
display: flex; align-items: flex-end; justify-content: center;
}
.detail-sheet {
background: var(--card); border-radius: var(--radius) var(--radius) 0 0;
width: 100%; max-width: 480px; max-height: 85dvh; overflow-y: auto;
padding: 20px 16px; animation: slideUp 0.2s ease;
}
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
.detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.detail-close { background: none; border: none; color: var(--muted); font-size: 24px; cursor: pointer; }
.ml-card {
background: rgba(167,139,250,0.08); border: 1px solid var(--border);
border-radius: 8px; padding: 12px; margin-bottom: 8px;
}
.ml-card .title { font-size: 10px; color: var(--violet); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
.ml-card .row { display: flex; justify-content: space-between; font-size: 13px; }
.ml-card .val { font-family: var(--mono); font-weight: 600; }
.bid-row { display: flex; gap: 8px; margin-top: 12px; }
.bid-row input {
flex: 1; padding: 12px; border-radius: var(--radius); border: 1px solid var(--border);
background: var(--card); color: var(--text); font-size: 20px; font-family: var(--mono);
text-align: center; outline: none;
}
.bid-row input:focus { border-color: var(--green); }
.bid-row button {
padding: 12px 24px; border-radius: var(--radius); border: none;
background: var(--green); color: var(--bg); font-weight: 700; font-size: 14px;
cursor: pointer; transition: opacity 0.15s;
}
.bid-row button:disabled { opacity: 0.4; cursor: not-allowed; }
.bid-row button.danger { background: var(--red); }
.bid-quick { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
.bid-quick button {
padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border);
background: var(--card); color: var(--text); font-size: 12px; cursor: pointer;
}
.bid-quick button.active { border-color: var(--green); background: rgba(0,208,132,0.1); color: var(--green); }
.bid-quick button.over { border-color: var(--red); background: rgba(255,77,94,0.1); color: var(--red); }
/* ── Roster ─────────────────────────────────────────────────── */
.roster-section { margin-bottom: 8px; }
.roster-section h3 {
font-size: 12px; color: var(--muted); text-transform: uppercase;
letter-spacing: 0.5px; margin-bottom: 4px;
}
.roster-player {
display: flex; align-items: center; gap: 8px; padding: 6px 0;
border-bottom: 1px solid var(--border); font-size: 13px;
}
.roster-player .name { flex: 1; font-weight: 500; }
.roster-player .price { font-family: var(--mono); color: var(--gold); font-size: 12px; }
.roster-player .remove {
background: none; border: none; color: var(--red); cursor: pointer; font-size: 16px; padding: 2px 6px;
}
/* ── Stats ──────────────────────────────────────────────────── */
.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 8px 16px; }
.stat-card {
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 12px; text-align: center;
}
.stat-card .num { font-size: 22px; font-family: var(--mono); font-weight: 700; }
.stat-card .lbl { font-size: 10px; color: var(--muted); text-transform: uppercase; margin-top: 2px; }
/* ── Toast ──────────────────────────────────────────────────── */
.toast {
position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); z-index: 30;
background: var(--green); color: var(--bg); padding: 10px 20px; border-radius: 20px;
font-weight: 600; font-size: 13px; animation: fadeIn 0.2s;
}
.toast.error { background: var(--red); color: white; }
@keyframes fadeIn { from { opacity: 0; transform: translateX(-50%) translateY(10px); } }
/* ── Empty state ────────────────────────────────────────────── */
.empty { text-align: center; padding: 40px 20px; color: var(--muted); font-size: 13px; }
/* ── Server indicator ───────────────────────────────────────── */
.ping { font-size: 10px; color: var(--green); text-align: center; padding: 4px; }
.ping.offline { color: var(--red); }
/* ── Responsive ─────────────────────────────────────────────── */
@media (min-width: 480px) {
.app { border-left: 1px solid var(--border); border-right: 1px solid var(--border); }
}
+23
View File
@@ -0,0 +1,23 @@
const CACHE = "fantabeto-v2";
const ASSETS = ["/", "/index.html", "/app.js", "/styles.css", "/manifest.json"];
self.addEventListener("install", (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(ASSETS)));
self.skipWaiting();
});
self.addEventListener("activate", (e) => {
e.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener("fetch", (e) => {
if (e.request.url.includes("/api/")) return; // bypass cache for API
e.respondWith(
caches.match(e.request).then((r) => r || fetch(e.request))
);
});
+4
View File
@@ -61,3 +61,7 @@ ruff>=0.3
# Bot # Bot
python-telegram-bot>=21.0 python-telegram-bot>=21.0
# PWA API
fastapi>=0.110
uvicorn>=0.29
Executable
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# Fantabeto 26/27 — Auction PWA Launcher
# Usage: ./run_pwa.sh [port]
set -e
PORT=${1:-8005}
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "============================================"
echo " Fantabeto 26/27 — Auction PWA"
echo " Serving on port $PORT"
echo "============================================"
echo ""
echo " Local: http://localhost:$PORT"
echo " Network: http://$(hostname -I | awk '{print $1}'):$PORT"
echo ""
echo "Press Ctrl+C to stop."
echo ""
cd "$SCRIPT_DIR"
exec python -m uvicorn pwa.api:app \
--host 0.0.0.0 \
--port "$PORT"