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()