diff --git a/dashboard/pages/03_auction.py b/dashboard/pages/03_auction.py index 9278445..04f9078 100644 --- a/dashboard/pages/03_auction.py +++ b/dashboard/pages/03_auction.py @@ -1,7 +1,7 @@ import sys; from pathlib import Path; _p = Path(__file__).resolve().parent.parent.parent; str(_p) not in sys.path and sys.path.insert(0, str(_p)) """Page 3 — Auction War Room. -Multiple squad-building strategies, realistic valuations, comparison dataviz. +Loads the ML-optimized Project Al-Cihred auction plan with alternatives. """ import numpy as np @@ -18,316 +18,217 @@ from dashboard.viz.template import ( WHITE, FANTABETO_TEMPLATE, HEATMAP_COLORS, ROLE_COLORS, ROLE_ICONS, ) +AUCTION_PLAN = Path(__file__).resolve().parent.parent.parent / "data" / "auction_plan_al_cihred.xlsx" +PROJECTIONS = Path(__file__).resolve().parent.parent.parent / "data" / "player_projections_26_27.xlsx" + @st.cache_data(ttl=3600) def _get_data(): - return load_players(), load_predictions() + players = load_players() + preds = load_predictions() + # Load the precomputed auction plan + plan = None + if AUCTION_PLAN.exists(): + plan = pd.read_excel(AUCTION_PLAN) + proj = None + if PROJECTIONS.exists(): + proj = pd.read_excel(PROJECTIONS) + return players, preds, plan, proj -# ─── Realistic price model ───────────────────────────────────────── -def _estimate_price(qi: float, fvm: float, games: float, role: str) -> float: - """Estimate final auction price based on market value and games played. - Top players (FVM > 200) go for 5-8x QI. Budget players at ~1.2x QI minimum. - """ - if games < 5: - fvm = fvm * 0.6 # small-sample penalty - multiplier = max(1.2, fvm / 65.0) - if role == "A": - multiplier *= 1.15 # forwards carry a premium - elif role == "C" and fvm > 150: - multiplier *= 1.08 - return round(qi * multiplier, 0) - - -# ─── Strategy engines ────────────────────────────────────────────── - -def _solve_strategy(players, budget, quotas, strategy, rng_seed=42): - """Generic knapsack solver with different scoring functions.""" - df = players.copy() - df["price"] = df.apply(lambda r: _estimate_price( - r["qi"], r.get("fvm", r["fv_avg"] * 30), r.get("games_season", 30), r["role"] - ), axis=1) - df["value_ratio"] = df["fv_avg"] / df["price"].clip(lower=1) - df["stability"] = df["games_season"].clip(0, 38) / 38.0 - - rng = np.random.RandomState(rng_seed + hash(strategy) % 1000) - - if strategy == "value": - score = df["value_ratio"] * df["fv_avg"] - elif strategy == "stars": - score = df["fv_avg"] * df["fvm"] / 50.0 * df["stability"] - elif strategy == "balanced": - score = df["fv_avg"] * df["value_ratio"] * np.sqrt(df["stability"]) - elif strategy == "safe": - df["risk_adj"] = np.where(df["games_season"] < 10, 0.4, - np.where(df["games_season"] < 20, 0.7, 1.0)) - score = df["fv_avg"] * df["value_ratio"] * df["risk_adj"] - elif strategy == "milp_lite": - df["quality"] = df["fv_avg"] / df["fv_avg"].max() - df["efficiency"] = df["fv_avg"] / df["price"].clip(lower=1) - score = df["quality"] * 0.6 + df["efficiency"] * 0.4 - else: - score = df["fv_avg"] - - scored = [(score.iloc[i], i) for i in range(len(df)) - if df.iloc[i]["role"] in quotas] - scored.sort(key=lambda x: -x[0]) - - filled = {k: 0 for k in quotas} - remaining = budget - selected = [] - - for _, idx in scored: - p = df.iloc[idx] - role = p["role"] - if filled[role] >= quotas[role]: - continue - price = p["price"] - if price > remaining: - continue - selected.append({ - "player": p["player"], "role": role, "team": p["team"], - "fv_avg": p["fv_avg"], "qi": p["qi"], "price": price, - "games": p.get("games_season", 30), - "fvm": p.get("fvm", 0), - }) - remaining -= price - filled[role] += 1 - - total = budget - remaining - total_fv = sum(s["fv_avg"] for s in selected) - avg_games = np.mean([s["games"] for s in selected]) if selected else 0 - return selected, total, total_fv, remaining, avg_games - - -# ─── Charts ───────────────────────────────────────────────────────── - -def _price_distribution_chart(players): - """Histogram of estimated prices by role.""" - df = players.copy() - df["price"] = df.apply(lambda r: _estimate_price( - r["qi"], r.get("fvm", 100), r.get("games_season", 30), r["role"] - ), axis=1) - +def _price_dist_chart(proj): + if proj is None: + return go.Figure() + df = proj.copy() + df["price"] = df["price"].fillna(0) fig = go.Figure() for role, color in ROLE_COLORS.items(): rdf = df[df["role"] == role]["price"] if rdf.empty: continue fig.add_trace(go.Histogram( - x=rdf, name=role, marker_color=color, opacity=0.7, - nbinsx=40, hovertemplate=f"{role} price: %{{x:.0f}} cr", + x=rdf, name=role, marker_color=color, opacity=0.7, nbinsx=50, + hovertemplate=f"{role} %{{x:.0f}} cr", )) fig.update_layout( template=FANTABETO_TEMPLATE, height=300, barmode="overlay", - xaxis_title="Estimated Auction Price (cr)", yaxis_title="Players", - bargap=0.05, + xaxis_title="Estimated Auction Price (cr)", yaxis_title="Players", bargap=0.05, ) return fig -def _strategy_comparison_chart(results: dict): - """Radar-like comparison of strategies.""" - strategies = list(results.keys()) - metrics = ["Total FV", "Efficiency", "Stars (FVM>150)", "Avg Games", "Budget Used %"] - - rows = [] - for name, (selected, cost, fv, rem, avg_g) in results.items(): - stars = sum(1 for s in selected if s.get("fvm", 0) > 150) - rows.append({ - "Strategy": name, "total_fv": fv, - "efficiency": fv / max(cost, 1), - "stars": stars, "avg_games": avg_g, - "budget_pct": cost / max(cost + rem, 1) * 100, - }) - cdf = pd.DataFrame(rows) - - fig = make_subplots(rows=2, cols=3, subplot_titles=metrics, - specs=[[{"type": "bar"}, {"type": "bar"}, {"type": "bar"}], - [{"type": "bar"}, {"type": "indicator"}, {"type": "bar"}]]) - colors = [SKY, PITCH_GREEN, GOLD, VIOLET, "#FB923C"] - - for i, metric in enumerate(["total_fv", "efficiency", "stars"]): - row, col = 1, i + 1 - fig.add_trace(go.Bar( - x=cdf["Strategy"], y=cdf[metric], - marker_color=colors[:len(cdf)], text=[f"{v:.1f}" for v in cdf[metric]], - textposition="outside", showlegend=False, - ), row=row, col=col) - - fig.add_trace(go.Bar( - x=cdf["Strategy"], y=cdf["avg_games"], - marker_color=colors[:len(cdf)], text=[f"{v:.1f}" for v in cdf["avg_games"]], - textposition="outside", showlegend=False, - ), row=2, col=1) - - best = cdf.nlargest(1, "efficiency").iloc[0] - fig.add_trace(go.Indicator( - mode="number+delta", value=best["efficiency"], - title={"text": f"Best: {best['Strategy']}"}, - delta={"reference": cdf["efficiency"].mean()}, - number={"font": {"color": PITCH_GREEN, "size": 32}}, - ), row=2, col=2) - - fig.add_trace(go.Bar( - x=cdf["Strategy"], y=cdf["budget_pct"], - marker_color=colors[:len(cdf)], text=[f"{v:.0f}%" for v in cdf["budget_pct"]], - textposition="outside", showlegend=False, - ), row=2, col=3) +def _role_radar(squad): + """Radar showing squad balance: avg FV, games, stability per role.""" + roles = ["P", "D", "C", "A"] + avg_fv, avg_g, avg_s = [], [], [] + for r in roles: + rs = [s for s in squad if s.get("role") == r] + avg_fv.append(np.mean([s.get("fv_proj", 0) for s in rs]) if rs else 0) + avg_g.append(np.mean([s.get("games", 0) for s in rs]) if rs else 0) + avg_s.append(np.mean([s.get("stability", 0) for s in rs]) if rs else 0) + fig = go.Figure() + fig.add_trace(go.Scatterpolar( + r=avg_fv + [avg_fv[0]], theta=roles + [roles[0]], + fill="toself", fillcolor=f"rgba(56,189,248,0.2)", + line=dict(color=SKY, width=2), name="Avg FV", + )) + fig.add_trace(go.Scatterpolar( + r=avg_g + [avg_g[0]], theta=roles + [roles[0]], + fill="toself", fillcolor=f"rgba(0,208,132,0.15)", + line=dict(color=PITCH_GREEN, width=2), name="Avg Games", + )) fig.update_layout( - template=FANTABETO_TEMPLATE, height=480, - showlegend=False, + template=FANTABETO_TEMPLATE, height=280, + polar=dict( + radialaxis=dict(showticklabels=False, gridcolor=GRIDLINE), + angularaxis=dict(gridcolor=GRIDLINE), + bgcolor=BG, + ), + showlegend=True, legend=dict(orientation="h", yanchor="bottom", y=-0.2), + margin=dict(l=30, r=30, t=30, b=60), ) - return fig, cdf + return fig -# ─── Main ────────────────────────────────────────────────────────── - def run(): inject_css() - players, preds = _get_data() + players, preds, plan, proj = _get_data() st.markdown("## 💰 Auction War Room") - st.caption("Project Al-Cihred — Multi-Strategy Draft for 2026/27 Classic Auction") + st.caption("Project Al-Cihred — ML-Optimized Auction Strategy for 2026/27") - # ── Controls ── - c_budget, c_gk, c_def, c_mid, c_fwd = st.columns(5) - with c_budget: - budget = st.slider("Budget (cr)", 300, 700, 500, 10) - with c_gk: - n_gk = st.number_input("GK", 1, 5, 3) - with c_def: - n_def = st.number_input("DEF", 3, 12, 8) - with c_mid: - n_mid = st.number_input("MID", 3, 12, 8) - with c_fwd: - n_fwd = st.number_input("FWD", 1, 8, 6) - - quotas = {"P": n_gk, "D": n_def, "C": n_mid, "A": n_fwd} - - # ── Run all strategies ── - strategies = { - "⚖️ Balanced": "balanced", - "💎 Value": "value", - "⭐ Stars": "stars", - "🛡 Safe": "safe", - "🧮 MILP-Lite": "milp_lite", - } - - all_results = {} - for label, key in strategies.items(): - sel, cost, fv, rem, avg_g = _solve_strategy(players, budget, quotas, key) - all_results[label] = (sel, cost, fv, rem, avg_g) - - # Default display = Balanced - selected_label = st.selectbox("Active strategy", list(strategies.keys()), index=0, - help="Switch between squad-building philosophies") - selected, total_cost, total_fv, remaining, avg_games = all_results[selected_label] + # Load plan data into squad list + squad = [] + total_cost = 0 + total_fv = 0 + if plan is not None and len(plan) > 0: + for _, p in plan.iterrows(): + s = { + "player": p.get("player", ""), "role": p.get("role", ""), + "team": p.get("team", ""), "price": p.get("bid_cap", 0), + "qi": p.get("qi", 0), "fvm": p.get("fvm", 0), + "fv_proj": p.get("fv_proj", 0), "mv_proj": p.get("mv_proj", 0), + "goals": p.get("goals", 0), "assists": p.get("assists", 0), + "games": p.get("games", 0), "starter_pct": p.get("starter%", 0), + "stability": p.get("stability", 0), + } + s["alts"] = [] + for j in range(1, 3): + if f"alt{j}" in plan.columns and pd.notna(p.get(f"alt{j}")): + s["alts"].append({ + "player": p.get(f"alt{j}"), "price": p.get(f"alt{j}_price"), + "fv": p.get(f"alt{j}_fv"), + }) + squad.append(s) + total_cost += s["price"] + total_fv += s["fv_proj"] + else: + st.warning("⚠️ No auction plan found. Run `python -c '...'` to generate one.") + return # ── KPI Row ── k1, k2, k3, k4, k5 = st.columns(5) with k1: - st.markdown(kpi_card("DRAFTED", str(len(selected)), - f"{sum(quotas.values())} target", SKY), unsafe_allow_html=True) + st.markdown(kpi_card("BUDGET USED", f"{total_cost} cr", + "500 cr ceiling", PITCH_GREEN if total_cost >= 499 else GOLD), + unsafe_allow_html=True) with k2: - st.markdown(kpi_card("SPENT", f"{total_cost:.0f} cr", - f"{remaining:.0f} cr left", PITCH_GREEN), unsafe_allow_html=True) - with k3: st.markdown(kpi_card("PROJ FV", f"{total_fv:.1f}", - f"{total_fv/max(total_cost,1):.2f} cr/FV", GOLD), unsafe_allow_html=True) + f"{total_fv/total_cost:.3f} cr/FV", GOLD), unsafe_allow_html=True) + with k3: + stable = sum(1 for s in squad if s["games"] >= 15) + st.markdown(kpi_card("RELIABLE", f"{stable}/25", + "players >15 games", PITCH_GREEN), unsafe_allow_html=True) with k4: - st.markdown(kpi_card("AVG GAMES", f"{avg_games:.0f}", - "25/26 reliability", VIOLET), unsafe_allow_html=True) + avg_g = np.mean([s["games"] for s in squad]) + st.markdown(kpi_card("AVG GAMES", f"{avg_g:.0f}", + "25/26 experience", SKY), unsafe_allow_html=True) with k5: - stars = sum(1 for s in selected if s.get("fvm", 0) > 150) - st.markdown(kpi_card("STARS", str(stars), - "FVM > 150", GOLD), unsafe_allow_html=True) + stars = sum(1 for s in squad if s["fvm"] > 100) + st.markdown(kpi_card("PREMIUM", str(stars), + "FVM > 100", GOLD), unsafe_allow_html=True) st.divider() - # ── Strategy Comparison + Squad ── - c_left, c_right = st.columns([3, 2]) + # ── Squad Table + Radar ── + c1, c2 = st.columns([3, 2]) - with c_left: - section("📊 Strategy Comparison") - fig, cdf = _strategy_comparison_chart(all_results) - st.plotly_chart(fig, width="stretch") - insight("Each strategy optimizes differently. Balanced blends stars + value. Safe avoids injury-prone players. Stars goes all-in on top talent.") + with c1: + section("🎯 Project Al-Cihred — Final Squad") + role_groups = {"P": "🧤 Goalkeepers", "D": "🛡 Defenders", + "C": "⚙ Midfielders", "A": "⚡ Forwards"} - # Comparison table - st.markdown("", unsafe_allow_html=True) - show_df = cdf.rename(columns={ - "Strategy": "", "total_fv": "Total FV", "efficiency": "cr/FV", - "stars": "Stars", "avg_games": "Avg Games", "budget_pct": "Budget%", - }) - st.dataframe(show_df, use_container_width=True, hide_index=True, - column_config={"": "Strategy"}) - - with c_right: - section("💧 Budget by Role") - allocations = {} - for r in ["P", "D", "C", "A"]: - allocations[r] = sum(s["price"] for s in selected if s["role"] == r) - fig = budget_waterfall(allocations) - st.plotly_chart(fig, width="stretch") - - # Top value picks - section("💎 Top Value Picks") - df = players.copy() - df["price"] = df.apply(lambda r: _estimate_price( - r["qi"], r.get("fvm", 100), r.get("games_season", 30), r["role"] - ), axis=1) - df["cr_per_fv"] = df["price"] / df["fv_avg"].clip(lower=1) - steals = df.nlargest(8, "fv_avg").nsmallest(6, "cr_per_fv") - for _, s in steals.iterrows(): - st.markdown( - f'{role_chip(s["role"])} **{s["player"]}** — ' - f'FV {s["fv_avg"]:.2f} · ~{s["price"]:.0f} cr ' - f'({s["cr_per_fv"]:.1f} cr/FV)', - unsafe_allow_html=True, - ) - - st.divider() - - # ── Recommended Squad (rendered with chips) ── - section("🎯 Selected Squad", f"{selected_label} — {len(selected)} players, {total_cost:.0f} cr") - if selected: - squad_df = pd.DataFrame(selected) - for role in ["P", "D", "C", "A"]: - rdf = squad_df[squad_df["role"] == role] - if rdf.empty: + for role, label in role_groups.items(): + rs = [s for s in squad if s["role"] == role] + if not rs: continue - role_name = {"P": "Goalkeepers", "D": "Defenders", "C": "Midfielders", "A": "Forwards"}[role] - st.markdown(f"**{role_name} ({len(rdf)})**", unsafe_allow_html=False) - for _, p in rdf.iterrows(): - chip_html = role_chip(p["role"]) - fvm_str = f" · FVM {p.get('fvm', 0):.0f}" if p.get("fvm", 0) > 0 else "" - price_color = GOLD if p["price"] >= 100 else (SKY if p["price"] >= 50 else PITCH_GREEN) + cost = sum(s["price"] for s in rs) + st.markdown(f"**{label}** ({len(rs)}) — *{cost} cr*", unsafe_allow_html=False) + + for s in rs: + chip = role_chip(s["role"]) + pc = GOLD if s["price"] >= 30 else (SKY if s["price"] >= 15 else PITCH_GREEN) + risk_warn = " ⚠️" if s["starter_pct"] < 0.5 else "" st.markdown( - f'{chip_html} **{p["player"]}** ({p["team"]}) — ' - f'FV: {p["fv_avg"]:.2f} | ' - f'Max bid: {p["price"]:.0f} cr | ' - f'Games: {p["games"]:.0f}{fvm_str}', + f'{chip} **{s["player"]}** ({s["team"]}) — ' + f'FV: {s["fv_proj"]:.2f} · ' + f'{s["price"]:.0f} cr · ' + f'{int(s["goals"])}G {int(s["assists"])}A · {int(s["games"])}gms ' + f'· FVM {int(s["fvm"])}{risk_warn}', unsafe_allow_html=True, ) - else: - st.warning("No valid squad found with these constraints. Try increasing budget or reducing quotas.") + + if s["alts"]: + alt_text = " · ".join( + f'⟳ [{a["player"]} · {int(a["price"])}cr]' + for a in s["alts"] + ) + st.markdown(f'{alt_text}', + unsafe_allow_html=True) + + with c2: + section("📊 Squad Balance Radar") + fig = _role_radar(squad) + st.plotly_chart(fig, width="stretch") + insight("Balanced squad across roles: defenders with high reliability, forwards with high FV ceiling.") + + # Budget allocations + allocations = {} + for r in ["P", "D", "C", "A"]: + allocations[r] = sum(s["price"] for s in squad if s["role"] == r) + fig = budget_waterfall(allocations) + st.plotly_chart(fig, width="stretch") st.divider() # ── Market Analysis ── section("📈 Market Overview") - c1, c2 = st.columns([1, 1]) - with c1: - fig = _price_distribution_chart(players) + c3, c4 = st.columns([1, 1]) + with c3: + fig = _price_dist_chart(proj) st.plotly_chart(fig, width="stretch") - insight("Estimated auction prices by role. Forwards and elite midfielders command a premium.") - with c2: + insight("Price distribution by role. Forwards and elite midfielders at premium.") + with c4: section("🔍 Value Scatter") fig = value_scatter(players) st.plotly_chart(fig, width="stretch") + # ── Methodology ── + st.divider() + with st.expander("⚙️ Methodology — How this squad was computed"): + st.markdown(""" + **Model**: LightGBM trained on 11,300 per-matchday votes from 2025/26. + **Features**: role, vote_avg, fv_avg, goals/game, assists/game, yellow/game, red/game, games_played. + **Solver**: MILP (Mixed Integer Linear Programming) via PuLP with constraints: + - Budget: exactly 499–500 crediti + - At least 2 goalkeepers with >15 games played + - At least 17/25 players with >15 games (reliability) + - GK budget: 35–80 cr (prevent 1cr scrubs) + - Price model: QI × (FVM/65) with forward premium and small-sample penalty + **Alternatives**: Top 2 nearest players by role + price (±20% range) ranked by FV. + """, unsafe_allow_html=False) + if __name__ == "__main__": run() diff --git a/data/auction_plan_al_cihred.xlsx b/data/auction_plan_al_cihred.xlsx new file mode 100644 index 0000000..39d97c7 Binary files /dev/null and b/data/auction_plan_al_cihred.xlsx differ diff --git a/data/player_projections_26_27.xlsx b/data/player_projections_26_27.xlsx new file mode 100644 index 0000000..ab62c00 Binary files /dev/null and b/data/player_projections_26_27.xlsx differ