diff --git a/dashboard/pages/02_players.py b/dashboard/pages/02_players.py index c1a7aeb..57fead0 100644 --- a/dashboard/pages/02_players.py +++ b/dashboard/pages/02_players.py @@ -89,7 +89,7 @@ def run(): role = p.get("role", "?") team = p.get("team", "?") st.markdown(f"### {ROLE_ICONS.get(role, '')} {selected}") - st.caption(f"{role_chip(role)} {team} · FVM: {p.get('fvm', 0)} · QI: {p.get('qi', 0)} cr") + st.caption(f"{ROLE_ICONS.get(role, '')} {role} · {team} · FVM: {p.get('fvm', 0)} · QI: {p.get('qi', 0)} cr") with h2: fv = p.get("fv_avg", 6.0) st.metric("Fantavoto Avg", f"{fv:.2f}", delta=None) diff --git a/dashboard/pages/03_auction.py b/dashboard/pages/03_auction.py index 4c11dcd..9278445 100644 --- a/dashboard/pages/03_auction.py +++ b/dashboard/pages/03_auction.py @@ -1,111 +1,197 @@ 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. -Budget waterfall, value scatter, grid-auction heatmap, budget slider simulator. +Multiple squad-building strategies, realistic valuations, comparison dataviz. """ 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.warehouse import load_players, load_predictions from dashboard.viz.components import inject_css, section, insight, role_chip, kpi_card from dashboard.viz.charts import budget_waterfall, value_scatter from dashboard.viz.template import ( - PITCH_GREEN, GOLD, RED, SKY, BG, CARD_BG, BORDER, TEXT_SECONDARY, - WHITE, FANTABETO_TEMPLATE, HEATMAP_COLORS, ROLE_COLORS, + PITCH_GREEN, GOLD, RED, SKY, VIOLET, BG, CARD_BG, BORDER, TEXT_SECONDARY, + WHITE, FANTABETO_TEMPLATE, HEATMAP_COLORS, ROLE_COLORS, ROLE_ICONS, ) @st.cache_data(ttl=3600) def _get_data(): - players = load_players() - preds = load_predictions() - return players, preds + return load_players(), load_predictions() -def _compute_auction(players, budget, gk, df, mf, fw): - """Greedy knapsack auction solver.""" - quotas = {"P": gk, "D": df, "C": mf, "A": fw} - filled = {"P": 0, "D": 0, "C": 0, "A": 0} - remaining = budget +# ─── 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["value_ratio"] = df["fv_avg"] / df["qi"].clip(lower=1) - df["estimated_price"] = df["qi"] * np.clip(np.random.RandomState(42).normal(2.5, 0.8, len(df)), 0.8, 6) + 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 - scored = [] - for _, p in df.iterrows(): - role = p["role"] - if role not in quotas: - continue - scored.append((p["value_ratio"] * p["fv_avg"], p)) + 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 _, p in scored: + + for _, idx in scored: + p = df.iloc[idx] role = p["role"] if filled[role] >= quotas[role]: continue - price = p["estimated_price"] + 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"], - "estimated_price": price, - "games_season": p.get("games_season", 30), + "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) - return selected, total, total_fv, remaining + avg_games = np.mean([s["games"] for s in selected]) if selected else 0 + return selected, total, total_fv, remaining, avg_games -def _grid_heatmap(players): - """Simplified grid auction heatmap: top players × bid levels.""" - top = players.nlargest(10, "fv_avg")[ - ["player", "role", "fv_avg", "qi"] - ].copy() - bid_multipliers = [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0] +# ─── Charts ───────────────────────────────────────────────────────── - matrix = [] - labels = [] - for _, p in top.iterrows(): - row = [] - for mult in bid_multipliers: - bid = p["qi"] * mult - surplus = p["fv_avg"] * 3 - bid # rough value - row.append(max(0, surplus)) - matrix.append(row) - labels.append(p["player"]) +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) - fig = go.Figure(data=go.Heatmap( - z=matrix, - x=[f"{m}x QI" for m in bid_multipliers], - y=labels, - colorscale=HEATMAP_COLORS, - hovertemplate="%{y}
Bid: %{x}
Surplus: %{z:.0f}", - )) + 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", + )) fig.update_layout( - template=FANTABETO_TEMPLATE, height=350, - xaxis=dict(side="top"), - yaxis=dict(autorange="reversed"), + template=FANTABETO_TEMPLATE, height=300, barmode="overlay", + 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) + + fig.update_layout( + template=FANTABETO_TEMPLATE, height=480, + showlegend=False, + ) + return fig, cdf + + +# ─── Main ────────────────────────────────────────────────────────── + def run(): inject_css() players, preds = _get_data() st.markdown("## 💰 Auction War Room") - st.caption("Project Al-Cihred — Draft Strategy for 2026/27 Classic Auction") + st.caption("Project Al-Cihred — Multi-Strategy Draft for 2026/27 Classic Auction") - # ── Budget controls ── + # ── 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) @@ -118,53 +204,94 @@ def run(): with c_fwd: n_fwd = st.number_input("FWD", 1, 8, 6) - selected, total_cost, total_fv, remaining = _compute_auction( - players, budget, n_gk, n_def, n_mid, n_fwd - ) + 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] # ── KPI Row ── - k1, k2, k3, k4 = st.columns(4) + k1, k2, k3, k4, k5 = st.columns(5) with k1: - st.markdown(kpi_card("PLAYERS DRAFTED", str(len(selected)), - f"{n_gk+n_def+n_mid+n_fwd} target", SKY), - unsafe_allow_html=True) + st.markdown(kpi_card("DRAFTED", str(len(selected)), + f"{sum(quotas.values())} target", SKY), unsafe_allow_html=True) with k2: - st.markdown(kpi_card("TOTAL SPENT", f"{total_cost:.0f} cr", - f"{remaining:.0f} cr remaining", PITCH_GREEN), - unsafe_allow_html=True) + 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("PROJECTED FV", f"{total_fv:.1f}", - f"{total_fv / max(total_cost, 1):.2f} cr/FV", GOLD), - unsafe_allow_html=True) + 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) with k4: - st.markdown(kpi_card("AVG PRICE", f"{total_cost / max(len(selected), 1):.0f} cr", - "per player", SKY), - unsafe_allow_html=True) + st.markdown(kpi_card("AVG GAMES", f"{avg_games:.0f}", + "25/26 reliability", VIOLET), 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) st.divider() - # ── Budget Waterfall + Value Scatter ── - c1, c2 = st.columns([2, 3]) - with c1: - section("💧 Budget Allocation") + # ── Strategy Comparison + Squad ── + c_left, c_right = 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.") + + # 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["estimated_price"] for s in selected if s["role"] == r) + allocations[r] = sum(s["price"] for s in selected if s["role"] == r) fig = budget_waterfall(allocations) st.plotly_chart(fig, width="stretch") - insight("How your budget maps across roles. Aim for ~15% GK, ~35% DEF, ~30% MID, ~20% FWD.") - with c2: - section("📈 Value Scatter") - fig = value_scatter(players) - st.plotly_chart(fig, width="stretch") - insight("Top-right: high FV, high price. Bottom-right: value steals. " - "Bubble size = games played. Dashed lines = cost-per-FV-point isolines.") + # 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() - # ── Target Squad ── - section("🎯 Recommended Squad") + # ── 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"]: @@ -172,23 +299,34 @@ def run(): if rdf.empty: continue role_name = {"P": "Goalkeepers", "D": "Defenders", "C": "Midfielders", "A": "Forwards"}[role] - st.markdown(f"**{role_name}** {role_chip(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) st.markdown( - f"- **{p['player']}** ({p['team']}) — " - f"FV: {p['fv_avg']:.2f} | " - f"Max bid: {p['estimated_price']:.0f} cr | " - f"Games: {p['games_season']:.0f}", + 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}', + unsafe_allow_html=True, ) + else: + st.warning("No valid squad found with these constraints. Try increasing budget or reducing quotas.") st.divider() - # ── Grid Auction Heatmap ── - section("🔢 Grid Auction Simulator") - fig = _grid_heatmap(players) - st.plotly_chart(fig, width="stretch") - insight("Green = good value at that bid multiplier. Red = overpaying. " - "Bid at the 'green' multiplier for each player.") + # ── Market Analysis ── + section("📈 Market Overview") + c1, c2 = st.columns([1, 1]) + with c1: + fig = _price_distribution_chart(players) + st.plotly_chart(fig, width="stretch") + insight("Estimated auction prices by role. Forwards and elite midfielders command a premium.") + with c2: + section("🔍 Value Scatter") + fig = value_scatter(players) + st.plotly_chart(fig, width="stretch") if __name__ == "__main__": diff --git a/dashboard/pages/04_lineup.py b/dashboard/pages/04_lineup.py index bf1c8e0..2759135 100644 --- a/dashboard/pages/04_lineup.py +++ b/dashboard/pages/04_lineup.py @@ -166,6 +166,7 @@ def run(): for p in bench[:7]: st.markdown( f'{role_chip(p.get("role","C"))} {p["player"]} — FV {p.get("fv_mean",0):.2f}', + unsafe_allow_html=True, ) st.divider()