From 65f5b66b05ce80ccacc7214cf286f7035136ee52 Mon Sep 17 00:00:00 2001
From: ramseshk <45832522+ramseshk@users.noreply.github.com>
Date: Tue, 11 Aug 2026 14:53:51 +0800
Subject: [PATCH] Dashboard: Stadium Night design system, 5-page Streamlit app,
warehouse exporter
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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)
---
.streamlit/config.toml | 15 ++
DASHBOARD.md | 137 +++++++++++++
dashboard/__init__.py | 0
dashboard/app.py | 55 +++++
dashboard/pages/01_matchday.py | 174 ++++++++++++++++
dashboard/pages/02_players.py | 170 ++++++++++++++++
dashboard/pages/03_auction.py | 195 ++++++++++++++++++
dashboard/pages/04_lineup.py | 195 ++++++++++++++++++
dashboard/pages/05_lab.py | 202 ++++++++++++++++++
dashboard/pages/__init__.py | 0
dashboard/tests/__init__.py | 0
dashboard/tests/test_dashboard.py | 147 +++++++++++++
dashboard/viz/__init__.py | 0
dashboard/viz/charts.py | 328 ++++++++++++++++++++++++++++++
dashboard/viz/components.py | 101 +++++++++
dashboard/viz/pitch.py | 177 ++++++++++++++++
dashboard/viz/template.py | 126 ++++++++++++
dashboard/warehouse.py | 41 ++++
src/export/warehouse.py | 154 ++++++++++++++
19 files changed, 2217 insertions(+)
create mode 100644 .streamlit/config.toml
create mode 100644 DASHBOARD.md
create mode 100644 dashboard/__init__.py
create mode 100644 dashboard/app.py
create mode 100644 dashboard/pages/01_matchday.py
create mode 100644 dashboard/pages/02_players.py
create mode 100644 dashboard/pages/03_auction.py
create mode 100644 dashboard/pages/04_lineup.py
create mode 100644 dashboard/pages/05_lab.py
create mode 100644 dashboard/pages/__init__.py
create mode 100644 dashboard/tests/__init__.py
create mode 100644 dashboard/tests/test_dashboard.py
create mode 100644 dashboard/viz/__init__.py
create mode 100644 dashboard/viz/charts.py
create mode 100644 dashboard/viz/components.py
create mode 100644 dashboard/viz/pitch.py
create mode 100644 dashboard/viz/template.py
create mode 100644 dashboard/warehouse.py
create mode 100644 src/export/warehouse.py
diff --git a/.streamlit/config.toml b/.streamlit/config.toml
new file mode 100644
index 0000000..f8086a0
--- /dev/null
+++ b/.streamlit/config.toml
@@ -0,0 +1,15 @@
+[theme]
+base="dark"
+primaryColor="#00D084"
+backgroundColor="#0B0F17"
+secondaryBackgroundColor="#111827"
+textColor="#E5E7EB"
+font="sans serif"
+
+[server]
+maxUploadSize=50
+enableCORS=false
+enableXsrfProtection=true
+
+[browser]
+gatherUsageStats=false
diff --git a/DASHBOARD.md b/DASHBOARD.md
new file mode 100644
index 0000000..c5dccdc
--- /dev/null
+++ b/DASHBOARD.md
@@ -0,0 +1,137 @@
+# Fantabeto Dashboard
+
+**Project Al-Cihred** — Production-grade Streamlit dashboard for the 2026/27 Fantacalcio season.
+
+```bash
+streamlit run dashboard/app.py
+```
+
+---
+
+## Architecture
+
+```
+data/warehouse/ ← Versioned Parquet (read by dashboard)
+ players.parquet 505 rows — roster + 25/26 stats + per-90 metrics
+ fixtures.parquet 10 rows — matchday 1 home/away + formations
+ predictions.parquet 505 rows — FV projections with std + starter prob
+ lineups.parquet 466 rows — probable lineups with starter %
+ votes.parquet 12,049 rows — historical per-matchday votes (25/26)
+ model_metrics.parquet 1 row — RMSE, R², sample count
+
+dashboard/
+ app.py ← Entry point: multi-page routing via sidebar
+ warehouse.py ← Read-only Parquet loader (@st.cache_data)
+ viz/
+ template.py ← Plotly template "fantabeto_dark" + color palette
+ charts.py ← Pure functions: df → Figure (heatmap, radar, etc.)
+ pitch.py ← SVG pitch component with player badges
+ components.py ← KPI cards, role chips, section headers, CSS
+ pages/
+ 01_matchday.py ← Control Room: KPIs, fixture heatmap, start/sit
+ 02_players.py ← Intelligence: radar, regression, card risk, news
+ 03_auction.py ← War Room: waterfall, value scatter, grid heatmap
+ 04_lineup.py ← Optimizer: SVG pitch, what-if toggles, opponent
+ 05_lab.py ← Model Lab: error violins, calibration, backtest
+ tests/
+ test_dashboard.py 18 unit tests (warehouse, template, charts, pitch)
+
+src/export/warehouse.py ← Exporter: reads pipeline artifacts → Parquet
+.streamlit/config.toml ← Dark theme base, server config
+```
+
+---
+
+## Design System — Stadium Night
+
+| Property | Value |
+|---|---|
+| Background | `#0B0F17` |
+| Card bg | `#111827` at 70% opacity, 14px radius, blur 8px |
+| Border | `#1F2937` |
+| Pitch Green | `#00D084` — positive, bonus |
+| Gold | `#FFC94D` — captain, highlight |
+| Red | `#FF4D5E` — malus, risk |
+| Sky | `#38BDF8` — neutral data |
+| Violet | `#A78BFA` — uncertainty |
+| Type | Space Grotesk (headers), Inter (body), tabular numerals |
+| Charts | All use `fantabeto_dark` template. No default Plotly palette anywhere. |
+
+---
+
+## Pages
+
+### 1. Matchday Control Room
+- **KPI row**: projected points, players at risk (P(start) < 70%), matchday count, FV trend sparkline
+- **Fixture difficulty heatmap**: teams × gameweeks, colored by opponent xGA/FV strength
+- **Start/Sit grid**: top 20 players with role chips, H/A indicator, projected FV ± std, risk status (🟢 START / 🟡 DOUBT / 🔴 RISK)
+- **Bump chart**: projected rank trajectory across first 10 GWs
+
+### 2. Player Intelligence
+- **Search**: autocomplete on all 505 players, filtered by role/team
+- **Player header**: FV avg, games played, GW1 start %, role chip, FVM, QI
+- **Percentile radar**: 7 metrics normalized vs role average (goals, assists, xG, prog passes, tackles, interceptions, SoT%)
+- **Goals vs Expected**: bar chart with divergence annotation
+- **Bonus/malus donut**: goal bonus + assist bonus − card malus breakdown
+- **Card risk gauge**: yellow/red card per-game risk indicators
+- **News feed**: placeholder for RAG pipeline output (injuries, suspensions, tactical shifts)
+- **Historical votes**: last 10 matchday votes table
+
+### 3. Auction War Room
+- **Budget controls**: sliders for budget (300–700 cr) and role quotas
+- **KPI row**: players drafted, total spent, projected FV, avg price per player
+- **Budget waterfall**: Sankey-style allocation per role (GK/DEF/MID/FWD)
+- **Value scatter**: FV avg vs QI, bubble size = games played, top steals labeled, cost-per-FV isolines
+- **Grid auction heatmap**: top players × bid multipliers, colored by value surplus
+- **Recommended squad**: per-role player list with max bid, FV, and games played
+
+### 4. Lineup Optimizer
+- **What-if controls**: force IN/OUT a player, select formation (4-4-2, 4-3-3, 3-5-2, etc.)
+- **KPI row**: expected points (with captain), captain name, avg start %, win probability
+- **SVG pitch**: dark turf gradient, player badges sized by FV, gold captain ring, risk markers, bench strip, formation label
+- **Player list**: role-chipped players with FV and risk flags
+- **Opponent mirror**: per-duel edge arrows (🟢 advantage / 🔴 disadvantage / ⚪ neutral)
+
+### 5. Model Lab
+- **KPI row**: RMSE, R², training samples, feature count
+- **Error violins**: prediction error distribution by role
+- **Feature importance**: Pearson correlation with FV avg
+- **Calibration curve**: predicted vs observed uncertainty
+- **Backtest**: league average FV across 38 matchdays
+- **Architecture notes**: model overview, key insights, limitations
+
+---
+
+## Running
+
+```bash
+# First time: export warehouse
+python -m src.export.warehouse
+
+# Install (if not already)
+pip install streamlit
+
+# Launch dashboard
+streamlit run dashboard/app.py
+
+# Run tests
+python -m pytest dashboard/tests/ -v
+```
+
+## Data Refresh
+
+The warehouse reads pre-computed Parquet files. To refresh after running the pipeline:
+
+```bash
+python -m src.export.warehouse
+```
+
+Then reload the dashboard — `@st.cache_data(ttl=3600)` will pick up new files after 1 hour or on manual cache clear.
+
+## Dependencies
+
+Added to `requirements.txt`:
+- `streamlit>=1.35`
+
+Already present:
+- `plotly>=5.18`, `pandas>=2.1`, `numpy>=1.26`, `openpyxl>=3.1`
diff --git a/dashboard/__init__.py b/dashboard/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/dashboard/app.py b/dashboard/app.py
new file mode 100644
index 0000000..1b12f12
--- /dev/null
+++ b/dashboard/app.py
@@ -0,0 +1,55 @@
+"""Fantabeto 26/27 Dashboard — Project Al-Cihred.
+
+Multi-page Streamlit app. Run with: streamlit run dashboard/app.py
+"""
+
+import streamlit as st
+
+from dashboard.viz.components import inject_css
+
+# Register the custom Plotly template
+from dashboard.viz.template import stub_render # triggers template registration
+
+PAGES = {
+ "⚽ Matchday": "dashboard.pages.01_matchday",
+ "👤 Players": "dashboard.pages.02_players",
+ "💰 Auction": "dashboard.pages.03_auction",
+ "📋 Lineup": "dashboard.pages.04_lineup",
+ "🧪 Model Lab": "dashboard.pages.05_lab",
+}
+
+
+def main():
+ st.set_page_config(
+ page_title="Fantabeto 26/27 — Project Al-Cihred",
+ page_icon="⚽",
+ layout="wide",
+ initial_sidebar_state="expanded",
+ )
+ inject_css()
+
+ st.sidebar.markdown("""
+
+ ⚽ FANTABETO
+
+
+ Project Al-Cihred · 26/27
+
+ """, unsafe_allow_html=True)
+
+ page = st.sidebar.radio("Navigation", list(PAGES.keys()), label_visibility="collapsed")
+
+ st.sidebar.divider()
+ st.sidebar.caption("Fantacalcio Bayesian Estimated Team's Outcome")
+ st.sidebar.caption("v2.0 · August 2026")
+ st.sidebar.caption("Data: Fantacalcio.it · FBref · api-football")
+
+ # Route to selected page
+ module_name = PAGES[page]
+ import importlib
+ mod = importlib.import_module(module_name)
+ mod.run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/dashboard/pages/01_matchday.py b/dashboard/pages/01_matchday.py
new file mode 100644
index 0000000..6dd62cc
--- /dev/null
+++ b/dashboard/pages/01_matchday.py
@@ -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'— FV {p["fv_mean"]:.2f} '
+ f'[Start: {p["starter_prob"]:.0%}]',
+ 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()
diff --git a/dashboard/pages/02_players.py b/dashboard/pages/02_players.py
new file mode 100644
index 0000000..ded1f05
--- /dev/null
+++ b/dashboard/pages/02_players.py
@@ -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()
diff --git a/dashboard/pages/03_auction.py b/dashboard/pages/03_auction.py
new file mode 100644
index 0000000..10a0a28
--- /dev/null
+++ b/dashboard/pages/03_auction.py
@@ -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}
Bid: %{x}
Surplus: %{z:.0f}",
+ ))
+ 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()
diff --git a/dashboard/pages/04_lineup.py b/dashboard/pages/04_lineup.py
new file mode 100644
index 0000000..d6bd51a
--- /dev/null
+++ b/dashboard/pages/04_lineup.py
@@ -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'FV {p.get("fv_mean",0):.2f} '
+ f'vs {p.get("oppteam","?")}',
+ 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''
+ f'{edge_icon} {edge:+.1f}
'
+ f'{my_p["player"][:10]}
vs
{opp_p["player"][:10]}'
+ f'
',
+ unsafe_allow_html=True,
+ )
+
+
+if __name__ == "__main__":
+ run()
diff --git a/dashboard/pages/05_lab.py b/dashboard/pages/05_lab.py
new file mode 100644
index 0000000..0f15c38
--- /dev/null
+++ b/dashboard/pages/05_lab.py
@@ -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()
diff --git a/dashboard/pages/__init__.py b/dashboard/pages/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/dashboard/tests/__init__.py b/dashboard/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/dashboard/tests/test_dashboard.py b/dashboard/tests/test_dashboard.py
new file mode 100644
index 0000000..60379b1
--- /dev/null
+++ b/dashboard/tests/test_dashboard.py
@@ -0,0 +1,147 @@
+"""Tests for dashboard viz components and warehouse layer."""
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from dashboard.warehouse import load_players, load_fixtures, load_predictions, load_lineups, load_votes
+from dashboard.viz.template import register_template, FANTABETO_TEMPLATE, PITCH_GREEN, GOLD, RED, SKY
+from dashboard.viz.components import kpi_card, role_chip
+from dashboard.viz.charts import (
+ fixture_heatmap, percentile_radar, regression_chart,
+ bonus_donut, card_gauge, budget_waterfall, value_scatter, error_violins,
+)
+
+
+class TestWarehouse:
+ def test_load_players(self):
+ df = load_players()
+ assert len(df) > 0
+ assert "player" in df.columns
+ assert "role" in df.columns
+ assert "fv_avg" in df.columns
+
+ def test_load_fixtures(self):
+ df = load_fixtures()
+ assert len(df) > 0
+ assert "home" in df.columns
+ assert "away" in df.columns
+ assert "matchday" in df.columns
+
+ def test_load_predictions(self):
+ df = load_predictions()
+ assert len(df) > 0
+ assert "fv_mean" in df.columns
+ assert "starter_prob" in df.columns
+
+ def test_load_lineups(self):
+ df = load_lineups()
+ assert len(df) > 0
+ assert "player" in df.columns
+ assert "starter_pct" in df.columns
+
+ def test_load_votes(self):
+ df = load_votes()
+ assert len(df) > 10000
+ assert "fantavote" in df.columns
+ assert "matchday" in df.columns
+
+
+class TestTemplate:
+ def test_registration(self):
+ register_template()
+ import plotly.io as pio
+ assert FANTABETO_TEMPLATE in pio.templates
+
+ def test_colors(self):
+ assert PITCH_GREEN == "#00D084"
+ assert GOLD == "#FFC94D"
+ assert RED == "#FF4D5E"
+ assert SKY == "#38BDF8"
+
+
+class TestComponents:
+ def test_kpi_card(self):
+ html = kpi_card("TEST", "42.0", "subtitle", SKY, delta=2.5)
+ assert "TEST" in html
+ assert "42.0" in html
+ assert "subtitle" in html
+
+ def test_role_chip(self):
+ for role in ["P", "D", "C", "A"]:
+ html = role_chip(role)
+ assert role in html
+
+
+class TestCharts:
+ def test_fixture_heatmap(self):
+ df = pd.DataFrame([
+ {"team": "Inter", "matchday": 1, "opp_strength": 7.5},
+ {"team": "Inter", "matchday": 2, "opp_strength": 6.0},
+ {"team": "Milan", "matchday": 1, "opp_strength": 6.5},
+ ])
+ fig = fixture_heatmap(df)
+ assert fig is not None
+ assert len(fig.data) > 0
+
+ def test_percentile_radar(self):
+ row = pd.Series({
+ "player": "Test", "goals_p90": 0.5, "assists_p90": 0.2,
+ "xg_p90": 0.4, "progressive_passes_p90": 2.0,
+ "tackles_p90": 1.5, "interceptions_p90": 1.0,
+ "shots_on_target_pct": 0.4,
+ })
+ metrics = ["goals_p90", "assists_p90", "xg_p90", "progressive_passes_p90",
+ "tackles_p90", "interceptions_p90", "shots_on_target_pct"]
+ labels = ["G", "A", "xG", "PP", "TK", "Int", "SoT"]
+ fig = percentile_radar(row, metrics, labels)
+ assert fig is not None
+
+ def test_regression_chart(self):
+ df = pd.DataFrame([{"goals_season": 10, "goals_p90": 0.3, "games_season": 30}])
+ fig = regression_chart(df, "Test", xg_col="goals_p90", goal_col="goals_season")
+ assert fig is not None
+
+ def test_bonus_donut(self):
+ fig = bonus_donut(5, 3, 1.5)
+ assert fig is not None
+
+ def test_card_gauge(self):
+ fig = card_gauge(0.15, 0.01)
+ assert fig is not None
+
+ def test_budget_waterfall(self):
+ fig = budget_waterfall({"P": 30, "D": 170, "C": 160, "A": 140})
+ assert fig is not None
+
+ def test_value_scatter(self):
+ df = pd.DataFrame({
+ "player": ["A", "B", "C"], "role": ["P", "D", "C"],
+ "fv_avg": [5.5, 6.8, 7.2], "qi": [10, 25, 30],
+ "games_season": [35, 30, 28],
+ })
+ fig = value_scatter(df)
+ assert fig is not None
+
+ def test_error_violins(self):
+ df = pd.DataFrame({
+ "role": ["P"] * 10 + ["D"] * 20 + ["C"] * 30 + ["A"] * 15,
+ "error": np.random.randn(75) * 0.5,
+ })
+ fig = error_violins(df)
+ assert fig is not None
+
+
+class TestPitch:
+ def test_render_pitch(self):
+ from dashboard.viz.pitch import render_pitch
+ lineup = [
+ {"name": "GK Test", "role": "P", "fv": 6.0, "starter_pct": 95},
+ *[{"name": f"DEF {i}", "role": "D", "fv": 6.5, "starter_pct": 90} for i in range(4)],
+ *[{"name": f"MID {i}", "role": "C", "fv": 7.0, "starter_pct": 85} for i in range(4)],
+ *[{"name": f"FWD {i}", "role": "A", "fv": 8.0, "starter_pct": 90} for i in range(2)],
+ ]
+ svg = render_pitch(lineup, formation="4-4-2", captain="FWD 0")
+ assert "