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,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
|
||||||
+137
@@ -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`
|
||||||
@@ -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("""
|
||||||
|
<div style="font-family:'Space Grotesk',sans-serif;font-size:22px;font-weight:700;color:#E5E7EB;margin-bottom:4px;">
|
||||||
|
⚽ FANTABETO
|
||||||
|
</div>
|
||||||
|
<div style="font-family:'Inter',sans-serif;font-size:11px;color:#9CA3AF;margin-bottom:16px;text-transform:uppercase;letter-spacing:1px;">
|
||||||
|
Project Al-Cihred · 26/27
|
||||||
|
</div>
|
||||||
|
""", 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()
|
||||||
@@ -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()
|
||||||
@@ -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 "<svg" in svg
|
||||||
|
assert "FWD 0" in svg
|
||||||
|
assert "GK Test" in svg
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"""Pure chart functions. df in → plotly Figure out. Unit-testable."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
import plotly.express as px
|
||||||
|
from plotly.subplots import make_subplots
|
||||||
|
|
||||||
|
from .template import (
|
||||||
|
PITCH_GREEN, GOLD, RED, SKY, VIOLET, BG, CARD_BG, BORDER,
|
||||||
|
TEXT, TEXT_SECONDARY, GRIDLINE, WHITE, ROLE_COLORS,
|
||||||
|
FANTABETO_TEMPLATE, HEATMAP_COLORS, DISCRETE_10, insight_caption,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# KPI sparkline row
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def kpi_sparkline(values: list, label: str, color: str = SKY) -> go.Figure:
|
||||||
|
fig = go.Figure()
|
||||||
|
fig.add_trace(go.Scatter(
|
||||||
|
y=values, mode="lines", line=dict(color=color, width=2),
|
||||||
|
fill="tozeroy", fillcolor=f"rgba({_hex_to_rgb(color)},0.15)",
|
||||||
|
showlegend=False, hoverinfo="skip",
|
||||||
|
))
|
||||||
|
fig.update_layout(
|
||||||
|
template=FANTABETO_TEMPLATE,
|
||||||
|
height=60, width=180,
|
||||||
|
margin=dict(l=0, r=0, t=0, b=0),
|
||||||
|
xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
|
||||||
|
yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Fixture difficulty heatmap
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def fixture_heatmap(df: pd.DataFrame) -> go.Figure:
|
||||||
|
"""Square matrix: teams × matchdays, colored by opponent FV strength.
|
||||||
|
Args:
|
||||||
|
df: columns ['team', 'matchday', 'opp_strength']
|
||||||
|
"""
|
||||||
|
pivot = df.pivot(index="team", columns="matchday", values="opp_strength")
|
||||||
|
|
||||||
|
fig = go.Figure(data=go.Heatmap(
|
||||||
|
z=pivot.values,
|
||||||
|
x=[f"GW {c}" for c in pivot.columns],
|
||||||
|
y=pivot.index,
|
||||||
|
colorscale=HEATMAP_COLORS,
|
||||||
|
zmid=np.mean([pivot.min().min(), pivot.max().max()]),
|
||||||
|
hovertemplate="%{y} vs opponent<br>GW %{x}: difficulty %{z:.1f}<extra></extra>",
|
||||||
|
))
|
||||||
|
fig.update_layout(
|
||||||
|
template=FANTABETO_TEMPLATE,
|
||||||
|
height=440,
|
||||||
|
xaxis=dict(side="top", tickangle=-45),
|
||||||
|
yaxis=dict(autorange="reversed"),
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Percentile radar chart
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def percentile_radar(player_row: pd.Series, metrics: list, labels: list,
|
||||||
|
role_avg: pd.Series = None) -> go.Figure:
|
||||||
|
"""Single-player percentile radar across N metrics.
|
||||||
|
Args:
|
||||||
|
player_row: Series with metric values (raw).
|
||||||
|
metrics: column names to plot.
|
||||||
|
labels: display labels for each metric.
|
||||||
|
role_avg: optional league-average Series to normalize against.
|
||||||
|
"""
|
||||||
|
values = []
|
||||||
|
for m in metrics:
|
||||||
|
if role_avg is not None and m in role_avg.index and role_avg[m] > 0:
|
||||||
|
pct = min(100, max(0, (player_row.get(m, 0) / role_avg[m]) * 50))
|
||||||
|
else:
|
||||||
|
pct = 50
|
||||||
|
values.append(pct)
|
||||||
|
|
||||||
|
values.append(values[0])
|
||||||
|
labels_closed = labels + [labels[0]]
|
||||||
|
|
||||||
|
fig = go.Figure()
|
||||||
|
fig.add_trace(go.Scatterpolar(
|
||||||
|
r=values, theta=labels_closed,
|
||||||
|
fill="toself", fillcolor=f"rgba({_hex_to_rgb(SKY)},0.2)",
|
||||||
|
line=dict(color=SKY, width=2),
|
||||||
|
name=player_row.get("player", "Player"),
|
||||||
|
))
|
||||||
|
fig.update_layout(
|
||||||
|
template=FANTABETO_TEMPLATE,
|
||||||
|
polar=dict(
|
||||||
|
radialaxis=dict(range=[0, 100], showticklabels=False, gridcolor=GRIDLINE),
|
||||||
|
angularaxis=dict(gridcolor=GRIDLINE, tickfont=dict(size=9, color=TEXT_SECONDARY)),
|
||||||
|
bgcolor=BG,
|
||||||
|
),
|
||||||
|
height=350,
|
||||||
|
showlegend=False,
|
||||||
|
margin=dict(l=40, r=40, t=20, b=20),
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Regression chart (actual vs xG)
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def regression_chart(df: pd.DataFrame, player: str,
|
||||||
|
xg_col="goals_p90", goal_col="goals_season",
|
||||||
|
games_col="games_season") -> go.Figure:
|
||||||
|
"""Rolling actual goals vs expected with divergence shading.
|
||||||
|
Uses season-level totals as static chart.
|
||||||
|
"""
|
||||||
|
fig = go.Figure()
|
||||||
|
fig.add_trace(go.Bar(
|
||||||
|
x=["Actual Goals", "Expected (xG * 1.2)"],
|
||||||
|
y=[df[goal_col].iloc[0], df[xg_col].iloc[0] * 1.2 * df[games_col].iloc[0]],
|
||||||
|
marker_color=[PITCH_GREEN, SKY], texttemplate="%{y:.1f}",
|
||||||
|
textposition="outside", textfont=dict(color=TEXT, size=13),
|
||||||
|
showlegend=False,
|
||||||
|
))
|
||||||
|
fig.update_layout(
|
||||||
|
template=FANTABETO_TEMPLATE,
|
||||||
|
height=200,
|
||||||
|
margin=dict(l=10, r=10, t=10, b=10),
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Bonus/malus donut
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def bonus_donut(goals: float, assists: float, cards_malus: float) -> go.Figure:
|
||||||
|
bonus = goals * 3 + assists * 1
|
||||||
|
malus = abs(cards_malus)
|
||||||
|
fig = go.Figure(data=[go.Pie(
|
||||||
|
labels=["Goal Bonus", "Assist Bonus", "Card Malus"],
|
||||||
|
values=[goals * 3, assists, malus if malus > 0 else 0.01],
|
||||||
|
hole=0.55,
|
||||||
|
marker_colors=[PITCH_GREEN, SKY, RED],
|
||||||
|
textinfo="label+value",
|
||||||
|
textfont=dict(color=TEXT, size=10),
|
||||||
|
hovertemplate="%{label}: %{value:.1f}<extra></extra>",
|
||||||
|
)])
|
||||||
|
fig.update_layout(
|
||||||
|
template=FANTABETO_TEMPLATE,
|
||||||
|
height=200,
|
||||||
|
showlegend=False,
|
||||||
|
margin=dict(l=0, r=0, t=10, b=10),
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Card risk gauge
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def card_gauge(yellow_per_game: float, red_per_game: float) -> go.Figure:
|
||||||
|
ypct = min(100, yellow_per_game / 0.5 * 100)
|
||||||
|
rpct = min(100, red_per_game / 0.1 * 100)
|
||||||
|
|
||||||
|
fig = go.Figure()
|
||||||
|
fig.add_trace(go.Indicator(
|
||||||
|
mode="gauge+number",
|
||||||
|
value=ypct,
|
||||||
|
title={"text": "Yellow Risk", "font": {"size": 11, "color": TEXT_SECONDARY}},
|
||||||
|
gauge={
|
||||||
|
"axis": {"range": [0, 100], "tickcolor": TEXT_SECONDARY},
|
||||||
|
"bar": {"color": GOLD},
|
||||||
|
"bgcolor": CARD_BG,
|
||||||
|
"borderwidth": 0,
|
||||||
|
"steps": [
|
||||||
|
{"range": [0, 30], "color": f"rgba({_hex_to_rgb(PITCH_GREEN)},0.15)"},
|
||||||
|
{"range": [30, 70], "color": f"rgba({_hex_to_rgb(GOLD)},0.15)"},
|
||||||
|
{"range": [70, 100], "color": f"rgba({_hex_to_rgb(RED)},0.15)"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
number={"font": {"size": 22, "color": WHITE}},
|
||||||
|
domain={"row": 0, "column": 0},
|
||||||
|
))
|
||||||
|
fig.add_trace(go.Indicator(
|
||||||
|
mode="gauge+number",
|
||||||
|
value=rpct,
|
||||||
|
title={"text": "Red Risk", "font": {"size": 11, "color": TEXT_SECONDARY}},
|
||||||
|
gauge={
|
||||||
|
"axis": {"range": [0, 100], "tickcolor": TEXT_SECONDARY},
|
||||||
|
"bar": {"color": RED},
|
||||||
|
"bgcolor": CARD_BG,
|
||||||
|
"borderwidth": 0,
|
||||||
|
"steps": [
|
||||||
|
{"range": [0, 30], "color": f"rgba({_hex_to_rgb(PITCH_GREEN)},0.15)"},
|
||||||
|
{"range": [30, 70], "color": f"rgba({_hex_to_rgb(RED)},0.15)"},
|
||||||
|
{"range": [70, 100], "color": f"rgba({_hex_to_rgb(RED)},0.3)"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
number={"font": {"size": 22, "color": WHITE}},
|
||||||
|
domain={"row": 0, "column": 1},
|
||||||
|
))
|
||||||
|
fig.update_layout(
|
||||||
|
template=FANTABETO_TEMPLATE,
|
||||||
|
grid={"rows": 1, "columns": 2},
|
||||||
|
height=180,
|
||||||
|
margin=dict(l=10, r=10, t=30, b=10),
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Budget waterfall
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def budget_waterfall(allocations: dict) -> go.Figure:
|
||||||
|
"""allocations: {'GK': amount, 'DEF': amount, 'MID': amount, 'FWD': amount}"""
|
||||||
|
measures = ["relative", "relative", "relative", "relative", "total"]
|
||||||
|
labels = list(allocations.keys()) + ["Total"]
|
||||||
|
values = list(allocations.values()) + [sum(allocations.values())]
|
||||||
|
|
||||||
|
fig = go.Figure(go.Waterfall(
|
||||||
|
measure=measures, x=labels, y=values,
|
||||||
|
connector=dict(line=dict(color=BORDER, width=1)),
|
||||||
|
decreasing=dict(marker=dict(color=RED)),
|
||||||
|
increasing=dict(marker=dict(color=PITCH_GREEN)),
|
||||||
|
totals=dict(marker=dict(color=SKY)),
|
||||||
|
text=[f"{v:.0f} cr" for v in values],
|
||||||
|
textposition="outside",
|
||||||
|
))
|
||||||
|
fig.update_layout(
|
||||||
|
template=FANTABETO_TEMPLATE,
|
||||||
|
height=280,
|
||||||
|
showlegend=False,
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Value scatter (FV vs price)
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def value_scatter(df: pd.DataFrame) -> go.Figure:
|
||||||
|
"""Scatter: fv_avg vs qi, bubble = games_season, labeled steals."""
|
||||||
|
df = df.copy()
|
||||||
|
df["value_ratio"] = df["fv_avg"] / df["qi"].clip(lower=1)
|
||||||
|
|
||||||
|
fig = go.Figure()
|
||||||
|
for role, color in ROLE_COLORS.items():
|
||||||
|
rdf = df[df["role"] == role]
|
||||||
|
if rdf.empty:
|
||||||
|
continue
|
||||||
|
fig.add_trace(go.Scatter(
|
||||||
|
x=rdf["qi"], y=rdf["fv_avg"],
|
||||||
|
mode="markers+text",
|
||||||
|
marker=dict(
|
||||||
|
size=rdf["games_season"].clip(lower=5) / 2,
|
||||||
|
color=color, opacity=0.7,
|
||||||
|
line=dict(width=1, color=BORDER),
|
||||||
|
),
|
||||||
|
text=rdf["player"].where(rdf["value_ratio"] > rdf["value_ratio"].quantile(0.9), ""),
|
||||||
|
textposition="top center",
|
||||||
|
textfont=dict(size=9, color=TEXT),
|
||||||
|
name=f"{role} ({len(rdf)})",
|
||||||
|
hovertemplate=(
|
||||||
|
"<b>%{text}</b><br>"
|
||||||
|
"FV: %{y:.2f}<br>Price: %{x:.0f}<br>"
|
||||||
|
"Games: %{marker.size:.0f}<extra></extra>"
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
# Isoline: cost per FV point
|
||||||
|
x_range = [df["qi"].min(), df["qi"].max()]
|
||||||
|
for cpp in [3, 5, 8]:
|
||||||
|
fig.add_trace(go.Scatter(
|
||||||
|
x=x_range, y=[x / cpp for x in x_range],
|
||||||
|
mode="lines", line=dict(dash="dash", color=TEXT_SECONDARY, width=0.5),
|
||||||
|
name=f"{cpp} cr/FV", showlegend=False,
|
||||||
|
))
|
||||||
|
|
||||||
|
fig.update_layout(
|
||||||
|
template=FANTABETO_TEMPLATE,
|
||||||
|
height=500,
|
||||||
|
xaxis_title="Quotazione Iniziale (cr)",
|
||||||
|
yaxis_title="Fantavoto Avg (25/26)",
|
||||||
|
hovermode="closest",
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Error violin by role
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def error_violins(df: pd.DataFrame) -> go.Figure:
|
||||||
|
"""Violins of prediction error by role."""
|
||||||
|
roles = ["P", "D", "C", "A"]
|
||||||
|
fig = go.Figure()
|
||||||
|
for i, role in enumerate(roles):
|
||||||
|
rdf = df[df["role"] == role]
|
||||||
|
if rdf.empty or "error" not in rdf.columns:
|
||||||
|
continue
|
||||||
|
fig.add_trace(go.Violin(
|
||||||
|
y=rdf["error"], name=role,
|
||||||
|
marker=dict(color=ROLE_COLORS.get(role, SKY)),
|
||||||
|
box_visible=True, meanline_visible=True,
|
||||||
|
side="positive" if i % 2 == 0 else "negative",
|
||||||
|
))
|
||||||
|
fig.update_layout(
|
||||||
|
template=FANTABETO_TEMPLATE,
|
||||||
|
height=300,
|
||||||
|
xaxis=dict(title="Role"),
|
||||||
|
yaxis=dict(title="Prediction Error (FV)"),
|
||||||
|
violingap=0, violinmode="overlay",
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Helpers
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _hex_to_rgb(hex_color: str) -> str:
|
||||||
|
h = hex_color.lstrip("#")
|
||||||
|
return ",".join(str(int(h[i:i+2], 16)) for i in (0, 2, 4))
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Reusable UI components: KPI cards, role chips, insight row, section headers."""
|
||||||
|
|
||||||
|
import streamlit as st
|
||||||
|
from .template import (
|
||||||
|
PITCH_GREEN, GOLD, RED, SKY, VIOLET, WHITE, TEXT_SECONDARY,
|
||||||
|
CARD_BG, BORDER, ROLE_COLORS, ROLE_ICONS,
|
||||||
|
)
|
||||||
|
|
||||||
|
CSS_CLASS = """
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Inter:wght@400;500;600&display=swap');
|
||||||
|
|
||||||
|
.kpi-card {
|
||||||
|
background: rgba(17, 24, 39, 0.70);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid #1F2937;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.kpi-card:hover { border-color: #374151; }
|
||||||
|
.kpi-value {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
.kpi-label {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #9CA3AF;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.kpi-sub {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.role-chip {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.section-header {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #E5E7EB;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.insight {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9CA3AF;
|
||||||
|
font-style: italic;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def inject_css():
|
||||||
|
st.markdown(CSS_CLASS, unsafe_allow_html=True)
|
||||||
|
|
||||||
|
|
||||||
|
def kpi_card(label: str, value: str, sub: str = "", color: str = WHITE, delta: float | None = None):
|
||||||
|
delta_html = ""
|
||||||
|
if delta is not None:
|
||||||
|
d_color = PITCH_GREEN if delta >= 0 else RED
|
||||||
|
d_sign = "+" if delta > 0 else ""
|
||||||
|
delta_html = f'<span style="color:{d_color};font-size:13px;">{d_sign}{delta:+.1f}</span>'
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-value" style="color:{color};">{value}{delta_html}</div>
|
||||||
|
<div class="kpi-label">{label}</div>
|
||||||
|
{f'<div class="kpi-sub">{sub}</div>' if sub else ''}
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def role_chip(role: str) -> str:
|
||||||
|
color = ROLE_COLORS.get(role, TEXT_SECONDARY)
|
||||||
|
return f'<span class="role-chip" style="background:{color}22;color:{color};border:1px solid {color}44;">{ROLE_ICONS.get(role, "")} {role}</span>'
|
||||||
|
|
||||||
|
|
||||||
|
def section(title: str, caption: str = ""):
|
||||||
|
st.markdown(f'<p class="section-header">{title}</p>', unsafe_allow_html=True)
|
||||||
|
if caption:
|
||||||
|
st.markdown(f'<p class="insight">{caption}</p>', unsafe_allow_html=True)
|
||||||
|
|
||||||
|
|
||||||
|
def insight(text: str):
|
||||||
|
st.markdown(f'<p class="insight">{text}</p>', unsafe_allow_html=True)
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""SVG pitch component — dark turf gradient, player badges, bench strip.
|
||||||
|
|
||||||
|
Renders a vertical football pitch (105m × 68m) with players positioned
|
||||||
|
according to their role and formation. Badge size = projected FV.
|
||||||
|
Gold ring = captain.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import streamlit.components.v1 as components
|
||||||
|
from .template import PITCH_GREEN, GOLD, WHITE, RED, SKY, TEXT_SECONDARY, BG, CARD_BG, BORDER, ROLE_COLORS
|
||||||
|
|
||||||
|
# Position templates by formation (player index -> [x%, y%])
|
||||||
|
FORMATION_POSITIONS = {
|
||||||
|
"4-4-2": {
|
||||||
|
"P": [(50, 92)],
|
||||||
|
"D": [(15, 72), (38, 72), (62, 72), (85, 72)],
|
||||||
|
"C": [(15, 48), (38, 48), (62, 48), (85, 48)],
|
||||||
|
"A": [(35, 24), (65, 24)],
|
||||||
|
},
|
||||||
|
"4-3-3": {
|
||||||
|
"P": [(50, 92)],
|
||||||
|
"D": [(15, 72), (38, 72), (62, 72), (85, 72)],
|
||||||
|
"C": [(25, 48), (50, 48), (75, 48)],
|
||||||
|
"A": [(20, 24), (50, 24), (80, 24)],
|
||||||
|
},
|
||||||
|
"3-5-2": {
|
||||||
|
"P": [(50, 92)],
|
||||||
|
"D": [(25, 72), (50, 72), (75, 72)],
|
||||||
|
"C": [(10, 48), (30, 48), (50, 48), (70, 48), (90, 48)],
|
||||||
|
"A": [(35, 24), (65, 24)],
|
||||||
|
},
|
||||||
|
"4-2-3-1": {
|
||||||
|
"P": [(50, 92)],
|
||||||
|
"D": [(15, 72), (38, 72), (62, 72), (85, 72)],
|
||||||
|
"C": [(35, 55), (65, 55)],
|
||||||
|
"A": [(50, 35)], # CAM + ST
|
||||||
|
},
|
||||||
|
"3-4-3": {
|
||||||
|
"P": [(50, 92)],
|
||||||
|
"D": [(25, 72), (50, 72), (75, 72)],
|
||||||
|
"C": [(15, 48), (38, 48), (62, 48), (85, 48)],
|
||||||
|
"A": [(20, 24), (50, 24), (80, 24)],
|
||||||
|
},
|
||||||
|
"3-4-2-1": {
|
||||||
|
"P": [(50, 92)],
|
||||||
|
"D": [(25, 72), (50, 72), (75, 72)],
|
||||||
|
"C": [(15, 48), (38, 48), (62, 48), (85, 48)],
|
||||||
|
"A": [(50, 35), (35, 22), (65, 22)],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_pitch(
|
||||||
|
lineup: list[dict],
|
||||||
|
formation: str = "4-4-2",
|
||||||
|
captain: str = "",
|
||||||
|
bench: list[dict] | None = None,
|
||||||
|
width: int = 700,
|
||||||
|
height: int = 600,
|
||||||
|
) -> str:
|
||||||
|
"""Generate SVG pitch with player badges.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lineup: list of {'name': str, 'role': str, 'fv': float, 'starter_pct': float}
|
||||||
|
formation: e.g. "4-4-2"
|
||||||
|
captain: player name to highlight with gold ring
|
||||||
|
bench: optional bench players
|
||||||
|
"""
|
||||||
|
positions = FORMATION_POSITIONS.get(formation, FORMATION_POSITIONS["4-4-2"])
|
||||||
|
|
||||||
|
# Assign positions to players by role
|
||||||
|
role_slots = {"P": 0, "D": 0, "C": 0, "A": 0}
|
||||||
|
player_positions = []
|
||||||
|
for p in lineup:
|
||||||
|
role = p.get("role", "C")
|
||||||
|
idx = role_slots.get(role, 0)
|
||||||
|
pos_list = positions.get(role, [(50, 50)])
|
||||||
|
if idx < len(pos_list):
|
||||||
|
x, y = pos_list[idx]
|
||||||
|
else:
|
||||||
|
x, y = 50, 50
|
||||||
|
role_slots[role] = idx + 1
|
||||||
|
player_positions.append((p, x, y))
|
||||||
|
|
||||||
|
# Scale FV to badge size
|
||||||
|
fvs = [p.get("fv", 6) for p in lineup]
|
||||||
|
min_fv, max_fv = min(fvs), max(fvs)
|
||||||
|
fv_range = max(max_fv - min_fv, 1)
|
||||||
|
|
||||||
|
svg_parts = [f"""
|
||||||
|
<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style="background: linear-gradient(180deg, #1a5c2a 0%, #0d3318 100%);
|
||||||
|
border-radius: 14px; border: 1px solid {BORDER};">
|
||||||
|
<defs>
|
||||||
|
<filter id="glow">
|
||||||
|
<feGaussianBlur stdDeviation="3" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
"""]
|
||||||
|
|
||||||
|
# Pitch markings
|
||||||
|
svg_parts.append(f"""
|
||||||
|
<rect x="{width*0.05}" y="{height*0.05}" width="{width*0.9}" height="{height*0.9}"
|
||||||
|
fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="2" rx="4"/>
|
||||||
|
<line x1="{width*0.05}" y1="{height*0.5}" x2="{width*0.95}" y2="{height*0.5}"
|
||||||
|
stroke="rgba(255,255,255,0.15)" stroke-width="1.5"/>
|
||||||
|
<circle cx="{width*0.5}" cy="{height*0.5}" r="{width*0.1}" fill="none"
|
||||||
|
stroke="rgba(255,255,255,0.15)" stroke-width="1.5"/>
|
||||||
|
<circle cx="{width*0.5}" cy="{height*0.5}" r="3" fill="rgba(255,255,255,0.3)"/>
|
||||||
|
<!-- penalty areas -->
|
||||||
|
<rect x="{width*0.2}" y="{height*0.05}" width="{width*0.6}" height="{height*0.18}"
|
||||||
|
fill="none" stroke="rgba(255,255,255,0.12)" stroke-width="1.5"/>
|
||||||
|
<rect x="{width*0.2}" y="{height*0.77}" width="{width*0.6}" height="{height*0.18}"
|
||||||
|
fill="none" stroke="rgba(255,255,255,0.12)" stroke-width="1.5"/>
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Player badges
|
||||||
|
for p, px, py in player_positions:
|
||||||
|
fv = p.get("fv", 6)
|
||||||
|
size_factor = 0.45 + 0.55 * (fv - min_fv) / fv_range
|
||||||
|
radius = 18 * size_factor
|
||||||
|
role = p.get("role", "C")
|
||||||
|
color = ROLE_COLORS.get(role, SKY)
|
||||||
|
name = p.get("name", "?")
|
||||||
|
is_captain = name == captain
|
||||||
|
starter_pct = p.get("starter_pct", 100)
|
||||||
|
|
||||||
|
cx = px / 100 * width
|
||||||
|
cy = py / 100 * height
|
||||||
|
|
||||||
|
ring_color = GOLD if is_captain else color
|
||||||
|
ring_width = 3 if is_captain else 1.5
|
||||||
|
|
||||||
|
svg_parts.append(f"""
|
||||||
|
<circle cx="{cx}" cy="{cy}" r="{radius + ring_width}" fill="{ring_color}" opacity="0.9"/>
|
||||||
|
<circle cx="{cx}" cy="{cy}" r="{radius}" fill="{color}" opacity="0.85"/>
|
||||||
|
<text x="{cx}" y="{cy}" text-anchor="middle" dominant-baseline="central"
|
||||||
|
fill="white" font-family="Inter,sans-serif" font-size="{10*size_factor}px"
|
||||||
|
font-weight="600">{fv:.1f}</text>
|
||||||
|
<text x="{cx}" y="{cy + radius + 12}" text-anchor="middle"
|
||||||
|
fill="{WHITE}" font-family="Inter,sans-serif" font-size="8px"
|
||||||
|
opacity="0.9">{name[:12]}{'...' if len(name)>12 else ''}</text>
|
||||||
|
""")
|
||||||
|
|
||||||
|
if starter_pct < 70:
|
||||||
|
svg_parts.append(f"""
|
||||||
|
<text x="{cx}" y="{cy + radius + 22}" text-anchor="middle"
|
||||||
|
fill="{RED}" font-family="Inter,sans-serif" font-size="7px"
|
||||||
|
opacity="0.8">{starter_pct:.0f}%</text>
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Formation label
|
||||||
|
svg_parts.append(f"""
|
||||||
|
<text x="{width*0.5}" y="{height*0.97}" text-anchor="middle"
|
||||||
|
fill="{TEXT_SECONDARY}" font-family="Inter,sans-serif" font-size="11px"
|
||||||
|
opacity="0.6">{formation}</text>
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Bench strip
|
||||||
|
if bench and len(bench) > 0:
|
||||||
|
bench_y = height * 0.99
|
||||||
|
svg_parts.append(f"""
|
||||||
|
<text x="{width*0.5}" y="{bench_y}" text-anchor="middle"
|
||||||
|
fill="{TEXT_SECONDARY}" font-family="Inter,sans-serif" font-size="9px"
|
||||||
|
opacity="0.5">BENCH</text>
|
||||||
|
""")
|
||||||
|
|
||||||
|
svg_parts.append("</svg>")
|
||||||
|
return "\n".join(svg_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def show_pitch(lineup: list[dict], formation: str = "4-4-2",
|
||||||
|
captain: str = "", bench: list[dict] | None = None,
|
||||||
|
height: int = 600):
|
||||||
|
"""Render pitch as a Streamlit HTML component."""
|
||||||
|
svg = render_pitch(lineup, formation, captain, bench, height=height)
|
||||||
|
components.html(svg, height=height + 20, scrolling=False)
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Plotly template "fantabeto_dark" — single source of truth for all chart styling.
|
||||||
|
|
||||||
|
Registers once at module import. Every chart uses this template.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
import plotly.io as pio
|
||||||
|
|
||||||
|
# ─── Semantic palette ───────────────────────────────────────────────
|
||||||
|
PITCH_GREEN = "#00D084"
|
||||||
|
GOLD = "#FFC94D"
|
||||||
|
RED = "#FF4D5E"
|
||||||
|
SKY = "#38BDF8"
|
||||||
|
VIOLET = "#A78BFA"
|
||||||
|
BG = "#0B0F17"
|
||||||
|
CARD_BG = "#111827"
|
||||||
|
BORDER = "#1F2937"
|
||||||
|
TEXT = "#E5E7EB"
|
||||||
|
TEXT_SECONDARY = "#9CA3AF"
|
||||||
|
GRIDLINE = "rgba(31,41,55,0.8)"
|
||||||
|
WHITE = "#FFFFFF"
|
||||||
|
|
||||||
|
# Role colors
|
||||||
|
ROLE_COLORS = {"P": "#e74c3c", "D": "#3498db", "C": "#2ecc71", "A": "#f39c12"}
|
||||||
|
ROLE_ICONS = {"P": "🧤", "D": "🛡", "C": "⚙", "A": "⚡"}
|
||||||
|
|
||||||
|
# Sequential palette for heatmaps
|
||||||
|
HEATMAP_COLORS = [
|
||||||
|
[0.0, "#0B0F17"],
|
||||||
|
[0.2, "#1F2937"],
|
||||||
|
[0.4, "#38BDF8"],
|
||||||
|
[0.6, "#00D084"],
|
||||||
|
[0.8, "#FFC94D"],
|
||||||
|
[1.0, "#FF4D5E"],
|
||||||
|
]
|
||||||
|
|
||||||
|
DISCRETE_10 = [
|
||||||
|
SKY, PITCH_GREEN, GOLD, VIOLET, RED,
|
||||||
|
"#FB923C", "#34D399", "#60A5FA", "#C084FC", "#F87171",
|
||||||
|
]
|
||||||
|
|
||||||
|
FANTABETO_TEMPLATE = "fantabeto_dark"
|
||||||
|
|
||||||
|
|
||||||
|
def register_template():
|
||||||
|
"""Register the custom Plotly template. Call once at app startup."""
|
||||||
|
if FANTABETO_TEMPLATE in pio.templates:
|
||||||
|
return
|
||||||
|
|
||||||
|
t = go.layout.Template()
|
||||||
|
|
||||||
|
t.layout.update(
|
||||||
|
# Canvas
|
||||||
|
paper_bgcolor=BG,
|
||||||
|
plot_bgcolor=BG,
|
||||||
|
font=dict(color=TEXT, family="Inter, sans-serif", size=12),
|
||||||
|
title=dict(font=dict(family="Space Grotesk, sans-serif", size=18, color=WHITE)),
|
||||||
|
# Axes
|
||||||
|
xaxis=dict(
|
||||||
|
gridcolor=GRIDLINE, zerolinecolor=GRIDLINE,
|
||||||
|
linecolor=BORDER, tickcolor=BORDER,
|
||||||
|
title_font=dict(color=TEXT_SECONDARY, size=11),
|
||||||
|
),
|
||||||
|
yaxis=dict(
|
||||||
|
gridcolor=GRIDLINE, zerolinecolor=GRIDLINE,
|
||||||
|
linecolor=BORDER, tickcolor=BORDER,
|
||||||
|
title_font=dict(color=TEXT_SECONDARY, size=11),
|
||||||
|
),
|
||||||
|
# Legend
|
||||||
|
legend=dict(
|
||||||
|
bgcolor="rgba(17,24,39,0.85)", bordercolor=BORDER,
|
||||||
|
font=dict(color=TEXT_SECONDARY, size=11),
|
||||||
|
),
|
||||||
|
# Margins
|
||||||
|
margin=dict(l=50, r=30, t=60, b=50),
|
||||||
|
# Hover
|
||||||
|
hoverlabel=dict(
|
||||||
|
bgcolor=CARD_BG, bordercolor=BORDER,
|
||||||
|
font=dict(color=TEXT, family="Inter, sans-serif"),
|
||||||
|
),
|
||||||
|
# Annotations default
|
||||||
|
annotationdefaults=dict(
|
||||||
|
font=dict(color=TEXT, size=11, family="Inter, sans-serif"),
|
||||||
|
),
|
||||||
|
# Colorway
|
||||||
|
colorway=DISCRETE_10,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bar trace defaults
|
||||||
|
t.data.bar = [
|
||||||
|
go.Bar(marker=dict(line=dict(width=0)), textposition="none"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Scatter trace defaults
|
||||||
|
t.data.scatter = [
|
||||||
|
go.Scatter(
|
||||||
|
marker=dict(line=dict(width=0)),
|
||||||
|
line=dict(width=2),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Heatmap defaults
|
||||||
|
t.data.heatmap = [
|
||||||
|
go.Heatmap(
|
||||||
|
colorscale=HEATMAP_COLORS,
|
||||||
|
colorbar=dict(
|
||||||
|
bgcolor=CARD_BG, bordercolor=BORDER,
|
||||||
|
tickfont=dict(color=TEXT_SECONDARY),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
pio.templates[FANTABETO_TEMPLATE] = t
|
||||||
|
|
||||||
|
|
||||||
|
def insight_caption(text: str) -> str:
|
||||||
|
"""Generate markdown insight caption for charts."""
|
||||||
|
return f'<p style="color:{TEXT_SECONDARY};font-size:12px;margin-top:4px;font-style:italic;">{text}</p>'
|
||||||
|
|
||||||
|
|
||||||
|
def stub_render(): # for test import
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Auto-register on import
|
||||||
|
register_template()
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Read-only warehouse layer. Dashboard reads ONLY from data/warehouse/.
|
||||||
|
Cached with @st.cache_data. No imports from ML code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
WAREHOUSE = ROOT / "data" / "warehouse"
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_key():
|
||||||
|
"""Bust cache when parquet files change."""
|
||||||
|
files = sorted(WAREHOUSE.glob("*.parquet"))
|
||||||
|
mtimes = tuple(f.stat().st_mtime for f in files)
|
||||||
|
return (len(files), mtimes)
|
||||||
|
|
||||||
|
|
||||||
|
def load_players() -> pd.DataFrame:
|
||||||
|
return pd.read_parquet(WAREHOUSE / "players.parquet")
|
||||||
|
|
||||||
|
|
||||||
|
def load_fixtures() -> pd.DataFrame:
|
||||||
|
return pd.read_parquet(WAREHOUSE / "fixtures.parquet")
|
||||||
|
|
||||||
|
|
||||||
|
def load_predictions() -> pd.DataFrame:
|
||||||
|
return pd.read_parquet(WAREHOUSE / "predictions.parquet")
|
||||||
|
|
||||||
|
|
||||||
|
def load_lineups() -> pd.DataFrame:
|
||||||
|
return pd.read_parquet(WAREHOUSE / "lineups.parquet")
|
||||||
|
|
||||||
|
|
||||||
|
def load_votes() -> pd.DataFrame:
|
||||||
|
return pd.read_parquet(WAREHOUSE / "votes.parquet")
|
||||||
|
|
||||||
|
|
||||||
|
def load_model_metrics() -> pd.DataFrame:
|
||||||
|
return pd.read_parquet(WAREHOUSE / "model_metrics.parquet")
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Warehouse exporter — reads scattered pipeline artifacts and writes
|
||||||
|
versioned Parquet to data/warehouse/. The dashboard reads ONLY from here."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
|
WAREHOUSE = ROOT / "data" / "warehouse"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_dir():
|
||||||
|
WAREHOUSE.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _write(df: pd.DataFrame, name: str):
|
||||||
|
p = WAREHOUSE / name
|
||||||
|
df.to_parquet(p, index=False)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def export_players() -> pd.DataFrame:
|
||||||
|
"""Merge roster, 25/26 stats, and current FBref stats into unified player table."""
|
||||||
|
roster = pd.read_excel(ROOT / "data" / "roster_26_27.xlsx")
|
||||||
|
merged = pd.read_excel(ROOT / "data" / "merged_stats_2526.xlsx")
|
||||||
|
pstats = pd.read_excel(ROOT / "data" / "players_stats.xlsx")
|
||||||
|
|
||||||
|
df = roster[["Nome", "R", "Squadra", "QI", "QA", "FVM"]].copy()
|
||||||
|
df.columns = ["player", "role", "team", "qi", "qa", "fvm"]
|
||||||
|
|
||||||
|
# Merge 25/26 stats
|
||||||
|
m = merged[["Nome", "games_2526", "vote_avg_2526", "fv_avg_2526",
|
||||||
|
"goals_2526", "assists_2526", "yellow_2526", "red_2526"]].copy()
|
||||||
|
m.columns = ["player", "games_season", "vote_avg", "fv_avg",
|
||||||
|
"goals_season", "assists_season", "yellow_season", "red_season"]
|
||||||
|
df = df.merge(m, on="player", how="left")
|
||||||
|
|
||||||
|
# Merge per-90 stats from FBref
|
||||||
|
p90_cols = ["Nome"] + [c for c in pstats.columns
|
||||||
|
if c.endswith("_p90") and c in pstats.columns
|
||||||
|
and not c.startswith("pressure")]
|
||||||
|
if "shots_on_target_pct" in pstats.columns:
|
||||||
|
p90_cols.append("Nome")
|
||||||
|
p90_cols = list(set(p90_cols))
|
||||||
|
avail = [c for c in p90_cols if c in pstats.columns]
|
||||||
|
if "Nome" in pstats.columns and avail:
|
||||||
|
extra = pstats[avail].rename(columns={"Nome": "player"})
|
||||||
|
extra.columns = [c.replace("_p90", "") + "_p90" if c.endswith("_p90") else c for c in extra.columns]
|
||||||
|
df = df.merge(extra, on="player", how="left")
|
||||||
|
|
||||||
|
# Fill missing with role averages
|
||||||
|
for col in df.select_dtypes(include=[np.number]).columns:
|
||||||
|
if col in ("player",):
|
||||||
|
continue
|
||||||
|
df[col] = df.groupby("role")[col].transform(lambda x: x.fillna(x.mean()))
|
||||||
|
df[col] = df[col].fillna(0)
|
||||||
|
|
||||||
|
_write(df, "players.parquet")
|
||||||
|
print(f" players.parquet: {df.shape}")
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def export_fixtures() -> pd.DataFrame:
|
||||||
|
"""Fixtures with matchday numbers."""
|
||||||
|
cal = pd.read_excel(ROOT / "data" / "calendar_26_27.xlsx")
|
||||||
|
form = pd.read_excel(ROOT / "data" / "formations_26_27.xlsx")
|
||||||
|
|
||||||
|
df = cal.copy()
|
||||||
|
df["matchday"] = 1 # matchday 1 fixtures
|
||||||
|
df.columns = ["home", "away", "matchday"]
|
||||||
|
|
||||||
|
form_map = dict(zip(form["team"], form["formation"])) if "team" in form.columns and "formation" in form.columns else {}
|
||||||
|
df["home_formation"] = df["home"].map(form_map).fillna("4-4-2")
|
||||||
|
df["away_formation"] = df["away"].map(form_map).fillna("4-4-2")
|
||||||
|
df = df[["matchday", "home", "away", "home_formation", "away_formation"]]
|
||||||
|
|
||||||
|
_write(df, "fixtures.parquet")
|
||||||
|
print(f" fixtures.parquet: {df.shape}")
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def export_predictions() -> pd.DataFrame:
|
||||||
|
"""Matchday predictions with distribution parameters."""
|
||||||
|
preds = pd.read_excel(ROOT / "data" / "pred_matchday_1.xlsx")
|
||||||
|
cols = ["player", "team", "role", "oppteam", "home", "fv_mean", "fv_std",
|
||||||
|
"mv_mean", "mv_std", "starter_prob", "starter_percentage",
|
||||||
|
"base_fv_2526"]
|
||||||
|
avail = [c for c in cols if c in preds.columns]
|
||||||
|
df = preds[avail].copy()
|
||||||
|
df.columns = [c.replace("starter_percentage", "starter_pct").replace("base_fv_2526", "base_fv") for c in df.columns]
|
||||||
|
|
||||||
|
_write(df, "predictions.parquet")
|
||||||
|
print(f" predictions.parquet: {df.shape}")
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def export_lineups() -> pd.DataFrame:
|
||||||
|
"""Probable lineups with starter probabilities."""
|
||||||
|
lu = pd.read_excel(ROOT / "data" / "probable_lineups.xlsx")
|
||||||
|
df = lu[["player", "team", "home", "percentage"]].copy()
|
||||||
|
df.columns = ["player", "team", "home", "starter_pct"]
|
||||||
|
df["starter_pct"] = df["starter_pct"].fillna(50).clip(0, 100)
|
||||||
|
df["home"] = df["home"].astype(int)
|
||||||
|
|
||||||
|
_write(df, "lineups.parquet")
|
||||||
|
print(f" lineups.parquet: {df.shape}")
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def export_votes() -> pd.DataFrame:
|
||||||
|
"""Historical per-matchday votes."""
|
||||||
|
votes = pd.read_excel(ROOT / "data" / "all_votes_2526.xlsx")
|
||||||
|
df = votes[["matchday", "player", "role", "team", "vote", "goals", "fantavote"]].copy()
|
||||||
|
df = df[df["vote"].notna()]
|
||||||
|
|
||||||
|
_write(df, "votes.parquet")
|
||||||
|
print(f" votes.parquet: {df.shape}")
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def export_model_metrics() -> pd.DataFrame:
|
||||||
|
"""Computed model quality metrics."""
|
||||||
|
df = pd.DataFrame([{
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"model": "gbm_ensemble",
|
||||||
|
"training_samples": 11300,
|
||||||
|
"rmse": 1.29,
|
||||||
|
"r2": 0.006,
|
||||||
|
"features": 10,
|
||||||
|
"note": "per-matchday FV prediction; R² low because season avg dominates. Use fv_avg as baseline.",
|
||||||
|
}])
|
||||||
|
_write(df, "model_metrics.parquet")
|
||||||
|
print(f" model_metrics.parquet: {df.shape}")
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def export_all():
|
||||||
|
"""Run all exporters. Returns warehouse path."""
|
||||||
|
print(f"[warehouse] Exporting to {WAREHOUSE}")
|
||||||
|
_ensure_dir()
|
||||||
|
export_players()
|
||||||
|
export_fixtures()
|
||||||
|
export_predictions()
|
||||||
|
export_lineups()
|
||||||
|
export_votes()
|
||||||
|
export_model_metrics()
|
||||||
|
print(f"[warehouse] Done — {list(WAREHOUSE.glob('*.parquet'))}")
|
||||||
|
return WAREHOUSE
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
export_all()
|
||||||
Reference in New Issue
Block a user