6c1fb46e8a
- Realistic price model: scales by FVM (Lautaro FVM=370 → ~199 cr, QI×5.7) Formula: qi * max(1.2, fvm/65) with forward premium and small-sample penalty - 5 strategy engines: Balanced, Value, Stars, Safe, MILP-Lite Each with different score functions (value ratio, stability, risk adj) - Strategy comparison radar grid: total FV, efficiency, stars, avg games, budget% - Squad table with color-coded price bands (gold >100cr, sky >50cr, green <50cr) - Market distribution histogram by role - Top value picks section with cr/FV efficiency - Fixed role_chip HTML rendering in all pages (unsafe_allow_html=True) - Verified: 0 errors, 31 role chips, 5 KPIs, 8 charts
334 lines
13 KiB
Python
334 lines
13 KiB
Python
import sys; from pathlib import Path; _p = Path(__file__).resolve().parent.parent.parent; str(_p) not in sys.path and sys.path.insert(0, str(_p))
|
|
"""Page 3 — Auction War Room.
|
|
|
|
Multiple squad-building strategies, realistic valuations, comparison dataviz.
|
|
"""
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import streamlit as st
|
|
import plotly.graph_objects as go
|
|
from plotly.subplots import make_subplots
|
|
|
|
from dashboard.warehouse import load_players, load_predictions
|
|
from dashboard.viz.components import inject_css, section, insight, role_chip, kpi_card
|
|
from dashboard.viz.charts import budget_waterfall, value_scatter
|
|
from dashboard.viz.template import (
|
|
PITCH_GREEN, GOLD, RED, SKY, VIOLET, BG, CARD_BG, BORDER, TEXT_SECONDARY,
|
|
WHITE, FANTABETO_TEMPLATE, HEATMAP_COLORS, ROLE_COLORS, ROLE_ICONS,
|
|
)
|
|
|
|
|
|
@st.cache_data(ttl=3600)
|
|
def _get_data():
|
|
return load_players(), load_predictions()
|
|
|
|
|
|
# ─── Realistic price model ─────────────────────────────────────────
|
|
def _estimate_price(qi: float, fvm: float, games: float, role: str) -> float:
|
|
"""Estimate final auction price based on market value and games played.
|
|
Top players (FVM > 200) go for 5-8x QI. Budget players at ~1.2x QI minimum.
|
|
"""
|
|
if games < 5:
|
|
fvm = fvm * 0.6 # small-sample penalty
|
|
multiplier = max(1.2, fvm / 65.0)
|
|
if role == "A":
|
|
multiplier *= 1.15 # forwards carry a premium
|
|
elif role == "C" and fvm > 150:
|
|
multiplier *= 1.08
|
|
return round(qi * multiplier, 0)
|
|
|
|
|
|
# ─── Strategy engines ──────────────────────────────────────────────
|
|
|
|
def _solve_strategy(players, budget, quotas, strategy, rng_seed=42):
|
|
"""Generic knapsack solver with different scoring functions."""
|
|
df = players.copy()
|
|
df["price"] = df.apply(lambda r: _estimate_price(
|
|
r["qi"], r.get("fvm", r["fv_avg"] * 30), r.get("games_season", 30), r["role"]
|
|
), axis=1)
|
|
df["value_ratio"] = df["fv_avg"] / df["price"].clip(lower=1)
|
|
df["stability"] = df["games_season"].clip(0, 38) / 38.0
|
|
|
|
rng = np.random.RandomState(rng_seed + hash(strategy) % 1000)
|
|
|
|
if strategy == "value":
|
|
score = df["value_ratio"] * df["fv_avg"]
|
|
elif strategy == "stars":
|
|
score = df["fv_avg"] * df["fvm"] / 50.0 * df["stability"]
|
|
elif strategy == "balanced":
|
|
score = df["fv_avg"] * df["value_ratio"] * np.sqrt(df["stability"])
|
|
elif strategy == "safe":
|
|
df["risk_adj"] = np.where(df["games_season"] < 10, 0.4,
|
|
np.where(df["games_season"] < 20, 0.7, 1.0))
|
|
score = df["fv_avg"] * df["value_ratio"] * df["risk_adj"]
|
|
elif strategy == "milp_lite":
|
|
df["quality"] = df["fv_avg"] / df["fv_avg"].max()
|
|
df["efficiency"] = df["fv_avg"] / df["price"].clip(lower=1)
|
|
score = df["quality"] * 0.6 + df["efficiency"] * 0.4
|
|
else:
|
|
score = df["fv_avg"]
|
|
|
|
scored = [(score.iloc[i], i) for i in range(len(df))
|
|
if df.iloc[i]["role"] in quotas]
|
|
scored.sort(key=lambda x: -x[0])
|
|
|
|
filled = {k: 0 for k in quotas}
|
|
remaining = budget
|
|
selected = []
|
|
|
|
for _, idx in scored:
|
|
p = df.iloc[idx]
|
|
role = p["role"]
|
|
if filled[role] >= quotas[role]:
|
|
continue
|
|
price = p["price"]
|
|
if price > remaining:
|
|
continue
|
|
selected.append({
|
|
"player": p["player"], "role": role, "team": p["team"],
|
|
"fv_avg": p["fv_avg"], "qi": p["qi"], "price": price,
|
|
"games": p.get("games_season", 30),
|
|
"fvm": p.get("fvm", 0),
|
|
})
|
|
remaining -= price
|
|
filled[role] += 1
|
|
|
|
total = budget - remaining
|
|
total_fv = sum(s["fv_avg"] for s in selected)
|
|
avg_games = np.mean([s["games"] for s in selected]) if selected else 0
|
|
return selected, total, total_fv, remaining, avg_games
|
|
|
|
|
|
# ─── Charts ─────────────────────────────────────────────────────────
|
|
|
|
def _price_distribution_chart(players):
|
|
"""Histogram of estimated prices by role."""
|
|
df = players.copy()
|
|
df["price"] = df.apply(lambda r: _estimate_price(
|
|
r["qi"], r.get("fvm", 100), r.get("games_season", 30), r["role"]
|
|
), axis=1)
|
|
|
|
fig = go.Figure()
|
|
for role, color in ROLE_COLORS.items():
|
|
rdf = df[df["role"] == role]["price"]
|
|
if rdf.empty:
|
|
continue
|
|
fig.add_trace(go.Histogram(
|
|
x=rdf, name=role, marker_color=color, opacity=0.7,
|
|
nbinsx=40, hovertemplate=f"{role} price: %{{x:.0f}} cr<extra></extra>",
|
|
))
|
|
fig.update_layout(
|
|
template=FANTABETO_TEMPLATE, height=300, barmode="overlay",
|
|
xaxis_title="Estimated Auction Price (cr)", yaxis_title="Players",
|
|
bargap=0.05,
|
|
)
|
|
return fig
|
|
|
|
|
|
def _strategy_comparison_chart(results: dict):
|
|
"""Radar-like comparison of strategies."""
|
|
strategies = list(results.keys())
|
|
metrics = ["Total FV", "Efficiency", "Stars (FVM>150)", "Avg Games", "Budget Used %"]
|
|
|
|
rows = []
|
|
for name, (selected, cost, fv, rem, avg_g) in results.items():
|
|
stars = sum(1 for s in selected if s.get("fvm", 0) > 150)
|
|
rows.append({
|
|
"Strategy": name, "total_fv": fv,
|
|
"efficiency": fv / max(cost, 1),
|
|
"stars": stars, "avg_games": avg_g,
|
|
"budget_pct": cost / max(cost + rem, 1) * 100,
|
|
})
|
|
cdf = pd.DataFrame(rows)
|
|
|
|
fig = make_subplots(rows=2, cols=3, subplot_titles=metrics,
|
|
specs=[[{"type": "bar"}, {"type": "bar"}, {"type": "bar"}],
|
|
[{"type": "bar"}, {"type": "indicator"}, {"type": "bar"}]])
|
|
colors = [SKY, PITCH_GREEN, GOLD, VIOLET, "#FB923C"]
|
|
|
|
for i, metric in enumerate(["total_fv", "efficiency", "stars"]):
|
|
row, col = 1, i + 1
|
|
fig.add_trace(go.Bar(
|
|
x=cdf["Strategy"], y=cdf[metric],
|
|
marker_color=colors[:len(cdf)], text=[f"{v:.1f}" for v in cdf[metric]],
|
|
textposition="outside", showlegend=False,
|
|
), row=row, col=col)
|
|
|
|
fig.add_trace(go.Bar(
|
|
x=cdf["Strategy"], y=cdf["avg_games"],
|
|
marker_color=colors[:len(cdf)], text=[f"{v:.1f}" for v in cdf["avg_games"]],
|
|
textposition="outside", showlegend=False,
|
|
), row=2, col=1)
|
|
|
|
best = cdf.nlargest(1, "efficiency").iloc[0]
|
|
fig.add_trace(go.Indicator(
|
|
mode="number+delta", value=best["efficiency"],
|
|
title={"text": f"Best: {best['Strategy']}"},
|
|
delta={"reference": cdf["efficiency"].mean()},
|
|
number={"font": {"color": PITCH_GREEN, "size": 32}},
|
|
), row=2, col=2)
|
|
|
|
fig.add_trace(go.Bar(
|
|
x=cdf["Strategy"], y=cdf["budget_pct"],
|
|
marker_color=colors[:len(cdf)], text=[f"{v:.0f}%" for v in cdf["budget_pct"]],
|
|
textposition="outside", showlegend=False,
|
|
), row=2, col=3)
|
|
|
|
fig.update_layout(
|
|
template=FANTABETO_TEMPLATE, height=480,
|
|
showlegend=False,
|
|
)
|
|
return fig, cdf
|
|
|
|
|
|
# ─── Main ──────────────────────────────────────────────────────────
|
|
|
|
def run():
|
|
inject_css()
|
|
players, preds = _get_data()
|
|
|
|
st.markdown("## 💰 Auction War Room")
|
|
st.caption("Project Al-Cihred — Multi-Strategy Draft for 2026/27 Classic Auction")
|
|
|
|
# ── 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)
|
|
|
|
quotas = {"P": n_gk, "D": n_def, "C": n_mid, "A": n_fwd}
|
|
|
|
# ── Run all strategies ──
|
|
strategies = {
|
|
"⚖️ Balanced": "balanced",
|
|
"💎 Value": "value",
|
|
"⭐ Stars": "stars",
|
|
"🛡 Safe": "safe",
|
|
"🧮 MILP-Lite": "milp_lite",
|
|
}
|
|
|
|
all_results = {}
|
|
for label, key in strategies.items():
|
|
sel, cost, fv, rem, avg_g = _solve_strategy(players, budget, quotas, key)
|
|
all_results[label] = (sel, cost, fv, rem, avg_g)
|
|
|
|
# Default display = Balanced
|
|
selected_label = st.selectbox("Active strategy", list(strategies.keys()), index=0,
|
|
help="Switch between squad-building philosophies")
|
|
selected, total_cost, total_fv, remaining, avg_games = all_results[selected_label]
|
|
|
|
# ── KPI Row ──
|
|
k1, k2, k3, k4, k5 = st.columns(5)
|
|
with k1:
|
|
st.markdown(kpi_card("DRAFTED", str(len(selected)),
|
|
f"{sum(quotas.values())} target", SKY), unsafe_allow_html=True)
|
|
with k2:
|
|
st.markdown(kpi_card("SPENT", f"{total_cost:.0f} cr",
|
|
f"{remaining:.0f} cr left", PITCH_GREEN), unsafe_allow_html=True)
|
|
with k3:
|
|
st.markdown(kpi_card("PROJ FV", f"{total_fv:.1f}",
|
|
f"{total_fv/max(total_cost,1):.2f} cr/FV", GOLD), unsafe_allow_html=True)
|
|
with k4:
|
|
st.markdown(kpi_card("AVG GAMES", f"{avg_games:.0f}",
|
|
"25/26 reliability", VIOLET), unsafe_allow_html=True)
|
|
with k5:
|
|
stars = sum(1 for s in selected if s.get("fvm", 0) > 150)
|
|
st.markdown(kpi_card("STARS", str(stars),
|
|
"FVM > 150", GOLD), unsafe_allow_html=True)
|
|
|
|
st.divider()
|
|
|
|
# ── Strategy Comparison + Squad ──
|
|
c_left, c_right = st.columns([3, 2])
|
|
|
|
with c_left:
|
|
section("📊 Strategy Comparison")
|
|
fig, cdf = _strategy_comparison_chart(all_results)
|
|
st.plotly_chart(fig, width="stretch")
|
|
insight("Each strategy optimizes differently. Balanced blends stars + value. Safe avoids injury-prone players. Stars goes all-in on top talent.")
|
|
|
|
# Comparison table
|
|
st.markdown("", unsafe_allow_html=True)
|
|
show_df = cdf.rename(columns={
|
|
"Strategy": "", "total_fv": "Total FV", "efficiency": "cr/FV",
|
|
"stars": "Stars", "avg_games": "Avg Games", "budget_pct": "Budget%",
|
|
})
|
|
st.dataframe(show_df, use_container_width=True, hide_index=True,
|
|
column_config={"": "Strategy"})
|
|
|
|
with c_right:
|
|
section("💧 Budget by Role")
|
|
allocations = {}
|
|
for r in ["P", "D", "C", "A"]:
|
|
allocations[r] = sum(s["price"] for s in selected if s["role"] == r)
|
|
fig = budget_waterfall(allocations)
|
|
st.plotly_chart(fig, width="stretch")
|
|
|
|
# Top value picks
|
|
section("💎 Top Value Picks")
|
|
df = players.copy()
|
|
df["price"] = df.apply(lambda r: _estimate_price(
|
|
r["qi"], r.get("fvm", 100), r.get("games_season", 30), r["role"]
|
|
), axis=1)
|
|
df["cr_per_fv"] = df["price"] / df["fv_avg"].clip(lower=1)
|
|
steals = df.nlargest(8, "fv_avg").nsmallest(6, "cr_per_fv")
|
|
for _, s in steals.iterrows():
|
|
st.markdown(
|
|
f'{role_chip(s["role"])} **{s["player"]}** — '
|
|
f'FV {s["fv_avg"]:.2f} · ~{s["price"]:.0f} cr '
|
|
f'<span style="color:{TEXT_SECONDARY};font-size:11px;">({s["cr_per_fv"]:.1f} cr/FV)</span>',
|
|
unsafe_allow_html=True,
|
|
)
|
|
|
|
st.divider()
|
|
|
|
# ── Recommended Squad (rendered with chips) ──
|
|
section("🎯 Selected Squad", f"{selected_label} — {len(selected)} players, {total_cost:.0f} cr")
|
|
if selected:
|
|
squad_df = pd.DataFrame(selected)
|
|
for role in ["P", "D", "C", "A"]:
|
|
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} ({len(rdf)})**", unsafe_allow_html=False)
|
|
for _, p in rdf.iterrows():
|
|
chip_html = role_chip(p["role"])
|
|
fvm_str = f" · FVM {p.get('fvm', 0):.0f}" if p.get("fvm", 0) > 0 else ""
|
|
price_color = GOLD if p["price"] >= 100 else (SKY if p["price"] >= 50 else PITCH_GREEN)
|
|
st.markdown(
|
|
f'{chip_html} **{p["player"]}** ({p["team"]}) — '
|
|
f'FV: {p["fv_avg"]:.2f} | '
|
|
f'Max bid: <span style="color:{price_color};font-weight:600;">{p["price"]:.0f} cr</span> | '
|
|
f'Games: {p["games"]:.0f}{fvm_str}',
|
|
unsafe_allow_html=True,
|
|
)
|
|
else:
|
|
st.warning("No valid squad found with these constraints. Try increasing budget or reducing quotas.")
|
|
|
|
st.divider()
|
|
|
|
# ── Market Analysis ──
|
|
section("📈 Market Overview")
|
|
c1, c2 = st.columns([1, 1])
|
|
with c1:
|
|
fig = _price_distribution_chart(players)
|
|
st.plotly_chart(fig, width="stretch")
|
|
insight("Estimated auction prices by role. Forwards and elite midfielders command a premium.")
|
|
with c2:
|
|
section("🔍 Value Scatter")
|
|
fig = value_scatter(players)
|
|
st.plotly_chart(fig, width="stretch")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
run()
|