Spaces:
Running
Running
| import streamlit as st | |
| import pandas as pd | |
| from datetime import datetime | |
| import requests | |
| import plotly.graph_objects as go | |
| import plotly.express as px | |
| import numpy as np | |
| from scipy import stats as scipy_stats | |
| from dotenv import load_dotenv | |
| import os | |
| load_dotenv() | |
| API_KEY = os.getenv("API_KEY") | |
| # --- CONFIGURACIÓN INICIAL --- | |
| st.set_page_config(layout="wide", page_title="Corners Forecast", page_icon="⚽") | |
| # 👈 AÑADIR MARGEN AL LAYOUT WIDE | |
| st.markdown(""" | |
| <style> | |
| .block-container { | |
| padding-left: 5rem; | |
| padding-right: 5rem; | |
| max-width: 1400px; | |
| margin: 0 auto; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # --- CONSTANTES DEL MODELO --- | |
| MSE_MODELO = 1.99 | |
| RMSE_MODELO = 2.4 | |
| R2_MODELO = 0.39 | |
| N_SIMULACIONES = 5000 | |
| # --- ERRORES ESTIMADOS POR MODELO (RMSE) --- | |
| # Corners | |
| RMSE_CK_TOTAL = 1.99 | |
| RMSE_CK_LOCAL = 1.64 | |
| RMSE_CK_AWAY = 1.45 | |
| # Goles | |
| RMSE_GF_TOTAL = .95 | |
| RMSE_GF_LOCAL = .6 | |
| RMSE_GF_AWAY = .6 | |
| # xG (Goles Esperados) | |
| RMSE_XG_TOTAL = 1 | |
| RMSE_XG_LOCAL = .6 | |
| RMSE_XG_AWAY = .6 | |
| # Tiros a Puerta (Shots on Target) | |
| RMSE_ST_TOTAL = 1.7 | |
| RMSE_ST_LOCAL = 1.4 | |
| RMSE_ST_AWAY = 1.3 | |
| # --- FUNCIONES AUXILIARES --- | |
| def probabilidad_a_momio(probabilidad): | |
| """Convierte probabilidad (%) a momio decimal""" | |
| if probabilidad <= 0: | |
| return 0 | |
| return round(100 / probabilidad, 2) | |
| def clasificar_valor_apuesta(momio_real, momio_modelo): | |
| """Determina si hay valor en la apuesta""" | |
| if momio_real > momio_modelo * 1.1: | |
| return "🟢 EXCELENTE VALOR" | |
| elif momio_real > momio_modelo: | |
| return "🟡 BUEN VALOR" | |
| else: | |
| return "🔴 SIN VALOR" | |
| def simular_lambda_montecarlo(lambda_pred, sigma=RMSE_MODELO, n_sims=N_SIMULACIONES): | |
| """Genera simulaciones Monte Carlo con CACHE""" | |
| lambdas = np.random.normal(lambda_pred, sigma, n_sims) | |
| lambdas = np.maximum(lambdas, 0.1) | |
| return lambdas | |
| def calcular_probabilidades_con_incertidumbre(lambda_pred, linea, tipo='over', sigma=RMSE_MODELO, n_sims=N_SIMULACIONES): | |
| """Calcula probabilidades con CACHE""" | |
| lambdas_sim = simular_lambda_montecarlo(lambda_pred, sigma, n_sims) | |
| probs = [] | |
| if tipo == 'over': | |
| for lam in lambdas_sim: | |
| prob = 1 - scipy_stats.poisson.cdf(int(linea), lam) | |
| probs.append(prob * 100) | |
| else: | |
| for lam in lambdas_sim: | |
| prob = scipy_stats.poisson.cdf(int(linea) - 1, lam) | |
| probs.append(prob * 100) | |
| probs = np.array(probs) | |
| return { | |
| 'prob_media': np.mean(probs), | |
| 'prob_low': np.percentile(probs, 5), | |
| 'prob_high': np.percentile(probs, 95), | |
| 'prob_std': np.std(probs), | |
| 'distribucion': probs | |
| } | |
| def calcular_expected_value(prob_media, momio_casa): | |
| """Calcula Expected Value (EV)""" | |
| prob_decimal = prob_media / 100 | |
| ev = (prob_decimal * momio_casa) - 1 | |
| return ev * 100 | |
| def calcular_kelly_criterion(prob_media, momio_casa): | |
| """Calcula Kelly Criterion""" | |
| p = prob_media / 100 | |
| if momio_casa <= 1: | |
| return 0 | |
| kelly = (p * momio_casa - 1) / (momio_casa - 1) | |
| if kelly < 0: | |
| return 0 | |
| return min(kelly, 0.25) | |
| def recomendar_apuesta_avanzada(prob_media, prob_low, prob_high, momio_casa): | |
| """Sistema avanzado de recomendación""" | |
| prob_casa = (1 / momio_casa) * 100 | |
| ev = calcular_expected_value(prob_media, momio_casa) | |
| kelly = calcular_kelly_criterion(prob_media, momio_casa) | |
| kelly_conservador = kelly * 0.25 | |
| ev_positivo = ev > 0 | |
| confianza_alta = prob_low > prob_casa | |
| margen_seguridad = (prob_media - prob_casa) / prob_casa | |
| if confianza_alta and ev > 5 and margen_seguridad > 0.1: | |
| nivel = "EXCELENTE" | |
| emoji = "🟢" | |
| recomendar = True | |
| elif confianza_alta and ev > 0: | |
| nivel = "BUENA" | |
| emoji = "🟡" | |
| recomendar = True | |
| elif ev > 0: | |
| nivel = "MODERADA" | |
| emoji = "🟠" | |
| recomendar = False | |
| else: | |
| nivel = "MALA" | |
| emoji = "🔴" | |
| recomendar = False | |
| return { | |
| 'recomendar': recomendar, | |
| 'nivel': nivel, | |
| 'emoji': emoji, | |
| 'ev': ev, | |
| 'kelly': kelly * 100, | |
| 'kelly_conservador': kelly_conservador * 100, | |
| 'prob_casa': prob_casa, | |
| 'prob_media': prob_media, | |
| 'prob_low': prob_low, | |
| 'prob_high': prob_high, | |
| 'margen_seguridad': margen_seguridad * 100, | |
| 'ev_positivo': ev_positivo, | |
| 'confianza_alta': confianza_alta | |
| } | |
| # --- DICCIONARIO DE LIGAS --- | |
| LEAGUES_DICT = { | |
| "Ligue 1": "FRA", | |
| "La Liga": "ESP", | |
| "Premier League": "ENG", | |
| "Eredivisie": "NED", | |
| "Liga NOS": "POR", | |
| "Pro League": "BEL", | |
| "Bundesliga": "GER", | |
| "Serie A": "ITA" | |
| } | |
| # --- HEADER --- | |
| st.markdown("<h1 style='text-align: center;'>Corners Forecast</h1>", unsafe_allow_html=True) | |
| # --- CARGAR DATOS --- | |
| def cargar_datos(): | |
| df_historic = pd.read_csv(r"https://raw.githubusercontent.com/danielsaed/futbol_corners_forecast/refs/heads/main/dataset/cleaned/dataset_cleaned.csv") | |
| df_current_year = pd.read_csv(r"https://raw.githubusercontent.com/danielsaed/futbol_corners_forecast/refs/heads/main/dataset/cleaned/dataset_cleaned_current_year.csv") | |
| df = pd.concat([df_historic,df_current_year]) | |
| return df[['local','league','season']].drop_duplicates() | |
| df = cargar_datos() | |
| # --- INICIALIZAR SESSION STATE --- | |
| if 'prediccion_realizada' not in st.session_state: | |
| st.session_state.prediccion_realizada = False | |
| if 'resultado_api' not in st.session_state: | |
| st.session_state.resultado_api = None | |
| # 👇 NUEVO: Guardar valores anteriores para detectar cambios | |
| if 'prev_liga' not in st.session_state: | |
| st.session_state.prev_liga = None | |
| if 'prev_jornada' not in st.session_state: | |
| st.session_state.prev_jornada = None | |
| if 'prev_temporada' not in st.session_state: | |
| st.session_state.prev_temporada = None | |
| if 'prev_local' not in st.session_state: | |
| st.session_state.prev_local = None | |
| if 'prev_away' not in st.session_state: | |
| st.session_state.prev_away = None | |
| st.markdown("") | |
| # --- SELECCIÓN DE PARÁMETROS --- | |
| col1, col2, col3 = st.columns([1, 1, 1]) | |
| with col2: | |
| option = st.selectbox( | |
| "🏆 Liga", | |
| ["La Liga", "Premier League", "Ligue 1", "Serie A", "Eredivisie", "Liga NOS", "Pro League", "Bundesliga"], | |
| index=None, | |
| placeholder="Selecciona liga", | |
| key="liga_select" | |
| ) | |
| # 👇 DETECTAR CAMBIO EN LIGA | |
| if option != st.session_state.prev_liga: | |
| st.session_state.prediccion_realizada = False | |
| st.session_state.resultado_api = None | |
| st.session_state.prev_liga = option | |
| st.write("") | |
| col_jornada1, col_jornada2, col_jornada3, col_jornada4 = st.columns([2, 1, 1, 2]) | |
| jornada = None | |
| temporada = None | |
| with col_jornada2: | |
| if option: | |
| jornada = st.number_input("📅 Jornada", min_value=5, max_value=42, value=15, step=1, key="jornada_input") | |
| # 👇 DETECTAR CAMBIO EN JORNADA | |
| if jornada != st.session_state.prev_jornada: | |
| st.session_state.prediccion_realizada = False | |
| st.session_state.resultado_api = None | |
| st.session_state.prev_jornada = jornada | |
| with col_jornada3: | |
| if option: | |
| temporada = st.selectbox( | |
| "Temporada", | |
| [2526, 2425, 2324, 2223, 2122], | |
| index=0, | |
| key="temporada_select" | |
| ) | |
| # 👇 DETECTAR CAMBIO EN TEMPORADA | |
| if temporada != st.session_state.prev_temporada: | |
| st.session_state.prediccion_realizada = False | |
| st.session_state.resultado_api = None | |
| st.session_state.prev_temporada = temporada | |
| st.write("") | |
| cl2, cl3, cl4 = st.columns([4, 1, 4]) | |
| option_local = None | |
| option_away = None | |
| with cl2: | |
| if option: | |
| if jornada: | |
| option_local = st.selectbox( | |
| "🏠 Equipo Local", | |
| list(df["local"][(df["league"] == LEAGUES_DICT[option]) & (df["season"] == temporada)]), | |
| index=None, | |
| placeholder="Equipo local", | |
| key="local_select" | |
| ) | |
| # 👇 DETECTAR CAMBIO EN EQUIPO LOCAL | |
| if option_local != st.session_state.prev_local: | |
| st.session_state.prediccion_realizada = False | |
| st.session_state.resultado_api = None | |
| st.session_state.prev_local = option_local | |
| with cl3: | |
| if option: | |
| st.write("") | |
| st.write("") | |
| st.markdown("<h3 style='text-align: center'>VS</h3>", unsafe_allow_html=True) | |
| with cl4: | |
| if option: | |
| if jornada: | |
| option_away = st.selectbox( | |
| "✈️ Equipo Visitante", | |
| list(df["local"][(df["league"] == LEAGUES_DICT[option]) & (df["season"] == temporada)]), | |
| index=None, | |
| placeholder="Equipo visitante", | |
| key="away_select" | |
| ) | |
| # 👇 DETECTAR CAMBIO EN EQUIPO VISITANTE | |
| if option_away != st.session_state.prev_away: | |
| st.session_state.prediccion_realizada = False | |
| st.session_state.resultado_api = None | |
| st.session_state.prev_away = option_away | |
| # --- BOTÓN PARA GENERAR PREDICCIÓN --- | |
| if option and option_local and option_away: | |
| st.markdown("---") | |
| col_btn1, col_btn2, col_btn3 = st.columns([1, 1, 1]) | |
| with col_btn2: | |
| if st.button("Generar Predicción", type="secondary", use_container_width=True): | |
| st.session_state.prediccion_realizada = True | |
| st.session_state.resultado_api = None | |
| st.write("") | |
| st.write("") | |
| # --- REALIZAR PREDICCIÓN (SOLO SI SE PRESIONÓ EL BOTÓN) --- | |
| if option and option_local and option_away and st.session_state.prediccion_realizada: | |
| if st.session_state.resultado_api is None: | |
| with st.spinner('🔮 Generando predicción con análisis de incertidumbre...'): | |
| url = "https://daniel-saed-futbol-corners-forecast-api.hf.space/items/" | |
| #url = "http://localhost:7860/items/" | |
| headers = {"X-API-Key": API_KEY} | |
| params = { | |
| "local": option_local, | |
| "visitante": option_away, | |
| "jornada": jornada, | |
| "league_code": LEAGUES_DICT[option], | |
| "temporada": str(temporada) | |
| } | |
| try: | |
| response = requests.get(url, headers=headers, params=params, timeout=30) | |
| if response.status_code == 200: | |
| st.session_state.resultado_api = response.json() | |
| st.success("✅ Predicción generada") | |
| elif response.status_code == 401: | |
| st.error("❌ Error de Autenticación - API Key inválida") | |
| st.stop() | |
| elif response.status_code == 400: | |
| st.error(f"❌ Error: {response.json().get('detail', 'Parámetros inválidos')}") | |
| st.stop() | |
| else: | |
| st.error(f"❌ Error {response.status_code}") | |
| st.stop() | |
| except requests.exceptions.Timeout: | |
| st.error("⏱️ Timeout - Intenta de nuevo") | |
| st.stop() | |
| except requests.exceptions.ConnectionError: | |
| st.error("🌐 Error de conexión") | |
| st.stop() | |
| except Exception as e: | |
| st.error(f"❌ Error: {str(e)}") | |
| import traceback | |
| st.code(traceback.format_exc()) | |
| st.stop() | |
| # --- MOSTRAR RESULTADOS --- | |
| if st.session_state.resultado_api: | |
| resultado = st.session_state.resultado_api | |
| lambda_pred = resultado['prediccion'] | |
| # Extraer predicciones detalladas | |
| pred_ck_total = resultado.get('prediccion', 0) | |
| pred_ck_local = resultado.get('prediccion_local', 0) | |
| pred_ck_away = resultado.get('prediccion_away', 0) | |
| pred_xg_total = resultado.get('prediccion_xg', 0) | |
| pred_xg_local = resultado.get('prediccion_xg_local', 0) | |
| pred_xg_away = resultado.get('prediccion_xg_away', 0) | |
| pred_gf_total = resultado.get('prediccion_gf', 0) | |
| pred_gf_local = resultado.get('prediccion_gf_local', 0) | |
| pred_gf_away = resultado.get('prediccion_gf_away', 0) | |
| pred_st_total = resultado.get('prediccion_st', 0) | |
| pred_st_local = resultado.get('prediccion_st_local', 0) | |
| pred_st_away = resultado.get('prediccion_st_away', 0) | |
| st.write("") | |
| st.write("") | |
| # ============================================ | |
| # 1. PREDICCIONES MACHINE LEARNING | |
| # ============================================ | |
| st.markdown("# Predicciones") | |
| st.write("") | |
| st.caption("Modelos XGBoost entrenados con alrededor de 13,000 partidos utilizando metricas avanzadas de futbol de las principales ligas europeas (2018 a 2025). Datos obtenidos de OPTA.") | |
| def mostrar_bloque_prediccion(titulo, total, local, away, rmse_total, rmse_local, rmse_away, icono): | |
| st.markdown(f"#### {icono} {titulo}") | |
| c1, c2, c3 = st.columns(3) | |
| with c1: | |
| st.metric("Total", f"{total:.2f}", delta=f"± {rmse_total}", delta_color="off", help=f"RMSE estimado: {rmse_total}") | |
| with c2: | |
| st.metric(f"Local ({option_local})", f"{local:.2f}", delta=f"± {rmse_local}", delta_color="off", help=f"RMSE estimado: {rmse_local}") | |
| with c3: | |
| st.metric(f"Visitante ({option_away})", f"{away:.2f}", delta=f"± {rmse_away}", delta_color="off", help=f"RMSE estimado: {rmse_away}") | |
| st.divider() | |
| # 1. Tiros de Esquina | |
| mostrar_bloque_prediccion( | |
| "Tiros de esquina", | |
| pred_ck_total, pred_ck_local, pred_ck_away, | |
| RMSE_CK_TOTAL, RMSE_CK_LOCAL, RMSE_CK_AWAY, | |
| "🚩" | |
| ) | |
| # 2. Goles | |
| mostrar_bloque_prediccion( | |
| "Goles", | |
| pred_gf_total, pred_gf_local, pred_gf_away, | |
| RMSE_GF_TOTAL, RMSE_GF_LOCAL, RMSE_GF_AWAY, | |
| "⚽" | |
| ) | |
| # 3. xG (Goles Esperados) | |
| mostrar_bloque_prediccion( | |
| "xG (Goles Esperados)", | |
| pred_xg_total, pred_xg_local, pred_xg_away, | |
| RMSE_XG_TOTAL, RMSE_XG_LOCAL, RMSE_XG_AWAY, | |
| "📈" | |
| ) | |
| # 4. Tiros a Puerta | |
| mostrar_bloque_prediccion( | |
| "Tiros a puerta", | |
| pred_st_total, pred_st_local, pred_st_away, | |
| RMSE_ST_TOTAL, RMSE_ST_LOCAL, RMSE_ST_AWAY, | |
| "🎯") | |
| st.write("") | |
| st.write("") | |
| st.write("") | |
| st.write("") | |
| st.write("") | |
| st.write("") | |
| st.write("") | |
| st.write("") | |
| # ============================================ | |
| # 2. ANÁLISIS DE EQUIPOS | |
| # ============================================ | |
| # Extraer datos nuevos | |
| # Extraer datos nuevos | |
| # Extraer datos nuevos | |
| stats_ck = resultado.get('stats_ck', {}) | |
| stats_gf = resultado.get('stats_gf', {}) | |
| stats_xg = resultado.get('stats_xg', {}) | |
| stats_st = resultado.get('stats_st', {}) # Nuevo | |
| ppp_local = resultado.get('ppp_local', 0) | |
| ppp_away = resultado.get('ppp_away', 0) | |
| riesgo = resultado['riesgo'] | |
| st.markdown("# Stats") | |
| # Métrica de Forma (PPP) | |
| col_form1, col_form2, col_form3 = st.columns(3) | |
| with col_form1: | |
| st.metric("Forma Local (PPP)", f"{ppp_local:.2f}", help="Puntos por Partido") | |
| with col_form2: | |
| diff_ppp = ppp_local - ppp_away | |
| st.metric("Diferencia de Nivel", f"{diff_ppp:.2f}", delta_color="off", help="Diferencia de PPP (Local - Visitante)") | |
| with col_form3: | |
| st.metric("Forma Visitante (PPP)", f"{ppp_away:.2f}", help="Puntos por Partido") | |
| st.write("") | |
| # --- FUNCIÓN PARA RENDERIZAR PESTAÑAS --- | |
| def render_stats_tab(stats_data, type_key, label_metric): | |
| """Renderiza el contenido de una pestaña de estadísticas con el nuevo layout""" | |
| # --- 1. PREPARAR DATOS --- | |
| # General | |
| l_h = stats_data.get(f'local_{type_key}_home', 0) | |
| l_a = stats_data.get(f'local_{type_key}_away', 0) | |
| a_h = stats_data.get(f'away_{type_key}_home', 0) | |
| a_a = stats_data.get(f'away_{type_key}_away', 0) | |
| l_rec_h = stats_data.get(f'local_{type_key}_received_home', 0) | |
| l_rec_a = stats_data.get(f'local_{type_key}_received_away', 0) | |
| a_rec_h = stats_data.get(f'away_{type_key}_received_home', 0) | |
| a_rec_a = stats_data.get(f'away_{type_key}_received_away', 0) | |
| # Forma | |
| l_h_f = stats_data.get(f'local_{type_key}_home_form', 0) | |
| l_a_f = stats_data.get(f'local_{type_key}_away_form', 0) | |
| a_h_f = stats_data.get(f'away_{type_key}_home_form', 0) | |
| a_a_f = stats_data.get(f'away_{type_key}_away_form', 0) | |
| l_rec_h_f = stats_data.get(f'local_{type_key}_received_home_form', 0) | |
| l_rec_a_f = stats_data.get(f'local_{type_key}_received_away_form', 0) | |
| a_rec_h_f = stats_data.get(f'away_{type_key}_received_home_form', 0) | |
| a_rec_a_f = stats_data.get(f'away_{type_key}_received_away_form', 0) | |
| # Globales (Promedio simple Home+Away) | |
| l_g = (l_h + l_a) / 2 | |
| a_g = (a_h + a_a) / 2 | |
| l_rec_g = (l_rec_h + l_rec_a) / 2 | |
| a_rec_g = (a_rec_h + a_rec_a) / 2 | |
| l_g_f = (l_h_f + l_a_f) / 2 | |
| a_g_f = (a_h_f + a_a_f) / 2 | |
| l_rec_g_f = (l_rec_h_f + l_rec_a_f) / 2 | |
| a_rec_g_f = (a_rec_h_f + a_rec_a_f) / 2 | |
| # --- FUNCIÓN AUXILIAR PARA MOSTRAR TABLA CON TOTALES --- | |
| def display_styled_df(teams, favors, contras): | |
| df = pd.DataFrame({ | |
| 'Equipo': teams, | |
| 'A Favor': favors, | |
| 'En Contra': contras | |
| }) | |
| # Calcular Totales por Fila (Total del equipo) | |
| df['Total'] = df['A Favor'] + df['En Contra'] | |
| # Calcular Totales por Columna (Suma de ambos equipos) | |
| # NOTA: El total de totales (esquina inferior derecha) se deja vacío | |
| total_row = pd.DataFrame({ | |
| 'Equipo': ['TOTAL'], | |
| 'A Favor': [df['A Favor'].sum()], | |
| 'En Contra': [df['En Contra'].sum()], | |
| 'Total': [0] | |
| }) | |
| df_final = pd.concat([df, total_row], ignore_index=True) | |
| # Estilos | |
| # na_rep="" hace que el None se muestre como celda vacía | |
| styler = df_final.style.format(subset=['A Favor', 'En Contra', 'Total'], formatter="{:.2f}", na_rep="") | |
| # Estilo: Fondo transparente y texto gris | |
| style_css = 'color: #888888; font-weight: bold;' | |
| # Resaltar última fila (Totales de columna) | |
| styler.apply(lambda x: [style_css if x.name == df_final.index[-1] else '' for _ in x], axis=1) | |
| # Resaltar columna Total (Totales de fila) | |
| styler.apply(lambda x: [style_css if x.name == 'Total' else '' for _ in x], axis=0) | |
| st.dataframe(styler, hide_index=True, use_container_width=True) | |
| # --- 2. RENDERIZAR SECCIÓN GENERAL --- | |
| st.markdown("#### 📊 Datos Generales (Temporada)") | |
| c1, c2, c3 = st.columns(3) | |
| # Columna 1: Contexto Real | |
| with c1: | |
| st.caption("🏟️ Contexto (Local en Casa / Vis. Fuera)") | |
| display_styled_df( | |
| [f'🏠 {option_local}', f'✈️ {option_away}'], | |
| [l_h, a_a], | |
| [l_rec_h, a_rec_a] | |
| ) | |
| # Columna 2: Inversa | |
| with c2: | |
| st.caption("🔄 Inversa (Local Fuera / Vis. Casa)") | |
| display_styled_df( | |
| [f'✈️ {option_local}', f'🏠 {option_away}'], | |
| [l_a, a_h], | |
| [l_rec_a, a_rec_h] | |
| ) | |
| # Columna 3: Global | |
| with c3: | |
| st.caption("🌍 Global (Promedio Total)") | |
| display_styled_df( | |
| [f'{option_local}', f'{option_away}'], | |
| [l_g, a_g], | |
| [l_rec_g, a_rec_g] | |
| ) | |
| # --- 3. RENDERIZAR SECCIÓN FORMA --- | |
| st.markdown("#### 🔥 Estado de Forma (Últimos 6 Partidos)") | |
| c1_f, c2_f, c3_f = st.columns(3) | |
| # Columna 1: Contexto Forma | |
| with c1_f: | |
| st.caption("🏟️ Contexto (Forma)") | |
| display_styled_df( | |
| [f'🏠 {option_local}', f'✈️ {option_away}'], | |
| [l_h_f, a_a_f], | |
| [l_rec_h_f, a_rec_a_f] | |
| ) | |
| # Columna 2: Inversa Forma | |
| with c2_f: | |
| st.caption("🔄 Inversa (Forma)") | |
| display_styled_df( | |
| [f'✈️ {option_local}', f'🏠 {option_away}'], | |
| [l_a_f, a_h_f], | |
| [l_rec_a_f, a_rec_h_f] | |
| ) | |
| # Columna 3: Global Forma | |
| with c3_f: | |
| st.caption("🌍 Global (Forma)") | |
| display_styled_df( | |
| [f'{option_local}', f'{option_away}'], | |
| [l_g_f, a_g_f], | |
| [l_rec_g_f, a_rec_g_f] | |
| ) | |
| # --- 4. RENDERIZAR H2H --- | |
| st.markdown("#### ⚔️ Head to Head (H2H)") | |
| h2h_val = stats_data.get(f'h2h_{type_key}_total', 0) | |
| st.metric(f"Promedio {label_metric} H2H", f"{h2h_val:.2f}") | |
| # Tabs para las diferentes estadísticas | |
| tab_ck, tab_gf, tab_xg, tab_st = st.tabs(["🚩 Corners", "⚽ Goles", "📈 xG (Esperados)", "🎯 Tiros a Puerta"]) | |
| with tab_ck: | |
| render_stats_tab(stats_ck, 'ck', 'Corners') | |
| with tab_gf: | |
| render_stats_tab(stats_gf, 'gf', 'Goles') | |
| with tab_xg: | |
| render_stats_tab(stats_xg, 'xg', 'xG') | |
| with tab_st: | |
| render_stats_tab(stats_st, 'st', 'Tiros a Puerta') | |
| # --- MOSTRAR TABLA H2H DETALLADA --- | |
| if 'h2h_matches' in resultado and resultado['h2h_matches']: | |
| st.markdown("### 📜 Historial de Partidos (H2H)") | |
| h2h_data = [] | |
| for match in resultado['h2h_matches']: | |
| # Datos del equipo local en ese partido | |
| home_team = match['match_home_team'] | |
| away_team = match['match_away_team'] | |
| # Identificar stats correctas | |
| if match['local_team_stats']['team'] == home_team: | |
| home_stats = match['local_team_stats'] | |
| away_stats = match['away_team_stats'] | |
| else: | |
| home_stats = match['away_team_stats'] | |
| away_stats = match['local_team_stats'] | |
| h2h_data.append({ | |
| 'Temporada': match['season'], | |
| 'Jornada': match['round'], | |
| 'Local': home_team, | |
| 'Visitante': away_team, | |
| 'Goles L': home_stats['goals'], | |
| 'Goles V': away_stats['goals'], | |
| 'Corners L': home_stats['corners'], | |
| 'Corners V': away_stats['corners'], | |
| 'xG L': home_stats['xg'], | |
| 'xG V': away_stats['xg'], | |
| 'SoT L': home_stats['sot'], | |
| 'SoT V': away_stats['sot'] | |
| }) | |
| df_h2h = pd.DataFrame(h2h_data) | |
| st.dataframe( | |
| df_h2h, | |
| hide_index=True, | |
| use_container_width=True, | |
| column_config={ | |
| 'Temporada': st.column_config.TextColumn('📅 Temp', width='small'), | |
| 'Jornada': st.column_config.NumberColumn('#', width='small', format="%d"), | |
| 'Goles L': st.column_config.NumberColumn('⚽ L', format="%.0f"), | |
| 'Goles V': st.column_config.NumberColumn('⚽ V', format="%.0f"), | |
| 'Corners L': st.column_config.NumberColumn('🚩 L', format="%.0f"), | |
| 'Corners V': st.column_config.NumberColumn('🚩 V', format="%.0f"), | |
| 'xG L': st.column_config.NumberColumn('📈 xG L', format="%.2f"), | |
| 'xG V': st.column_config.NumberColumn('📈 xG V', format="%.2f"), | |
| 'SoT L': st.column_config.NumberColumn('🎯 SoT L', format="%.0f"), | |
| 'SoT V': st.column_config.NumberColumn('🎯 SoT V', format="%.0f"), | |
| } | |
| ) | |
| st.divider() | |
| st.write("") | |
| st.write("") | |
| st.write("") | |
| st.write("") | |
| st.write("") | |
| st.write("") | |
| st.write("") | |
| st.write("") | |
| st.markdown("# Momios y Valor de Apuesta") | |
| st.write("") | |
| st.write("") | |
| st.markdown("### Fiabilidad") | |
| col_fiab1, col_fiab2, col_fiab3 = st.columns(3) | |
| with col_fiab1: | |
| st.markdown(f"**🏠 {option_local}**") | |
| st.write(f"**Score:** {riesgo['score_local']:.0f}/100") | |
| st.write(f"**Nivel:** {riesgo['nivel_local']}") | |
| st.write(f"**CV:** {riesgo['cv_local']:.1f}%") | |
| st.progress(riesgo['score_local'] / 100) | |
| with col_fiab2: | |
| st.markdown("**📊 Fiabilidad Global**") | |
| score_promedio = riesgo['score_promedio'] | |
| st.write(f"**Score:** {score_promedio:.0f}/100") | |
| st.write("") | |
| if score_promedio >= 65: | |
| st.success("🟢 Fiabilidad MUY ALTA") | |
| elif score_promedio >= 50: | |
| st.info("🟡 Fiabilidad ALTA") | |
| elif score_promedio >= 35: | |
| st.warning("🟠 Fiabilidad MEDIA") | |
| else: | |
| st.error("🔴 Fiabilidad BAJA") | |
| with col_fiab3: | |
| st.markdown(f"**✈️ {option_away}**") | |
| st.write(f"**Score:** {riesgo['score_away']:.0f}/100") | |
| st.write(f"**Nivel:** {riesgo['nivel_away']}") | |
| st.write(f"**CV:** {riesgo['cv_away']:.1f}%") | |
| st.progress(riesgo['score_away'] / 100) | |
| st.write("") | |
| st.write("") | |
| st.markdown("---") | |
| st.write("") | |
| st.write("") | |
| # ============================================ | |
| # 3. PROBABILIDADES | |
| # ============================================ | |
| st.info(f"🔬 **Análisis con {N_SIMULACIONES:,} simulaciones Monte Carlo** considerando RMSE={RMSE_MODELO}") | |
| tab_over, tab_under = st.tabs(["⬆️ OVER", "⬇️ UNDER"]) | |
| with tab_over: | |
| probs_over = resultado['probabilidades_over'] | |
| st.markdown("### 📈 Probabilidades Over (con Intervalos de Confianza 90%)") | |
| df_over_incertidumbre = [] | |
| with st.spinner('Calculando incertidumbres Over...'): | |
| for linea_str in sorted(probs_over.keys(), key=float, reverse=True): | |
| linea = float(linea_str) | |
| resultado_inc = calcular_probabilidades_con_incertidumbre( | |
| lambda_pred, linea, tipo='over' | |
| ) | |
| prob_media = resultado_inc['prob_media'] | |
| prob_low = resultado_inc['prob_low'] | |
| prob_high = resultado_inc['prob_high'] | |
| momio_medio = probabilidad_a_momio(prob_media) | |
| momio_low = probabilidad_a_momio(prob_high) | |
| momio_high = probabilidad_a_momio(prob_low) | |
| df_over_incertidumbre.append({ | |
| 'Línea': f"Over {linea_str}", | |
| 'Prob. Media': f"{prob_media:.1f}%", | |
| 'IC 90%': f"[{prob_low:.1f}%, {prob_high:.1f}%]", | |
| 'Momio Justo': f"@{momio_medio:.2f}", | |
| 'Rango Momio': f"[@{momio_low:.2f} - @{momio_high:.2f}]", | |
| 'linea_num': linea, | |
| 'prob_media_raw': prob_media, | |
| 'prob_low_raw': prob_low, | |
| 'prob_high_raw': prob_high, | |
| 'tipo': 'Over' | |
| }) | |
| df_over_display = pd.DataFrame(df_over_incertidumbre) | |
| st.dataframe( | |
| df_over_display[['Línea', 'Prob. Media', 'Momio Justo']], | |
| hide_index=True, | |
| use_container_width=True, | |
| column_config={ | |
| 'Línea': st.column_config.TextColumn('🎯 Línea', width='small'), | |
| 'Prob. Media': st.column_config.TextColumn('📊 Probabilidad', width='small'), | |
| 'Momio Justo': st.column_config.TextColumn('💰 Momio', width='small'), | |
| } | |
| ) | |
| st.write("") | |
| fig_over = go.Figure() | |
| lineas_sorted = sorted([x['linea_num'] for x in df_over_incertidumbre]) | |
| probs_medias = [x['prob_media_raw'] for x in sorted(df_over_incertidumbre, key=lambda x: x['linea_num'])] | |
| probs_low = [x['prob_low_raw'] for x in sorted(df_over_incertidumbre, key=lambda x: x['linea_num'])] | |
| probs_high = [x['prob_high_raw'] for x in sorted(df_over_incertidumbre, key=lambda x: x['linea_num'])] | |
| fig_over.add_trace(go.Scatter( | |
| x=[f"Over {l}" for l in lineas_sorted] + [f"Over {l}" for l in lineas_sorted[::-1]], | |
| y=probs_high + probs_low[::-1], | |
| fill='toself', | |
| fillcolor='rgba(46, 204, 113, 0.2)', | |
| line=dict(color='rgba(255,255,255,0)'), | |
| showlegend=True, | |
| name='IC 90%', | |
| hoverinfo='skip' | |
| )) | |
| fig_over.add_trace(go.Scatter( | |
| x=[f"Over {l}" for l in lineas_sorted], | |
| y=probs_medias, | |
| mode='lines+markers', | |
| name='Probabilidad Media', | |
| line=dict(color='#2ecc71', width=3), | |
| marker=dict(size=10) | |
| )) | |
| fig_over.update_layout( | |
| title="Probabilidades Over con Banda de Incertidumbre (Monte Carlo)", | |
| xaxis_title="Línea", | |
| yaxis_title="Probabilidad (%)", | |
| height=500, | |
| hovermode='x unified' | |
| ) | |
| st.plotly_chart(fig_over, use_container_width=True) | |
| with tab_under: | |
| probs_under = resultado['probabilidades_under'] | |
| st.markdown("### 📉 Probabilidades Under (con Intervalos de Confianza 90%)") | |
| df_under_incertidumbre = [] | |
| with st.spinner('Calculando incertidumbres Under...'): | |
| for linea_str in sorted(probs_under.keys(), key=float, reverse=True): | |
| linea = float(linea_str) | |
| resultado_inc = calcular_probabilidades_con_incertidumbre( | |
| lambda_pred, linea, tipo='under' | |
| ) | |
| prob_media = resultado_inc['prob_media'] | |
| prob_low = resultado_inc['prob_low'] | |
| prob_high = resultado_inc['prob_high'] | |
| momio_medio = probabilidad_a_momio(prob_media) | |
| momio_low = probabilidad_a_momio(prob_high) | |
| momio_high = probabilidad_a_momio(prob_low) | |
| df_under_incertidumbre.append({ | |
| 'Línea': f"Under {linea_str}", | |
| 'Prob. Media': f"{prob_media:.1f}%", | |
| 'IC 90%': f"[{prob_low:.1f}%, {prob_high:.1f}%]", | |
| 'Momio Justo': f"@{momio_medio:.2f}", | |
| 'Rango Momio': f"[@{momio_low:.2f} - @{momio_high:.2f}]", | |
| 'linea_num': linea, | |
| 'prob_media_raw': prob_media, | |
| 'prob_low_raw': prob_low, | |
| 'prob_high_raw': prob_high, | |
| 'tipo': 'Under' | |
| }) | |
| df_under_display = pd.DataFrame(df_under_incertidumbre) | |
| st.dataframe( | |
| df_under_display[['Línea', 'Prob. Media', 'IC 90%', 'Momio Justo', 'Rango Momio']], | |
| hide_index=True, | |
| use_container_width=True, | |
| column_config={ | |
| 'Línea': st.column_config.TextColumn('🎯 Línea', width='small'), | |
| 'Prob. Media': st.column_config.TextColumn('📊 Probabilidad', width='small'), | |
| 'IC 90%': st.column_config.TextColumn('📉 Intervalo 90%', width='medium'), | |
| 'Momio Justo': st.column_config.TextColumn('💰 Momio', width='small'), | |
| 'Rango Momio': st.column_config.TextColumn('📈 Rango Momios', width='medium') | |
| } | |
| ) | |
| st.write("") | |
| fig_under = go.Figure() | |
| lineas_sorted_under = sorted([x['linea_num'] for x in df_under_incertidumbre]) | |
| probs_medias_under = [x['prob_media_raw'] for x in sorted(df_under_incertidumbre, key=lambda x: x['linea_num'])] | |
| probs_low_under = [x['prob_low_raw'] for x in sorted(df_under_incertidumbre, key=lambda x: x['linea_num'])] | |
| probs_high_under = [x['prob_high_raw'] for x in sorted(df_under_incertidumbre, key=lambda x: x['linea_num'])] | |
| fig_under.add_trace(go.Scatter( | |
| x=[f"Under {l}" for l in lineas_sorted_under] + [f"Under {l}" for l in lineas_sorted_under[::-1]], | |
| y=probs_high_under + probs_low_under[::-1], | |
| fill='toself', | |
| fillcolor='rgba(231, 76, 60, 0.2)', | |
| line=dict(color='rgba(255,255,255,0)'), | |
| showlegend=True, | |
| name='IC 90%', | |
| hoverinfo='skip' | |
| )) | |
| fig_under.add_trace(go.Scatter( | |
| x=[f"Under {l}" for l in lineas_sorted_under], | |
| y=probs_medias_under, | |
| mode='lines+markers', | |
| name='Probabilidad Media', | |
| line=dict(color='#e74c3c', width=3), | |
| marker=dict(size=10) | |
| )) | |
| fig_under.update_layout( | |
| title="Probabilidades Under con Banda de Incertidumbre (Monte Carlo)", | |
| xaxis_title="Línea", | |
| yaxis_title="Probabilidad (%)", | |
| height=500, | |
| hovermode='x unified' | |
| ) | |
| st.plotly_chart(fig_under, use_container_width=True) | |
| st.markdown("---") | |
| st.write("") | |
| # ============================================ | |
| # 4. CALCULADORA | |
| # ============================================ | |
| st.markdown("### 💰 Calculadora de Valor") | |
| st.write("") | |
| todas_lineas_datos = {} | |
| for item in df_over_incertidumbre: | |
| todas_lineas_datos[item['Línea']] = item | |
| for item in df_under_incertidumbre: | |
| todas_lineas_datos[item['Línea']] = item | |
| todas_lineas_ordenadas = sorted( | |
| todas_lineas_datos.keys(), | |
| key=lambda x: (0 if 'Over' in x else 1, float(x.split()[1])), | |
| reverse=True | |
| ) | |
| col_calc1, col_calc2 = st.columns(2) | |
| with col_calc1: | |
| linea_calc = st.selectbox( | |
| "🎯 Selecciona línea", | |
| todas_lineas_ordenadas, | |
| key="calc_linea" | |
| ) | |
| with col_calc2: | |
| momio_casa = st.number_input( | |
| "💰 Momio del casino", | |
| min_value=1.01, | |
| max_value=20.0, | |
| value=2.0, | |
| step=0.01, | |
| key="calc_momio", | |
| help="Ingresa el momio decimal que ofrece la casa de apuestas" | |
| ) | |
| st.write("") | |
| datos_linea = todas_lineas_datos[linea_calc] | |
| prob_media = datos_linea['prob_media_raw'] | |
| prob_low = datos_linea['prob_low_raw'] | |
| prob_high = datos_linea['prob_high_raw'] | |
| recomendacion = recomendar_apuesta_avanzada( | |
| prob_media, prob_low, prob_high, momio_casa | |
| ) | |
| st.markdown("### 📊 Métricas de la Apuesta") | |
| col_m1, col_m2, col_m3, col_m4 = st.columns(4) | |
| with col_m1: | |
| st.metric( | |
| "Prob. Media", | |
| f"{prob_media:.1f}%", | |
| help="Probabilidad media según Monte Carlo" | |
| ) | |
| with col_m2: | |
| momio_justo = probabilidad_a_momio(prob_media) | |
| st.metric( | |
| "Momio Justo", | |
| f"@{momio_justo:.2f}", | |
| help="Momio que refleja la probabilidad real" | |
| ) | |
| with col_m3: | |
| delta_ev = "📈 Positivo" if recomendacion['ev'] > 0 else "📉 Negativo" | |
| st.metric( | |
| "Expected Value", | |
| f"{recomendacion['ev']:+.2f}%", | |
| delta=delta_ev, | |
| help="Ganancia esperada por cada $1 apostado" | |
| ) | |
| with col_m4: | |
| st.metric( | |
| "Prob. Casino", | |
| f"{recomendacion['prob_casa']:.1f}%", | |
| help="Probabilidad implícita del momio del casino" | |
| ) | |
| st.write("") | |
| st.write("") | |
| st.markdown("### 💵 Gestión de Bankroll (Kelly Criterion)") | |
| col_kelly1, col_kelly2 = st.columns(2) | |
| with col_kelly1: | |
| if recomendacion['kelly'] > 0: | |
| st.write(f"**Kelly Completo:** {recomendacion['kelly']:.2f}% del bankroll") | |
| st.write(f"**Kelly Conservador (1/4):** {recomendacion['kelly_conservador']:.2f}% del bankroll ⭐") | |
| st.write("") | |
| st.markdown("**Ejemplo con Bankroll de $1,000:**") | |
| apuesta_kelly = (recomendacion['kelly'] / 100) * 1000 | |
| apuesta_conservador = (recomendacion['kelly_conservador'] / 100) * 1000 | |
| st.write(f"- Kelly Completo: **${apuesta_kelly:.2f}**") | |
| st.write(f"- Conservador: **${apuesta_conservador:.2f}**") | |
| ganancia_potencial = apuesta_conservador * (momio_casa - 1) | |
| st.write(f"- Ganancia potencial: **${ganancia_potencial:.2f}**") | |
| else: | |
| st.error("❌ Kelly = 0 - No apostar") | |
| with col_kelly2: | |
| st.write(f"**EV:** {recomendacion['ev']:+.2f}%") | |
| st.write(f"**Margen de Seguridad:** {recomendacion['margen_seguridad']:+.1f}%") | |
| st.write(f"**IC 90%:** [{prob_low:.1f}%, {prob_high:.1f}%]") | |
| st.write("") | |
| if recomendacion['confianza_alta']: | |
| st.success("✅ Alta confianza: IC inferior supera prob. casino") | |
| else: | |
| st.warning("⚠️ Baja confianza: IC inferior NO supera prob. casino") | |
| if recomendacion['ev'] > 10: | |
| st.success("🟢 EV excelente (>10%)") | |
| elif recomendacion['ev'] > 5: | |
| st.info("🟡 EV bueno (5-10%)") | |
| elif recomendacion['ev'] > 0: | |
| st.warning("🟠 EV positivo pero bajo (<5%)") | |
| else: | |
| st.error("🔴 EV negativo") | |
| st.write("") | |
| st.write("") | |
| st.markdown("---") | |
| st.caption(f"🤖 XGBoost v4.2 + Monte Carlo | 🎲 {N_SIMULACIONES:,} simulaciones | 📊 RMSE: {RMSE_MODELO} | ⏰ {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") | |
| else: | |
| if option: | |
| if option_local and option_away: | |
| pass | |
| else: | |
| st.info("👆 Selecciona ambos equipos") | |
| else: | |
| st.info("👆 Selecciona una liga para comenzar") | |
| # Sidebar | |
| with st.sidebar: | |
| st.markdown("# Corners Forecast") | |
| st.markdown("---") | |
| st.markdown("### 🔗 Enlaces") | |
| st.markdown(""" | |
| [](https://github.com/danielsaed/futbol_corners_forecast) | |
| [](https://huggingface.co/spaces/daniel-saed/futbol-corners-forecast-api) | |
| """) | |
| st.markdown("---") | |
| st.markdown("### Ligas") | |
| for league in LEAGUES_DICT.keys(): | |
| st.write(f"• {league}") | |
| if st.button("🗑️ Limpiar Cache", use_container_width=True): | |
| st.cache_data.clear() | |
| st.session_state.prediccion_realizada = False | |
| st.session_state.resultado_api = None | |
| st.success("✅ Cache limpiado") | |
| st.rerun() | |
| st.markdown("---") |