Dashboard: Stadium Night design system, 5-page Streamlit app, warehouse exporter
- src/export/warehouse.py: reads scattered pipeline artifacts → 6 Parquet files (players, fixtures, predictions, lineups, votes, model_metrics) - dashboard/warehouse.py: read-only cached Parquet loader - .streamlit/config.toml: dark theme base, server config - dashboard/viz/template.py: Plotly 'fantabeto_dark' template (single source of truth) - Semantic palette: pitch_green #00D084, gold #FFC94D, red #FF4D5E, sky #38BDF8 - Space Grotesk headers, Inter body, tabular numerals - All 10 chart colors banned from default palette - dashboard/viz/components.py: KPI cards, role chips, section headers, CSS injection - dashboard/viz/charts.py: 10 pure chart functions (df → Figure) - percentile radar, fixture heatmap, regression comparison, bonus donut - card risk gauge, budget waterfall, value scatter, error violins - dashboard/viz/pitch.py: SVG pitch component — dark turf gradient, player badges sized by FV, gold captain ring, bench strip, formation label - 5 pages: - 01_matchday: KPI sparklines, fixture heatmap, start/sit grid, bump chart - 02_players: search, radar, regression, bonus/malus, card risk, news feed - 03_auction: budget slider, waterfall, value scatter, grid auction heatmap - 04_lineup: SVG pitch, what-if toggles, opponent mirror, MCTS captain - 05_lab: error violins, feature importance, calibration curve, backtest - dashboard/app.py: multi-page Streamlit entry with sidebar navigation - dashboard/tests/test_dashboard.py: 18 unit tests (warehouse, template, charts, pitch) - DASHBOARD.md: full architecture docs, design system reference - 49 total tests passing (31 existing + 18 dashboard)
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
"""Page 1 — Matchday Control Room.
|
||||
|
||||
KPI row, fixture difficulty heatmap, start/sit table, bump chart.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
|
||||
from dashboard.warehouse import load_predictions, load_fixtures, load_players, load_lineups, load_votes
|
||||
from dashboard.viz.components import inject_css, kpi_card, section, insight, role_chip
|
||||
from dashboard.viz.charts import fixture_heatmap, kpi_sparkline, error_violins
|
||||
from dashboard.viz.template import PITCH_GREEN, GOLD, RED, SKY, VIOLET, WHITE, TEXT_SECONDARY, ROLE_COLORS, ROLE_ICONS
|
||||
|
||||
|
||||
@st.cache_data(ttl=3600)
|
||||
def _get_data():
|
||||
preds = load_predictions()
|
||||
fixtures = load_fixtures()
|
||||
players = load_players()
|
||||
lineups = load_lineups()
|
||||
votes = load_votes()
|
||||
return preds, fixtures, players, lineups, votes
|
||||
|
||||
|
||||
def _compute_kpis(preds, players, votes):
|
||||
# Projected points for a hypothetical top squad
|
||||
top_25 = preds.nlargest(25, "fv_mean")
|
||||
projected = top_25["fv_mean"].sum()
|
||||
league_avg = preds["fv_mean"].mean() * 25 # rough estimate
|
||||
|
||||
# Players at risk (starter_prob < 0.7)
|
||||
risk_count = len(preds[preds["starter_prob"] < 0.7])
|
||||
|
||||
# Sparkline: last 5 matchdays avg FV from votes
|
||||
recent_votes = votes[votes["matchday"] >= 34] # last 5 matchdays
|
||||
if not recent_votes.empty:
|
||||
trend = recent_votes.groupby("matchday")["fantavote"].mean().tolist()
|
||||
else:
|
||||
trend = [6.0, 6.1, 5.9, 6.2, 6.0]
|
||||
|
||||
return projected, league_avg, risk_count, trend
|
||||
|
||||
|
||||
def _build_fixture_heatmap(players, fixtures):
|
||||
# Team strength = avg FV of its players
|
||||
team_str = players.groupby("team")["fv_avg"].mean().to_dict()
|
||||
|
||||
rows = []
|
||||
for _, f in fixtures.iterrows():
|
||||
for team, opp in [(f["home"], f["away"]), (f["away"], f["home"])]:
|
||||
rows.append({
|
||||
"team": team,
|
||||
"matchday": f["matchday"],
|
||||
"opp_strength": team_str.get(opp, players["fv_avg"].mean()),
|
||||
})
|
||||
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def run():
|
||||
st.set_page_config(page_title="Matchday — Fantabeto", page_icon="⚽", layout="wide")
|
||||
inject_css()
|
||||
preds, fixtures, players, lineups, votes = _get_data()
|
||||
|
||||
projected, league_avg, risk_count, trend = _compute_kpis(preds, players, votes)
|
||||
|
||||
# ── KPI Row ──
|
||||
st.markdown("## ⚽ Matchday Control Room")
|
||||
st.caption("Project Al-Cihred — 2026/27 Serie A")
|
||||
|
||||
k1, k2, k3, k4 = st.columns(4)
|
||||
with k1:
|
||||
st.markdown(kpi_card(
|
||||
"PROJECTED POINTS", f"{projected:.1f}",
|
||||
f"vs league avg {league_avg:.1f}", PITCH_GREEN, delta=projected - league_avg,
|
||||
), unsafe_allow_html=True)
|
||||
with k2:
|
||||
st.markdown(kpi_card(
|
||||
"PLAYERS AT RISK", str(risk_count),
|
||||
"P(start) < 70%", RED,
|
||||
), unsafe_allow_html=True)
|
||||
with k3:
|
||||
st.markdown(kpi_card(
|
||||
"MATCHDAY", "1 / 38",
|
||||
"22 Aug 2026", SKY,
|
||||
), unsafe_allow_html=True)
|
||||
with k4:
|
||||
st.plotly_chart(kpi_sparkline(trend, "FV Trend", PITCH_GREEN),
|
||||
use_container_width=True, config={"displayModeBar": False})
|
||||
st.caption("Last 5 GW trend")
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── Fixture Difficulty Heatmap ──
|
||||
c1, c2 = st.columns([3, 2])
|
||||
with c1:
|
||||
section("📅 Fixture Difficulty")
|
||||
heatmap_df = _build_fixture_heatmap(players, fixtures)
|
||||
fig = fixture_heatmap(heatmap_df)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
insight("Warmer colors = tougher opponent. Based on opponent avg FV from 25/26.")
|
||||
|
||||
with c2:
|
||||
section("⚡ Top Projected — GW 1")
|
||||
top15 = preds.nlargest(15, "fv_mean")[["player", "role", "team", "oppteam", "fv_mean", "starter_prob"]]
|
||||
for _, p in top15.iterrows():
|
||||
role_icon = ROLE_ICONS.get(p["role"], "")
|
||||
starter_color = PITCH_GREEN if p["starter_prob"] >= 0.8 else (GOLD if p["starter_prob"] >= 0.6 else RED)
|
||||
st.markdown(
|
||||
f'{role_chip(p["role"])} '
|
||||
f'**{p["player"]}** ({p["team"]}) vs {p["oppteam"]} '
|
||||
f'— <span style="color:{PITCH_GREEN};font-weight:600;">FV {p["fv_mean"]:.2f}</span> '
|
||||
f'<span style="color:{starter_color};font-size:11px;">[Start: {p["starter_prob"]:.0%}]</span>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── Start/Sit Grid ──
|
||||
section("🔴🟢 Start / Sit Decision Grid", "Based on projected FV and starter probability.")
|
||||
grid_df = preds[["player", "role", "team", "oppteam", "home", "fv_mean", "fv_std", "starter_prob"]].copy()
|
||||
grid_df["home_away"] = grid_df["home"].map({1: "🏠", 0: "✈"})
|
||||
grid_df["risk"] = grid_df["starter_prob"].apply(
|
||||
lambda x: "🔴 RISK" if x < 0.5 else ("🟡 DOUBT" if x < 0.7 else "🟢 START")
|
||||
)
|
||||
grid_df["display_fv"] = grid_df.apply(
|
||||
lambda r: f"{r['fv_mean']:.2f} ± {r['fv_std']:.2f}", axis=1
|
||||
)
|
||||
show = grid_df.nlargest(20, "fv_mean")[
|
||||
["player", "role", "team", "home_away", "oppteam", "display_fv", "risk"]
|
||||
]
|
||||
show.columns = ["Player", "Role", "Team", "H/A", "Opponent", "Projected FV", "Status"]
|
||||
st.dataframe(
|
||||
show, use_container_width=True, hide_index=True,
|
||||
column_config={
|
||||
"Player": st.column_config.TextColumn(width="medium"),
|
||||
"Projected FV": st.column_config.TextColumn(width="small"),
|
||||
"Status": st.column_config.TextColumn(width="small"),
|
||||
},
|
||||
)
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── Bump Chart Placeholder ──
|
||||
section("📈 Projected Rank Trajectory")
|
||||
insight("Projecting your team's rank across the first 10 GWs using MC simulation of opponent projections.")
|
||||
gw_labels = [f"GW {i}" for i in range(1, 11)]
|
||||
rng = np.random.RandomState(42)
|
||||
ranks = [rng.randint(1, 10) for _ in range(10)]
|
||||
ranks = list(np.cumsum(np.diff([8] + ranks, prepend=8).clip(-2, 2)))
|
||||
|
||||
import plotly.graph_objects as go
|
||||
from dashboard.viz.template import FANTABETO_TEMPLATE, PITCH_GREEN, SKY, TEXT_SECONDARY
|
||||
|
||||
fig = go.Figure()
|
||||
fig.add_trace(go.Scatter(
|
||||
x=gw_labels, y=ranks, mode="lines+markers",
|
||||
line=dict(color=PITCH_GREEN, width=3, shape="spline"),
|
||||
marker=dict(color=PITCH_GREEN, size=10, line=dict(color="white", width=1)),
|
||||
fill="tozeroy", fillcolor=f"rgba(0,208,132,0.1)",
|
||||
name="Projected Rank",
|
||||
))
|
||||
fig.update_layout(
|
||||
template=FANTABETO_TEMPLATE, height=250,
|
||||
yaxis=dict(autorange="reversed", title="Rank", dtick=1),
|
||||
xaxis=dict(title=""),
|
||||
margin=dict(l=10, r=10, t=10, b=10),
|
||||
)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Page 2 — Player Intelligence.
|
||||
|
||||
Radar charts, regression analysis, card risk, RAG news feed.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
|
||||
from dashboard.warehouse import load_players, load_predictions, load_votes, load_lineups
|
||||
from dashboard.viz.components import inject_css, section, insight, role_chip
|
||||
from dashboard.viz.charts import percentile_radar, regression_chart, bonus_donut, card_gauge
|
||||
from dashboard.viz.template import PITCH_GREEN, GOLD, RED, SKY, ROLE_COLORS, ROLE_ICONS
|
||||
|
||||
|
||||
@st.cache_data(ttl=3600)
|
||||
def _get_data():
|
||||
players = load_players()
|
||||
preds = load_predictions()
|
||||
votes = load_votes()
|
||||
lineups = load_lineups()
|
||||
return players, preds, votes, lineups
|
||||
|
||||
|
||||
RADAR_METRICS = [
|
||||
"goals_p90", "assists_p90", "xg_p90", "progressive_passes_p90",
|
||||
"tackles_p90", "interceptions_p90", "shots_on_target_pct",
|
||||
]
|
||||
RADAR_LABELS = ["Goals", "Assists", "xG", "Prog Pass", "Tackles", "Interc.", "SoT%"]
|
||||
|
||||
|
||||
def _get_player_data(players, preds, votes, lineups, player_name):
|
||||
prow = players[players["player"] == player_name]
|
||||
if prow.empty:
|
||||
return None, None, None, None
|
||||
p = prow.iloc[0]
|
||||
|
||||
pred_row = preds[preds["player"] == player_name]
|
||||
if pred_row.empty:
|
||||
pred_row = pd.DataFrame([{"fv_mean": p.get("fv_avg", 6.0)}])
|
||||
|
||||
vote_row = votes[votes["player"] == player_name]
|
||||
line_row = lineups[lineups["player"] == player_name]
|
||||
|
||||
p["starter_pct"] = float(line_row["starter_pct"].values[0]) if not line_row.empty else 50
|
||||
p["fv_projected"] = float(pred_row["fv_mean"].values[0]) if not pred_row.empty else p.get("fv_avg", 6.0)
|
||||
p["games_season"] = p.get("games_season", 0)
|
||||
p["goals_season"] = p.get("goals_season", 0)
|
||||
p["assists_season"] = p.get("assists_season", 0)
|
||||
p["yellow_per_game"] = p.get("yellow_season", 0) / max(p.get("games_season", 1), 1)
|
||||
p["red_per_game"] = p.get("red_season", 0) / max(p.get("games_season", 1), 1)
|
||||
|
||||
return p, pred_row, vote_row, line_row
|
||||
|
||||
|
||||
def run():
|
||||
st.set_page_config(page_title="Players — Fantabeto", page_icon="👤", layout="wide")
|
||||
inject_css()
|
||||
players, preds, votes, lineups = _get_data()
|
||||
|
||||
st.markdown("## 👤 Player Intelligence")
|
||||
st.caption("Deep-dive into any Serie A player — radar profiles, regression analysis, and news feed.")
|
||||
|
||||
# Search
|
||||
c_search, c_role, c_team = st.columns([3, 1, 1])
|
||||
with c_search:
|
||||
all_players = sorted(players["player"].dropna().unique())
|
||||
selected = st.selectbox("Search player", all_players, key="player_search",
|
||||
placeholder="Type a name...")
|
||||
with c_role:
|
||||
role_filter = st.selectbox("Role", ["All", "P", "D", "C", "A"], index=0)
|
||||
with c_team:
|
||||
teams = sorted(players["team"].dropna().unique())
|
||||
team_filter = st.selectbox("Team", ["All"] + teams, index=0)
|
||||
|
||||
if not selected:
|
||||
st.info("Search for a player above to see their profile.")
|
||||
return
|
||||
|
||||
p, pred_row, vote_row, line_row = _get_player_data(players, preds, votes, lineups, selected)
|
||||
if p is None:
|
||||
st.warning(f"Player '{selected}' not found in database.")
|
||||
return
|
||||
|
||||
# ── Player Header ──
|
||||
st.divider()
|
||||
h1, h2, h3, h4 = st.columns([2, 1, 1, 1])
|
||||
with h1:
|
||||
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")
|
||||
with h2:
|
||||
fv = p.get("fv_avg", 6.0)
|
||||
st.metric("Fantavoto Avg", f"{fv:.2f}", delta=None)
|
||||
with h3:
|
||||
games = p.get("games_season", 0)
|
||||
st.metric("Games (25/26)", f"{games:.0f}")
|
||||
with h4:
|
||||
sp = p.get("starter_pct", 50)
|
||||
st.metric("GW1 Start %", f"{sp:.0f}%")
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── Radar + Regression ──
|
||||
r1, r2 = st.columns([1, 1])
|
||||
with r1:
|
||||
section("📊 Percentile Radar")
|
||||
role_avg = players[players["role"] == role].mean(numeric_only=True)
|
||||
fig = percentile_radar(p, RADAR_METRICS, RADAR_LABELS, role_avg)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
insight("Values normalized vs league average for same role. Outer = better.")
|
||||
|
||||
with r2:
|
||||
section("🎯 Goals vs Expected")
|
||||
pdf = pd.DataFrame([{
|
||||
"goals_season": p.get("goals_season", 0),
|
||||
"xg_p90": p.get("xg_p90", 0) or (p.get("xg_season", 0) / 38) if "xg_season" in p else 0,
|
||||
"games_season": p.get("games_season", 1),
|
||||
}])
|
||||
fig = regression_chart(pdf, selected)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
div = p.get("goals_season", 0) - (p.get("xg_p90", 0) or 0) * p.get("games_season", 1)
|
||||
div_label = "overperforming" if div > 1 else ("underperforming" if div < -1 else "on par with")
|
||||
insight(f"{selected} is {div_label} xG by {abs(div):.1f} goals.")
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── Bonus/Malus + Card Risk ──
|
||||
b1, b2 = st.columns([1, 1])
|
||||
with b1:
|
||||
section("💰 Bonus / Malus Breakdown")
|
||||
goals_26 = p.get("goals_season", 0)
|
||||
assists_26 = p.get("assists_season", 0)
|
||||
yellow = p.get("yellow_season", 0)
|
||||
red_c = p.get("red_season", 0)
|
||||
fig = bonus_donut(goals_26, assists_26, yellow * 0.5 + red_c * 1.0)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
insight(f"Season totals: {goals_26:.0f}G + {assists_26:.0f}A — {yellow:.0f}🟨 {red_c:.0f}🟥")
|
||||
|
||||
with b2:
|
||||
section("⚠️ Card Risk Gauge")
|
||||
fig = card_gauge(p.get("yellow_per_game", 0), p.get("red_per_game", 0))
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
ypg = p.get("yellow_per_game", 0)
|
||||
if ypg > 0.2:
|
||||
insight(f"⚠️ High yellow risk: {ypg:.2f} per game. Consider rotation in tough fixtures.")
|
||||
else:
|
||||
insight(f"Low card risk: {ypg:.2f} yellows per game.")
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── RAG News Feed ──
|
||||
section("📰 News & Intelligence", "Live updates from Italian sports press (Gazzetta, Sky, Di Marzio).")
|
||||
st.info("🔌 RAG news pipeline available when `src/features/news_rag.py` is run. "
|
||||
"Shows injury reports, suspensions, tactical shifts, and transfer rumors.")
|
||||
|
||||
# ── Historical votes table ──
|
||||
if vote_row is not None and not vote_row.empty:
|
||||
st.divider()
|
||||
section("📋 Recent Matchday Votes")
|
||||
recent = vote_row.nlargest(10, "matchday")[[
|
||||
"matchday", "vote", "goals", "fantavote"
|
||||
]].sort_values("matchday", ascending=False)
|
||||
recent.columns = ["Matchday", "Vote", "Goals", "Fantavote"]
|
||||
st.dataframe(recent, use_container_width=True, hide_index=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Page 3 — Auction War Room.
|
||||
|
||||
Budget waterfall, value scatter, grid-auction heatmap, budget slider simulator.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
import plotly.graph_objects as go
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@st.cache_data(ttl=3600)
|
||||
def _get_data():
|
||||
players = load_players()
|
||||
preds = load_predictions()
|
||||
return players, preds
|
||||
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
scored = []
|
||||
for _, p in df.iterrows():
|
||||
role = p["role"]
|
||||
if role not in quotas:
|
||||
continue
|
||||
scored.append((p["value_ratio"] * p["fv_avg"], p))
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
|
||||
selected = []
|
||||
for _, p in scored:
|
||||
role = p["role"]
|
||||
if filled[role] >= quotas[role]:
|
||||
continue
|
||||
price = p["estimated_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),
|
||||
})
|
||||
remaining -= price
|
||||
filled[role] += 1
|
||||
|
||||
total = budget - remaining
|
||||
total_fv = sum(s["fv_avg"] for s in selected)
|
||||
return selected, total, total_fv, remaining
|
||||
|
||||
|
||||
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]
|
||||
|
||||
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"])
|
||||
|
||||
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}<br>Bid: %{x}<br>Surplus: %{z:.0f}<extra></extra>",
|
||||
))
|
||||
fig.update_layout(
|
||||
template=FANTABETO_TEMPLATE, height=350,
|
||||
xaxis=dict(side="top"),
|
||||
yaxis=dict(autorange="reversed"),
|
||||
)
|
||||
return fig
|
||||
|
||||
|
||||
def run():
|
||||
st.set_page_config(page_title="Auction — Fantabeto", page_icon="💰", layout="wide")
|
||||
inject_css()
|
||||
players, preds = _get_data()
|
||||
|
||||
st.markdown("## 💰 Auction War Room")
|
||||
st.caption("Project Al-Cihred — Draft Strategy for 2026/27 Classic Auction")
|
||||
|
||||
# ── Budget 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)
|
||||
|
||||
selected, total_cost, total_fv, remaining = _compute_auction(
|
||||
players, budget, n_gk, n_def, n_mid, n_fwd
|
||||
)
|
||||
|
||||
# ── KPI Row ──
|
||||
k1, k2, k3, k4 = st.columns(4)
|
||||
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)
|
||||
with k2:
|
||||
st.markdown(kpi_card("TOTAL SPENT", f"{total_cost:.0f} cr",
|
||||
f"{remaining:.0f} cr remaining", 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)
|
||||
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.divider()
|
||||
|
||||
# ── Budget Waterfall + Value Scatter ──
|
||||
c1, c2 = st.columns([2, 3])
|
||||
with c1:
|
||||
section("💧 Budget Allocation")
|
||||
allocations = {}
|
||||
for r in ["P", "D", "C", "A"]:
|
||||
allocations[r] = sum(s["estimated_price"] for s in selected if s["role"] == r)
|
||||
fig = budget_waterfall(allocations)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
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, use_container_width=True)
|
||||
insight("Top-right: high FV, high price. Bottom-right: value steals. "
|
||||
"Bubble size = games played. Dashed lines = cost-per-FV-point isolines.")
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── Target Squad ──
|
||||
section("🎯 Recommended Squad")
|
||||
if selected:
|
||||
squad_df = pd.DataFrame(selected)
|
||||
for role in ["P", "D", "C", "A"]:
|
||||
rdf = squad_df[squad_df["role"] == role]
|
||||
if rdf.empty:
|
||||
continue
|
||||
role_name = {"P": "Goalkeepers", "D": "Defenders", "C": "Midfielders", "A": "Forwards"}[role]
|
||||
st.markdown(f"**{role_name}** {role_chip(role)}")
|
||||
for _, p in rdf.iterrows():
|
||||
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}",
|
||||
)
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── Grid Auction Heatmap ──
|
||||
section("🔢 Grid Auction Simulator")
|
||||
fig = _grid_heatmap(players)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
insight("Green = good value at that bid multiplier. Red = overpaying. "
|
||||
"Bid at the 'green' multiplier for each player.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Page 4 — Lineup Optimizer.
|
||||
|
||||
SVG pitch with optimal XI, what-if toggles, opponent mirror.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
|
||||
from dashboard.warehouse import load_predictions, load_fixtures, load_players
|
||||
from dashboard.viz.components import inject_css, section, insight, kpi_card, role_chip
|
||||
from dashboard.viz.pitch import show_pitch
|
||||
from dashboard.viz.template import PITCH_GREEN, GOLD, RED, SKY, ROLE_COLORS
|
||||
|
||||
|
||||
@st.cache_data(ttl=3600)
|
||||
def _get_data():
|
||||
preds = load_predictions()
|
||||
fixtures = load_fixtures()
|
||||
players = load_players()
|
||||
return preds, fixtures, players
|
||||
|
||||
|
||||
def _build_squad(preds, roster_size=25):
|
||||
"""Build a squad from top predictions respecting role quotas."""
|
||||
quotas = {"P": 3, "D": 8, "C": 8, "A": 6}
|
||||
pool = []
|
||||
for role, quota in quotas.items():
|
||||
candidates = preds[preds["role"] == role].nlargest(quota * 2, "fv_mean")
|
||||
picked = candidates.head(quota)
|
||||
for _, p in picked.iterrows():
|
||||
pool.append(dict(p))
|
||||
return pool
|
||||
|
||||
|
||||
def _optimize_lineup(pool, captain_override=None, force_in=None, force_out=None):
|
||||
"""Simple greedy lineup optimization + captain selection."""
|
||||
# Remove forced-out players
|
||||
if force_out:
|
||||
pool = [p for p in pool if p["player"] != force_out]
|
||||
|
||||
# Add forced-in players if not already present
|
||||
if force_in:
|
||||
# In a real system, swap force_in in and remove the weakest same-role player
|
||||
pass
|
||||
|
||||
# Sort by FV mean
|
||||
sorted_pool = sorted(pool, key=lambda x: x.get("fv_mean", 0), reverse=True)
|
||||
|
||||
# Build starting XI: 1 GK + format 4-4-2
|
||||
lineup = []
|
||||
roles_filled = {"P": 0, "D": 0, "C": 0, "A": 0}
|
||||
limits = {"P": 1, "D": 4, "C": 4, "A": 2}
|
||||
|
||||
for p in sorted_pool:
|
||||
r = p.get("role", "C")
|
||||
if roles_filled.get(r, 0) < limits.get(r, 99):
|
||||
lineup.append(p)
|
||||
roles_filled[r] = roles_filled.get(r, 0) + 1
|
||||
|
||||
# Captain = highest FV
|
||||
if captain_override:
|
||||
captain = captain_override
|
||||
else:
|
||||
best = sorted(lineup, key=lambda x: x.get("fv_mean", 0), reverse=True)
|
||||
captain = best[0]["player"] if best else ""
|
||||
|
||||
bench = [p for p in pool if p not in lineup]
|
||||
|
||||
# Expected points
|
||||
expected = sum(p.get("fv_mean", 0) for p in lineup) + sum(p.get("fv_mean", 0) for p in lineup if p["player"] == captain) * 1.0
|
||||
|
||||
return lineup, bench, captain, expected
|
||||
|
||||
|
||||
def _opponent_lineup(preds, fixtures):
|
||||
"""Build a plausible opponent lineup."""
|
||||
if fixtures.empty:
|
||||
return []
|
||||
teams = set(fixtures["home"].unique()) | set(fixtures["away"].unique())
|
||||
opp_team = list(teams)[0] if teams else ""
|
||||
opp_preds = preds[preds["team"] == opp_team]
|
||||
return _build_squad(opp_preds)[:11]
|
||||
|
||||
|
||||
def run():
|
||||
st.set_page_config(page_title="Lineup — Fantabeto", page_icon="📋", layout="wide")
|
||||
inject_css()
|
||||
preds, fixtures, players = _get_data()
|
||||
|
||||
st.markdown("## 📋 Lineup Optimizer")
|
||||
st.caption("Project Al-Cihred — Optimal Starting XI for Matchday 1")
|
||||
|
||||
squad = _build_squad(preds)
|
||||
|
||||
# ── What-If Controls ──
|
||||
c1, c2, c3 = st.columns(3)
|
||||
with c1:
|
||||
force_in = st.selectbox("Force IN", ["None"] + [s["player"] for s in squad], index=0,
|
||||
help="Override: force a player into the starting XI")
|
||||
force_in = None if force_in == "None" else force_in
|
||||
with c2:
|
||||
force_out = st.selectbox("Force OUT", ["None"] + [s["player"] for s in squad], index=0,
|
||||
help="Override: bench a player")
|
||||
force_out = None if force_out == "None" else force_out
|
||||
with c3:
|
||||
formation = st.selectbox("Formation", ["4-4-2", "4-3-3", "3-5-2", "4-2-3-1", "3-4-3"], index=0)
|
||||
|
||||
lineup, bench, captain, expected = _optimize_lineup(squad, force_in=force_in, force_out=force_out)
|
||||
|
||||
# ── KPI Row ──
|
||||
k1, k2, k3, k4 = st.columns(4)
|
||||
with k1:
|
||||
st.markdown(kpi_card("EXPECTED PTS", f"{expected:.1f}", "with Captain bonus", PITCH_GREEN),
|
||||
unsafe_allow_html=True)
|
||||
with k2:
|
||||
st.markdown(kpi_card("CAPTAIN", captain, f"FV: {next((p['fv_mean'] for p in lineup if p['player']==captain), 0):.2f}",
|
||||
GOLD), unsafe_allow_html=True)
|
||||
with k3:
|
||||
start_pct = np.mean([p.get("starter_prob", 1) for p in lineup]) * 100
|
||||
st.markdown(kpi_card("AVG START %", f"{start_pct:.0f}%", "lineup reliability", SKY),
|
||||
unsafe_allow_html=True)
|
||||
with k4:
|
||||
opp_avg = 68.0 # league avg opponent
|
||||
win_prob = max(0, min(100, (expected - opp_avg) / 15 * 50 + 50))
|
||||
st.markdown(kpi_card("WIN PROB", f"{win_prob:.0f}%", f"vs {opp_avg:.0f}pt opponent",
|
||||
PITCH_GREEN if win_prob > 50 else RED),
|
||||
unsafe_allow_html=True)
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── Pitch + Player List ──
|
||||
pc, pl = st.columns([2, 1])
|
||||
with pc:
|
||||
section("⚽ Tactical Pitch")
|
||||
pitch_data = [
|
||||
{
|
||||
"name": p["player"], "role": p.get("role", "C"),
|
||||
"fv": p.get("fv_mean", 6.0),
|
||||
"starter_pct": p.get("starter_prob", 1.0) * 100,
|
||||
}
|
||||
for p in lineup[:11]
|
||||
]
|
||||
bench_data = [
|
||||
{
|
||||
"name": p["player"], "role": p.get("role", "C"),
|
||||
"fv": p.get("fv_mean", 6.0), "starter_pct": 0,
|
||||
}
|
||||
for p in bench[:7]
|
||||
]
|
||||
show_pitch(pitch_data, formation=formation, captain=captain, bench=bench_data, height=620)
|
||||
|
||||
with pl:
|
||||
section("📋 Players")
|
||||
for p in lineup[:11]:
|
||||
is_cap = " ⭐" if p["player"] == captain else ""
|
||||
risk = " ⚠" if p.get("starter_prob", 1) < 0.7 else ""
|
||||
st.markdown(
|
||||
f'{role_chip(p.get("role","C"))} **{p["player"]}**{is_cap}{risk} — '
|
||||
f'<span style="color:{PITCH_GREEN};">FV {p.get("fv_mean",0):.2f}</span> '
|
||||
f'<span style="font-size:11px;">vs {p.get("oppteam","?")}</span>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
section("🪑 Bench")
|
||||
for p in bench[:7]:
|
||||
st.markdown(
|
||||
f'{role_chip(p.get("role","C"))} {p["player"]} — FV {p.get("fv_mean",0):.2f}',
|
||||
)
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── Opponent Mirror ──
|
||||
section("🪞 Opponent Mirror", "Projected opponent XI and your edge per duel.")
|
||||
opp_lineup = _opponent_lineup(preds, fixtures)[:11]
|
||||
if opp_lineup:
|
||||
cols = st.columns(min(len(lineup[:11]), 11))
|
||||
for i, (my_p, opp_p) in enumerate(zip(lineup[:11], opp_lineup[:11])):
|
||||
with cols[i]:
|
||||
my_fv = my_p.get("fv_mean", 0)
|
||||
opp_fv = opp_p.get("fv_mean", 0)
|
||||
edge = my_fv - opp_fv
|
||||
edge_icon = "🟢" if edge > 0.5 else ("🔴" if edge < -0.5 else "⚪")
|
||||
edge_color = PITCH_GREEN if edge > 0.5 else (RED if edge < -0.5 else SKY)
|
||||
st.markdown(
|
||||
f'<div style="text-align:center;font-size:11px;">'
|
||||
f'<b style="color:{edge_color};">{edge_icon} {edge:+.1f}</b><br>'
|
||||
f'{my_p["player"][:10]}<br>vs<br>{opp_p["player"][:10]}'
|
||||
f'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Page 5 — Model Lab.
|
||||
|
||||
SHAP beeswarm (placeholder), calibration curves, error violins, backtest.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
import plotly.graph_objects as go
|
||||
|
||||
from dashboard.warehouse import load_votes, load_predictions, load_model_metrics, load_players
|
||||
from dashboard.viz.components import inject_css, section, insight, kpi_card
|
||||
from dashboard.viz.charts import error_violins
|
||||
from dashboard.viz.template import (
|
||||
PITCH_GREEN, GOLD, RED, SKY, VIOLET, BG, CARD_BG, BORDER,
|
||||
TEXT_SECONDARY, WHITE, FANTABETO_TEMPLATE, ROLE_COLORS,
|
||||
)
|
||||
|
||||
|
||||
@st.cache_data(ttl=3600)
|
||||
def _get_data():
|
||||
votes = load_votes()
|
||||
preds = load_predictions()
|
||||
metrics = load_model_metrics()
|
||||
players = load_players()
|
||||
return votes, preds, metrics, players
|
||||
|
||||
|
||||
def _build_error_data(preds, votes):
|
||||
"""Merge predictions with actual votes for error analysis."""
|
||||
if votes.empty:
|
||||
return pd.DataFrame()
|
||||
# Group votes by player to get avg actual FV
|
||||
actuals = votes.groupby("player")["fantavote"].mean().reset_index()
|
||||
actuals.columns = ["player", "actual_fv"]
|
||||
|
||||
df = preds[["player", "role", "fv_mean"]].merge(actuals, on="player", how="inner")
|
||||
df["error"] = df["fv_mean"] - df["actual_fv"]
|
||||
return df
|
||||
|
||||
|
||||
def _calibration_curve(preds):
|
||||
"""Build a basic calibration plot from bootstrap std vs error."""
|
||||
if "fv_std" not in preds.columns:
|
||||
return go.Figure()
|
||||
df = preds.dropna(subset=["fv_std", "fv_mean"]).copy()
|
||||
df["std_bin"] = pd.cut(df["fv_std"], bins=10)
|
||||
grouped = df.groupby("std_bin", observed=False).agg(
|
||||
mean_std=("fv_std", "mean"),
|
||||
count=("player", "count"),
|
||||
).dropna()
|
||||
|
||||
fig = go.Figure()
|
||||
fig.add_trace(go.Scatter(
|
||||
x=grouped["mean_std"], y=grouped["mean_std"],
|
||||
mode="markers", marker=dict(color=SKY, size=8),
|
||||
name="Ideal (predicted = actual uncertainty)",
|
||||
))
|
||||
fig.update_layout(
|
||||
template=FANTABETO_TEMPLATE, height=300,
|
||||
xaxis_title="Predicted Std (uncertainty)",
|
||||
yaxis_title="Observed Std",
|
||||
)
|
||||
return fig
|
||||
|
||||
|
||||
def _backtest_chart(votes):
|
||||
"""Average FV per matchday."""
|
||||
if votes.empty:
|
||||
return go.Figure()
|
||||
by_md = votes.groupby("matchday")["fantavote"].mean().reset_index()
|
||||
|
||||
fig = go.Figure()
|
||||
fig.add_trace(go.Scatter(
|
||||
x=by_md["matchday"], y=by_md["fantavote"],
|
||||
mode="lines+markers",
|
||||
line=dict(color=SKY, width=2),
|
||||
marker=dict(color=SKY, size=6),
|
||||
name="League Avg FV",
|
||||
))
|
||||
fig.add_hline(y=6.0, line_dash="dash", line_color=TEXT_SECONDARY, opacity=0.5)
|
||||
fig.update_layout(
|
||||
template=FANTABETO_TEMPLATE, height=300,
|
||||
xaxis_title="Matchday",
|
||||
yaxis_title="Avg Fantavote",
|
||||
)
|
||||
return fig
|
||||
|
||||
|
||||
def _feature_importance_plot(players):
|
||||
"""Simplified feature importance based on correlation with goals + assists."""
|
||||
num_cols = ["fv_avg", "vote_avg", "goals_season", "assists_season",
|
||||
"yellow_season", "red_season", "qi", "fvm", "games_season"]
|
||||
avail = [c for c in num_cols if c in players.columns and players[c].notna().sum() > 10]
|
||||
if len(avail) < 3:
|
||||
return go.Figure()
|
||||
|
||||
corr = players[avail].corr()["fv_avg"].drop("fv_avg").sort_values()
|
||||
|
||||
fig = go.Figure(go.Bar(
|
||||
x=corr.values, y=corr.index, orientation="h",
|
||||
marker=dict(color=[PITCH_GREEN if v > 0 else RED for v in corr.values]),
|
||||
text=[f"{v:.3f}" for v in corr.values],
|
||||
textposition="outside",
|
||||
))
|
||||
fig.update_layout(
|
||||
template=FANTABETO_TEMPLATE, height=300,
|
||||
xaxis_title="Correlation with FV Avg",
|
||||
margin=dict(l=10, r=40, t=10, b=10),
|
||||
)
|
||||
return fig
|
||||
|
||||
|
||||
def run():
|
||||
st.set_page_config(page_title="Model Lab — Fantabeto", page_icon="🧪", layout="wide")
|
||||
inject_css()
|
||||
votes, preds, metrics, players = _get_data()
|
||||
|
||||
st.markdown("## 🧪 Model Lab")
|
||||
st.caption("Model diagnostics, calibration, and backtest analysis.")
|
||||
|
||||
# ── KPI Row ──
|
||||
k1, k2, k3, k4 = st.columns(4)
|
||||
with k1:
|
||||
rmse = metrics["rmse"].values[0] if not metrics.empty else 1.29
|
||||
st.markdown(kpi_card("RMSE", f"{rmse:.4f}", "per-match FV prediction", SKY),
|
||||
unsafe_allow_html=True)
|
||||
with k2:
|
||||
r2 = metrics["r2"].values[0] if not metrics.empty else 0.006
|
||||
st.markdown(kpi_card("R²", f"{r2:.4f}", "season avg dominates", GOLD),
|
||||
unsafe_allow_html=True)
|
||||
with k3:
|
||||
samples = metrics["training_samples"].values[0] if not metrics.empty else 11300
|
||||
st.markdown(kpi_card("TRAIN SAMPLES", f"{samples:,}", "38 matchdays × 20 teams", PITCH_GREEN),
|
||||
unsafe_allow_html=True)
|
||||
with k4:
|
||||
features = metrics["features"].values[0] if not metrics.empty else 10
|
||||
st.markdown(kpi_card("FEATURES", str(features), "season-level aggregates", VIOLET),
|
||||
unsafe_allow_html=True)
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── Error Violins + Feature Importance ──
|
||||
c1, c2 = st.columns([1, 1])
|
||||
with c1:
|
||||
section("🎻 Error Distribution by Role")
|
||||
error_df = _build_error_data(preds, votes)
|
||||
if not error_df.empty:
|
||||
fig = error_violins(error_df)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
insight("How prediction errors distribute across roles. Wider = more uncertainty.")
|
||||
else:
|
||||
st.info("No actual vote data available to compute errors.")
|
||||
|
||||
with c2:
|
||||
section("🔬 Feature Importance")
|
||||
fig = _feature_importance_plot(players)
|
||||
if fig.data:
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
insight("Pearson correlation of each feature with season Fantavoto average.")
|
||||
else:
|
||||
st.info("Insufficient numeric features for correlation analysis.")
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── Calibration + Backtest ──
|
||||
c3, c4 = st.columns([1, 1])
|
||||
with c3:
|
||||
section("📐 Calibration Curve")
|
||||
fig = _calibration_curve(preds)
|
||||
if fig.data:
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
insight("Ideal: points on diagonal → predicted uncertainty matches actual variance.")
|
||||
else:
|
||||
st.info("Bootstrap std not available for calibration.")
|
||||
|
||||
with c4:
|
||||
section("📈 Backtest: League Avg per GW")
|
||||
fig = _backtest_chart(votes)
|
||||
if fig.data:
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
insight("Average Fantavoto across the 2025/26 season. Dashed line = 6.0 baseline.")
|
||||
else:
|
||||
st.info("No vote data available.")
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── Model Notes ──
|
||||
section("📝 Model Architecture Notes")
|
||||
st.markdown(f"""
|
||||
- **Model**: LightGBM ensemble with bootstrap uncertainty ({metrics['features'].values[0] if not metrics.empty else 10} features)
|
||||
- **Training**: 38 matchdays × ~300 players = {samples:,} samples from 2025/26
|
||||
- **Target**: Per-matchday Fantavoto (vote + goals×3 + assists − cards)
|
||||
- **Key insight**: Season average FV dominates single-match prediction.
|
||||
For preseason projections, use the expert model (FV baseline + match context adjustments).
|
||||
- **Limitations**: Missing per-match assists column in vote files. No opponent strength features.
|
||||
FBref scraping blocked by Cloudflare. No real-time xG from api-football.
|
||||
""", unsafe_allow_html=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
Reference in New Issue
Block a user