65f5b66b05
- 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)
196 lines
7.2 KiB
Python
196 lines
7.2 KiB
Python
"""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()
|