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