Inicial
Browse files- requirements.txt +9 -3
- src/=6.0.0 +0 -0
- src/CLAUDE.md +41 -0
- src/dataset_partidos_procesados.csv +51 -0
- src/pages/analisis_ia.py +235 -0
- src/pages/comparacion.py +37 -0
- src/pages/cuotas_kelly.py +226 -0
- src/pages/estadisticas.py +444 -0
- src/pages/inicio.py +185 -0
- src/pages/prediccion_rf.py +108 -0
- src/requirements.txt +9 -0
- src/src/__init__.py +0 -0
- src/src/__pycache__/__init__.cpython-313.pyc +0 -0
- src/src/__pycache__/betting.cpython-313.pyc +0 -0
- src/src/__pycache__/charts.cpython-313.pyc +0 -0
- src/src/__pycache__/config.cpython-313.pyc +0 -0
- src/src/__pycache__/data_loader.cpython-313.pyc +0 -0
- src/src/__pycache__/models.cpython-313.pyc +0 -0
- src/src/__pycache__/social_image.cpython-313.pyc +0 -0
- src/src/__pycache__/statistics.cpython-313.pyc +0 -0
- src/src/__pycache__/styles.cpython-313.pyc +0 -0
- src/src/__pycache__/utils.cpython-313.pyc +0 -0
- src/src/betting.py +60 -0
- src/src/charts.py +210 -0
- src/src/config.py +202 -0
- src/src/data_loader.py +28 -0
- src/src/models.py +169 -0
- src/src/social_image.py +259 -0
- src/src/statistics.py +137 -0
- src/src/styles.py +505 -0
- src/src/utils.py +23 -0
- src/streamlit_app.py +276 -38
requirements.txt
CHANGED
|
@@ -1,3 +1,9 @@
|
|
| 1 |
-
|
| 2 |
-
pandas
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.32.0
|
| 2 |
+
pandas>=2.1.0
|
| 3 |
+
numpy>=1.26.0
|
| 4 |
+
plotly>=5.18.0
|
| 5 |
+
scikit-learn>=1.4.0
|
| 6 |
+
anthropic>=0.40.0
|
| 7 |
+
duckduckgo-search>=6.0.0
|
| 8 |
+
kaleido>=0.2.1
|
| 9 |
+
|
src/=6.0.0
ADDED
|
File without changes
|
src/CLAUDE.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CLAUDE.md
|
| 2 |
+
|
| 3 |
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
| 4 |
+
|
| 5 |
+
## Running the Application
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
pip install -r requirements.txt
|
| 9 |
+
streamlit run streamlit_app.py
|
| 10 |
+
```
|
| 11 |
+
|
| 12 |
+
There are no tests, no build step, and no linting configuration.
|
| 13 |
+
|
| 14 |
+
## Architecture
|
| 15 |
+
|
| 16 |
+
Streamlit football analytics app (Spanish UI) for match prediction and Kelly Criterion betting recommendations. Data source: [football-data.co.uk](https://www.football-data.co.uk).
|
| 17 |
+
|
| 18 |
+
### Project structure
|
| 19 |
+
|
| 20 |
+
```
|
| 21 |
+
sta20/
|
| 22 |
+
├── streamlit_app.py # Entry point: page config + UI orchestration (Steps 1-6)
|
| 23 |
+
├── src/
|
| 24 |
+
│ ├── config.py # All constants, URLs, column mappings, colors, thresholds, RF hyperparams
|
| 25 |
+
│ ├── data_loader.py # cargar_fixtures(), cargar_historico(), ultimos_n()
|
| 26 |
+
│ ├── statistics.py # calcular_estadisticas(), construir_comparacion(), colorear_*, mostrar_*
|
| 27 |
+
│ ├── charts.py # All grafico_*() Plotly functions
|
| 28 |
+
│ ├── models.py # preparar_dataset_rf(), entrenar_rf_equipo(), mostrar_rf_equipo()
|
| 29 |
+
│ └── betting.py # calc_probs(), kelly()
|
| 30 |
+
├── requirements.txt
|
| 31 |
+
└── .gitignore
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
### Key design decisions
|
| 35 |
+
|
| 36 |
+
- **`src/config.py` is the single source of truth** for every magic number, color, URL, and column mapping. Edit constants there, not in the modules.
|
| 37 |
+
- `cargar_fixtures()` and `cargar_historico()` use `@st.cache_data`; `entrenar_rf_equipo()` also caches — avoid mutating their return values.
|
| 38 |
+
- `calc_probs()` blends probabilities: **40% home team history + 40% away team history + 20% league average**. Falls back to league average when a team has fewer than `MIN_PARTIDOS_EQUIPO` (3) matches.
|
| 39 |
+
- Separate `RandomForestRegressor` models are trained per team using rolling-window features (configurable 3–15 matches via `RF_N_ROLLING_*` constants).
|
| 40 |
+
- The UI flow in `streamlit_app.py` uses `st.stop()` after each mandatory selection so the page never renders with missing data.
|
| 41 |
+
- Local-scope style functions (`_color_kelly`, `_color_ve`) in `streamlit_app.py` are intentional — they exist only where used and depend on config constants imported at the top.
|
src/dataset_partidos_procesados.csv
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Div,Date,Time,HomeTeam,AwayTeam,Referee,B365H,B365D,B365A,BFDH,BFDD,BFDA,BMGMH,BMGMD,BMGMA,BVH,BVD,BVA,BWH,BWD,BWA,CLH,CLD,CLA,LBH,LBD,LBA,PSH,PSD,PSA,MaxH,MaxD,MaxA,AvgH,AvgD,AvgA,BFEH,BFED,BFEA,B365>2.5,B365<2.5,P>2.5,P<2.5,Max>2.5,Max<2.5,Avg>2.5,Avg<2.5,BFE>2.5,BFE<2.5,AHh,B365AHH,B365AHA,PAHH,PAHA,MaxAHH,MaxAHA,AvgAHH,AvgAHA,BFEAHH,BFEAHA,B365CH,B365CD,B365CA,BFDCH,BFDCD,BFDCA,BMGMCH,BMGMCD,BMGMCA,BVCH,BVCD,BVCA,BWCH,BWCD,BWCA,CLCH,CLCD,CLCA,LBCH,LBCD,LBCA,PSCH,PSCD,PSCA,MaxCH,MaxCD,MaxCA,AvgCH,AvgCD,AvgCA,BFECH,BFECD,BFECA,B365C>2.5,B365C<2.5,PC>2.5,PC<2.5,MaxC>2.5,MaxC<2.5,AvgC>2.5,AvgC<2.5,BFEC>2.5,BFEC<2.5,AHCh,B365CAHH,B365CAHA,PCAHH,PCAHA,MaxCAHH,MaxCAHA,AvgCAHH,AvgCAHA,BFECAHH,BFECAHA
|
| 2 |
+
E1,17/03/2026,19:45,Watford,Wrexham,A Herczeg,2.2,3.4,3.0,2.3,3.3,3.0,2.2,3.4,3.2,2.3,3.3,3.1,2.25,3.4,3.1,,,,,,,,,,2.36,3.45,3.2,2.27,3.34,3.07,2.38,3.45,3.35,2.1,1.73,,,2.1,1.85,2.0,1.77,2.1,1.88,-0.25,2.0,1.85,,,2.0,1.93,1.92,1.85,2.04,1.93,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 3 |
+
E1,18/03/2026,19:45,Southampton,Norwich,L Smith,1.91,3.5,3.75,1.95,3.6,3.5,1.86,3.75,3.85,1.95,3.7,3.6,1.95,3.75,3.5,,,,,,,,,,1.96,3.8,3.9,1.93,3.64,3.63,2.04,3.9,3.9,1.73,2.1,,,1.73,2.18,1.69,2.1,1.79,2.18,-0.5,1.98,1.88,,,1.98,1.89,1.89,1.84,2.04,1.95,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 4 |
+
E2,17/03/2026,19:45,AFC Wimbledon,Leyton Orient,A Dale,2.25,3.25,3.0,2.3,3.3,3.0,2.28,3.25,3.05,2.3,3.25,3.1,2.3,3.3,3.0,,,,,,,,,,2.3,3.5,3.25,2.27,3.26,3.05,2.4,3.35,3.35,2.1,1.7,,,2.1,1.75,2.05,1.7,2.2,1.79,-0.25,2.03,1.83,,,2.03,1.83,1.95,1.77,2.04,1.92,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 5 |
+
E2,17/03/2026,19:45,Blackpool,Port Vale,W Finnie,1.86,3.3,4.1,1.95,3.4,3.75,1.97,3.3,3.7,1.9,3.4,4.1,1.95,3.3,3.8,,,,,,,,,,1.97,3.5,4.1,1.91,3.37,3.92,1.97,3.6,4.3,2.15,1.67,,,2.15,1.76,2.08,1.69,2.2,1.79,-0.5,1.93,1.93,,,1.93,1.93,1.87,1.83,1.97,2.0,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 6 |
+
E2,17/03/2026,19:45,Bolton,Doncaster,D Drysdale,1.56,4.1,4.75,1.6,4.0,5.0,1.65,4.0,4.6,1.6,4.2,5.0,1.63,4.1,4.75,,,,,,,,,,1.65,4.4,5.1,1.61,4.1,4.83,1.65,4.4,5.5,1.6,2.3,,,1.62,2.38,1.58,2.27,1.64,2.46,-1.0,2.05,1.8,,,2.15,1.8,2.03,1.71,2.08,1.86,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 7 |
+
E2,17/03/2026,19:45,Bradford,Mansfield,J Oldham,1.86,3.5,3.9,1.91,3.5,3.75,1.93,3.5,3.65,1.87,3.5,4.1,1.9,3.4,3.9,,,,,,,,,,1.93,3.6,4.2,1.89,3.47,3.87,1.94,3.7,4.3,2.03,1.83,,,2.03,1.86,1.93,1.8,2.08,1.9,-0.5,1.93,1.93,,,1.93,1.93,1.85,1.85,1.94,2.04,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 8 |
+
E2,17/03/2026,19:45,Burton,Reading,E Duckworth,2.2,3.25,3.2,2.2,3.4,3.1,2.28,3.35,2.95,2.25,3.4,3.1,2.15,3.4,3.1,,,,,,,,,,2.28,3.5,3.2,2.21,3.36,3.08,2.4,3.45,3.3,1.98,1.88,,,1.98,1.91,1.89,1.84,2.02,1.95,-0.25,1.98,1.88,,,2.16,1.88,1.96,1.77,2.05,1.92,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 9 |
+
E2,17/03/2026,19:45,Cardiff,Wycombe,C Brook,1.62,3.8,4.75,1.67,4.0,4.6,1.74,3.75,4.25,1.65,4.0,4.8,1.68,3.9,4.6,,,,,,,,,,1.74,4.2,5.0,1.66,3.92,4.61,1.72,4.1,5.3,1.7,2.1,,,1.73,2.1,1.69,2.08,1.75,2.26,-0.75,1.85,2.0,,,2.08,2.0,1.88,1.85,1.91,2.05,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 10 |
+
E2,17/03/2026,19:45,Huddersfield,Lincoln,B Speedie,3.0,3.1,2.35,3.0,3.1,2.38,2.88,3.25,2.38,3.1,3.1,2.38,2.95,3.25,2.35,,,,,,,,,,3.1,3.3,2.42,3.0,3.15,2.36,3.25,3.35,2.5,2.35,1.57,,,2.38,1.64,2.26,1.58,2.48,1.65,0.25,1.78,2.1,,,1.8,2.2,1.7,2.07,1.86,2.12,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 11 |
+
E2,17/03/2026,19:45,Luton,Exeter,A Chilowicz,1.57,3.8,5.0,1.62,4.0,5.0,1.7,3.7,4.7,1.62,3.9,5.25,1.65,3.9,4.8,,,,,,,,,,1.7,4.0,5.25,1.64,3.83,4.93,1.69,4.0,5.7,1.85,2.0,,,1.85,2.0,1.8,1.94,1.9,2.06,-0.75,1.83,2.03,,,1.93,2.03,1.83,1.89,1.87,2.08,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 12 |
+
E2,17/03/2026,19:45,Peterboro,Rotherham,M Russell,2.35,3.6,2.55,2.4,3.6,2.6,2.43,3.5,2.65,2.45,3.6,2.63,2.45,3.7,2.5,,,,,,,,,,2.5,3.75,2.65,2.43,3.6,2.58,2.58,3.8,2.78,1.67,2.15,,,1.67,2.25,1.65,2.14,1.72,2.32,-0.25,2.03,1.83,,,2.1,2.1,1.93,1.82,2.24,1.77,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 13 |
+
E2,17/03/2026,19:45,Plymouth,Stevenage,E Bell,1.91,3.25,3.9,1.91,3.5,3.25,1.97,3.35,3.7,1.95,3.3,4.0,2.0,3.25,3.75,,,,,,,,,,2.0,3.5,4.0,1.96,3.3,3.78,2.08,3.4,4.1,2.25,1.62,,,2.25,1.68,2.2,1.61,2.38,1.7,-0.5,1.98,1.88,,,1.98,1.88,1.92,1.79,2.08,1.89,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 14 |
+
E2,17/03/2026,19:45,Stockport,Northampton,S Oldham,1.42,4.33,6.25,1.44,4.33,6.5,1.52,4.1,5.75,1.45,4.4,7.0,1.49,4.4,5.75,,,,,,,,,,1.52,4.5,7.0,1.46,4.31,6.25,1.5,4.8,7.0,1.73,2.08,,,1.75,2.1,1.71,2.04,1.8,2.22,-1.25,2.05,1.8,,,2.06,1.8,2.03,1.71,2.12,1.81,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 15 |
+
E2,17/03/2026,20:00,Barnsley,Wigan,R Martin,2.2,3.2,3.1,2.2,3.5,3.0,2.08,3.45,3.25,2.25,3.5,3.0,2.3,3.4,2.95,,,,,,,,,,2.3,3.7,3.25,2.21,3.42,3.05,2.36,3.55,3.25,1.85,1.95,,,1.85,2.0,1.8,1.93,1.9,2.06,-0.25,1.98,1.88,,,1.98,1.91,1.87,1.84,2.03,1.92,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 16 |
+
E3,17/03/2026,19:30,Newport County,Bromley,P Howard,3.8,3.5,1.86,3.75,3.5,1.9,3.85,3.55,1.85,3.9,3.5,1.91,3.9,3.4,1.9,,,,,,,,,,4.25,3.7,1.91,3.89,3.48,1.88,4.3,3.6,1.99,1.95,1.85,,,1.95,1.92,1.88,1.83,2.08,1.87,0.5,1.93,1.93,,,1.93,1.93,1.86,1.85,2.0,1.99,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 17 |
+
E3,17/03/2026,19:45,Accrington,Notts County,O Yates,4.2,3.25,1.81,4.0,3.3,1.91,3.95,3.25,1.91,4.1,3.4,1.91,3.9,3.3,1.95,,,,,,,,,,4.2,3.5,1.95,4.04,3.31,1.9,4.5,3.55,1.98,2.15,1.67,,,2.2,1.67,2.13,1.64,2.3,1.72,0.5,1.93,1.93,,,1.93,1.93,1.84,1.87,2.0,1.98,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 18 |
+
E3,17/03/2026,19:45,Bristol Rvs,Shrewsbury,D Rock,2.01,3.2,3.6,2.05,3.3,3.5,2.07,3.15,3.5,2.05,3.3,3.6,2.0,3.25,3.7,,,,,,,,,,2.1,3.5,3.8,2.04,3.24,3.58,2.14,3.4,3.9,2.2,1.65,,,2.2,1.73,2.12,1.66,2.24,1.79,-0.5,2.08,1.73,,,2.18,1.8,2.04,1.7,2.14,1.83,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 19 |
+
E3,17/03/2026,19:45,Cheltenham,Crewe,S Copeland,2.65,3.1,2.55,2.75,3.3,2.5,2.55,3.25,2.6,2.7,3.25,2.6,2.7,3.2,2.5,,,,,,,,,,2.8,3.5,2.6,2.69,3.22,2.54,2.86,3.35,2.72,2.1,1.7,,,2.1,1.8,2.02,1.72,2.18,1.81,0.0,1.98,1.88,,,1.98,1.88,1.92,1.81,2.01,1.91,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 20 |
+
E3,17/03/2026,19:45,Chesterfield,Oldham,R Joyce,1.96,3.4,3.6,2.05,3.4,3.4,1.98,3.4,3.45,2.0,3.5,3.6,2.05,3.4,3.4,,,,,,,,,,2.05,3.7,3.6,2.01,3.46,3.45,2.08,3.65,3.85,1.9,1.9,,,1.91,1.91,1.85,1.86,1.95,1.99,-0.5,2.03,1.83,,,2.03,1.83,1.97,1.76,2.1,1.87,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 21 |
+
E3,17/03/2026,19:45,Crawley Town,Barnet,H Wager,3.25,3.8,1.96,3.4,3.6,2.0,3.65,3.45,1.89,3.5,3.6,2.0,3.5,3.6,1.98,,,,,,,,,,3.65,3.8,2.0,3.43,3.61,1.97,3.6,3.85,2.1,1.75,2.05,,,1.8,2.1,1.74,2.0,1.86,2.12,0.5,1.83,2.03,,,1.83,2.03,1.78,1.94,1.87,2.1,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 22 |
+
E3,17/03/2026,19:45,Gillingham,Swindon,E Heaslip,3.0,3.3,2.25,2.88,3.4,2.3,2.88,3.25,2.33,3.0,3.4,2.3,2.95,3.4,2.25,,,,,,,,,,3.0,3.6,2.33,2.91,3.4,2.28,3.15,3.55,2.42,1.98,1.88,,,1.98,1.91,1.88,1.84,1.98,1.98,0.25,1.83,2.03,,,1.83,2.2,1.72,2.03,1.88,2.08,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 23 |
+
E3,17/03/2026,19:45,Grimsby,Fleetwood Town,W Cartmel,1.91,3.5,3.6,1.95,3.5,3.6,1.89,3.4,3.7,1.95,3.5,3.7,1.98,3.4,3.6,,,,,,,,,,1.98,3.7,3.8,1.94,3.45,3.67,2.04,3.65,4.0,1.95,1.85,,,1.95,1.91,1.88,1.84,2.04,1.9,-0.5,2.0,1.85,,,2.0,1.85,1.91,1.8,2.04,1.9,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 24 |
+
E3,17/03/2026,19:45,Salford,Barrow,I Searle,1.45,4.1,7.0,1.44,4.2,6.5,1.54,3.9,5.5,1.45,4.2,7.0,1.49,4.2,6.0,,,,,,,,,,1.54,4.33,7.0,1.47,4.12,6.45,1.53,4.5,7.2,1.9,1.9,,,1.91,1.93,1.83,1.88,1.94,2.02,-1.0,1.83,2.03,,,1.83,2.03,1.77,1.98,1.88,2.02,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 25 |
+
E3,17/03/2026,19:45,Tranmere,Harrogate,K Dowle,2.15,3.25,3.2,2.2,3.3,3.2,2.0,3.35,3.45,2.2,3.4,3.2,2.25,3.25,3.1,,,,,,,,,,2.25,3.4,3.45,2.17,3.3,3.22,2.28,3.4,3.6,2.03,1.83,,,2.03,1.86,1.94,1.79,2.04,1.94,-0.25,1.93,1.93,,,1.93,1.93,1.87,1.85,1.96,2.0,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 26 |
+
E3,17/03/2026,19:45,Walsall,Cambridge,S Mather,3.8,3.1,2.05,3.6,3.1,2.1,3.25,3.0,2.18,3.8,3.1,2.1,3.7,3.0,2.1,,,,,,,,,,3.8,3.2,2.18,3.6,3.08,2.11,4.0,3.25,2.18,2.6,1.48,,,2.62,1.53,2.51,1.48,2.76,1.53,0.25,2.05,1.8,,,2.05,1.8,1.98,1.76,2.11,1.86,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 27 |
+
E3,18/03/2026,20:00,Milton Keynes Dons,Colchester,S Mulhall,1.66,3.6,4.75,1.73,3.5,5.0,1.77,3.5,4.25,1.7,3.6,5.0,1.71,3.6,4.8,,,,,,,,,,1.77,3.75,5.0,1.7,3.57,4.79,1.74,3.9,5.7,2.08,1.73,,,2.12,1.78,2.04,1.7,2.22,1.72,-0.75,1.93,1.93,,,1.93,1.93,1.87,1.86,1.98,1.96,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 28 |
+
EC,17/03/2026,19:45,Brackley Town,Solihull,C Walchester,2.55,3.4,2.45,2.5,3.4,2.4,2.6,3.2,2.4,2.55,3.4,2.45,2.55,3.3,2.3,,,,,,,,,,2.66,3.4,2.5,2.55,3.34,2.43,2.7,3.6,2.56,1.75,2.05,,,1.75,2.1,1.73,1.98,1.85,2.12,0.0,1.95,1.85,,,1.95,1.85,1.89,1.8,2.0,1.9,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 29 |
+
EC,17/03/2026,19:45,Gateshead,Wealdstone,A Jackson,2.45,3.6,2.45,2.5,3.75,2.3,2.43,3.5,2.4,2.45,3.7,2.4,2.4,3.6,2.3,,,,,,,,,,2.5,3.75,2.5,2.44,3.62,2.4,2.6,3.9,2.54,1.5,2.5,,,1.5,2.75,1.46,2.54,1.53,2.72,0.0,1.9,1.9,,,1.9,1.9,1.85,1.84,1.98,1.93,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 30 |
+
EC,17/03/2026,19:45,Southend,Rochdale,C Breakspear,2.6,3.1,2.6,2.6,3.1,2.5,2.7,3.1,2.35,2.6,3.1,2.6,2.5,3.0,2.5,,,,,,,,,,2.7,3.1,2.71,2.6,3.08,2.54,2.82,3.2,2.76,2.15,1.67,,,2.15,1.68,2.09,1.65,2.2,1.79,0.0,1.9,1.9,,,1.9,1.9,1.85,1.85,1.95,1.93,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 31 |
+
EC,17/03/2026,19:45,Truro,Scunthorpe,A Quelch,3.6,3.5,1.9,3.4,3.5,1.91,3.95,3.55,1.73,3.5,3.5,1.93,3.4,3.4,1.87,,,,,,,,,,3.95,3.55,1.96,3.55,3.48,1.88,3.7,3.65,2.04,1.73,2.08,,,1.77,2.1,1.7,2.01,1.8,2.12,0.5,1.85,1.95,,,1.85,1.95,1.79,1.89,1.88,2.04,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 32 |
+
EC,17/03/2026,19:45,Woking,Yeovil,R Watkins,1.91,3.4,3.5,1.91,3.25,3.6,1.96,3.2,3.5,1.93,3.3,3.7,1.93,3.2,3.5,,,,,,,,,,2.0,3.4,3.8,1.93,3.27,3.6,2.04,3.35,4.0,2.05,1.75,,,2.05,1.75,1.99,1.72,2.16,1.79,-0.5,1.95,1.85,,,1.95,1.85,1.89,1.79,2.04,1.89,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 33 |
+
I2,17/03/2026,18:00,Palermo,Juve Stabia,,1.62,3.3,6.0,1.67,3.5,5.0,1.65,3.6,5.0,1.65,3.4,5.25,1.68,3.6,5.0,,,,,,,,,,1.68,3.6,6.0,1.64,3.48,5.25,1.74,3.6,6.2,2.2,1.65,,,2.2,1.72,2.13,1.65,2.34,1.73,-0.75,1.85,1.95,,,1.9,1.95,1.84,1.87,1.97,1.99,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 34 |
+
I2,17/03/2026,19:00,Catanzaro,Modena,,2.65,3.25,2.55,2.75,3.1,2.5,2.88,3.15,2.38,2.63,3.2,2.5,2.8,3.1,2.5,,,,,,,,,,2.88,3.25,2.6,2.74,3.11,2.5,2.98,3.25,2.7,2.08,1.73,,,2.1,1.75,2.04,1.7,2.12,1.84,0.0,1.98,1.83,,,1.98,1.83,1.93,1.8,2.08,1.87,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 35 |
+
I2,17/03/2026,19:00,Mantova,Cesena,,2.35,3.4,2.82,2.3,3.4,2.88,2.3,3.3,2.85,2.3,3.25,2.8,2.35,3.3,2.9,,,,,,,,,,2.38,3.4,3.0,2.31,3.27,2.87,2.52,3.4,3.1,1.93,1.88,,,1.93,1.91,1.86,1.84,2.02,1.93,-0.25,2.08,1.73,,,2.14,1.8,2.04,1.7,2.15,1.82,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 36 |
+
I2,17/03/2026,19:00,Reggiana,Monza,,6.0,3.3,1.66,5.5,3.4,1.67,5.2,3.5,1.65,5.25,3.4,1.65,5.25,3.5,1.68,,,,,,,,,,6.0,3.5,1.68,5.33,3.39,1.66,6.2,3.6,1.75,2.3,1.6,,,2.3,1.64,2.22,1.59,2.4,1.69,0.75,1.9,1.9,,,1.9,2.06,1.8,1.91,1.98,1.99,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 37 |
+
I2,17/03/2026,19:00,Spezia,Empoli,,2.65,2.65,3.1,2.6,2.8,3.0,2.5,2.8,2.95,2.45,2.9,2.9,2.55,2.87,2.95,,,,,,,,,,2.65,2.9,3.2,2.54,2.78,2.98,2.7,3.05,3.2,2.25,1.62,,,2.25,1.62,2.2,1.6,2.36,1.72,0.0,1.75,2.05,,,1.8,2.05,1.72,2.02,1.81,2.18,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 38 |
+
I2,17/03/2026,19:00,Venezia,Padova,,1.22,6.25,9.0,1.25,5.5,10.0,1.3,4.9,8.5,1.22,5.5,10.5,1.3,5.5,8.75,,,,,,,,,,1.3,6.25,11.0,1.25,5.42,9.79,1.29,6.2,13.0,1.57,2.35,,,1.6,2.4,1.57,2.28,1.63,2.5,-1.75,1.98,1.83,,,2.1,1.83,1.98,1.75,2.03,1.88,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 39 |
+
I2,18/03/2026,18:00,Frosinone,Bari,,1.42,4.2,7.0,1.44,4.33,6.0,1.48,4.25,5.75,1.44,4.33,6.0,1.5,4.5,5.5,,,,,,,,,,1.5,4.5,7.0,1.45,4.3,5.94,1.49,4.7,7.0,1.57,2.35,,,1.62,2.35,1.58,2.25,1.64,2.38,-1.25,2.0,1.8,,,2.18,1.8,2.02,1.71,2.04,1.85,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 40 |
+
I2,18/03/2026,19:00,Avellino,Sudtirol,,2.82,2.82,2.7,2.8,2.88,2.63,2.65,2.9,2.7,2.7,3.0,2.55,2.8,2.95,2.6,,,,,,,,,,2.9,3.0,2.7,2.76,2.9,2.63,3.0,2.72,2.58,2.3,1.6,,,2.3,1.62,2.24,1.58,2.2,1.58,0.0,1.95,1.85,,,1.95,1.85,1.91,1.8,2.05,1.76,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 41 |
+
I2,18/03/2026,19:00,Carrarese,Sampdoria,,2.35,2.82,3.2,2.5,2.88,3.0,2.5,2.65,3.2,2.45,3.0,2.8,2.5,2.9,3.0,,,,,,,,,,2.5,3.0,3.6,2.46,2.79,3.08,2.52,2.96,3.3,2.25,1.62,,,2.4,1.62,2.28,1.56,2.34,1.65,-0.25,2.05,1.75,,,2.1,1.75,2.04,1.69,2.12,1.8,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 42 |
+
I2,18/03/2026,19:00,Pescara,Virtus Entella,,2.1,3.4,3.3,2.15,3.4,3.1,2.17,3.25,3.15,2.1,3.3,3.2,2.15,3.4,3.1,,,,,,,,,,2.2,3.4,3.33,2.14,3.31,3.16,2.2,3.5,3.7,1.95,1.85,,,1.95,1.85,1.87,1.83,1.96,1.92,-0.25,1.85,1.95,,,1.98,1.95,1.87,1.84,1.88,2.07,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 43 |
+
SC2,17/03/2026,19:45,Inverness C,Peterhead,G Calder,1.3,4.5,8.0,1.3,4.6,8.0,1.3,4.5,8.0,1.29,4.8,9.0,1.3,4.8,7.5,,,,,,,,,,1.33,5.0,9.0,1.29,4.62,7.93,1.35,5.2,10.0,1.67,2.15,,,1.67,2.3,1.6,2.17,1.71,2.26,-1.5,1.95,1.85,,,1.95,1.85,1.87,1.78,1.97,1.83,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 44 |
+
SC3,17/03/2026,19:45,Spartans,Forfar,A Hendry,1.83,3.2,3.8,1.9,3.3,3.6,1.76,3.4,3.8,1.87,3.3,3.8,1.85,3.25,3.6,,,,,,,,,,1.91,3.4,4.0,1.83,3.29,3.72,1.95,3.45,4.2,2.05,1.75,,,2.05,1.93,1.9,1.77,2.12,1.82,-0.5,1.9,1.9,,,1.9,1.9,1.81,1.83,1.95,1.96,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 45 |
+
T1,17/03/2026,17:00,Fenerbahce,Gaziantep,,1.22,6.25,8.0,1.22,5.5,10.0,1.24,6.0,10.0,1.22,5.75,10.0,1.27,5.75,9.75,,,,,,,,,,1.27,6.25,11.0,1.23,5.75,9.67,1.28,6.6,12.5,1.44,2.7,,,1.5,2.7,1.45,2.58,1.51,2.88,-1.75,1.9,1.9,,,1.9,1.9,1.85,1.84,2.02,1.94,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 46 |
+
T1,18/03/2026,13:00,Alanyaspor,Kocaelispor,,1.96,3.25,3.9,2.0,3.1,3.6,2.08,3.2,3.45,1.95,3.2,3.75,2.1,3.2,3.5,,,,,,,,,,2.1,3.25,3.9,1.99,3.18,3.65,2.02,3.2,4.5,2.25,1.62,,,2.25,1.68,2.2,1.61,2.28,1.66,-0.5,2.0,1.8,,,2.0,1.8,1.96,1.74,2.03,1.87,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 47 |
+
T1,18/03/2026,17:00,Buyuksehyr,Antalyaspor,,1.49,4.0,6.0,1.5,4.0,5.5,1.42,4.5,6.75,1.5,4.1,5.5,1.51,4.33,5.5,,,,,,,,,,1.51,4.5,6.75,1.47,4.21,5.74,1.52,4.5,6.2,1.67,2.15,,,1.67,2.25,1.62,2.17,1.72,2.18,-1.0,1.85,1.95,,,1.85,2.12,1.75,1.97,1.87,1.97,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 48 |
+
T1,18/03/2026,17:00,Eyupspor,Trabzonspor,,4.75,3.9,1.61,4.5,4.0,1.6,5.3,4.2,1.54,4.6,3.9,1.62,4.6,4.1,1.63,,,,,,,,,,5.3,4.2,1.66,4.67,3.95,1.61,5.2,4.5,1.67,1.67,2.15,,,1.67,2.2,1.65,2.13,1.71,2.22,0.75,1.98,1.83,,,2.0,1.83,1.94,1.77,2.09,1.84,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 49 |
+
T1,19/03/2026,13:00,Kayserispor,Karagumruk,,1.86,3.6,3.8,1.95,3.4,3.4,1.93,3.5,3.65,1.9,3.4,3.6,1.95,3.5,3.6,,,,,,,,,,1.97,3.6,4.0,1.9,3.42,3.65,1.94,3.5,4.5,1.85,1.95,,,1.85,1.95,1.83,1.87,1.88,1.93,-0.5,1.93,1.88,,,1.95,1.88,1.87,1.81,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 50 |
+
T1,19/03/2026,17:00,Besiktas,Kasimpasa,,1.35,4.75,8.0,1.33,4.6,7.5,1.36,4.8,7.5,1.33,4.75,7.5,1.37,5.0,7.25,,,,,,,,,,1.37,5.0,9.0,1.33,4.79,7.56,1.36,5.9,10.5,1.57,2.35,,,1.57,2.38,1.55,2.31,1.61,2.38,-1.5,2.0,1.8,,,2.0,1.83,1.93,1.77,1.93,1.81,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
| 51 |
+
T1,19/03/2026,17:00,Konyaspor,Genclerbirligi,,1.76,3.5,4.5,1.8,3.4,4.0,1.83,3.6,4.0,1.75,3.4,4.33,1.82,3.5,4.2,,,,,,,,,,1.83,3.6,4.5,1.78,3.45,4.16,1.79,3.55,5.5,2.05,1.75,,,2.05,1.89,1.95,1.77,2.04,1.78,-0.5,1.78,2.03,,,1.8,2.03,1.76,1.93,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
src/pages/analisis_ia.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
|
| 3 |
+
from src.utils import get_pred
|
| 4 |
+
|
| 5 |
+
st.session_state["_current_page"] = "analisis_ia"
|
| 6 |
+
|
| 7 |
+
ctx = st.session_state.get("_ctx", {})
|
| 8 |
+
if not ctx:
|
| 9 |
+
st.warning("Selecciona un partido en la página de Inicio.")
|
| 10 |
+
st.stop()
|
| 11 |
+
|
| 12 |
+
eq_l = ctx["eq_l"]
|
| 13 |
+
eq_v = ctx["eq_v"]
|
| 14 |
+
liga = ctx["liga"]
|
| 15 |
+
kelly_ok = ctx["kelly_ok"]
|
| 16 |
+
probs = ctx["probs"]
|
| 17 |
+
cH, cD, cA = ctx["cH"], ctx["cD"], ctx["cA"]
|
| 18 |
+
veH, veD, veA = ctx["veH"], ctx["veD"], ctx["veA"]
|
| 19 |
+
pH, pD, pA = ctx["pH"], ctx["pD"], ctx["pA"]
|
| 20 |
+
rf_l, rf_v = ctx["rf_l"], ctx["rf_v"]
|
| 21 |
+
l_anota, v_recibe = ctx["l_anota"], ctx["v_recibe"]
|
| 22 |
+
v_anota, l_recibe = ctx["v_anota"], ctx["l_recibe"]
|
| 23 |
+
ventaja_pct = ctx["ventaja_pct"]
|
| 24 |
+
ventaja_equipo = ctx["ventaja_equipo"]
|
| 25 |
+
|
| 26 |
+
# ── Panel IA ──────────────────────────────────────────────────────────────
|
| 27 |
+
try:
|
| 28 |
+
import anthropic as _ant
|
| 29 |
+
_ant_ok = True
|
| 30 |
+
except ImportError:
|
| 31 |
+
_ant_ok = False
|
| 32 |
+
|
| 33 |
+
if not _ant_ok:
|
| 34 |
+
st.warning("Instala `anthropic>=0.40.0` en requirements.txt para activar el análisis IA.")
|
| 35 |
+
st.stop()
|
| 36 |
+
|
| 37 |
+
import os as _os
|
| 38 |
+
_api_key = ""
|
| 39 |
+
try:
|
| 40 |
+
_api_key = st.secrets["ANTHROPIC_API_KEY"]
|
| 41 |
+
except Exception:
|
| 42 |
+
_api_key = _os.environ.get("ANTHROPIC_API_KEY", "")
|
| 43 |
+
|
| 44 |
+
if not _api_key:
|
| 45 |
+
_api_key = st.text_input(
|
| 46 |
+
"API Key de Anthropic:",
|
| 47 |
+
type="password",
|
| 48 |
+
placeholder="sk-ant-...",
|
| 49 |
+
help="La clave no se almacena. También puedes definirla en `.streamlit/secrets.toml` o como variable de entorno `ANTHROPIC_API_KEY`.",
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
if not _api_key:
|
| 53 |
+
st.caption("Introduce tu API key para activar el análisis IA.")
|
| 54 |
+
st.stop()
|
| 55 |
+
|
| 56 |
+
st.markdown("""<div class="fs-ai">
|
| 57 |
+
<div class="ai-hdr">
|
| 58 |
+
<div class="ai-ico">🤖</div>
|
| 59 |
+
<div>
|
| 60 |
+
<div class="ai-title">Claude Analysis</div>
|
| 61 |
+
<div class="ai-sub">claude-opus-4-6 · Análisis cuantitativo de valor de apuesta</div>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
</div>""", unsafe_allow_html=True)
|
| 65 |
+
|
| 66 |
+
if not st.button("🤖 Analizar con IA", type="primary", key="btn_ia"):
|
| 67 |
+
st.stop()
|
| 68 |
+
|
| 69 |
+
# ── Búsqueda de noticias externas ─────────────────────────────────────────
|
| 70 |
+
_noticias = []
|
| 71 |
+
try:
|
| 72 |
+
from duckduckgo_search import DDGS
|
| 73 |
+
_queries = [
|
| 74 |
+
f"{eq_l} {eq_v} lesiones bajas alineación",
|
| 75 |
+
f"{eq_l} {eq_v} preview predicción",
|
| 76 |
+
f"{eq_l} últimas noticias forma reciente",
|
| 77 |
+
f"{eq_v} últimas noticias forma reciente",
|
| 78 |
+
]
|
| 79 |
+
with st.spinner("🔍 Buscando información actualizada..."):
|
| 80 |
+
with DDGS() as ddgs:
|
| 81 |
+
for _q in _queries:
|
| 82 |
+
for _r in ddgs.text(_q, max_results=3, region="es-es"):
|
| 83 |
+
_noticias.append(f"[{_r['title']}] {_r['body']}")
|
| 84 |
+
except ImportError:
|
| 85 |
+
st.caption("⚠️ Instala `duckduckgo-search>=6.0.0` para activar la búsqueda de noticias.")
|
| 86 |
+
except Exception as _e:
|
| 87 |
+
st.caption(f"⚠️ Búsqueda de noticias no disponible: {_e}")
|
| 88 |
+
|
| 89 |
+
# ── Datos estadísticos ────────────────────────────────────────────────────
|
| 90 |
+
_rf_disponible = rf_l is not None and rf_v is not None
|
| 91 |
+
_lines = [f"Partido: {eq_l} (Local) vs {eq_v} (Visitante) — Liga: {liga}"]
|
| 92 |
+
|
| 93 |
+
if kelly_ok:
|
| 94 |
+
_lines += [
|
| 95 |
+
f"Cuotas B365: Local={cH:.2f}, Empate={cD:.2f}, Visitante={cA:.2f}",
|
| 96 |
+
f"Prob. implícitas: Local={1/cH:.1%}, Empate={1/cD:.1%}, Visitante={1/cA:.1%}",
|
| 97 |
+
f"VE vs cuota: Local={veH:+.2f}%, Empate={veD:+.2f}%, Visitante={veA:+.2f}%",
|
| 98 |
+
]
|
| 99 |
+
if probs is not None:
|
| 100 |
+
_lines.append(
|
| 101 |
+
f"Prob. estimadas (histórico 40/40/20): Local={probs['H']:.1%}, Empate={probs['D']:.1%}, Visitante={probs['A']:.1%}"
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
_lines += [
|
| 105 |
+
f"Goles esperados — {eq_l} ataca / {eq_v} defiende: {eq_l} anota {l_anota}, {eq_v} recibe {v_recibe} (Δ={v_recibe-l_anota:+.2f})",
|
| 106 |
+
f"Goles esperados — {eq_v} ataca / {eq_l} defiende: {eq_v} anota {v_anota}, {eq_l} recibe {l_recibe} (Δ={l_recibe-v_anota:+.2f})",
|
| 107 |
+
]
|
| 108 |
+
|
| 109 |
+
# ── xG si disponible ──────────────────────────────────────────────────────
|
| 110 |
+
import pandas as _pd_ia
|
| 111 |
+
|
| 112 |
+
def _xg_mean(df, col):
|
| 113 |
+
if df is None or df.empty or col not in df.columns:
|
| 114 |
+
return None
|
| 115 |
+
s = _pd_ia.to_numeric(df[col], errors="coerce").dropna()
|
| 116 |
+
return round(s.mean(), 2) if not s.empty else None
|
| 117 |
+
|
| 118 |
+
_dl_ia = ctx.get("dl")
|
| 119 |
+
_dv_ia = ctx.get("dv")
|
| 120 |
+
_xgs_l_ia = _xg_mean(_dl_ia, "HxG")
|
| 121 |
+
_xgc_l_ia = _xg_mean(_dl_ia, "AxG")
|
| 122 |
+
_xgs_v_ia = _xg_mean(_dv_ia, "AxG")
|
| 123 |
+
_xgc_v_ia = _xg_mean(_dv_ia, "HxG")
|
| 124 |
+
|
| 125 |
+
if _xgs_l_ia is not None and _xgs_v_ia is not None:
|
| 126 |
+
_xg_match_l = round((_xgs_l_ia + (_xgc_v_ia or _xgs_l_ia)) / 2, 2)
|
| 127 |
+
_xg_match_v = round((_xgs_v_ia + (_xgc_l_ia or _xgs_v_ia)) / 2, 2)
|
| 128 |
+
_xg_total = round(_xg_match_l + _xg_match_v, 2)
|
| 129 |
+
_lines += [
|
| 130 |
+
f"xG por partido — {eq_l}: anota {_xgs_l_ia} xG, recibe {_xgc_l_ia} xGA (rendimiento real vs xG: {round(l_anota - _xgs_l_ia, 2):+.2f})",
|
| 131 |
+
f"xG por partido — {eq_v}: anota {_xgs_v_ia} xG, recibe {_xgc_v_ia} xGA (rendimiento real vs xG: {round(v_anota - _xgs_v_ia, 2):+.2f})",
|
| 132 |
+
f"Predicción xG del partido: {eq_l}={_xg_match_l}, {eq_v}={_xg_match_v}, Total={_xg_total} ({'Sobre' if _xg_total>2.5 else 'Bajo'} 2.5 / {'Sobre' if _xg_total>3.5 else 'Bajo'} 3.5)",
|
| 133 |
+
]
|
| 134 |
+
|
| 135 |
+
if rf_l is not None and rf_v is not None:
|
| 136 |
+
_el = (get_pred(rf_l, "Goles Anotados") + get_pred(rf_v, "Goles Recibidos")) / 2
|
| 137 |
+
_ev = (get_pred(rf_v, "Goles Anotados") + get_pred(rf_l, "Goles Recibidos")) / 2
|
| 138 |
+
_et = _el + _ev
|
| 139 |
+
_el_ht = (get_pred(rf_l, "Goles Anotados (HT)") + get_pred(rf_v, "Goles Recibidos (HT)")) / 2
|
| 140 |
+
_ev_ht = (get_pred(rf_v, "Goles Anotados (HT)") + get_pred(rf_l, "Goles Recibidos (HT)")) / 2
|
| 141 |
+
_lines += [
|
| 142 |
+
f"RF FT: {eq_l}={_el:.2f} goles, {eq_v}={_ev:.2f} goles, Total={_et:.2f} ({'Sobre' if _et>2.5 else 'Bajo'} 2.5 / {'Sobre' if _et>3.5 else 'Bajo'} 3.5)",
|
| 143 |
+
f"RF HT: {eq_l}={_el_ht:.2f}, {eq_v}={_ev_ht:.2f}, Total 1T={_el_ht+_ev_ht:.2f} goles",
|
| 144 |
+
]
|
| 145 |
+
|
| 146 |
+
_equipo_ventaja = None
|
| 147 |
+
if ventaja_pct > 0 and ventaja_equipo:
|
| 148 |
+
_equipo_ventaja = eq_l if eq_l in ventaja_equipo else eq_v
|
| 149 |
+
_lines.append(
|
| 150 |
+
f"VENTAJA INFORMACIONAL DECLARADA: +{ventaja_pct}% a favor de {_equipo_ventaja}. "
|
| 151 |
+
"Ajusta su probabilidad estimada al alza en ese porcentaje y recalcula VE para todos los mercados."
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
_datos_str = "\n".join(f" - {l}" for l in _lines)
|
| 155 |
+
_noticias_str = (
|
| 156 |
+
"\n".join(f" · {n}" for n in _noticias[:12])
|
| 157 |
+
if _noticias
|
| 158 |
+
else " (No se encontraron noticias externas en este momento)"
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
_prompt = f"""Actúa como un apostador profesional de fútbol y analista cuantitativo senior.
|
| 162 |
+
Tu objetivo es identificar mercados con valor esperado positivo usando EXCLUSIVAMENTE los datos proporcionados.
|
| 163 |
+
No inventes, no asumas y no menciones factores que no aparezcan en los datos.
|
| 164 |
+
{"Si hay ventaja informacional declarada, aplícala ajustando la probabilidad del equipo beneficiado ANTES de cualquier cálculo." if _equipo_ventaja else "No hay ventaja informacional declarada; usa las probabilidades estadísticas sin modificar."}
|
| 165 |
+
|
| 166 |
+
---
|
| 167 |
+
DATOS ESTADÍSTICOS:
|
| 168 |
+
{_datos_str}
|
| 169 |
+
|
| 170 |
+
NOTICIAS Y CONTEXTO EXTERNO (fuentes públicas, valida su relevancia):
|
| 171 |
+
{_noticias_str}
|
| 172 |
+
---
|
| 173 |
+
|
| 174 |
+
ANÁLISIS REQUERIDO — responde en Markdown estructurado:
|
| 175 |
+
|
| 176 |
+
**1. Evaluación del contexto externo**
|
| 177 |
+
Revisa las noticias encontradas. Extrae solo los datos que puedan cambiar el resultado:
|
| 178 |
+
lesiones confirmadas, sanciones, rotaciones declaradas, rachas, condición de campo, viajes.
|
| 179 |
+
Descarta titulares sin sustancia. Si una noticia contradice otra, señálalo.
|
| 180 |
+
Indica cómo cada factor relevante afecta las probabilidades estimadas y en qué dirección.
|
| 181 |
+
|
| 182 |
+
**2. Goles — Over/Under**
|
| 183 |
+
Calcula λ local y λ visitante usando goles esperados{"y RF" if _rf_disponible else ""}{" y xG" if _xgs_l_ia is not None else ""}.
|
| 184 |
+
{"Si hay datos xG, compara con los goles reales: si un equipo supera consistentemente su xG es clínico; si está por debajo, puede ser que mejore. Usa xG como ancla de predicción y goles reales como indicador de tendencia." if _xgs_l_ia is not None else ""}
|
| 185 |
+
Evalúa líneas 1.5 / 2.5 / 3.5. Para cada línea: ¿la probabilidad estimada supera la implícita de las cuotas?
|
| 186 |
+
Señala la línea con mayor valor y justifica con los números.
|
| 187 |
+
|
| 188 |
+
**3. Resultado 1X2 y Asian Handicap**
|
| 189 |
+
Compara prob. estimadas vs implícitas.
|
| 190 |
+
{"Ajusta las probabilidades con la ventaja informacional declarada antes de comparar." if _equipo_ventaja else ""}
|
| 191 |
+
Identifica si hay valor en Local, Empate o Visitante.
|
| 192 |
+
Evalúa Draw No Bet si el empate diluye una ventaja clara.
|
| 193 |
+
Indica hándicap asiático recomendado si aplica.
|
| 194 |
+
|
| 195 |
+
**4. Resultado al Medio Tiempo (HT)**
|
| 196 |
+
{"Usa RF HT para inferir el resultado más probable en primer tiempo." if _rf_disponible else "Infiere del volumen de goles esperados; descarta si los datos son insuficientes."}
|
| 197 |
+
¿Hay sesgo de goles en 1T o 2T que genere valor en el mercado HT?
|
| 198 |
+
|
| 199 |
+
**5. Córners**
|
| 200 |
+
Usa medianas de córners si están disponibles en los datos.
|
| 201 |
+
Evalúa Over/Under total de córners y hándicap asiático de córners.
|
| 202 |
+
Si los datos son insuficientes, descarta explícitamente esta categoría.
|
| 203 |
+
|
| 204 |
+
**6. Ambos Marcan (BTTS)**
|
| 205 |
+
Infiere de goles anotados y recibidos por cada equipo.
|
| 206 |
+
¿Ambos equipos tienen tendencia a anotar y recibir? ¿El mercado lo refleja?
|
| 207 |
+
|
| 208 |
+
**7. Resumen ejecutivo**
|
| 209 |
+
|
| 210 |
+
| Campo | Detalle |
|
| 211 |
+
|---|---|
|
| 212 |
+
| Apuesta principal | mercado · resultado · tamaño relativo (pequeño/medio/alto) |
|
| 213 |
+
| Apuesta secundaria | mercado · resultado · tamaño relativo |
|
| 214 |
+
| Mercados descartados | categoría — razón breve |
|
| 215 |
+
| Nivel de confianza | bajo / medio / alto — justificación en una línea |
|
| 216 |
+
|
| 217 |
+
No uses Kelly ni porcentajes de bankroll. Usa tamaños relativos: pequeño (<1u), medio (1–2u), alto (2–3u).
|
| 218 |
+
Sé directo. Cada conclusión debe respaldarse con al menos un número de los datos."""
|
| 219 |
+
|
| 220 |
+
# ── Stream de respuesta ────────────────────────────────────────────────────
|
| 221 |
+
def _stream_ia():
|
| 222 |
+
_client = _ant.Anthropic(api_key=_api_key)
|
| 223 |
+
with _client.messages.stream(
|
| 224 |
+
model="claude-opus-4-6",
|
| 225 |
+
max_tokens=6000,
|
| 226 |
+
messages=[{"role": "user", "content": _prompt}],
|
| 227 |
+
) as _s:
|
| 228 |
+
for _txt in _s.text_stream:
|
| 229 |
+
yield _txt
|
| 230 |
+
|
| 231 |
+
if _noticias:
|
| 232 |
+
st.caption(f"🔍 {len(_noticias)} fragmentos de noticias incorporados al análisis")
|
| 233 |
+
|
| 234 |
+
with st.container():
|
| 235 |
+
st.write_stream(_stream_ia())
|
src/pages/comparacion.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
|
| 3 |
+
from src.config import PARES_ATAQUE_LOCAL, PARES_ATAQUE_VISITANTE
|
| 4 |
+
from src.statistics import construir_comparacion, mostrar_comparacion
|
| 5 |
+
from src.charts import grafico_radar
|
| 6 |
+
from src.utils import sec
|
| 7 |
+
|
| 8 |
+
st.session_state["_current_page"] = "comparacion"
|
| 9 |
+
|
| 10 |
+
ctx = st.session_state.get("_ctx", {})
|
| 11 |
+
if not ctx:
|
| 12 |
+
st.warning("Selecciona un partido en la página de Inicio.")
|
| 13 |
+
st.stop()
|
| 14 |
+
|
| 15 |
+
eq_l = ctx["eq_l"]
|
| 16 |
+
eq_v = ctx["eq_v"]
|
| 17 |
+
dl = ctx["dl"]
|
| 18 |
+
dv = ctx["dv"]
|
| 19 |
+
|
| 20 |
+
if dl.empty or dv.empty:
|
| 21 |
+
st.markdown('<div class="fs-empty"><div class="em-icon">📊</div>Datos insuficientes para la comparación.</div>', unsafe_allow_html=True)
|
| 22 |
+
st.stop()
|
| 23 |
+
|
| 24 |
+
# ── Radar ─────────────────────────────────────────────────────────────────
|
| 25 |
+
sec("📡", "Radar Estadístico")
|
| 26 |
+
rc1, rc2, rc3 = st.columns(3)
|
| 27 |
+
for col, agg in zip([rc1, rc2, rc3], ["mean", "median", "mode"]):
|
| 28 |
+
fig_r = grafico_radar(dl, dv, eq_l, eq_v, agg=agg)
|
| 29 |
+
if fig_r:
|
| 30 |
+
col.plotly_chart(fig_r, use_container_width=True)
|
| 31 |
+
|
| 32 |
+
# ── Comparaciones ─────────────────────────────────────────────────────────
|
| 33 |
+
sec("🏠🗡️", f"Ataque {eq_l} vs Defensa {eq_v}")
|
| 34 |
+
mostrar_comparacion(construir_comparacion(eq_l, eq_v, dl, dv, PARES_ATAQUE_LOCAL, "ataque_local"))
|
| 35 |
+
|
| 36 |
+
sec("✈️🗡️", f"Ataque {eq_v} vs Defensa {eq_l}")
|
| 37 |
+
mostrar_comparacion(construir_comparacion(eq_l, eq_v, dl, dv, PARES_ATAQUE_VISITANTE, "ataque_visitante"))
|
src/pages/cuotas_kelly.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import plotly.graph_objects as go
|
| 4 |
+
|
| 5 |
+
from src.config import (
|
| 6 |
+
COLOR_LOCAL, COLOR_VISITANTE,
|
| 7 |
+
KELLY_MIN_RECOMENDADO, BANK_MIN, BANK_DEFAULT, BANK_STEP,
|
| 8 |
+
)
|
| 9 |
+
from src.charts import _THEME as _CHART_THEME
|
| 10 |
+
from src.utils import sec
|
| 11 |
+
|
| 12 |
+
st.session_state["_current_page"] = "cuotas_kelly"
|
| 13 |
+
|
| 14 |
+
ctx = st.session_state.get("_ctx", {})
|
| 15 |
+
if not ctx:
|
| 16 |
+
st.warning("Selecciona un partido en la página de Inicio.")
|
| 17 |
+
st.stop()
|
| 18 |
+
|
| 19 |
+
eq_l = ctx["eq_l"]
|
| 20 |
+
eq_v = ctx["eq_v"]
|
| 21 |
+
kelly_ok = ctx["kelly_ok"]
|
| 22 |
+
probs = ctx["probs"]
|
| 23 |
+
cH, cD, cA = ctx["cH"], ctx["cD"], ctx["cA"]
|
| 24 |
+
kH, kD, kA = ctx["kH"], ctx["kD"], ctx["kA"]
|
| 25 |
+
veH, veD, veA = ctx["veH"], ctx["veD"], ctx["veA"]
|
| 26 |
+
pH, pD, pA = ctx["pH"], ctx["pD"], ctx["pA"]
|
| 27 |
+
|
| 28 |
+
if not kelly_ok:
|
| 29 |
+
st.markdown('<div class="fs-empty"><div class="em-icon">💰</div>Datos insuficientes para calcular Kelly (cuotas o probabilidades no disponibles).</div>', unsafe_allow_html=True)
|
| 30 |
+
st.stop()
|
| 31 |
+
|
| 32 |
+
# ── Barra de probabilidades ───────────────────────────────────────────────
|
| 33 |
+
sec("📊", "Probabilidades Estimadas vs Implícitas")
|
| 34 |
+
_inv = 1/cH + 1/cD + 1/cA
|
| 35 |
+
piH_n = 1/cH / _inv * 100
|
| 36 |
+
piD_n = 1/cD / _inv * 100
|
| 37 |
+
piA_n = 1/cA / _inv * 100
|
| 38 |
+
st.markdown(f"""<div class="fs-prob">
|
| 39 |
+
<div class="labels">
|
| 40 |
+
<span>🏠 {eq_l}</span>
|
| 41 |
+
<span>Empate</span>
|
| 42 |
+
<span>✈️ {eq_v}</span>
|
| 43 |
+
</div>
|
| 44 |
+
<div class="bar">
|
| 45 |
+
<div class="seg h" style="width:{piH_n:.1f}%"></div>
|
| 46 |
+
<div class="seg d" style="width:{piD_n:.1f}%"></div>
|
| 47 |
+
<div class="seg a" style="width:{piA_n:.1f}%"></div>
|
| 48 |
+
</div>
|
| 49 |
+
<div class="vals">
|
| 50 |
+
<span class="vh">{piH_n:.1f}%</span>
|
| 51 |
+
<span class="vd">{piD_n:.1f}%</span>
|
| 52 |
+
<span class="va">{piA_n:.1f}%</span>
|
| 53 |
+
</div>
|
| 54 |
+
</div>""", unsafe_allow_html=True)
|
| 55 |
+
|
| 56 |
+
# ── Tarjetas de cuotas ────────────────────────────────────────────────────
|
| 57 |
+
def _odds_card(label, odd, prob_est, ve, best):
|
| 58 |
+
cls = "best" if best else ""
|
| 59 |
+
ve_cls = "pos" if ve > 0 else "neg"
|
| 60 |
+
ve_sign = "+" if ve > 0 else ""
|
| 61 |
+
return f"""<div class="oc {cls}">
|
| 62 |
+
<div class="on">{label}</div>
|
| 63 |
+
<div class="ov">{odd:.2f}</div>
|
| 64 |
+
<div class="op">{prob_est:.1f}% estimado · {1/odd*100:.1f}% implícito</div>
|
| 65 |
+
<div class="ove {ve_cls}">VE: {ve_sign}{ve:.1f}%</div>
|
| 66 |
+
</div>"""
|
| 67 |
+
|
| 68 |
+
st.markdown(f"""<div class="fs-odds">
|
| 69 |
+
{_odds_card(f"1 — {eq_l}", cH, pH, veH, probs["H"] > 1/cH)}
|
| 70 |
+
{_odds_card("X — Empate", cD, pD, veD, probs["D"] > 1/cD)}
|
| 71 |
+
{_odds_card(f"2 — {eq_v}", cA, pA, veA, probs["A"] > 1/cA)}
|
| 72 |
+
</div>""", unsafe_allow_html=True)
|
| 73 |
+
|
| 74 |
+
# ── Gráfico prob estimada vs implícita ────────────────────────────────────
|
| 75 |
+
piH, piD, piA = 1/cH*100, 1/cD*100, 1/cA*100
|
| 76 |
+
fig_p = go.Figure()
|
| 77 |
+
fig_p.add_trace(go.Bar(
|
| 78 |
+
x=["Local", "Empate", "Visitante"],
|
| 79 |
+
y=[pH, pD, pA],
|
| 80 |
+
marker_color=[COLOR_LOCAL, "#9BA3AE", COLOR_VISITANTE],
|
| 81 |
+
text=[f"{v:.1f}%" for v in [pH, pD, pA]],
|
| 82 |
+
textposition="outside", textfont=dict(size=14), name="Estimada",
|
| 83 |
+
))
|
| 84 |
+
fig_p.add_trace(go.Scatter(
|
| 85 |
+
x=["Local", "Empate", "Visitante"], y=[piH, piD, piA],
|
| 86 |
+
mode="markers+text", name="Implícita (cuota)",
|
| 87 |
+
marker=dict(size=14, color="#B07D0E", symbol="diamond"),
|
| 88 |
+
text=[f"{v:.1f}%" for v in [piH, piD, piA]],
|
| 89 |
+
textposition="top center",
|
| 90 |
+
))
|
| 91 |
+
fig_p.update_layout(
|
| 92 |
+
height=340, title=dict(text="Prob. Estimada vs Implícita", x=0.5),
|
| 93 |
+
yaxis_title="%",
|
| 94 |
+
yaxis=dict(range=[0, max(pH, pD, pA) * 1.45]),
|
| 95 |
+
margin=dict(l=20, r=20, t=55, b=40),
|
| 96 |
+
legend=dict(orientation="h", yanchor="bottom", y=-0.25, xanchor="center", x=0.5),
|
| 97 |
+
**_CHART_THEME,
|
| 98 |
+
)
|
| 99 |
+
st.plotly_chart(fig_p, use_container_width=True)
|
| 100 |
+
|
| 101 |
+
# ── Kelly Criterion ───────────────────────────────────────────────────────
|
| 102 |
+
sec("🎯", "Kelly Criterion")
|
| 103 |
+
|
| 104 |
+
def _kelly_cls(k):
|
| 105 |
+
if k > KELLY_MIN_RECOMENDADO:
|
| 106 |
+
return "positive"
|
| 107 |
+
if 0 < k <= KELLY_MIN_RECOMENDADO:
|
| 108 |
+
return "yellow"
|
| 109 |
+
return "negative"
|
| 110 |
+
|
| 111 |
+
def _kelly_card_html(result, team, odd, k, prob, ve):
|
| 112 |
+
cls = _kelly_cls(k)
|
| 113 |
+
k_sign = "+" if k > 0 else ""
|
| 114 |
+
ve_sign = "+" if ve > 0 else ""
|
| 115 |
+
return f"""<div class="fs-kelly-card {cls}">
|
| 116 |
+
<div class="kc-result">{result}</div>
|
| 117 |
+
<div class="kc-team">{team}</div>
|
| 118 |
+
<div class="kc-odd">{odd:.2f}</div>
|
| 119 |
+
<div class="kc-sep"></div>
|
| 120 |
+
<div class="kc-pct">{k_sign}{k:.1f}%</div>
|
| 121 |
+
<div class="kc-label">Kelly recomendado</div>
|
| 122 |
+
<div class="kc-meta">{prob:.1f}% prob · VE: {ve_sign}{ve:.1f}%</div>
|
| 123 |
+
</div>"""
|
| 124 |
+
|
| 125 |
+
st.markdown(f"""<div class="fs-kelly-grid">
|
| 126 |
+
{_kelly_card_html("1 — Local", eq_l, cH, kH, pH, veH)}
|
| 127 |
+
{_kelly_card_html("X — Empate", "Empate", cD, kD, pD, veD)}
|
| 128 |
+
{_kelly_card_html("2 — Visitante", eq_v, cA, kA, pA, veA)}
|
| 129 |
+
</div>""", unsafe_allow_html=True)
|
| 130 |
+
|
| 131 |
+
# ── Recomendación ─────────────────────────────────────────────────────────
|
| 132 |
+
df_k = pd.DataFrame([
|
| 133 |
+
{"Resultado": "Local", "Prob. Estimada": f"{probs['H']:.1%}", "Prob. Implícita": f"{1/cH:.1%}",
|
| 134 |
+
"Cuota B365": f"{cH:.2f}", "Kelly (%)": round(kH, 2), "Valor Esperado": round(veH, 2)},
|
| 135 |
+
{"Resultado": "Empate", "Prob. Estimada": f"{probs['D']:.1%}", "Prob. Implícita": f"{1/cD:.1%}",
|
| 136 |
+
"Cuota B365": f"{cD:.2f}", "Kelly (%)": round(kD, 2), "Valor Esperado": round(veD, 2)},
|
| 137 |
+
{"Resultado": "Visitante", "Prob. Estimada": f"{probs['A']:.1%}", "Prob. Implícita": f"{1/cA:.1%}",
|
| 138 |
+
"Cuota B365": f"{cA:.2f}", "Kelly (%)": round(kA, 2), "Valor Esperado": round(veA, 2)},
|
| 139 |
+
])
|
| 140 |
+
mejor = df_k.loc[df_k["Kelly (%)"].idxmax()]
|
| 141 |
+
if mejor["Kelly (%)"] > 0:
|
| 142 |
+
st.markdown(f"""<div class="fs-recommend">
|
| 143 |
+
<div class="rec-icon">✅</div>
|
| 144 |
+
<div>
|
| 145 |
+
<div class="rec-title">Mejor apuesta: {mejor['Resultado']}</div>
|
| 146 |
+
<div class="rec-detail">
|
| 147 |
+
{mejor['Kelly (%)']:.2f}% del bankroll · Cuota {mejor['Cuota B365']} · VE: {mejor['Valor Esperado']:+.2f}%
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
</div>""", unsafe_allow_html=True)
|
| 151 |
+
else:
|
| 152 |
+
st.markdown("""<div class="fs-no-bet">
|
| 153 |
+
<div class="nb-icon">⛔</div>
|
| 154 |
+
<div class="nb-text">Kelly no recomienda apostar en ningún resultado.</div>
|
| 155 |
+
</div>""", unsafe_allow_html=True)
|
| 156 |
+
|
| 157 |
+
# ── Tabla detallada ───────────────────────────────────────────────────────
|
| 158 |
+
sec("📋", "Tabla Detallada")
|
| 159 |
+
|
| 160 |
+
def _color_kelly(v) -> str:
|
| 161 |
+
try:
|
| 162 |
+
n = float(v)
|
| 163 |
+
if n > KELLY_MIN_RECOMENDADO:
|
| 164 |
+
return "background-color:#0D9E6E;color:white;font-weight:bold"
|
| 165 |
+
elif n > 0:
|
| 166 |
+
return "background-color:#4CAF7D;color:white"
|
| 167 |
+
else:
|
| 168 |
+
return "background-color:#D93025;color:white"
|
| 169 |
+
except Exception:
|
| 170 |
+
return ""
|
| 171 |
+
|
| 172 |
+
def _color_ve(v) -> str:
|
| 173 |
+
try:
|
| 174 |
+
return (
|
| 175 |
+
"background-color:#0D6E4E;color:white;font-weight:bold"
|
| 176 |
+
if float(v) > 0
|
| 177 |
+
else "background-color:#B71C1C;color:white"
|
| 178 |
+
)
|
| 179 |
+
except Exception:
|
| 180 |
+
return ""
|
| 181 |
+
|
| 182 |
+
st.dataframe(
|
| 183 |
+
df_k.style
|
| 184 |
+
.map(_color_kelly, subset=["Kelly (%)"])
|
| 185 |
+
.map(_color_ve, subset=["Valor Esperado"])
|
| 186 |
+
.format({"Kelly (%)": "{:.2f}%", "Valor Esperado": "{:+.2f}%"}),
|
| 187 |
+
use_container_width=True, hide_index=True,
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
with st.expander("Ver desglose de probabilidades (40/40/20)"):
|
| 191 |
+
cp1, cp2, cp3 = st.columns(3)
|
| 192 |
+
with cp1:
|
| 193 |
+
st.markdown("**Fuente**")
|
| 194 |
+
st.markdown(f"🏠 {eq_l} en casa ({probs['nh']} p.)")
|
| 195 |
+
st.markdown(f"✈️ {eq_v} fuera ({probs['na']} p.)")
|
| 196 |
+
st.markdown(f"📊 Liga ({probs['nl']} p.)")
|
| 197 |
+
st.markdown("**🎯 Final**")
|
| 198 |
+
with cp2:
|
| 199 |
+
st.markdown("**L / E / V**")
|
| 200 |
+
st.markdown(f"{probs['ph']['H']:.1%} / {probs['ph']['D']:.1%} / {probs['ph']['A']:.1%}")
|
| 201 |
+
st.markdown(f"{probs['pa']['H']:.1%} / {probs['pa']['D']:.1%} / {probs['pa']['A']:.1%}")
|
| 202 |
+
st.markdown(f"{probs['pl']['H']:.1%} / {probs['pl']['D']:.1%} / {probs['pl']['A']:.1%}")
|
| 203 |
+
st.markdown(f"**{probs['H']:.1%} / {probs['D']:.1%} / {probs['A']:.1%}**")
|
| 204 |
+
with cp3:
|
| 205 |
+
st.markdown("**Peso**")
|
| 206 |
+
st.markdown("40%")
|
| 207 |
+
st.markdown("40%")
|
| 208 |
+
st.markdown("20%")
|
| 209 |
+
st.markdown("**100%**")
|
| 210 |
+
|
| 211 |
+
# ── Simulador de bankroll ─────────────────────────────────────────────────
|
| 212 |
+
sec("🏦", "Simulador de Bankroll")
|
| 213 |
+
bank = st.number_input("Bankroll ($):", min_value=BANK_MIN, value=BANK_DEFAULT, step=BANK_STEP)
|
| 214 |
+
sim = []
|
| 215 |
+
for _, r in df_k.iterrows():
|
| 216 |
+
k_pct = max(r["Kelly (%)"], 0)
|
| 217 |
+
apuesta = bank * k_pct / 100
|
| 218 |
+
sim.append({
|
| 219 |
+
"Resultado": r["Resultado"],
|
| 220 |
+
"Kelly %": f"{k_pct:.2f}%",
|
| 221 |
+
"Apuesta ($)": f"${apuesta:.2f}",
|
| 222 |
+
"Ganancia potencial ($)": f"${apuesta * (float(r['Cuota B365']) - 1):.2f}",
|
| 223 |
+
"Cuota": r["Cuota B365"],
|
| 224 |
+
})
|
| 225 |
+
st.dataframe(pd.DataFrame(sim), use_container_width=True, hide_index=True)
|
| 226 |
+
st.caption("⚠️ Herramienta matemática. No garantiza ganancias. Apuesta responsablemente.")
|
src/pages/estadisticas.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import pandas as pd
|
| 3 |
+
|
| 4 |
+
from src.config import KELLY_MIN_RECOMENDADO
|
| 5 |
+
from src.data_loader import ultimos_n
|
| 6 |
+
from src.statistics import mostrar_tabla_partidos
|
| 7 |
+
from src.utils import sec, med
|
| 8 |
+
|
| 9 |
+
st.session_state["_current_page"] = "estadisticas"
|
| 10 |
+
|
| 11 |
+
ctx = st.session_state.get("_ctx", {})
|
| 12 |
+
if not ctx:
|
| 13 |
+
st.warning("Selecciona un partido en la página de Inicio.")
|
| 14 |
+
st.stop()
|
| 15 |
+
|
| 16 |
+
eq_l = ctx["eq_l"]
|
| 17 |
+
eq_v = ctx["eq_v"]
|
| 18 |
+
liga = ctx["liga"]
|
| 19 |
+
df_hist = ctx["df_hist"]
|
| 20 |
+
kelly_ok = ctx["kelly_ok"]
|
| 21 |
+
probs = ctx["probs"]
|
| 22 |
+
cH, cD, cA = ctx["cH"], ctx["cD"], ctx["cA"]
|
| 23 |
+
kH, kD, kA = ctx["kH"], ctx["kD"], ctx["kA"]
|
| 24 |
+
veH, veD, veA = ctx["veH"], ctx["veD"], ctx["veA"]
|
| 25 |
+
pH, pD, pA = ctx["pH"], ctx["pD"], ctx["pA"]
|
| 26 |
+
|
| 27 |
+
# ── Filtro local de partidos (recomputa dl/dv al instante) ───────────────────
|
| 28 |
+
_dl_all = df_hist[df_hist["HomeTeam"] == eq_l]
|
| 29 |
+
_dv_all = df_hist[df_hist["AwayTeam"] == eq_v]
|
| 30 |
+
|
| 31 |
+
_n_max = min(len(_dl_all), len(_dv_all), 20)
|
| 32 |
+
_opts = [0] + list(range(3, _n_max + 1))
|
| 33 |
+
_stored = st.session_state.get("n_both")
|
| 34 |
+
_n_idx = (
|
| 35 |
+
_opts.index(_stored) if _stored in _opts else
|
| 36 |
+
(_opts.index(5) if 5 in _opts else
|
| 37 |
+
(1 if len(_opts) > 1 else 0))
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
_fc, _ = st.columns([3, 7])
|
| 41 |
+
with _fc:
|
| 42 |
+
_n = st.selectbox(
|
| 43 |
+
f"📊 Partidos a analizar · 🏠 {len(_dl_all)} · ✈️ {len(_dv_all)} disponibles",
|
| 44 |
+
_opts,
|
| 45 |
+
index=_n_idx,
|
| 46 |
+
format_func=lambda x: "Todos los disponibles" if x == 0 else f"Últimos {x}",
|
| 47 |
+
key="n_both",
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
dl = ultimos_n(_dl_all, _n)
|
| 51 |
+
dv = ultimos_n(_dv_all, _n)
|
| 52 |
+
el = ev = "Todos" if _n == 0 else f"Últimos {_n}"
|
| 53 |
+
|
| 54 |
+
l_anota = med(dl, "FTHG") if not dl.empty else 0.0
|
| 55 |
+
v_recibe = med(dv, "FTHG") if not dv.empty else 0.0
|
| 56 |
+
v_anota = med(dv, "FTAG") if not dv.empty else 0.0
|
| 57 |
+
l_recibe = med(dl, "FTAG") if not dl.empty else 0.0
|
| 58 |
+
|
| 59 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 60 |
+
# RESUMEN RÁPIDO — barra de probs + recomendación
|
| 61 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 62 |
+
if kelly_ok:
|
| 63 |
+
_inv = 1/cH + 1/cD + 1/cA
|
| 64 |
+
piH_n = 1/cH / _inv * 100
|
| 65 |
+
piD_n = 1/cD / _inv * 100
|
| 66 |
+
piA_n = 1/cA / _inv * 100
|
| 67 |
+
st.markdown(f"""<div class="fs-prob" style="margin-bottom:8px">
|
| 68 |
+
<div class="labels">
|
| 69 |
+
<span>🏠 {eq_l} <strong>{piH_n:.1f}%</strong> · Cuota {cH:.2f} <small style="opacity:.65">(hist. {pH:.1f}%)</small></span>
|
| 70 |
+
<span>Empate <strong>{piD_n:.1f}%</strong> · Cuota {cD:.2f} <small style="opacity:.65">(hist. {pD:.1f}%)</small></span>
|
| 71 |
+
<span>✈️ {eq_v} <strong>{piA_n:.1f}%</strong> · Cuota {cA:.2f} <small style="opacity:.65">(hist. {pA:.1f}%)</small></span>
|
| 72 |
+
</div>
|
| 73 |
+
<div class="bar">
|
| 74 |
+
<div class="seg h" style="width:{piH_n:.1f}%"></div>
|
| 75 |
+
<div class="seg d" style="width:{piD_n:.1f}%"></div>
|
| 76 |
+
<div class="seg a" style="width:{piA_n:.1f}%"></div>
|
| 77 |
+
</div>
|
| 78 |
+
</div>""", unsafe_allow_html=True)
|
| 79 |
+
|
| 80 |
+
_best = max(
|
| 81 |
+
[("Local", eq_l, cH, kH, veH),
|
| 82 |
+
("Empate", "Empate", cD, kD, veD),
|
| 83 |
+
("Visitante", eq_v, cA, kA, veA)],
|
| 84 |
+
key=lambda x: x[3],
|
| 85 |
+
)
|
| 86 |
+
if _best[3] > 0:
|
| 87 |
+
st.markdown(f"""<div class="fs-recommend" style="margin-bottom:16px">
|
| 88 |
+
<div class="rec-icon">✅</div>
|
| 89 |
+
<div>
|
| 90 |
+
<div class="rec-title">Mejor apuesta: {_best[0]} — {_best[1]}</div>
|
| 91 |
+
<div class="rec-detail">
|
| 92 |
+
Kelly {_best[3]:.2f}% del bankroll · Cuota {_best[2]:.2f} · VE: {_best[4]:+.2f}%
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
</div>""", unsafe_allow_html=True)
|
| 96 |
+
else:
|
| 97 |
+
st.markdown("""<div class="fs-no-bet" style="margin-bottom:16px">
|
| 98 |
+
<div class="nb-icon">⛔</div>
|
| 99 |
+
<div class="nb-text">Kelly no recomienda apostar en ningún resultado.</div>
|
| 100 |
+
</div>""", unsafe_allow_html=True)
|
| 101 |
+
|
| 102 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 103 |
+
# HELPERS
|
| 104 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 105 |
+
|
| 106 |
+
# (col_l = column in dl, col_v = column in dv, invert = True when less is better)
|
| 107 |
+
_CATS = [
|
| 108 |
+
("⚽ Goles FT", "FTHG", "FTAG", False),
|
| 109 |
+
("🕐 Goles HT", "HTHG", "HTAG", False),
|
| 110 |
+
("🛡️ Goles Recibidos", "FTAG", "FTHG", True),
|
| 111 |
+
("🎯 Tiros", "HS", "AS", False),
|
| 112 |
+
("🎯 T. a puerta", "HST", "AST", False),
|
| 113 |
+
("🚩 Córners", "HC", "AC", False),
|
| 114 |
+
("📋 Faltas", "HF", "AF", True),
|
| 115 |
+
]
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _pill_color(val: float, median: float, invert: bool) -> str:
|
| 119 |
+
diff = val - median
|
| 120 |
+
if invert:
|
| 121 |
+
diff = -diff
|
| 122 |
+
if diff > 0.5: return "#0D9E6E"
|
| 123 |
+
if diff > -0.5: return "#9BA3AE"
|
| 124 |
+
return "#D93025"
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _result_pills_html(series: pd.Series, invert: bool) -> str:
|
| 128 |
+
if series.empty:
|
| 129 |
+
return ""
|
| 130 |
+
series_median = series.median()
|
| 131 |
+
pills = "".join(
|
| 132 |
+
f'<div class="rp" style="background:{_pill_color(v, series_median, invert)}" title="{v}">'
|
| 133 |
+
f'{int(v) if float(v) == int(v) else f"{v:.1f}"}</div>'
|
| 134 |
+
for v in reversed(series.tolist())
|
| 135 |
+
)
|
| 136 |
+
return f'<div class="fs-result-pills">{pills}</div>'
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _stats_panel(df: pd.DataFrame, col: str, team: str, period: str,
|
| 140 |
+
invert: bool, is_home: bool) -> None:
|
| 141 |
+
icon = "🏠" if is_home else "✈️"
|
| 142 |
+
panel_cls = "fs-panel-home" if is_home else "fs-panel-away"
|
| 143 |
+
title_cls = "home" if is_home else "away"
|
| 144 |
+
|
| 145 |
+
st.markdown(
|
| 146 |
+
f'<div class="{panel_cls}">'
|
| 147 |
+
f'<div class="fs-panel-title {title_cls}">'
|
| 148 |
+
f'{icon} {team} — {period}</div></div>',
|
| 149 |
+
unsafe_allow_html=True,
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
if df.empty or col not in df.columns:
|
| 153 |
+
st.caption("Sin datos suficientes.")
|
| 154 |
+
return
|
| 155 |
+
|
| 156 |
+
s = pd.to_numeric(df[col], errors="coerce").dropna()
|
| 157 |
+
if s.empty:
|
| 158 |
+
st.caption("Columna no disponible en este período.")
|
| 159 |
+
return
|
| 160 |
+
|
| 161 |
+
media = round(s.mean(), 2)
|
| 162 |
+
mediana = round(s.median(), 2)
|
| 163 |
+
moda_s = s.mode()
|
| 164 |
+
moda = round(float(moda_s.iloc[0]), 2) if not moda_s.empty else "—"
|
| 165 |
+
|
| 166 |
+
_c1, _c2, _c3 = st.columns(3)
|
| 167 |
+
_c1.metric("📊 Media", media)
|
| 168 |
+
_c2.metric("📍 Mediana", mediana)
|
| 169 |
+
_c3.metric("🎯 Moda", moda)
|
| 170 |
+
|
| 171 |
+
st.caption(f"Últimos {len(s)} partidos (más reciente primero):")
|
| 172 |
+
st.markdown(_result_pills_html(s, invert), unsafe_allow_html=True)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _center_compare(df_l: pd.DataFrame, col_l: str,
|
| 176 |
+
df_v: pd.DataFrame, col_v: str,
|
| 177 |
+
invert: bool) -> None:
|
| 178 |
+
"""Compact comparison block shown between the two panels."""
|
| 179 |
+
if df_l.empty or col_l not in df_l.columns:
|
| 180 |
+
return
|
| 181 |
+
if df_v.empty or col_v not in df_v.columns:
|
| 182 |
+
return
|
| 183 |
+
|
| 184 |
+
s_l = pd.to_numeric(df_l[col_l], errors="coerce").dropna()
|
| 185 |
+
s_v = pd.to_numeric(df_v[col_v], errors="coerce").dropna()
|
| 186 |
+
if s_l.empty or s_v.empty:
|
| 187 |
+
return
|
| 188 |
+
|
| 189 |
+
med_l = round(s_l.median(), 2)
|
| 190 |
+
med_v = round(s_v.median(), 2)
|
| 191 |
+
diff = round(med_l - med_v, 2)
|
| 192 |
+
|
| 193 |
+
# Who has the advantage?
|
| 194 |
+
if diff == 0:
|
| 195 |
+
adv_label, adv_cls = "Igualados", "neu"
|
| 196 |
+
elif (diff > 0 and not invert) or (diff < 0 and invert):
|
| 197 |
+
adv_label, adv_cls = f"↑ {eq_l}", "pos"
|
| 198 |
+
else:
|
| 199 |
+
adv_label, adv_cls = f"↑ {eq_v}", "neg"
|
| 200 |
+
|
| 201 |
+
sign = "+" if diff >= 0 else ""
|
| 202 |
+
|
| 203 |
+
st.markdown(
|
| 204 |
+
f'<div class="fs-compare-cell">'
|
| 205 |
+
f'<div class="cc-lbl">Mediana</div>'
|
| 206 |
+
f'<div class="cc-val">{med_l:.2f} · {med_v:.2f}</div>'
|
| 207 |
+
f'<div class="cc-delta {adv_cls}">{sign}{diff} {adv_label}</div>'
|
| 208 |
+
f'</div>',
|
| 209 |
+
unsafe_allow_html=True,
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
# Bar comparison
|
| 213 |
+
max_v = max(med_l, med_v, 0.01)
|
| 214 |
+
pct_l = med_l / max_v * 100
|
| 215 |
+
pct_v = med_v / max_v * 100
|
| 216 |
+
st.markdown(
|
| 217 |
+
f'<div style="margin-bottom:8px">'
|
| 218 |
+
f'<div style="font-size:10px;color:var(--text-muted);margin-bottom:3px">🏠 {eq_l}</div>'
|
| 219 |
+
f'<div style="height:8px;border-radius:4px;background:var(--bg-hover);overflow:hidden">'
|
| 220 |
+
f'<div style="width:{pct_l:.0f}%;height:100%;background:#2570D4;border-radius:4px"></div></div>'
|
| 221 |
+
f'<div style="font-size:10px;color:var(--text-muted);margin:5px 0 3px">✈️ {eq_v}</div>'
|
| 222 |
+
f'<div style="height:8px;border-radius:4px;background:var(--bg-hover);overflow:hidden">'
|
| 223 |
+
f'<div style="width:{pct_v:.0f}%;height:100%;background:#D93025;border-radius:4px"></div></div>'
|
| 224 |
+
f'</div>',
|
| 225 |
+
unsafe_allow_html=True,
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
# Mean comparison
|
| 229 |
+
mean_l = round(s_l.mean(), 2)
|
| 230 |
+
mean_v = round(s_v.mean(), 2)
|
| 231 |
+
st.markdown(
|
| 232 |
+
f'<div style="font-size:11px;color:var(--text-muted)">'
|
| 233 |
+
f'Media: <strong>{mean_l}</strong> vs <strong>{mean_v}</strong></div>',
|
| 234 |
+
unsafe_allow_html=True,
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 239 |
+
# TABS DE CATEGORÍA
|
| 240 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 241 |
+
tabs = st.tabs([c[0] for c in _CATS])
|
| 242 |
+
|
| 243 |
+
for tab, (cat_label, col_l, col_v, invert) in zip(tabs, _CATS):
|
| 244 |
+
with tab:
|
| 245 |
+
_lc, _cc, _rc = st.columns([5, 3, 5], gap="medium")
|
| 246 |
+
|
| 247 |
+
with _lc:
|
| 248 |
+
_stats_panel(dl, col_l, eq_l, el, invert, is_home=True)
|
| 249 |
+
|
| 250 |
+
with _cc:
|
| 251 |
+
_center_compare(dl, col_l, dv, col_v, invert)
|
| 252 |
+
|
| 253 |
+
with _rc:
|
| 254 |
+
_stats_panel(dv, col_v, eq_v, ev, invert, is_home=False)
|
| 255 |
+
|
| 256 |
+
# ── Ataque vs Defensa — solo en Goles FT ──────────────────────────
|
| 257 |
+
if col_l == "FTHG" and not dl.empty and not dv.empty:
|
| 258 |
+
sec("⚔️", f"Ataque vs Defensa — Goles esperados en el partido")
|
| 259 |
+
|
| 260 |
+
_av1, _av2 = st.columns(2, gap="medium")
|
| 261 |
+
|
| 262 |
+
with _av1:
|
| 263 |
+
st.markdown(f"##### 🏠 {eq_l} ataca · ✈️ {eq_v} defiende")
|
| 264 |
+
diff_a = round(v_recibe - l_anota, 2)
|
| 265 |
+
_a1, _a2, _a3 = st.columns(3)
|
| 266 |
+
_a1.metric(f"⚽ {eq_l} anota", l_anota)
|
| 267 |
+
_a2.metric(f"🛡️ {eq_v} recibe", v_recibe)
|
| 268 |
+
_a3.metric(
|
| 269 |
+
"Δ Def vs Ata", f"{diff_a:+.2f}",
|
| 270 |
+
delta="Ventaja defensa" if diff_a < 0 else
|
| 271 |
+
"Ventaja ataque" if diff_a > 0 else "Equilibrio",
|
| 272 |
+
delta_color="normal",
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
with _av2:
|
| 276 |
+
st.markdown(f"##### ✈️ {eq_v} ataca · 🏠 {eq_l} defiende")
|
| 277 |
+
diff_b = round(l_recibe - v_anota, 2)
|
| 278 |
+
_b1, _b2, _b3 = st.columns(3)
|
| 279 |
+
_b1.metric(f"⚽ {eq_v} anota", v_anota)
|
| 280 |
+
_b2.metric(f"🛡️ {eq_l} recibe", l_recibe)
|
| 281 |
+
_b3.metric(
|
| 282 |
+
"Δ Def vs Ata", f"{diff_b:+.2f}",
|
| 283 |
+
delta="Ventaja defensa" if diff_b < 0 else
|
| 284 |
+
"Ventaja ataque" if diff_b > 0 else "Equilibrio",
|
| 285 |
+
delta_color="normal",
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 289 |
+
# xG — GOLES ESPERADOS
|
| 290 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 291 |
+
def _xg_s(df, col):
|
| 292 |
+
if df.empty or col not in df.columns:
|
| 293 |
+
return pd.Series(dtype=float)
|
| 294 |
+
return pd.to_numeric(df[col], errors="coerce").dropna()
|
| 295 |
+
|
| 296 |
+
_xgs_l = _xg_s(dl, "HxG") # xG anotados — local como local
|
| 297 |
+
_xgc_l = _xg_s(dl, "AxG") # xG recibidos — local como local
|
| 298 |
+
_xgs_v = _xg_s(dv, "AxG") # xG anotados — visitante como visitante
|
| 299 |
+
_xgc_v = _xg_s(dv, "HxG") # xG recibidos — visitante como visitante
|
| 300 |
+
_goals_l = _xg_s(dl, "FTHG")
|
| 301 |
+
_goals_v = _xg_s(dv, "FTAG")
|
| 302 |
+
|
| 303 |
+
if not (_xgs_l.empty and _xgs_v.empty):
|
| 304 |
+
sec("📐", "xG — Goles Esperados")
|
| 305 |
+
|
| 306 |
+
_xc1, _xc2 = st.columns(2, gap="medium")
|
| 307 |
+
|
| 308 |
+
for _col, _xgs, _xgc, _goals, _team, _is_home in [
|
| 309 |
+
(_xc1, _xgs_l, _xgc_l, _goals_l, eq_l, True),
|
| 310 |
+
(_xc2, _xgs_v, _xgc_v, _goals_v, eq_v, False),
|
| 311 |
+
]:
|
| 312 |
+
icon = "🏠" if _is_home else "✈️"
|
| 313 |
+
with _col:
|
| 314 |
+
st.markdown(f"**{icon} {_team}**")
|
| 315 |
+
if _xgs.empty:
|
| 316 |
+
st.caption("Sin datos xG.")
|
| 317 |
+
else:
|
| 318 |
+
_xg_avg = round(_xgs.mean(), 2)
|
| 319 |
+
_xgc_avg = round(_xgc.mean(), 2) if not _xgc.empty else None
|
| 320 |
+
_real = round(_goals.mean(), 2) if not _goals.empty else None
|
| 321 |
+
_m1, _m2, _m3 = st.columns(3)
|
| 322 |
+
_m1.metric("xG anotados", _xg_avg)
|
| 323 |
+
if _xgc_avg is not None:
|
| 324 |
+
_m2.metric("xGA recibidos", _xgc_avg)
|
| 325 |
+
if _real is not None:
|
| 326 |
+
_delta = round(_real - _xg_avg, 2)
|
| 327 |
+
_lbl = "sobre xG" if _delta > 0 else ("bajo xG" if _delta < 0 else "=xG")
|
| 328 |
+
_m3.metric("Goles reales", _real, delta=f"{_delta:+.2f} {_lbl}")
|
| 329 |
+
|
| 330 |
+
# Historial pill de xG por partido
|
| 331 |
+
if not _xgs.empty:
|
| 332 |
+
st.caption(f"xG por partido (más reciente primero):")
|
| 333 |
+
st.markdown(_result_pills_html(_xgs, invert=False), unsafe_allow_html=True)
|
| 334 |
+
|
| 335 |
+
# ── Predicción xG del partido ─────────────────────────────────────────
|
| 336 |
+
if not _xgs_l.empty and not _xgs_v.empty and not _xgc_l.empty and not _xgc_v.empty:
|
| 337 |
+
st.markdown("---")
|
| 338 |
+
st.markdown("##### Predicción del partido (xG)")
|
| 339 |
+
_xg_match_l = round((_xgs_l.mean() + _xgc_v.mean()) / 2, 2)
|
| 340 |
+
_xg_match_v = round((_xgs_v.mean() + _xgc_l.mean()) / 2, 2)
|
| 341 |
+
_xg_total = round(_xg_match_l + _xg_match_v, 2)
|
| 342 |
+
|
| 343 |
+
_px1, _px2, _px3, _px4, _px5 = st.columns(5)
|
| 344 |
+
_px1.metric(f"xG {eq_l[:14]}", _xg_match_l)
|
| 345 |
+
_px2.metric(f"xG {eq_v[:14]}", _xg_match_v)
|
| 346 |
+
_px3.metric("Total xG", _xg_total)
|
| 347 |
+
_px4.metric("O/U 2.5", "Sobre" if _xg_total > 2.5 else "Bajo",
|
| 348 |
+
delta=f"{_xg_total - 2.5:+.2f}")
|
| 349 |
+
_px5.metric("O/U 3.5", "Sobre" if _xg_total > 3.5 else "Bajo",
|
| 350 |
+
delta=f"{_xg_total - 3.5:+.2f}")
|
| 351 |
+
|
| 352 |
+
# Comparar con predicción de goles reales
|
| 353 |
+
_est_l_real = round((l_anota + v_recibe) / 2, 2)
|
| 354 |
+
_est_v_real = round((v_anota + l_recibe) / 2, 2)
|
| 355 |
+
st.caption(
|
| 356 |
+
f"Predicción por goles reales: {eq_l} {_est_l_real} — {eq_v} {_est_v_real} "
|
| 357 |
+
f"(total {round(_est_l_real + _est_v_real, 2)}) · "
|
| 358 |
+
f"Diferencia xG vs reales: {round(_xg_total - (_est_l_real + _est_v_real), 2):+.2f}"
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 362 |
+
# DATOS DETALLADOS
|
| 363 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 364 |
+
sec("📋", "Datos Detallados")
|
| 365 |
+
|
| 366 |
+
_dt1, _dt2 = st.columns(2, gap="medium")
|
| 367 |
+
with _dt1:
|
| 368 |
+
with st.expander(f"Partidos de {eq_l} como local ({el})"):
|
| 369 |
+
mostrar_tabla_partidos(
|
| 370 |
+
dl,
|
| 371 |
+
cols_extra=["FTHG", "FTAG", "FTR", "HxG", "AxG",
|
| 372 |
+
"HTHG", "HTAG", "HS", "HST", "HC", "HF", "HY", "HR",
|
| 373 |
+
"B365H", "B365D", "B365A"],
|
| 374 |
+
)
|
| 375 |
+
with _dt2:
|
| 376 |
+
with st.expander(f"Partidos de {eq_v} como visitante ({ev})"):
|
| 377 |
+
mostrar_tabla_partidos(
|
| 378 |
+
dv,
|
| 379 |
+
cols_extra=["FTHG", "FTAG", "FTR", "HxG", "AxG",
|
| 380 |
+
"HTAG", "HTHG", "AS", "AST", "AC", "AF", "AY", "AR",
|
| 381 |
+
"B365H", "B365D", "B365A"],
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
with st.expander("🗂️ Histórico completo de la liga"):
|
| 385 |
+
mostrar_tabla_partidos(
|
| 386 |
+
df_hist,
|
| 387 |
+
cols_extra=["FTHG", "FTAG", "FTR", "HS", "AS", "HST", "AST",
|
| 388 |
+
"HC", "AC", "HF", "AF", "HY", "AY", "HR", "AR",
|
| 389 |
+
"B365H", "B365D", "B365A"],
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 393 |
+
# DESCARGA — imagen para redes sociales
|
| 394 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 395 |
+
sec("📤", "Compartir en redes sociales")
|
| 396 |
+
|
| 397 |
+
st.caption("Genera una imagen 1080×1080 lista para Instagram, Twitter/X o WhatsApp.")
|
| 398 |
+
|
| 399 |
+
if st.button("🖼️ Generar imagen del resumen", key="btn_gen_img"):
|
| 400 |
+
import pandas as _pd
|
| 401 |
+
from src.social_image import generar_post_imagen
|
| 402 |
+
|
| 403 |
+
def _mean(df, col):
|
| 404 |
+
if df.empty or col not in df.columns:
|
| 405 |
+
return None
|
| 406 |
+
s = _pd.to_numeric(df[col], errors="coerce").dropna()
|
| 407 |
+
return round(s.mean(), 2) if not s.empty else None
|
| 408 |
+
|
| 409 |
+
def _mean0(df, col):
|
| 410 |
+
v = _mean(df, col)
|
| 411 |
+
return v if v is not None else 0.0
|
| 412 |
+
|
| 413 |
+
# xG match prediction (None si la liga no tiene datos xG)
|
| 414 |
+
_xgs_l = _mean(dl, "HxG")
|
| 415 |
+
_xgc_l = _mean(dl, "AxG")
|
| 416 |
+
_xgs_v = _mean(dv, "AxG")
|
| 417 |
+
_xgc_v = _mean(dv, "HxG")
|
| 418 |
+
if all(v is not None for v in [_xgs_l, _xgc_l, _xgs_v, _xgc_v]):
|
| 419 |
+
_xg_img_l = round((_xgs_l + _xgc_v) / 2, 2)
|
| 420 |
+
_xg_img_v = round((_xgs_v + _xgc_l) / 2, 2)
|
| 421 |
+
else:
|
| 422 |
+
_xg_img_l = _xg_img_v = None
|
| 423 |
+
|
| 424 |
+
with st.spinner("Generando imagen..."):
|
| 425 |
+
_img_bytes = generar_post_imagen(
|
| 426 |
+
eq_l=eq_l, eq_v=eq_v, liga=liga,
|
| 427 |
+
pH=pH, pD=pD, pA=pA,
|
| 428 |
+
cH=cH, cD=cD, cA=cA,
|
| 429 |
+
l_anota=_mean0(dl, "FTHG"), v_recibe=_mean0(dv, "FTHG"),
|
| 430 |
+
v_anota=_mean0(dv, "FTAG"), l_recibe=_mean0(dl, "FTAG"),
|
| 431 |
+
kelly_ok=kelly_ok,
|
| 432 |
+
periodo=el,
|
| 433 |
+
xg_l=_xg_img_l,
|
| 434 |
+
xg_v=_xg_img_v,
|
| 435 |
+
)
|
| 436 |
+
st.image(_img_bytes, use_container_width=False, width=540)
|
| 437 |
+
_fname = f"{eq_l}_vs_{eq_v}.png".replace(" ", "_")
|
| 438 |
+
st.download_button(
|
| 439 |
+
"⬇️ Descargar imagen",
|
| 440 |
+
data=_img_bytes,
|
| 441 |
+
file_name=_fname,
|
| 442 |
+
mime="image/png",
|
| 443 |
+
type="primary",
|
| 444 |
+
)
|
src/pages/inicio.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import date
|
| 2 |
+
|
| 3 |
+
import streamlit as st
|
| 4 |
+
|
| 5 |
+
from src.config import LIGA_INFO
|
| 6 |
+
|
| 7 |
+
st.session_state["_current_page"] = "inicio"
|
| 8 |
+
|
| 9 |
+
df_proximos = st.session_state.get("_df_proximos")
|
| 10 |
+
if df_proximos is None:
|
| 11 |
+
st.error("No se pudo cargar los fixtures.")
|
| 12 |
+
st.stop()
|
| 13 |
+
|
| 14 |
+
today = date.today()
|
| 15 |
+
df_up = df_proximos[df_proximos["Date"].dt.date >= today].copy()
|
| 16 |
+
|
| 17 |
+
sort_cols = ["Date", "Time_local"] if "Time_local" in df_up.columns else ["Date"]
|
| 18 |
+
df_up = df_up.sort_values(sort_cols, na_position="last").reset_index(drop=True)
|
| 19 |
+
|
| 20 |
+
_sel = st.session_state.get("_selected_match", {}) or {}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _liga_info(div: str) -> dict:
|
| 24 |
+
return LIGA_INFO.get(div, {"pais": div, "bandera": "🏟️", "liga": div})
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _is_selected(row) -> bool:
|
| 28 |
+
return (
|
| 29 |
+
bool(_sel)
|
| 30 |
+
and _sel.get("HomeTeam") == row["HomeTeam"]
|
| 31 |
+
and _sel.get("AwayTeam") == row["AwayTeam"]
|
| 32 |
+
and _sel.get("Div") == row["Div"]
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _card(row, key: str) -> None:
|
| 37 |
+
hora = row.get("Time_local", "")
|
| 38 |
+
info = _liga_info(row["Div"])
|
| 39 |
+
meta = f"{hora}" if hora else row["Date"].strftime("%d %b")
|
| 40 |
+
sel = _is_selected(row)
|
| 41 |
+
cls = "selected" if sel else ""
|
| 42 |
+
st.markdown(f"""<div class="fs-mc {cls}">
|
| 43 |
+
<div class="mc-league">{info['liga']}</div>
|
| 44 |
+
<div class="mc-home">{row['HomeTeam']}</div>
|
| 45 |
+
<div class="mc-vs">vs</div>
|
| 46 |
+
<div class="mc-away">{row['AwayTeam']}</div>
|
| 47 |
+
<div class="mc-meta">{meta}</div>
|
| 48 |
+
</div>""", unsafe_allow_html=True)
|
| 49 |
+
label = "✓ Seleccionado" if sel else "Analizar →"
|
| 50 |
+
btn_type = "primary" if sel else "secondary"
|
| 51 |
+
if st.button(label, key=key, use_container_width=True, type=btn_type):
|
| 52 |
+
st.session_state["_selected_match"] = row.to_dict()
|
| 53 |
+
pg_dest = st.session_state.get("_p_estadisticas", "pages/estadisticas.py")
|
| 54 |
+
st.switch_page(pg_dest)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ── Fechas disponibles ───────────────────────────────────────────────────────
|
| 58 |
+
_fechas_disp = sorted(df_up["Date"].dt.date.unique())
|
| 59 |
+
_fecha_map = {f.strftime("%a %d %b %Y"): f for f in _fechas_disp}
|
| 60 |
+
_fecha_opts = list(_fecha_map.keys())
|
| 61 |
+
_default_fecha = today if today in _fechas_disp else (_fechas_disp[0] if _fechas_disp else today)
|
| 62 |
+
_def_str = _default_fecha.strftime("%a %d %b %Y")
|
| 63 |
+
_def_idx = _fecha_opts.index(_def_str) if _def_str in _fecha_opts else 0
|
| 64 |
+
|
| 65 |
+
# ── Cabecera: título + búsqueda + fecha ─────────────────────────────────────
|
| 66 |
+
total_ligas = df_up["Div"].nunique() if "Div" in df_up.columns else "-"
|
| 67 |
+
total_partidos = len(df_up)
|
| 68 |
+
|
| 69 |
+
_h1, _h2, _h3 = st.columns([4, 3, 2])
|
| 70 |
+
|
| 71 |
+
with _h1:
|
| 72 |
+
st.markdown(f"""<div class="fs-header">
|
| 73 |
+
<div>
|
| 74 |
+
<h1>Partidos</h1>
|
| 75 |
+
<div class="sub">🏆 {total_ligas} ligas · ⚽ {total_partidos} disponibles</div>
|
| 76 |
+
</div>
|
| 77 |
+
</div>""", unsafe_allow_html=True)
|
| 78 |
+
|
| 79 |
+
with _h2:
|
| 80 |
+
_search = st.text_input(
|
| 81 |
+
"Buscar",
|
| 82 |
+
placeholder="🔍 Buscar equipo...",
|
| 83 |
+
key="_search",
|
| 84 |
+
label_visibility="collapsed",
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
with _h3:
|
| 88 |
+
if _fecha_opts:
|
| 89 |
+
_fecha_sel_str = st.selectbox(
|
| 90 |
+
"Fecha",
|
| 91 |
+
_fecha_opts,
|
| 92 |
+
index=_def_idx,
|
| 93 |
+
key="_fecha_filtro",
|
| 94 |
+
label_visibility="collapsed",
|
| 95 |
+
)
|
| 96 |
+
_fecha_sel = _fecha_map.get(_fecha_sel_str, _default_fecha)
|
| 97 |
+
else:
|
| 98 |
+
_fecha_sel = _default_fecha
|
| 99 |
+
|
| 100 |
+
if df_up.empty:
|
| 101 |
+
st.markdown('<div class="fs-empty"><div class="em-icon">📭</div>No hay partidos próximos disponibles.</div>', unsafe_allow_html=True)
|
| 102 |
+
st.stop()
|
| 103 |
+
|
| 104 |
+
# ── Próximos 5 (independiente del filtro de fecha) ───────────────────────────
|
| 105 |
+
_df_next5 = df_up
|
| 106 |
+
if _search:
|
| 107 |
+
_q = _search.lower().strip()
|
| 108 |
+
_df_next5 = _df_next5[
|
| 109 |
+
_df_next5["HomeTeam"].str.lower().str.contains(_q, na=False) |
|
| 110 |
+
_df_next5["AwayTeam"].str.lower().str.contains(_q, na=False)
|
| 111 |
+
]
|
| 112 |
+
|
| 113 |
+
df_next5 = _df_next5.head(5)
|
| 114 |
+
next5_keys = set(zip(df_next5["HomeTeam"], df_next5["AwayTeam"], df_next5["Div"]))
|
| 115 |
+
|
| 116 |
+
if not df_next5.empty:
|
| 117 |
+
st.markdown('<div class="fs-sec"><span>⚡</span> Próximos partidos</div>', unsafe_allow_html=True)
|
| 118 |
+
cols5 = st.columns(len(df_next5), gap="small")
|
| 119 |
+
for i, (_, row) in enumerate(df_next5.iterrows()):
|
| 120 |
+
with cols5[i]:
|
| 121 |
+
_card(row, f"n5_{i}")
|
| 122 |
+
|
| 123 |
+
# ── Partidos del día seleccionado ────────────────────────────────────────────
|
| 124 |
+
st.markdown("---")
|
| 125 |
+
_is_today = _fecha_sel == today
|
| 126 |
+
_fecha_label = "Hoy" if _is_today else _fecha_sel.strftime("%a %d de %B")
|
| 127 |
+
|
| 128 |
+
_sl, _sr = st.columns([6, 2])
|
| 129 |
+
with _sl:
|
| 130 |
+
st.markdown(
|
| 131 |
+
f'<div class="fs-sec"><span>🗓️</span> Partidos · {_fecha_label}</div>',
|
| 132 |
+
unsafe_allow_html=True,
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
df_day = df_up[df_up["Date"].dt.date == _fecha_sel].copy()
|
| 136 |
+
|
| 137 |
+
if _search:
|
| 138 |
+
_q = _search.lower().strip()
|
| 139 |
+
df_day = df_day[
|
| 140 |
+
df_day["HomeTeam"].str.lower().str.contains(_q, na=False) |
|
| 141 |
+
df_day["AwayTeam"].str.lower().str.contains(_q, na=False)
|
| 142 |
+
]
|
| 143 |
+
|
| 144 |
+
if df_day.empty:
|
| 145 |
+
if _search:
|
| 146 |
+
st.markdown(
|
| 147 |
+
f'<div class="fs-empty"><div class="em-icon">🔍</div>'
|
| 148 |
+
f'Sin resultados para "<strong>{_search}</strong>" el {_fecha_label}.</div>',
|
| 149 |
+
unsafe_allow_html=True,
|
| 150 |
+
)
|
| 151 |
+
else:
|
| 152 |
+
st.markdown(
|
| 153 |
+
f'<div class="fs-empty"><div class="em-icon">📭</div>'
|
| 154 |
+
f'No hay partidos el {_fecha_label}. Elige otra fecha.</div>',
|
| 155 |
+
unsafe_allow_html=True,
|
| 156 |
+
)
|
| 157 |
+
st.stop()
|
| 158 |
+
|
| 159 |
+
# Añadir columnas de país
|
| 160 |
+
df_day["_pais"] = df_day["Div"].apply(lambda d: _liga_info(d)["pais"])
|
| 161 |
+
df_day["_bandera"] = df_day["Div"].apply(lambda d: _liga_info(d)["bandera"])
|
| 162 |
+
df_day["_liga"] = df_day["Div"].apply(lambda d: _liga_info(d)["liga"])
|
| 163 |
+
|
| 164 |
+
sort_time = "Time_local" if "Time_local" in df_day.columns else "Date"
|
| 165 |
+
df_day = df_day.sort_values(["_pais", sort_time], na_position="last")
|
| 166 |
+
|
| 167 |
+
idx = 100
|
| 168 |
+
for pais, grupo_pais in df_day.groupby("_pais", sort=True):
|
| 169 |
+
bandera = grupo_pais["_bandera"].iloc[0]
|
| 170 |
+
ligas = " · ".join(sorted(grupo_pais["_liga"].unique()))
|
| 171 |
+
n = len(grupo_pais)
|
| 172 |
+
|
| 173 |
+
st.markdown(
|
| 174 |
+
f'<div class="fs-date-label">{bandera} {pais} · {ligas}'
|
| 175 |
+
f' · {n} partido{"s" if n != 1 else ""}</div>',
|
| 176 |
+
unsafe_allow_html=True,
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
chunks = [grupo_pais.iloc[i:i + 4] for i in range(0, len(grupo_pais), 4)]
|
| 180 |
+
for chunk in chunks:
|
| 181 |
+
row_cols = st.columns(4, gap="small")
|
| 182 |
+
for j, (_, row) in enumerate(chunk.iterrows()):
|
| 183 |
+
with row_cols[j]:
|
| 184 |
+
_card(row, f"list_{idx}")
|
| 185 |
+
idx += 1
|
src/pages/prediccion_rf.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import plotly.graph_objects as go
|
| 3 |
+
|
| 4 |
+
from src.config import (
|
| 5 |
+
COLOR_LOCAL, COLOR_VISITANTE,
|
| 6 |
+
COLOR_LOCAL_HT, COLOR_VISITANTE_HT,
|
| 7 |
+
COLOR_MEDIA,
|
| 8 |
+
RF_N_ROLLING_MIN, RF_N_ROLLING_MAX, RF_N_ROLLING_DEFAULT,
|
| 9 |
+
)
|
| 10 |
+
from src.charts import _THEME as _CHART_THEME
|
| 11 |
+
from src.models import mostrar_rf_equipo
|
| 12 |
+
from src.utils import sec, get_pred
|
| 13 |
+
|
| 14 |
+
st.session_state["_current_page"] = "prediccion_rf"
|
| 15 |
+
|
| 16 |
+
ctx = st.session_state.get("_ctx", {})
|
| 17 |
+
if not ctx:
|
| 18 |
+
st.warning("Selecciona un partido en la página de Inicio.")
|
| 19 |
+
st.stop()
|
| 20 |
+
|
| 21 |
+
eq_l = ctx["eq_l"]
|
| 22 |
+
eq_v = ctx["eq_v"]
|
| 23 |
+
rf_l = ctx["rf_l"]
|
| 24 |
+
rf_v = ctx["rf_v"]
|
| 25 |
+
|
| 26 |
+
# ── Modelos RF por equipo ─────────────────────────────────────────────────
|
| 27 |
+
col_rf1, col_rf2 = st.columns(2, gap="medium")
|
| 28 |
+
|
| 29 |
+
with col_rf1:
|
| 30 |
+
st.markdown(f'<div class="fs-panel-home"><div class="fs-panel-title home">🏠 {eq_l} — Local</div></div>', unsafe_allow_html=True)
|
| 31 |
+
if rf_l is not None and not rf_l.empty:
|
| 32 |
+
mostrar_rf_equipo(rf_l, eq_l, COLOR_LOCAL)
|
| 33 |
+
else:
|
| 34 |
+
st.markdown(f'<div class="fs-empty"><div class="em-icon">🤖</div>Datos insuficientes para {eq_l}.</div>', unsafe_allow_html=True)
|
| 35 |
+
|
| 36 |
+
with col_rf2:
|
| 37 |
+
st.markdown(f'<div class="fs-panel-away"><div class="fs-panel-title away">✈️ {eq_v} — Visitante</div></div>', unsafe_allow_html=True)
|
| 38 |
+
if rf_v is not None and not rf_v.empty:
|
| 39 |
+
mostrar_rf_equipo(rf_v, eq_v, COLOR_VISITANTE)
|
| 40 |
+
else:
|
| 41 |
+
st.markdown(f'<div class="fs-empty"><div class="em-icon">🤖</div>Datos insuficientes para {eq_v}.</div>', unsafe_allow_html=True)
|
| 42 |
+
|
| 43 |
+
# ── Estimación final ──────────────────────────────────────────────────────
|
| 44 |
+
if rf_l is None or rf_v is None:
|
| 45 |
+
st.stop()
|
| 46 |
+
|
| 47 |
+
sec("📋", "Estimación Final de Goles")
|
| 48 |
+
|
| 49 |
+
gl_anota = get_pred(rf_l, "Goles Anotados")
|
| 50 |
+
gl_recibe = get_pred(rf_l, "Goles Recibidos")
|
| 51 |
+
gv_anota = get_pred(rf_v, "Goles Anotados")
|
| 52 |
+
gv_recibe = get_pred(rf_v, "Goles Recibidos")
|
| 53 |
+
gl_anota_ht = get_pred(rf_l, "Goles Anotados (HT)")
|
| 54 |
+
gl_recibe_ht = get_pred(rf_l, "Goles Recibidos (HT)")
|
| 55 |
+
gv_anota_ht = get_pred(rf_v, "Goles Anotados (HT)")
|
| 56 |
+
gv_recibe_ht = get_pred(rf_v, "Goles Recibidos (HT)")
|
| 57 |
+
|
| 58 |
+
est_local = (gl_anota + gv_recibe) / 2
|
| 59 |
+
est_visit = (gv_anota + gl_recibe) / 2
|
| 60 |
+
est_total = est_local + est_visit
|
| 61 |
+
est_local_ht = (gl_anota_ht + gv_recibe_ht) / 2
|
| 62 |
+
est_visit_ht = (gv_anota_ht + gl_recibe_ht) / 2
|
| 63 |
+
est_total_ht = est_local_ht + est_visit_ht
|
| 64 |
+
|
| 65 |
+
ce1, ce2, ce3, ce4, ce5, ce6 = st.columns(6)
|
| 66 |
+
ce1.metric(f"🏠 {eq_l} FT", f"{est_local:.2f}")
|
| 67 |
+
ce2.metric(f"✈️ {eq_v} FT", f"{est_visit:.2f}")
|
| 68 |
+
ce3.metric("⚽ Total FT", f"{est_total:.2f}",
|
| 69 |
+
delta="Más de 2.5" if est_total > 2.5 else "Menos de 2.5",
|
| 70 |
+
delta_color="normal" if est_total > 2.5 else "inverse")
|
| 71 |
+
ce4.metric(f"🏠 {eq_l} HT", f"{est_local_ht:.2f}")
|
| 72 |
+
ce5.metric(f"✈️ {eq_v} HT", f"{est_visit_ht:.2f}")
|
| 73 |
+
ce6.metric("⚽ Total HT", f"{est_total_ht:.2f}",
|
| 74 |
+
delta="Más de 0.5" if est_total_ht > 0.5 else "Menos de 0.5",
|
| 75 |
+
delta_color="normal" if est_total_ht > 0.5 else "inverse")
|
| 76 |
+
|
| 77 |
+
fig_est = go.Figure()
|
| 78 |
+
fig_est.add_trace(go.Bar(
|
| 79 |
+
x=[f"{eq_l} FT", f"{eq_l} HT", f"{eq_v} FT", f"{eq_v} HT"],
|
| 80 |
+
y=[est_local, est_local_ht, est_visit, est_visit_ht],
|
| 81 |
+
marker_color=[COLOR_LOCAL, COLOR_LOCAL_HT, COLOR_VISITANTE, COLOR_VISITANTE_HT],
|
| 82 |
+
text=[f"{v:.2f}" for v in [est_local, est_local_ht, est_visit, est_visit_ht]],
|
| 83 |
+
textposition="outside", textfont=dict(size=14),
|
| 84 |
+
))
|
| 85 |
+
fig_est.add_hline(
|
| 86 |
+
y=est_total / 2, line_dash="dash", line_color=COLOR_MEDIA,
|
| 87 |
+
annotation_text=f"Total FT: {est_total:.2f} goles",
|
| 88 |
+
)
|
| 89 |
+
fig_est.update_layout(
|
| 90 |
+
height=360, title=dict(text="Estimación de Goles (FT y HT)", x=0.5),
|
| 91 |
+
yaxis_title="Goles",
|
| 92 |
+
yaxis=dict(range=[0, max(est_local, est_visit, 0.1) * 1.7]),
|
| 93 |
+
margin=dict(l=20, r=20, t=55, b=40), showlegend=False,
|
| 94 |
+
**_CHART_THEME,
|
| 95 |
+
)
|
| 96 |
+
st.plotly_chart(fig_est, use_container_width=True)
|
| 97 |
+
|
| 98 |
+
# ── Ajuste de ventana RF (después de mostrar resultados) ──────────────────
|
| 99 |
+
st.markdown("---")
|
| 100 |
+
_sc, _ = st.columns([3, 7])
|
| 101 |
+
with _sc:
|
| 102 |
+
st.slider(
|
| 103 |
+
"🤖 Ventana RF — partidos usados para el modelo:",
|
| 104 |
+
RF_N_ROLLING_MIN, RF_N_ROLLING_MAX,
|
| 105 |
+
st.session_state.get("nrf", RF_N_ROLLING_DEFAULT), 1,
|
| 106 |
+
key="nrf",
|
| 107 |
+
help="Cambia el número de partidos recientes usados para entrenar el modelo. El análisis se recalculará.",
|
| 108 |
+
)
|
src/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.32.0
|
| 2 |
+
pandas>=2.1.0
|
| 3 |
+
numpy>=1.26.0
|
| 4 |
+
plotly>=5.18.0
|
| 5 |
+
scikit-learn>=1.4.0
|
| 6 |
+
anthropic>=0.40.0
|
| 7 |
+
duckduckgo-search>=6.0.0
|
| 8 |
+
kaleido>=0.2.1
|
| 9 |
+
|
src/src/__init__.py
ADDED
|
File without changes
|
src/src/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (161 Bytes). View file
|
|
|
src/src/__pycache__/betting.cpython-313.pyc
ADDED
|
Binary file (2.82 kB). View file
|
|
|
src/src/__pycache__/charts.cpython-313.pyc
ADDED
|
Binary file (12.8 kB). View file
|
|
|
src/src/__pycache__/config.cpython-313.pyc
ADDED
|
Binary file (5.84 kB). View file
|
|
|
src/src/__pycache__/data_loader.cpython-313.pyc
ADDED
|
Binary file (1.84 kB). View file
|
|
|
src/src/__pycache__/models.cpython-313.pyc
ADDED
|
Binary file (8.62 kB). View file
|
|
|
src/src/__pycache__/social_image.cpython-313.pyc
ADDED
|
Binary file (11.3 kB). View file
|
|
|
src/src/__pycache__/statistics.cpython-313.pyc
ADDED
|
Binary file (7.96 kB). View file
|
|
|
src/src/__pycache__/styles.cpython-313.pyc
ADDED
|
Binary file (20.1 kB). View file
|
|
|
src/src/__pycache__/utils.cpython-313.pyc
ADDED
|
Binary file (1.71 kB). View file
|
|
|
src/src/betting.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
|
| 3 |
+
from src.config import MIN_PARTIDOS_EQUIPO
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def calc_probs(df_hist: pd.DataFrame, eq_local: str, eq_visit: str) -> dict | None:
|
| 7 |
+
"""
|
| 8 |
+
Mezcla ponderada de probabilidades:
|
| 9 |
+
40% forma local del equipo de casa
|
| 10 |
+
40% forma visitante del equipo fuera
|
| 11 |
+
20% media de liga
|
| 12 |
+
"""
|
| 13 |
+
if "FTR" not in df_hist.columns or len(df_hist) == 0:
|
| 14 |
+
return None
|
| 15 |
+
|
| 16 |
+
t = len(df_hist)
|
| 17 |
+
pl = {
|
| 18 |
+
"H": (df_hist["FTR"] == "H").sum() / t,
|
| 19 |
+
"D": (df_hist["FTR"] == "D").sum() / t,
|
| 20 |
+
"A": (df_hist["FTR"] == "A").sum() / t,
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
dh = df_hist[df_hist["HomeTeam"] == eq_local]
|
| 24 |
+
if len(dh) >= MIN_PARTIDOS_EQUIPO:
|
| 25 |
+
nh = len(dh)
|
| 26 |
+
ph = {
|
| 27 |
+
"H": (dh["FTR"] == "H").sum() / nh,
|
| 28 |
+
"D": (dh["FTR"] == "D").sum() / nh,
|
| 29 |
+
"A": (dh["FTR"] == "A").sum() / nh,
|
| 30 |
+
}
|
| 31 |
+
else:
|
| 32 |
+
ph = pl.copy()
|
| 33 |
+
|
| 34 |
+
da = df_hist[df_hist["AwayTeam"] == eq_visit]
|
| 35 |
+
if len(da) >= MIN_PARTIDOS_EQUIPO:
|
| 36 |
+
na = len(da)
|
| 37 |
+
pa = {
|
| 38 |
+
"H": (da["FTR"] == "H").sum() / na,
|
| 39 |
+
"D": (da["FTR"] == "D").sum() / na,
|
| 40 |
+
"A": (da["FTR"] == "A").sum() / na,
|
| 41 |
+
}
|
| 42 |
+
else:
|
| 43 |
+
pa = pl.copy()
|
| 44 |
+
|
| 45 |
+
r = {k: 0.4 * ph[k] + 0.4 * pa[k] + 0.2 * pl[k] for k in ["H", "D", "A"]}
|
| 46 |
+
s = sum(r.values())
|
| 47 |
+
if s > 0:
|
| 48 |
+
r = {k: v / s for k, v in r.items()}
|
| 49 |
+
|
| 50 |
+
return {
|
| 51 |
+
**r,
|
| 52 |
+
"ph": ph, "pa": pa, "pl": pl,
|
| 53 |
+
"nh": len(dh), "na": len(da), "nl": t,
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def kelly(prob: float, odds: float) -> float:
|
| 58 |
+
"""Kelly criterion: fracción óptima del bankroll a apostar."""
|
| 59 |
+
b = odds - 1
|
| 60 |
+
return (b * prob - (1 - prob)) / b * 100 if b > 0 else 0
|
src/src/charts.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import plotly.graph_objects as go
|
| 4 |
+
|
| 5 |
+
from src.config import (
|
| 6 |
+
COLOR_LOCAL,
|
| 7 |
+
COLOR_VISITANTE,
|
| 8 |
+
COLOR_NEGATIVO,
|
| 9 |
+
COLOR_EMPATE,
|
| 10 |
+
COLOR_MEDIA,
|
| 11 |
+
COLOR_LOCAL_ALPHA,
|
| 12 |
+
COLOR_VISITANTE_ALPHA,
|
| 13 |
+
KELLY_GAUGE_MAX,
|
| 14 |
+
RADAR_CATS,
|
| 15 |
+
RADAR_COLS_LOCAL,
|
| 16 |
+
RADAR_COLS_VISIT,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
_THEME = dict(
|
| 20 |
+
paper_bgcolor="rgba(0,0,0,0)",
|
| 21 |
+
plot_bgcolor="rgba(0,0,0,0)",
|
| 22 |
+
font=dict(
|
| 23 |
+
family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
| 24 |
+
color="#1A1F2E",
|
| 25 |
+
size=12,
|
| 26 |
+
),
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _agg_col(df, col: str, agg: str) -> float:
|
| 31 |
+
if col not in df.columns:
|
| 32 |
+
return np.nan
|
| 33 |
+
s = pd.to_numeric(df[col], errors="coerce").dropna()
|
| 34 |
+
if s.empty:
|
| 35 |
+
return np.nan
|
| 36 |
+
if agg == "median":
|
| 37 |
+
return s.median()
|
| 38 |
+
if agg == "mode":
|
| 39 |
+
m = s.mode()
|
| 40 |
+
return float(m.iloc[0]) if not m.empty else np.nan
|
| 41 |
+
return s.mean() # default: mean
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def grafico_radar(
|
| 45 |
+
df_local, df_visit, eq_local: str, eq_visit: str, agg: str = "mean"
|
| 46 |
+
) -> go.Figure | None:
|
| 47 |
+
titulos = {"mean": "Promedio", "median": "Mediana", "mode": "Moda"}
|
| 48 |
+
titulo = titulos.get(agg, agg.capitalize())
|
| 49 |
+
|
| 50 |
+
vl = [0 if np.isnan(v) else v for v in [_agg_col(df_local, c, agg) for c in RADAR_COLS_LOCAL]]
|
| 51 |
+
vv = [0 if np.isnan(v) else v for v in [_agg_col(df_visit, c, agg) for c in RADAR_COLS_VISIT]]
|
| 52 |
+
if all(v == 0 for v in vl + vv):
|
| 53 |
+
return None
|
| 54 |
+
mx = [max(a, b, 0.01) for a, b in zip(vl, vv)]
|
| 55 |
+
nl = [v / m * 100 for v, m in zip(vl, mx)]
|
| 56 |
+
nv = [v / m * 100 for v, m in zip(vv, mx)]
|
| 57 |
+
fig = go.Figure()
|
| 58 |
+
fig.add_trace(go.Scatterpolar(
|
| 59 |
+
r=nl + [nl[0]], theta=RADAR_CATS + [RADAR_CATS[0]], fill="toself",
|
| 60 |
+
name=f"{eq_local} (Local)", line=dict(color=COLOR_LOCAL, width=2),
|
| 61 |
+
fillcolor="rgba(30,136,229,0.15)",
|
| 62 |
+
customdata=[f"{v:.2f}" for v in vl] + [f"{vl[0]:.2f}"],
|
| 63 |
+
hovertemplate="%{theta}: %{customdata}<extra></extra>",
|
| 64 |
+
))
|
| 65 |
+
fig.add_trace(go.Scatterpolar(
|
| 66 |
+
r=nv + [nv[0]], theta=RADAR_CATS + [RADAR_CATS[0]], fill="toself",
|
| 67 |
+
name=f"{eq_visit} (Visitante)", line=dict(color=COLOR_NEGATIVO, width=2),
|
| 68 |
+
fillcolor="rgba(217,48,37,0.15)",
|
| 69 |
+
customdata=[f"{v:.2f}" for v in vv] + [f"{vv[0]:.2f}"],
|
| 70 |
+
hovertemplate="%{theta}: %{customdata}<extra></extra>",
|
| 71 |
+
))
|
| 72 |
+
fig.update_layout(
|
| 73 |
+
polar=dict(radialaxis=dict(visible=True, range=[0, 110], showticklabels=False)),
|
| 74 |
+
showlegend=True, height=380,
|
| 75 |
+
title=dict(text=titulo, x=0.5, font=dict(size=14)),
|
| 76 |
+
margin=dict(l=50, r=50, t=55, b=35),
|
| 77 |
+
legend=dict(orientation="h", yanchor="bottom", y=-0.18, xanchor="center", x=0.5),
|
| 78 |
+
**_THEME,
|
| 79 |
+
)
|
| 80 |
+
return fig
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def grafico_tendencia_goles(df, col_anot: str, col_rec: str, titulo: str) -> go.Figure | None:
|
| 85 |
+
dc = df.copy().reset_index(drop=True)
|
| 86 |
+
for c in [col_anot, col_rec]:
|
| 87 |
+
if c in dc.columns:
|
| 88 |
+
dc[c] = pd.to_numeric(dc[c], errors="coerce")
|
| 89 |
+
|
| 90 |
+
if col_anot not in dc.columns or dc.empty:
|
| 91 |
+
return None
|
| 92 |
+
dc = dc.dropna(subset=[col_anot])
|
| 93 |
+
dc["J"] = range(1, len(dc) + 1)
|
| 94 |
+
dc["PM_A"] = dc[col_anot].rolling(3, min_periods=1).mean()
|
| 95 |
+
fig = go.Figure()
|
| 96 |
+
fig.add_trace(go.Bar(x=dc["J"], y=dc[col_anot], name="Anotados", marker_color=COLOR_LOCAL_ALPHA))
|
| 97 |
+
if col_rec in dc.columns:
|
| 98 |
+
dc["PM_R"] = dc[col_rec].rolling(3, min_periods=1).mean()
|
| 99 |
+
fig.add_trace(go.Bar(x=dc["J"], y=dc[col_rec], name="Recibidos", marker_color=COLOR_VISITANTE_ALPHA))
|
| 100 |
+
fig.add_trace(go.Scatter(
|
| 101 |
+
x=dc["J"], y=dc["PM_A"], name="Prom. Anot. (3)",
|
| 102 |
+
line=dict(color="#1565C0", width=3),
|
| 103 |
+
))
|
| 104 |
+
if "PM_R" in dc.columns:
|
| 105 |
+
fig.add_trace(go.Scatter(
|
| 106 |
+
x=dc["J"], y=dc["PM_R"], name="Prom. Rec. (3)",
|
| 107 |
+
line=dict(color="#C62828", width=3, dash="dash"),
|
| 108 |
+
))
|
| 109 |
+
fig.update_layout(
|
| 110 |
+
barmode="group", height=320, title=dict(text=titulo, x=0.5),
|
| 111 |
+
xaxis_title="Jornada", yaxis_title="Goles",
|
| 112 |
+
margin=dict(l=20, r=20, t=50, b=40),
|
| 113 |
+
legend=dict(orientation="h", yanchor="bottom", y=-0.3, xanchor="center", x=0.5),
|
| 114 |
+
**_THEME,
|
| 115 |
+
)
|
| 116 |
+
return fig
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def grafico_dist_goles(df, col: str, titulo: str, color: str) -> go.Figure | None:
|
| 120 |
+
if col not in df.columns or df.empty:
|
| 121 |
+
return None
|
| 122 |
+
s = pd.to_numeric(df[col], errors="coerce").dropna()
|
| 123 |
+
if s.empty:
|
| 124 |
+
return None
|
| 125 |
+
cnt = s.value_counts().sort_index()
|
| 126 |
+
fig = go.Figure()
|
| 127 |
+
fig.add_trace(go.Bar(
|
| 128 |
+
x=cnt.index.astype(int), y=cnt.values,
|
| 129 |
+
marker_color=color, text=cnt.values, textposition="outside",
|
| 130 |
+
))
|
| 131 |
+
fig.add_vline(
|
| 132 |
+
x=s.mean(), line_dash="dash", line_color=COLOR_MEDIA, line_width=2,
|
| 133 |
+
annotation_text=f"Prom: {s.mean():.2f}", annotation_position="top right",
|
| 134 |
+
)
|
| 135 |
+
fig.update_layout(
|
| 136 |
+
height=280, title=dict(text=titulo, x=0.5),
|
| 137 |
+
xaxis_title="Goles", yaxis_title="Frecuencia",
|
| 138 |
+
xaxis=dict(dtick=1), margin=dict(l=20, r=20, t=50, b=40),
|
| 139 |
+
**_THEME,
|
| 140 |
+
)
|
| 141 |
+
return fig
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def grafico_pred_equipo(df_rf, equipo: str, color: str) -> go.Figure | None:
|
| 145 |
+
if df_rf is None or df_rf.empty:
|
| 146 |
+
return None
|
| 147 |
+
fig = go.Figure()
|
| 148 |
+
fig.add_trace(go.Bar(
|
| 149 |
+
x=df_rf["Estadística"], y=df_rf["Predicción RF"], name="Predicción RF",
|
| 150 |
+
marker_color=color,
|
| 151 |
+
text=[f"{v:.2f}" for v in df_rf["Predicción RF"]], textposition="outside",
|
| 152 |
+
))
|
| 153 |
+
fig.add_trace(go.Bar(
|
| 154 |
+
x=df_rf["Estadística"], y=df_rf["Promedio Histórico"], name="Promedio",
|
| 155 |
+
marker_color=COLOR_EMPATE,
|
| 156 |
+
text=[f"{v:.2f}" for v in df_rf["Promedio Histórico"]], textposition="outside",
|
| 157 |
+
))
|
| 158 |
+
my = max(df_rf["Predicción RF"].max(), df_rf["Promedio Histórico"].max()) * 1.4
|
| 159 |
+
fig.update_layout(
|
| 160 |
+
barmode="group", height=400, title=dict(text=f"Predicción RF — {equipo}", x=0.5),
|
| 161 |
+
xaxis_tickangle=-35, yaxis=dict(range=[0, my]),
|
| 162 |
+
margin=dict(l=20, r=20, t=60, b=80),
|
| 163 |
+
legend=dict(orientation="h", yanchor="bottom", y=-0.35, xanchor="center", x=0.5),
|
| 164 |
+
**_THEME,
|
| 165 |
+
)
|
| 166 |
+
return fig
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def grafico_gauge_kelly(val: float, titulo: str, color: str) -> go.Figure:
|
| 170 |
+
v = max(0, min(val, KELLY_GAUGE_MAX))
|
| 171 |
+
fig = go.Figure(go.Indicator(
|
| 172 |
+
mode="gauge+number", value=v,
|
| 173 |
+
number={"suffix": "%", "font": {"size": 36}},
|
| 174 |
+
title={"text": titulo, "font": {"size": 14}},
|
| 175 |
+
gauge={
|
| 176 |
+
"axis": {"range": [0, KELLY_GAUGE_MAX]},
|
| 177 |
+
"bar": {"color": color},
|
| 178 |
+
"bgcolor": "white",
|
| 179 |
+
"borderwidth": 1,
|
| 180 |
+
"steps": [
|
| 181 |
+
{"range": [0, 5], "color": "#E8F5E9"},
|
| 182 |
+
{"range": [5, 15], "color": "#C8E6C9"},
|
| 183 |
+
{"range": [15, 30], "color": "#FFF9C4"},
|
| 184 |
+
{"range": [30, 50], "color": "#FFCDD2"},
|
| 185 |
+
],
|
| 186 |
+
"threshold": {"line": {"color": "red", "width": 3}, "thickness": 0.8, "value": 25},
|
| 187 |
+
},
|
| 188 |
+
))
|
| 189 |
+
fig.update_layout(height=250, margin=dict(l=20, r=20, t=50, b=20), **_THEME)
|
| 190 |
+
return fig
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def grafico_kelly_barras(df_kelly) -> go.Figure:
|
| 194 |
+
cols = {"Local": COLOR_LOCAL, "Empate": COLOR_EMPATE, "Visitante": COLOR_VISITANTE}
|
| 195 |
+
fig = go.Figure()
|
| 196 |
+
for _, r in df_kelly.iterrows():
|
| 197 |
+
k = max(r["Kelly (%)"], 0)
|
| 198 |
+
fig.add_trace(go.Bar(
|
| 199 |
+
y=[r["Resultado"]], x=[k], orientation="h",
|
| 200 |
+
marker_color=cols.get(r["Resultado"], "#666"),
|
| 201 |
+
text=f"{k:.2f}%", textposition="outside", showlegend=False,
|
| 202 |
+
))
|
| 203 |
+
fig.update_layout(
|
| 204 |
+
height=200, title=dict(text="Kelly Criterion — % Óptimo", x=0.5),
|
| 205 |
+
xaxis_title="Kelly %",
|
| 206 |
+
xaxis=dict(range=[0, max(df_kelly["Kelly (%)"].max() * 1.5, 10)]),
|
| 207 |
+
margin=dict(l=20, r=20, t=50, b=30),
|
| 208 |
+
**_THEME,
|
| 209 |
+
)
|
| 210 |
+
return fig
|
src/src/config.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- URLs de datos ---
|
| 2 |
+
URL_FIXTURES = "https://www.football-data.co.uk/fixtures.csv"
|
| 3 |
+
URL_HISTORICO_BASE = "https://www.football-data.co.uk/mmz4281/2526/{liga}.csv"
|
| 4 |
+
ARCHIVO_DATASET = "dataset_partidos_procesados.csv"
|
| 5 |
+
|
| 6 |
+
# --- Configuración de la página ---
|
| 7 |
+
PAGE_TITLE = "Análisis de Ligas"
|
| 8 |
+
PAGE_LAYOUT = "wide"
|
| 9 |
+
|
| 10 |
+
# --- Colores ---
|
| 11 |
+
COLOR_LOCAL = "#2570D4"
|
| 12 |
+
COLOR_VISITANTE = "#7C4DCC"
|
| 13 |
+
COLOR_EMPATE = "#9BA3AE"
|
| 14 |
+
COLOR_POSITIVO = "#0D9E6E"
|
| 15 |
+
COLOR_NEGATIVO = "#D93025"
|
| 16 |
+
COLOR_NEUTRO = "#5A6270"
|
| 17 |
+
COLOR_MEDIA = "#B07D0E"
|
| 18 |
+
COLOR_ADVERTENCIA = "#f9a825"
|
| 19 |
+
COLOR_LOCAL_ALPHA = "rgba(37,112,212,0.3)"
|
| 20 |
+
COLOR_VISITANTE_ALPHA = "rgba(124,77,204,0.3)"
|
| 21 |
+
COLOR_LOCAL_HT = "#7DAAED"
|
| 22 |
+
COLOR_VISITANTE_HT = "#B599E0"
|
| 23 |
+
|
| 24 |
+
# --- Hiperparámetros del modelo Random Forest ---
|
| 25 |
+
RF_N_ESTIMATORS = 100
|
| 26 |
+
RF_MAX_DEPTH = 6
|
| 27 |
+
RF_MIN_SAMPLES_SPLIT = 4
|
| 28 |
+
RF_RANDOM_STATE = 42
|
| 29 |
+
RF_N_ROLLING_DEFAULT = 10
|
| 30 |
+
RF_N_ROLLING_MIN = 3
|
| 31 |
+
RF_N_ROLLING_MAX = 15
|
| 32 |
+
|
| 33 |
+
# --- Umbrales ---
|
| 34 |
+
CV_UMBRAL_ESTABLE = 10.0 # CV < 10% → dato estable
|
| 35 |
+
R2_UMBRAL_BUENO = 0.5 # R² ≥ 0.5 → buen ajuste
|
| 36 |
+
R2_UMBRAL_ACEPTABLE = 0.3 # R² ≥ 0.3 → ajuste aceptable
|
| 37 |
+
KELLY_MIN_RECOMENDADO = 5.0 # Kelly > 5% → apuesta recomendada
|
| 38 |
+
KELLY_GAUGE_MAX = 50 # Rango máximo del gauge Kelly
|
| 39 |
+
|
| 40 |
+
# --- Bankroll ---
|
| 41 |
+
BANK_MIN = 100
|
| 42 |
+
BANK_DEFAULT = 1_000
|
| 43 |
+
BANK_STEP = 100
|
| 44 |
+
|
| 45 |
+
# --- Tipos de apuesta ---
|
| 46 |
+
TIPOS_APUESTA = ["⚽ Goles", "🎯 Tiros", "🎯 T. a puerta", "🚩 Córners", "📋 Otros"]
|
| 47 |
+
|
| 48 |
+
COLS_APUESTA = {
|
| 49 |
+
"⚽ Goles": {"local": ["FTHG", "HTHG"], "visit": ["FTAG", "HTAG"],
|
| 50 |
+
"label": "Goles anotados por partido"},
|
| 51 |
+
"🎯 Tiros": {"local": ["HS"], "visit": ["AS"],
|
| 52 |
+
"label": "Tiros totales por partido"},
|
| 53 |
+
"🎯 T. a puerta": {"local": ["HST"], "visit": ["AST"],
|
| 54 |
+
"label": "Tiros a puerta por partido"},
|
| 55 |
+
"🚩 Córners": {"local": ["HC"], "visit": ["AC"],
|
| 56 |
+
"label": "Córners por partido"},
|
| 57 |
+
"📋 Otros": {"local": ["HF", "HY", "HR"], "visit": ["AF", "AY", "AR"],
|
| 58 |
+
"label": "Faltas, amarillas y rojas por partido"},
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
# --- Información de ligas (código → país, bandera, nombre) ---
|
| 62 |
+
LIGA_INFO = {
|
| 63 |
+
"E0": {"pais": "Inglaterra", "bandera": "🏴", "liga": "Premier League"},
|
| 64 |
+
"E1": {"pais": "Inglaterra", "bandera": "🏴", "liga": "Championship"},
|
| 65 |
+
"E2": {"pais": "Inglaterra", "bandera": "🏴", "liga": "League One"},
|
| 66 |
+
"E3": {"pais": "Inglaterra", "bandera": "🏴", "liga": "League Two"},
|
| 67 |
+
"EC": {"pais": "Inglaterra", "bandera": "🏴", "liga": "Conference"},
|
| 68 |
+
"SC0": {"pais": "Escocia", "bandera": "🏴", "liga": "Premiership"},
|
| 69 |
+
"SC1": {"pais": "Escocia", "bandera": "🏴", "liga": "Championship"},
|
| 70 |
+
"SC2": {"pais": "Escocia", "bandera": "🏴", "liga": "League One"},
|
| 71 |
+
"SC3": {"pais": "Escocia", "bandera": "🏴", "liga": "League Two"},
|
| 72 |
+
"D1": {"pais": "Alemania", "bandera": "🇩🇪", "liga": "Bundesliga"},
|
| 73 |
+
"D2": {"pais": "Alemania", "bandera": "🇩🇪", "liga": "2. Bundesliga"},
|
| 74 |
+
"I1": {"pais": "Italia", "bandera": "🇮🇹", "liga": "Serie A"},
|
| 75 |
+
"I2": {"pais": "Italia", "bandera": "🇮🇹", "liga": "Serie B"},
|
| 76 |
+
"SP1": {"pais": "España", "bandera": "🇪🇸", "liga": "La Liga"},
|
| 77 |
+
"SP2": {"pais": "España", "bandera": "🇪🇸", "liga": "Segunda División"},
|
| 78 |
+
"F1": {"pais": "Francia", "bandera": "🇫🇷", "liga": "Ligue 1"},
|
| 79 |
+
"F2": {"pais": "Francia", "bandera": "🇫🇷", "liga": "Ligue 2"},
|
| 80 |
+
"N1": {"pais": "Países Bajos", "bandera": "🇳🇱", "liga": "Eredivisie"},
|
| 81 |
+
"B1": {"pais": "Bélgica", "bandera": "🇧🇪", "liga": "First Division A"},
|
| 82 |
+
"P1": {"pais": "Portugal", "bandera": "🇵🇹", "liga": "Primeira Liga"},
|
| 83 |
+
"T1": {"pais": "Turquía", "bandera": "🇹🇷", "liga": "Süper Lig"},
|
| 84 |
+
"G1": {"pais": "Grecia", "bandera": "🇬🇷", "liga": "Super League"},
|
| 85 |
+
"ARG": {"pais": "Argentina", "bandera": "🇦🇷", "liga": "Primera División"},
|
| 86 |
+
"BRA": {"pais": "Brasil", "bandera": "🇧🇷", "liga": "Série A"},
|
| 87 |
+
"MEX": {"pais": "México", "bandera": "🇲🇽", "liga": "Liga MX"},
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
# --- Mínimo de partidos para análisis ---
|
| 91 |
+
MIN_PARTIDOS_EQUIPO = 3
|
| 92 |
+
MIN_PARTIDOS_RF = 10
|
| 93 |
+
MIN_PARTIDOS_RF_TARGET = 5
|
| 94 |
+
|
| 95 |
+
# --- Mapeos de columnas ---
|
| 96 |
+
COLS_LOCAL = {
|
| 97 |
+
"FTHG": "Goles (FT)",
|
| 98 |
+
"HTHG": "Goles (HT)",
|
| 99 |
+
"HS": "Tiros",
|
| 100 |
+
"HST": "Tiros a Puerta",
|
| 101 |
+
"HC": "Córners",
|
| 102 |
+
"HF": "Faltas",
|
| 103 |
+
"HY": "Tarjetas Amarillas",
|
| 104 |
+
"HR": "Tarjetas Rojas",
|
| 105 |
+
}
|
| 106 |
+
COLS_VISITANTE = {
|
| 107 |
+
"FTAG": "Goles (FT)",
|
| 108 |
+
"HTAG": "Goles (HT)",
|
| 109 |
+
"AS": "Tiros",
|
| 110 |
+
"AST": "Tiros a Puerta",
|
| 111 |
+
"AC": "Córners",
|
| 112 |
+
"AF": "Faltas",
|
| 113 |
+
"AY": "Tarjetas Amarillas",
|
| 114 |
+
"AR": "Tarjetas Rojas",
|
| 115 |
+
}
|
| 116 |
+
COLS_CUOTAS = {
|
| 117 |
+
"B365H": "Bet365 Local",
|
| 118 |
+
"B365D": "Bet365 Empate",
|
| 119 |
+
"B365A": "Bet365 Visitante",
|
| 120 |
+
}
|
| 121 |
+
RENAME_PARTIDOS = {
|
| 122 |
+
"Date": "Fecha",
|
| 123 |
+
"Div": "Liga",
|
| 124 |
+
"HomeTeam": "Local",
|
| 125 |
+
"AwayTeam": "Visitante",
|
| 126 |
+
"FTHG": "Goles Local (FT)",
|
| 127 |
+
"FTAG": "Goles Visitante (FT)",
|
| 128 |
+
"FTR": "Resultado",
|
| 129 |
+
"HTHG": "Goles Local (HT)",
|
| 130 |
+
"HTAG": "Goles Visitante (HT)",
|
| 131 |
+
"HS": "Tiros Local",
|
| 132 |
+
"AS": "Tiros Visitante",
|
| 133 |
+
"HST": "Tiros a Puerta Local",
|
| 134 |
+
"AST": "Tiros a Puerta Visitante",
|
| 135 |
+
"HC": "Córners Local",
|
| 136 |
+
"AC": "Córners Visitante",
|
| 137 |
+
"HF": "Faltas Local",
|
| 138 |
+
"AF": "Faltas Visitante",
|
| 139 |
+
"HY": "Amarillas Local",
|
| 140 |
+
"AY": "Amarillas Visitante",
|
| 141 |
+
"HR": "Rojas Local",
|
| 142 |
+
"AR": "Rojas Visitante",
|
| 143 |
+
"B365H": "Bet365 Local",
|
| 144 |
+
"B365D": "Bet365 Empate",
|
| 145 |
+
"B365A": "Bet365 Visitante",
|
| 146 |
+
"Referee": "Árbitro",
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
# --- Pares de comparación (nombre_display, col_ataque, col_defensa) ---
|
| 150 |
+
PARES_ATAQUE_LOCAL = [
|
| 151 |
+
("Goles (FT)", "FTHG", "FTHG"),
|
| 152 |
+
("Goles (HT)", "HTHG", "HTHG"),
|
| 153 |
+
("Tiros", "HS", "HS"),
|
| 154 |
+
("Tiros a Puerta", "HST", "HST"),
|
| 155 |
+
("Córners", "HC", "HC"),
|
| 156 |
+
("Faltas", "HF", "HF"),
|
| 157 |
+
]
|
| 158 |
+
PARES_ATAQUE_VISITANTE = [
|
| 159 |
+
("Goles (FT)", "FTAG", "FTAG"),
|
| 160 |
+
("Goles (HT)", "HTAG", "HTAG"),
|
| 161 |
+
("Tiros", "AS", "AS"),
|
| 162 |
+
("Tiros a Puerta", "AST", "AST"),
|
| 163 |
+
("Córners", "AC", "AC"),
|
| 164 |
+
("Faltas", "AF", "AF"),
|
| 165 |
+
]
|
| 166 |
+
|
| 167 |
+
# --- Features y targets del modelo ---
|
| 168 |
+
FEATURE_COLS = [
|
| 169 |
+
"FTHG", "FTAG", "HTHG", "HTAG",
|
| 170 |
+
"HS", "AS", "HST", "AST",
|
| 171 |
+
"HC", "AC", "HF", "AF",
|
| 172 |
+
"HY", "AY", "HR", "AR",
|
| 173 |
+
]
|
| 174 |
+
RF_TARGETS_LOCAL = {
|
| 175 |
+
"FTHG": "Goles Anotados",
|
| 176 |
+
"FTAG": "Goles Recibidos",
|
| 177 |
+
"HTHG": "Goles Anotados (HT)",
|
| 178 |
+
"HTAG": "Goles Recibidos (HT)",
|
| 179 |
+
"HS": "Tiros",
|
| 180 |
+
"HST": "Tiros a Puerta",
|
| 181 |
+
"HC": "Córners",
|
| 182 |
+
"HF": "Faltas",
|
| 183 |
+
"HY": "Amarillas",
|
| 184 |
+
"HR": "Rojas",
|
| 185 |
+
}
|
| 186 |
+
RF_TARGETS_VISIT = {
|
| 187 |
+
"FTAG": "Goles Anotados",
|
| 188 |
+
"FTHG": "Goles Recibidos",
|
| 189 |
+
"HTAG": "Goles Anotados (HT)",
|
| 190 |
+
"HTHG": "Goles Recibidos (HT)",
|
| 191 |
+
"AS": "Tiros",
|
| 192 |
+
"AST": "Tiros a Puerta",
|
| 193 |
+
"AC": "Córners",
|
| 194 |
+
"AF": "Faltas",
|
| 195 |
+
"AY": "Amarillas",
|
| 196 |
+
"AR": "Rojas",
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
# --- Categorías para el radar ---
|
| 200 |
+
RADAR_CATS = ["Goles", "Tiros", "Tiros a Puerta", "Córners", "Faltas", "Amarillas"]
|
| 201 |
+
RADAR_COLS_LOCAL = ["FTHG", "HS", "HST", "HC", "HF", "HY"]
|
| 202 |
+
RADAR_COLS_VISIT = ["FTAG", "AS", "AST", "AC", "AF", "AY"]
|
src/src/data_loader.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import pandas as pd
|
| 3 |
+
|
| 4 |
+
from src.config import URL_HISTORICO_BASE, ARCHIVO_DATASET
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@st.cache_data
|
| 8 |
+
def cargar_fixtures(url: str) -> pd.DataFrame | None:
|
| 9 |
+
try:
|
| 10 |
+
df = pd.read_csv(url)
|
| 11 |
+
df.to_csv(ARCHIVO_DATASET, index=False)
|
| 12 |
+
return df
|
| 13 |
+
except Exception as e:
|
| 14 |
+
st.error(f"Error al cargar fixtures: {e}")
|
| 15 |
+
return None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@st.cache_data
|
| 19 |
+
def cargar_historico(liga: str) -> pd.DataFrame | None:
|
| 20 |
+
try:
|
| 21 |
+
return pd.read_csv(URL_HISTORICO_BASE.format(liga=liga))
|
| 22 |
+
except Exception as e:
|
| 23 |
+
st.warning(f"No se pudieron cargar datos históricos para '{liga}': {e}")
|
| 24 |
+
return None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def ultimos_n(df: pd.DataFrame, n: int) -> pd.DataFrame:
|
| 28 |
+
return df if n == 0 else df.tail(n)
|
src/src/models.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import numpy as np
|
| 4 |
+
from sklearn.ensemble import RandomForestRegressor
|
| 5 |
+
|
| 6 |
+
from src.config import (
|
| 7 |
+
FEATURE_COLS,
|
| 8 |
+
RF_N_ESTIMATORS,
|
| 9 |
+
RF_MAX_DEPTH,
|
| 10 |
+
RF_MIN_SAMPLES_SPLIT,
|
| 11 |
+
RF_RANDOM_STATE,
|
| 12 |
+
MIN_PARTIDOS_RF,
|
| 13 |
+
MIN_PARTIDOS_RF_TARGET,
|
| 14 |
+
COLOR_POSITIVO,
|
| 15 |
+
COLOR_NEGATIVO,
|
| 16 |
+
COLOR_ADVERTENCIA,
|
| 17 |
+
)
|
| 18 |
+
from src.charts import grafico_pred_equipo
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def preparar_dataset_rf(df_hist: pd.DataFrame, n_rolling: int = 5) -> pd.DataFrame:
|
| 22 |
+
df = df_hist.copy().reset_index(drop=True)
|
| 23 |
+
for c in FEATURE_COLS:
|
| 24 |
+
if c in df.columns:
|
| 25 |
+
df[c] = pd.to_numeric(df[c], errors="coerce")
|
| 26 |
+
filas = []
|
| 27 |
+
for idx in range(len(df)):
|
| 28 |
+
row = df.iloc[idx]
|
| 29 |
+
prev = df.iloc[:idx]
|
| 30 |
+
if len(prev) < n_rolling:
|
| 31 |
+
continue
|
| 32 |
+
feat: dict = {}
|
| 33 |
+
for c in FEATURE_COLS:
|
| 34 |
+
if c in df.columns:
|
| 35 |
+
dh = prev[prev["HomeTeam"] == row["HomeTeam"]].tail(n_rolling)
|
| 36 |
+
feat[f"H_{c}_avg"] = dh[c].mean() if not dh.empty else 0
|
| 37 |
+
da = prev[prev["AwayTeam"] == row["AwayTeam"]].tail(n_rolling)
|
| 38 |
+
feat[f"A_{c}_avg"] = da[c].mean() if not da.empty else 0
|
| 39 |
+
feat["H_n"] = len(
|
| 40 |
+
pd.concat([
|
| 41 |
+
prev[prev["HomeTeam"] == row["HomeTeam"]],
|
| 42 |
+
prev[prev["AwayTeam"] == row["HomeTeam"]],
|
| 43 |
+
]).tail(n_rolling)
|
| 44 |
+
)
|
| 45 |
+
feat["A_n"] = len(
|
| 46 |
+
pd.concat([
|
| 47 |
+
prev[prev["HomeTeam"] == row["AwayTeam"]],
|
| 48 |
+
prev[prev["AwayTeam"] == row["AwayTeam"]],
|
| 49 |
+
]).tail(n_rolling)
|
| 50 |
+
)
|
| 51 |
+
for c in FEATURE_COLS:
|
| 52 |
+
if c in df.columns:
|
| 53 |
+
feat[f"target_{c}"] = row[c]
|
| 54 |
+
filas.append(feat)
|
| 55 |
+
return pd.DataFrame(filas)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@st.cache_data
|
| 59 |
+
def entrenar_rf_equipo(
|
| 60 |
+
df_hist: pd.DataFrame,
|
| 61 |
+
eq_local: str,
|
| 62 |
+
eq_visit: str,
|
| 63 |
+
n_rolling: int,
|
| 64 |
+
targets_dict: dict,
|
| 65 |
+
) -> pd.DataFrame | None:
|
| 66 |
+
df_rf = preparar_dataset_rf(df_hist, n_rolling)
|
| 67 |
+
if df_rf.empty or len(df_rf) < MIN_PARTIDOS_RF:
|
| 68 |
+
return None
|
| 69 |
+
|
| 70 |
+
fnames = [c for c in df_rf.columns if c.startswith("H_") or c.startswith("A_")]
|
| 71 |
+
df_rf[fnames] = df_rf[fnames].fillna(0)
|
| 72 |
+
|
| 73 |
+
# Punto de predicción: características del próximo partido
|
| 74 |
+
fp: dict = {}
|
| 75 |
+
for c in FEATURE_COLS:
|
| 76 |
+
if c in df_hist.columns:
|
| 77 |
+
dh = df_hist[df_hist["HomeTeam"] == eq_local].tail(n_rolling)
|
| 78 |
+
fp[f"H_{c}_avg"] = pd.to_numeric(dh[c], errors="coerce").mean() if not dh.empty else 0
|
| 79 |
+
da = df_hist[df_hist["AwayTeam"] == eq_visit].tail(n_rolling)
|
| 80 |
+
fp[f"A_{c}_avg"] = pd.to_numeric(da[c], errors="coerce").mean() if not da.empty else 0
|
| 81 |
+
fp["H_n"] = len(
|
| 82 |
+
pd.concat([
|
| 83 |
+
df_hist[df_hist["HomeTeam"] == eq_local],
|
| 84 |
+
df_hist[df_hist["AwayTeam"] == eq_local],
|
| 85 |
+
]).tail(n_rolling)
|
| 86 |
+
)
|
| 87 |
+
fp["A_n"] = len(
|
| 88 |
+
pd.concat([
|
| 89 |
+
df_hist[df_hist["HomeTeam"] == eq_visit],
|
| 90 |
+
df_hist[df_hist["AwayTeam"] == eq_visit],
|
| 91 |
+
]).tail(n_rolling)
|
| 92 |
+
)
|
| 93 |
+
Xp = pd.DataFrame([fp])[fnames].fillna(0)
|
| 94 |
+
|
| 95 |
+
res = []
|
| 96 |
+
for col_real, nombre in targets_dict.items():
|
| 97 |
+
tc = f"target_{col_real}"
|
| 98 |
+
if tc not in df_rf.columns:
|
| 99 |
+
continue
|
| 100 |
+
y = df_rf[tc].dropna()
|
| 101 |
+
X = df_rf.loc[y.index, fnames]
|
| 102 |
+
if len(y) < MIN_PARTIDOS_RF_TARGET:
|
| 103 |
+
continue
|
| 104 |
+
try:
|
| 105 |
+
rf = RandomForestRegressor(
|
| 106 |
+
n_estimators=RF_N_ESTIMATORS,
|
| 107 |
+
max_depth=RF_MAX_DEPTH,
|
| 108 |
+
min_samples_split=RF_MIN_SAMPLES_SPLIT,
|
| 109 |
+
random_state=RF_RANDOM_STATE,
|
| 110 |
+
n_jobs=-1,
|
| 111 |
+
)
|
| 112 |
+
rf.fit(X, y)
|
| 113 |
+
pred = rf.predict(Xp)[0]
|
| 114 |
+
res.append({
|
| 115 |
+
"Estadística": nombre,
|
| 116 |
+
"Predicción RF": round(pred, 2),
|
| 117 |
+
"Promedio Histórico": round(y.mean(), 2),
|
| 118 |
+
"Diferencia": round(pred - y.mean(), 2),
|
| 119 |
+
"R² (ajuste)": round(rf.score(X, y), 3),
|
| 120 |
+
})
|
| 121 |
+
except Exception:
|
| 122 |
+
continue
|
| 123 |
+
return pd.DataFrame(res) if res else None
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def mostrar_rf_equipo(df_rf: pd.DataFrame, equipo: str, color: str) -> None:
|
| 127 |
+
fig = grafico_pred_equipo(df_rf, equipo, color)
|
| 128 |
+
if fig:
|
| 129 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 130 |
+
|
| 131 |
+
def _color_r2(val) -> str:
|
| 132 |
+
try:
|
| 133 |
+
n = float(val)
|
| 134 |
+
if n >= 0.5:
|
| 135 |
+
return f"background-color: {COLOR_POSITIVO}; color: white; font-weight: bold"
|
| 136 |
+
elif n >= 0.3:
|
| 137 |
+
return f"background-color: {COLOR_ADVERTENCIA}; color: black; font-weight: bold"
|
| 138 |
+
else:
|
| 139 |
+
return f"background-color: {COLOR_NEGATIVO}; color: white; font-weight: bold"
|
| 140 |
+
except Exception:
|
| 141 |
+
return ""
|
| 142 |
+
|
| 143 |
+
def _color_diff(val) -> str:
|
| 144 |
+
try:
|
| 145 |
+
n = float(val)
|
| 146 |
+
if n > 0:
|
| 147 |
+
return f"color: {COLOR_POSITIVO}; font-weight: bold"
|
| 148 |
+
elif n < 0:
|
| 149 |
+
return f"color: {COLOR_NEGATIVO}; font-weight: bold"
|
| 150 |
+
except Exception:
|
| 151 |
+
pass
|
| 152 |
+
return ""
|
| 153 |
+
|
| 154 |
+
styled = df_rf.style.map(_color_r2, subset=["R² (ajuste)"]).map(
|
| 155 |
+
_color_diff, subset=["Diferencia"]
|
| 156 |
+
).format({
|
| 157 |
+
"Predicción RF": "{:.2f}",
|
| 158 |
+
"Promedio Histórico": "{:.2f}",
|
| 159 |
+
"Diferencia": "{:+.2f}",
|
| 160 |
+
"R² (ajuste)": "{:.3f}",
|
| 161 |
+
})
|
| 162 |
+
st.dataframe(styled, use_container_width=True, hide_index=True)
|
| 163 |
+
|
| 164 |
+
cols_m = st.columns(min(len(df_rf), 5))
|
| 165 |
+
for i, (_, row) in enumerate(df_rf.iterrows()):
|
| 166 |
+
if i >= len(cols_m):
|
| 167 |
+
break
|
| 168 |
+
with cols_m[i]:
|
| 169 |
+
st.metric(row["Estadística"], f"{row['Predicción RF']:.1f}", f"{row['Diferencia']:+.2f}")
|
src/src/social_image.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Genera una imagen 1080×1080 minimalista con colores claros
|
| 3 |
+
para compartir en redes sociales.
|
| 4 |
+
"""
|
| 5 |
+
import plotly.graph_objects as go
|
| 6 |
+
|
| 7 |
+
from src.config import LIGA_INFO
|
| 8 |
+
|
| 9 |
+
# ── Paleta clara minimalista ─────────────────────────────────────────────────
|
| 10 |
+
_BG = "#FFFFFF"
|
| 11 |
+
_BG2 = "#F6F8FA"
|
| 12 |
+
_BORDER = "#D0D7DE"
|
| 13 |
+
_BLUE = "#1A6FD4"
|
| 14 |
+
_RED = "#CF2A1E"
|
| 15 |
+
_DRAW = "#8B98A8"
|
| 16 |
+
_TEXT = "#1C2128"
|
| 17 |
+
_MUTED = "#6E7781"
|
| 18 |
+
_GREEN = "#1A7F4B"
|
| 19 |
+
_ORANGE = "#9A5900"
|
| 20 |
+
_W = 1080
|
| 21 |
+
_H = 1080
|
| 22 |
+
|
| 23 |
+
_FONT = "Inter,'Helvetica Neue',Arial,sans-serif"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _r(type_="rect", x0=0, y0=0, x1=1, y1=1,
|
| 27 |
+
fill="#FFFFFF", border="#D0D7DE", bw=1, layer="below") -> dict:
|
| 28 |
+
return dict(type=type_, xref="paper", yref="paper",
|
| 29 |
+
x0=x0, y0=y0, x1=x1, y1=y1,
|
| 30 |
+
fillcolor=fill,
|
| 31 |
+
line=dict(color=border, width=bw),
|
| 32 |
+
layer=layer)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _a(text, x, y, size=16, color=_TEXT, anchor="center",
|
| 36 |
+
bold=False, italic=False) -> dict:
|
| 37 |
+
fmt = f"<b>{text}</b>" if bold else (f"<i>{text}</i>" if italic else str(text))
|
| 38 |
+
return dict(text=fmt, x=x, y=y,
|
| 39 |
+
xref="paper", yref="paper",
|
| 40 |
+
showarrow=False,
|
| 41 |
+
xanchor=anchor, yanchor="middle",
|
| 42 |
+
font=dict(size=size, color=color, family=_FONT))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _prob_bar(pH, pD, pA, x0, x1, y, h) -> list:
|
| 46 |
+
w = x1 - x0
|
| 47 |
+
wH, wD, wA = w * pH / 100, w * pD / 100, w * pA / 100
|
| 48 |
+
r = h / 2 # border-radius approximation — use rounded edges via line
|
| 49 |
+
return [
|
| 50 |
+
dict(type="rect", xref="paper", yref="paper",
|
| 51 |
+
x0=x0, x1=x0+wH, y0=y, y1=y+h,
|
| 52 |
+
fillcolor=_BLUE, line_width=0),
|
| 53 |
+
dict(type="rect", xref="paper", yref="paper",
|
| 54 |
+
x0=x0+wH, x1=x0+wH+wD, y0=y, y1=y+h,
|
| 55 |
+
fillcolor=_DRAW, line_width=0),
|
| 56 |
+
dict(type="rect", xref="paper", yref="paper",
|
| 57 |
+
x0=x0+wH+wD, x1=x1, y0=y, y1=y+h,
|
| 58 |
+
fillcolor=_RED, line_width=0),
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def generar_post_imagen(
|
| 63 |
+
eq_l: str, eq_v: str, liga: str,
|
| 64 |
+
pH: float, pD: float, pA: float,
|
| 65 |
+
cH: float, cD: float, cA: float,
|
| 66 |
+
l_anota: float, v_recibe: float,
|
| 67 |
+
v_anota: float, l_recibe: float,
|
| 68 |
+
kelly_ok: bool,
|
| 69 |
+
periodo: str = "Todos",
|
| 70 |
+
xg_l: float | None = None,
|
| 71 |
+
xg_v: float | None = None,
|
| 72 |
+
) -> bytes:
|
| 73 |
+
"""
|
| 74 |
+
Genera un PNG 1080×1080 minimalista con colores claros.
|
| 75 |
+
Devuelve bytes listos para st.download_button.
|
| 76 |
+
"""
|
| 77 |
+
info = LIGA_INFO.get(liga, {})
|
| 78 |
+
bandera = info.get("bandera", "🏟️")
|
| 79 |
+
nombre_liga = info.get("liga", liga)
|
| 80 |
+
|
| 81 |
+
est_l = round((l_anota + v_recibe) / 2, 2)
|
| 82 |
+
est_v = round((v_anota + l_recibe) / 2, 2)
|
| 83 |
+
est_t = round(est_l + est_v, 2)
|
| 84 |
+
over_lbl = "Sobre 2.5" if est_t > 2.5 else "Bajo 2.5"
|
| 85 |
+
over_color = _GREEN if est_t > 2.5 else _ORANGE
|
| 86 |
+
|
| 87 |
+
shapes, ann = [], []
|
| 88 |
+
|
| 89 |
+
# ── Fondo blanco ─────────────────────────────────────────────────────────
|
| 90 |
+
shapes.append(_r(fill=_BG, border=_BG, bw=0))
|
| 91 |
+
|
| 92 |
+
# ── Acento superior (línea fina azul) ────────────────────────────────────
|
| 93 |
+
shapes.append(_r(y0=0.964, y1=0.972, fill=_BLUE, border=_BLUE, bw=0))
|
| 94 |
+
|
| 95 |
+
# ── Liga + bandera ────────────────────────────────────────────────────────
|
| 96 |
+
ann.append(_a(f"{bandera} {nombre_liga}",
|
| 97 |
+
0.5, 0.948, size=18, color=_MUTED))
|
| 98 |
+
|
| 99 |
+
# ── Separador ────────────────────────────────────────────────────────────
|
| 100 |
+
shapes.append(_r(y0=0.926, y1=0.9265, fill=_BORDER, border=_BORDER, bw=0))
|
| 101 |
+
|
| 102 |
+
# ── Equipos ───────────────────────────────────────────────────────────────
|
| 103 |
+
ann += [
|
| 104 |
+
_a(eq_l, 0.24, 0.886, size=34, bold=True, color=_BLUE),
|
| 105 |
+
_a("vs", 0.50, 0.886, size=20, color=_MUTED, italic=True),
|
| 106 |
+
_a(eq_v, 0.76, 0.886, size=34, bold=True, color=_RED),
|
| 107 |
+
_a("Local", 0.24, 0.855, size=13, color=_MUTED),
|
| 108 |
+
_a(f"{periodo}", 0.50, 0.855, size=13, color=_MUTED),
|
| 109 |
+
_a("Visitante", 0.76, 0.855, size=13, color=_MUTED),
|
| 110 |
+
]
|
| 111 |
+
|
| 112 |
+
# ── Separador ────────────────────────────────────────────────────────────
|
| 113 |
+
shapes.append(_r(y0=0.834, y1=0.8345, fill=_BORDER, border=_BORDER, bw=0))
|
| 114 |
+
|
| 115 |
+
# ── Probabilidades ─────────────────────��──────────────────────────────────
|
| 116 |
+
if kelly_ok:
|
| 117 |
+
_inv = 1/cH + 1/cD + 1/cA
|
| 118 |
+
bH = 1/cH / _inv * 100
|
| 119 |
+
bD = 1/cD / _inv * 100
|
| 120 |
+
bA = 1/cA / _inv * 100
|
| 121 |
+
prob_lbl = "Probabilidades implícitas (cuotas)"
|
| 122 |
+
else:
|
| 123 |
+
bH, bD, bA = pH, pD, pA
|
| 124 |
+
prob_lbl = "Probabilidades estimadas"
|
| 125 |
+
ann.append(_a(prob_lbl, 0.5, 0.818, size=12, color=_MUTED))
|
| 126 |
+
shapes += _prob_bar(bH, bD, bA, 0.06, 0.94, 0.775, 0.026)
|
| 127 |
+
ann += [
|
| 128 |
+
_a(f"{bH:.1f}%", 0.18, 0.752, size=26, bold=True, color=_BLUE),
|
| 129 |
+
_a(f"{bD:.1f}%", 0.50, 0.752, size=26, bold=True, color=_DRAW),
|
| 130 |
+
_a(f"{bA:.1f}%", 0.82, 0.752, size=26, bold=True, color=_RED),
|
| 131 |
+
_a("Local", 0.18, 0.724, size=12, color=_MUTED),
|
| 132 |
+
_a("Empate", 0.50, 0.724, size=12, color=_MUTED),
|
| 133 |
+
_a("Visitante", 0.82, 0.724, size=12, color=_MUTED),
|
| 134 |
+
]
|
| 135 |
+
if kelly_ok:
|
| 136 |
+
ann += [
|
| 137 |
+
_a(f"cuota {cH:.2f}", 0.18, 0.709, size=11, color=_MUTED, italic=True),
|
| 138 |
+
_a(f"cuota {cD:.2f}", 0.50, 0.709, size=11, color=_MUTED, italic=True),
|
| 139 |
+
_a(f"cuota {cA:.2f}", 0.82, 0.709, size=11, color=_MUTED, italic=True),
|
| 140 |
+
]
|
| 141 |
+
|
| 142 |
+
# ── Separador ────────────────────────────────────────────────────────────
|
| 143 |
+
shapes.append(_r(y0=0.690, y1=0.6905, fill=_BORDER, border=_BORDER, bw=0))
|
| 144 |
+
|
| 145 |
+
# ── Goles esperados / xG ─────────────────────────────────────────────────
|
| 146 |
+
_has_xg = xg_l is not None and xg_v is not None
|
| 147 |
+
if _has_xg:
|
| 148 |
+
show_l, show_v = xg_l, xg_v
|
| 149 |
+
show_t = round(xg_l + xg_v, 2)
|
| 150 |
+
section_lbl = "xG predicción del partido"
|
| 151 |
+
else:
|
| 152 |
+
show_l, show_v = est_l, est_v
|
| 153 |
+
show_t = est_t
|
| 154 |
+
section_lbl = "Goles esperados por partido"
|
| 155 |
+
over_lbl = "Sobre 2.5" if show_t > 2.5 else "Bajo 2.5"
|
| 156 |
+
over_color = _GREEN if show_t > 2.5 else _ORANGE
|
| 157 |
+
|
| 158 |
+
ann.append(_a(section_lbl, 0.5, 0.673, size=12, color=_MUTED))
|
| 159 |
+
ann += [
|
| 160 |
+
_a(f"{show_l:.2f}", 0.20, 0.638, size=48, bold=True, color=_BLUE),
|
| 161 |
+
_a(f"{show_v:.2f}", 0.80, 0.638, size=48, bold=True, color=_RED),
|
| 162 |
+
_a(f"{show_t:.2f}", 0.50, 0.645, size=28, bold=True, color=_TEXT),
|
| 163 |
+
_a("total", 0.50, 0.614, size=12, color=_MUTED),
|
| 164 |
+
_a(over_lbl, 0.50, 0.598, size=13, bold=True, color=over_color),
|
| 165 |
+
_a(eq_l[:16], 0.20, 0.602, size=11, color=_MUTED),
|
| 166 |
+
_a(eq_v[:16], 0.80, 0.602, size=11, color=_MUTED),
|
| 167 |
+
]
|
| 168 |
+
if _has_xg:
|
| 169 |
+
ann += [
|
| 170 |
+
_a(f"(avg {est_l:.2f})", 0.20, 0.584, size=10, color=_MUTED, italic=True),
|
| 171 |
+
_a(f"(avg {est_v:.2f})", 0.80, 0.584, size=10, color=_MUTED, italic=True),
|
| 172 |
+
]
|
| 173 |
+
|
| 174 |
+
# ── Separador ────────────────────────────────────────────────────────────
|
| 175 |
+
shapes.append(_r(y0=0.576, y1=0.5765, fill=_BORDER, border=_BORDER, bw=0))
|
| 176 |
+
|
| 177 |
+
# ── Tabla de promedios — 2 columnas ──────────────────────────────────────
|
| 178 |
+
ann.append(_a("Promedios por partido", 0.5, 0.559, size=12, color=_MUTED))
|
| 179 |
+
|
| 180 |
+
# Fila encabezados
|
| 181 |
+
ann += [
|
| 182 |
+
_a("", 0.06, 0.535, size=12, color=_MUTED, anchor="left"),
|
| 183 |
+
_a(eq_l[:16], 0.45, 0.535, size=13, bold=True, color=_BLUE, anchor="right"),
|
| 184 |
+
_a(eq_v[:16], 0.94, 0.535, size=13, bold=True, color=_RED, anchor="right"),
|
| 185 |
+
]
|
| 186 |
+
shapes.append(_r(y0=0.521, y1=0.5215, fill=_BORDER, border=_BORDER, bw=0))
|
| 187 |
+
|
| 188 |
+
# Columna izquierda = Local, columna derecha = Visitante (siempre)
|
| 189 |
+
rows = [
|
| 190 |
+
("⚽ Goles anotados", l_anota, v_anota), # local anota / visit anota
|
| 191 |
+
("🛡️ Goles recibidos", l_recibe, v_recibe), # local recibe / visit recibe
|
| 192 |
+
("⚔️ Local ataca · Visita defiende", l_anota, v_recibe), # local anota vs visita recibe
|
| 193 |
+
("⚔️ Visita ataca · Local defiende", l_recibe, v_anota), # local recibe vs visita anota
|
| 194 |
+
]
|
| 195 |
+
row_ys = [0.499, 0.463, 0.427, 0.391]
|
| 196 |
+
|
| 197 |
+
for (label, vl, vv), ry in zip(rows, row_ys):
|
| 198 |
+
# alternating row background
|
| 199 |
+
shapes.append(_r(y0=ry-0.018, y1=ry+0.018,
|
| 200 |
+
fill=_BG2 if rows.index((label, vl, vv)) % 2 == 0 else _BG,
|
| 201 |
+
border=_BG2, bw=0))
|
| 202 |
+
ann += [
|
| 203 |
+
_a(label, 0.06, ry, size=13, color=_TEXT, anchor="left"),
|
| 204 |
+
_a(f"{vl:.2f}", 0.45, ry, size=15, bold=True, color=_BLUE, anchor="right"),
|
| 205 |
+
_a(f"{vv:.2f}", 0.94, ry, size=15, bold=True, color=_RED, anchor="right"),
|
| 206 |
+
]
|
| 207 |
+
|
| 208 |
+
shapes.append(_r(y0=0.370, y1=0.3705, fill=_BORDER, border=_BORDER, bw=0))
|
| 209 |
+
|
| 210 |
+
# ── Delta ataque ─────────────────────────────────────────────────────────
|
| 211 |
+
# Positivo = la defensa rival recibe más de lo que el atacante anota → defensa débil
|
| 212 |
+
d_l = round(v_recibe - l_anota, 2)
|
| 213 |
+
d_v = round(l_recibe - v_anota, 2)
|
| 214 |
+
d_l_color = _GREEN if d_l > 0 else (_RED if d_l < 0 else _MUTED)
|
| 215 |
+
d_v_color = _GREEN if d_v > 0 else (_RED if d_v < 0 else _MUTED)
|
| 216 |
+
|
| 217 |
+
def _delta_label(d):
|
| 218 |
+
if d > 0: return "defensa débil"
|
| 219 |
+
if d < 0: return "defensa sólida"
|
| 220 |
+
return "equilibrio"
|
| 221 |
+
|
| 222 |
+
ann += [
|
| 223 |
+
_a("Ventaja ataque vs defensa rival", 0.5, 0.356, size=12, color=_MUTED),
|
| 224 |
+
_a(f"{d_l:+.2f}", 0.30, 0.330, size=24, bold=True, color=d_l_color),
|
| 225 |
+
_a(f"{d_v:+.2f}", 0.70, 0.330, size=24, bold=True, color=d_v_color),
|
| 226 |
+
_a(f"{eq_l[:14]} ataca", 0.30, 0.308, size=11, color=_MUTED),
|
| 227 |
+
_a(f"{eq_v[:14]} ataca", 0.70, 0.308, size=11, color=_MUTED),
|
| 228 |
+
_a(_delta_label(d_l), 0.30, 0.291, size=11, bold=True, color=d_l_color),
|
| 229 |
+
_a(_delta_label(d_v), 0.70, 0.291, size=11, bold=True, color=d_v_color),
|
| 230 |
+
]
|
| 231 |
+
|
| 232 |
+
# ── Separador ────────────────────────────────────────────────────────────
|
| 233 |
+
shapes.append(_r(y0=0.282, y1=0.2825, fill=_BORDER, border=_BORDER, bw=0))
|
| 234 |
+
|
| 235 |
+
# ── Branding mínimo ───────────────────────────────────────────────────────
|
| 236 |
+
ann += [
|
| 237 |
+
_a("⚽ FutStats", 0.5, 0.255, size=20, bold=True, color=_TEXT),
|
| 238 |
+
_a("Análisis estadístico de fútbol", 0.5, 0.228,
|
| 239 |
+
size=13, color=_MUTED, italic=True),
|
| 240 |
+
_a("El análisis estadístico no garantiza resultados. Apuesta con responsabilidad.",
|
| 241 |
+
0.5, 0.200, size=11, color=_MUTED),
|
| 242 |
+
]
|
| 243 |
+
|
| 244 |
+
# ── Acento inferior ───────────────────────────────────────────────────────
|
| 245 |
+
shapes.append(_r(y0=0.178, y1=0.186, fill=_BLUE, border=_BLUE, bw=0))
|
| 246 |
+
|
| 247 |
+
# ── Construir figura ──────────────────────────────────────────────────────
|
| 248 |
+
fig = go.Figure()
|
| 249 |
+
fig.update_layout(
|
| 250 |
+
width=_W, height=_H,
|
| 251 |
+
paper_bgcolor=_BG,
|
| 252 |
+
plot_bgcolor=_BG,
|
| 253 |
+
margin=dict(l=0, r=0, t=0, b=0),
|
| 254 |
+
shapes=shapes,
|
| 255 |
+
annotations=ann,
|
| 256 |
+
xaxis=dict(visible=False, range=[0, 1]),
|
| 257 |
+
yaxis=dict(visible=False, range=[0, 1]),
|
| 258 |
+
)
|
| 259 |
+
return fig.to_image(format="png", width=_W, height=_H, scale=2)
|
src/src/statistics.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
from src.config import (
|
| 6 |
+
COLOR_POSITIVO,
|
| 7 |
+
COLOR_NEGATIVO,
|
| 8 |
+
COLOR_ADVERTENCIA,
|
| 9 |
+
RENAME_PARTIDOS,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def calcular_estadisticas(df: pd.DataFrame, cols_dict: dict) -> pd.DataFrame | None:
|
| 14 |
+
cols_validas = {k: v for k, v in cols_dict.items() if k in df.columns}
|
| 15 |
+
if not cols_validas:
|
| 16 |
+
return None
|
| 17 |
+
df_num = df[list(cols_validas.keys())].apply(pd.to_numeric, errors="coerce")
|
| 18 |
+
filas = []
|
| 19 |
+
for col_orig, col_nombre in cols_validas.items():
|
| 20 |
+
serie = df_num[col_orig].dropna()
|
| 21 |
+
if serie.empty:
|
| 22 |
+
continue
|
| 23 |
+
mo = serie.mode()
|
| 24 |
+
filas.append({
|
| 25 |
+
"Estadística": col_nombre,
|
| 26 |
+
"Promedio": round(serie.mean(), 2),
|
| 27 |
+
"Mediana": round(serie.median(), 2),
|
| 28 |
+
"Moda": mo.iloc[0] if not mo.empty else np.nan,
|
| 29 |
+
"Desv. Estándar": round(serie.std(), 2),
|
| 30 |
+
})
|
| 31 |
+
return pd.DataFrame(filas)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def promedio_col(df: pd.DataFrame, col: str) -> float:
|
| 35 |
+
if col not in df.columns:
|
| 36 |
+
return np.nan
|
| 37 |
+
s = pd.to_numeric(df[col], errors="coerce").dropna()
|
| 38 |
+
return s.mean() if not s.empty else np.nan
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def construir_comparacion(
|
| 42 |
+
nombre_local: str,
|
| 43 |
+
nombre_visitante: str,
|
| 44 |
+
df_local: pd.DataFrame,
|
| 45 |
+
df_visit: pd.DataFrame,
|
| 46 |
+
pares: list,
|
| 47 |
+
tipo: str,
|
| 48 |
+
) -> pd.DataFrame:
|
| 49 |
+
filas = []
|
| 50 |
+
for nombre_stat, col_atq, col_def in pares:
|
| 51 |
+
if tipo == "ataque_local":
|
| 52 |
+
va, vd = promedio_col(df_local, col_atq), promedio_col(df_visit, col_def)
|
| 53 |
+
la, ld = f"{nombre_local} genera", f"{nombre_visitante} recibe"
|
| 54 |
+
else:
|
| 55 |
+
va, vd = promedio_col(df_visit, col_atq), promedio_col(df_local, col_def)
|
| 56 |
+
la, ld = f"{nombre_visitante} genera", f"{nombre_local} recibe"
|
| 57 |
+
diff = vd - va if not (np.isnan(va) or np.isnan(vd)) else np.nan
|
| 58 |
+
filas.append({
|
| 59 |
+
"Estadística": nombre_stat,
|
| 60 |
+
la: round(va, 2) if not np.isnan(va) else "-",
|
| 61 |
+
ld: round(vd, 2) if not np.isnan(vd) else "-",
|
| 62 |
+
"Diferencia": round(diff, 2) if not np.isnan(diff) else "-",
|
| 63 |
+
})
|
| 64 |
+
return pd.DataFrame(filas)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# --- Funciones de estilo ---
|
| 68 |
+
|
| 69 |
+
def _colorear_diferencia_serie(series: pd.Series) -> list[str]:
|
| 70 |
+
vals = pd.to_numeric(series, errors="coerce")
|
| 71 |
+
umbral = vals.abs().std() * 0.5
|
| 72 |
+
result = []
|
| 73 |
+
for v in vals:
|
| 74 |
+
if pd.isna(v):
|
| 75 |
+
result.append("")
|
| 76 |
+
elif abs(v) <= umbral:
|
| 77 |
+
result.append(f"background-color: {COLOR_ADVERTENCIA}; color: black; font-weight: bold")
|
| 78 |
+
elif v > 0:
|
| 79 |
+
result.append(f"background-color: {COLOR_POSITIVO}; color: white; font-weight: bold")
|
| 80 |
+
else:
|
| 81 |
+
result.append(f"background-color: {COLOR_NEGATIVO}; color: white; font-weight: bold")
|
| 82 |
+
return result
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# --- Funciones de display (Streamlit) ---
|
| 86 |
+
|
| 87 |
+
def _colorear_desv(series: pd.Series) -> list[str]:
|
| 88 |
+
vals = pd.to_numeric(series, errors="coerce")
|
| 89 |
+
q33 = vals.quantile(0.33)
|
| 90 |
+
q66 = vals.quantile(0.66)
|
| 91 |
+
result = []
|
| 92 |
+
for v in vals:
|
| 93 |
+
if pd.isna(v):
|
| 94 |
+
result.append("")
|
| 95 |
+
elif v <= q33:
|
| 96 |
+
result.append(f"background-color: {COLOR_POSITIVO}; color: white; font-weight: bold")
|
| 97 |
+
elif v <= q66:
|
| 98 |
+
result.append(f"background-color: {COLOR_ADVERTENCIA}; color: black; font-weight: bold")
|
| 99 |
+
else:
|
| 100 |
+
result.append(f"background-color: {COLOR_NEGATIVO}; color: white; font-weight: bold")
|
| 101 |
+
return result
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def mostrar_estadisticas_coloreadas(df_stats: pd.DataFrame | None) -> None:
|
| 105 |
+
if df_stats is None or df_stats.empty:
|
| 106 |
+
st.warning("No hay datos suficientes.")
|
| 107 |
+
return
|
| 108 |
+
num_cols = ["Promedio", "Mediana", "Desv. Estándar"]
|
| 109 |
+
styled = (
|
| 110 |
+
df_stats.style
|
| 111 |
+
.apply(_colorear_desv, subset=["Desv. Estándar"], axis=0)
|
| 112 |
+
.format({c: "{:.2f}" for c in num_cols} | {"Moda": "{}"})
|
| 113 |
+
)
|
| 114 |
+
st.dataframe(styled, use_container_width=True, hide_index=True)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def mostrar_tabla_partidos(df: pd.DataFrame, cols_extra: list | None = None) -> None:
|
| 118 |
+
base = ["Date", "HomeTeam", "AwayTeam"]
|
| 119 |
+
if cols_extra:
|
| 120 |
+
base += [c for c in cols_extra if c in df.columns]
|
| 121 |
+
cols_mostrar = [c for c in base if c in df.columns]
|
| 122 |
+
st.dataframe(
|
| 123 |
+
df[cols_mostrar].copy().rename(columns=RENAME_PARTIDOS).reset_index(drop=True),
|
| 124 |
+
use_container_width=True,
|
| 125 |
+
hide_index=True,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def mostrar_comparacion(df_comp: pd.DataFrame | None) -> None:
|
| 130 |
+
if df_comp is None or df_comp.empty:
|
| 131 |
+
st.warning("No hay datos para la comparación.")
|
| 132 |
+
return
|
| 133 |
+
st.dataframe(
|
| 134 |
+
df_comp.style.apply(_colorear_diferencia_serie, subset=["Diferencia"], axis=0),
|
| 135 |
+
use_container_width=True,
|
| 136 |
+
hide_index=True,
|
| 137 |
+
)
|
src/src/styles.py
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
|
| 3 |
+
_CSS = """<style>
|
| 4 |
+
:root {
|
| 5 |
+
--bg-primary: #F6F8FA;
|
| 6 |
+
--bg-secondary: #FFFFFF;
|
| 7 |
+
--bg-card: #FFFFFF;
|
| 8 |
+
--bg-hover: #F0F2F5;
|
| 9 |
+
--bg-active: #EBF2FF;
|
| 10 |
+
--border: #E2E6EA;
|
| 11 |
+
--border-light: #EAECEF;
|
| 12 |
+
--text-primary: #1A1F2E;
|
| 13 |
+
--text-secondary:#5A6270;
|
| 14 |
+
--text-muted: #9BA3AE;
|
| 15 |
+
--green: #0D9E6E;
|
| 16 |
+
--green-dim: rgba(13,158,110,0.08);
|
| 17 |
+
--blue: #2570D4;
|
| 18 |
+
--blue-dim: rgba(37,112,212,0.08);
|
| 19 |
+
--yellow: #B07D0E;
|
| 20 |
+
--yellow-dim: rgba(176,125,14,0.08);
|
| 21 |
+
--red: #D93025;
|
| 22 |
+
--red-dim: rgba(217,48,37,0.08);
|
| 23 |
+
--purple: #7C4DCC;
|
| 24 |
+
--purple-dim: rgba(124,77,204,0.08);
|
| 25 |
+
--radius-sm: 6px;
|
| 26 |
+
--radius-md: 10px;
|
| 27 |
+
--radius-lg: 14px;
|
| 28 |
+
--shadow: 0 2px 12px rgba(0,0,0,0.06);
|
| 29 |
+
--shadow-md: 0 4px 20px rgba(0,0,0,0.1);
|
| 30 |
+
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/* ─── App shell ─── */
|
| 34 |
+
.stApp { background:var(--bg-primary) !important; font-family:var(--font) !important; }
|
| 35 |
+
.main .block-container {
|
| 36 |
+
background:var(--bg-primary) !important;
|
| 37 |
+
padding:24px 28px 60px !important;
|
| 38 |
+
max-width:1400px !important;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/* ─── Hide Sidebar ─── */
|
| 42 |
+
[data-testid="stSidebar"],
|
| 43 |
+
[data-testid="stSidebarCollapsedControl"],
|
| 44 |
+
[data-testid="stSidebarNav"],
|
| 45 |
+
section[data-testid="stSidebar"] {
|
| 46 |
+
display:none !important;
|
| 47 |
+
}
|
| 48 |
+
.main { margin-left:0 !important; }
|
| 49 |
+
|
| 50 |
+
/* ─── Typography ─── */
|
| 51 |
+
h1,h2,h3,h4,h5 { font-family:var(--font) !important; color:var(--text-primary) !important; }
|
| 52 |
+
|
| 53 |
+
/* ─── Metrics ─── */
|
| 54 |
+
[data-testid="stMetric"] {
|
| 55 |
+
background:var(--bg-card) !important;
|
| 56 |
+
border:1px solid var(--border) !important;
|
| 57 |
+
border-radius:var(--radius-md) !important;
|
| 58 |
+
padding:14px 16px !important;
|
| 59 |
+
box-shadow:var(--shadow) !important;
|
| 60 |
+
}
|
| 61 |
+
[data-testid="stMetricValue"] {
|
| 62 |
+
color:var(--green) !important; font-size:24px !important; font-weight:700 !important;
|
| 63 |
+
}
|
| 64 |
+
[data-testid="stMetricLabel"] {
|
| 65 |
+
color:var(--text-muted) !important; font-size:10px !important;
|
| 66 |
+
font-weight:600 !important; text-transform:uppercase !important; letter-spacing:0.5px !important;
|
| 67 |
+
}
|
| 68 |
+
[data-testid="stMetricDelta"] { font-size:11px !important; }
|
| 69 |
+
|
| 70 |
+
/* ─── Dataframes ─── */
|
| 71 |
+
[data-testid="stDataFrame"] {
|
| 72 |
+
border:1px solid var(--border) !important;
|
| 73 |
+
border-radius:var(--radius-md) !important;
|
| 74 |
+
overflow:hidden !important;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/* ─── Expanders ─── */
|
| 78 |
+
[data-testid="stExpander"] {
|
| 79 |
+
background:var(--bg-card) !important;
|
| 80 |
+
border:1px solid var(--border) !important;
|
| 81 |
+
border-radius:var(--radius-md) !important;
|
| 82 |
+
overflow:hidden !important;
|
| 83 |
+
box-shadow:none !important;
|
| 84 |
+
}
|
| 85 |
+
[data-testid="stExpander"] details summary {
|
| 86 |
+
background:var(--bg-card) !important;
|
| 87 |
+
color:var(--text-secondary) !important;
|
| 88 |
+
font-size:13px !important; font-weight:500 !important; padding:12px 16px !important;
|
| 89 |
+
}
|
| 90 |
+
[data-testid="stExpander"] details summary:hover { background:var(--bg-hover) !important; }
|
| 91 |
+
|
| 92 |
+
/* ─── Select boxes ─── */
|
| 93 |
+
[data-testid="stSelectbox"]>div>div {
|
| 94 |
+
background:var(--bg-card) !important;
|
| 95 |
+
border:1px solid var(--border) !important;
|
| 96 |
+
border-radius:var(--radius-md) !important;
|
| 97 |
+
color:var(--text-primary) !important; font-size:13px !important;
|
| 98 |
+
}
|
| 99 |
+
[data-testid="stSelectbox"] label {
|
| 100 |
+
color:var(--text-secondary) !important; font-size:11px !important;
|
| 101 |
+
font-weight:600 !important; text-transform:uppercase !important; letter-spacing:0.4px !important;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
/* ─── Buttons ─── */
|
| 105 |
+
.stButton>button {
|
| 106 |
+
background:var(--green) !important; color:#fff !important;
|
| 107 |
+
border:none !important; border-radius:var(--radius-md) !important;
|
| 108 |
+
font-weight:600 !important; font-size:13px !important;
|
| 109 |
+
padding:9px 22px !important; transition:opacity 0.15s !important; box-shadow:none !important;
|
| 110 |
+
}
|
| 111 |
+
.stButton>button:hover { opacity:0.88 !important; border:none !important; }
|
| 112 |
+
.stButton>button[kind="primary"] { background:var(--blue) !important; }
|
| 113 |
+
|
| 114 |
+
/* ─── Alerts ─── */
|
| 115 |
+
[data-testid="stAlert"] { border-radius:var(--radius-md) !important; font-size:13px !important; }
|
| 116 |
+
|
| 117 |
+
/* ─── Number input ─── */
|
| 118 |
+
[data-testid="stNumberInput"] input {
|
| 119 |
+
background:var(--bg-card) !important; border:1px solid var(--border) !important;
|
| 120 |
+
border-radius:var(--radius-md) !important; color:var(--text-primary) !important;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/* ─── Slider ─── */
|
| 124 |
+
[data-testid="stSlider"] label {
|
| 125 |
+
color:var(--text-secondary) !important; font-size:11px !important;
|
| 126 |
+
font-weight:600 !important; text-transform:uppercase !important; letter-spacing:0.4px !important;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/* ─── Caption ─── */
|
| 130 |
+
[data-testid="stCaptionContainer"] { color:var(--text-muted) !important; font-size:11px !important; }
|
| 131 |
+
|
| 132 |
+
/* ─── Tabs ─── */
|
| 133 |
+
.stTabs [data-testid="stTabBar"] {
|
| 134 |
+
background:var(--bg-card) !important;
|
| 135 |
+
border:1px solid var(--border) !important;
|
| 136 |
+
border-radius:var(--radius-md) !important;
|
| 137 |
+
padding:4px !important; gap:2px !important;
|
| 138 |
+
margin-bottom:20px !important;
|
| 139 |
+
}
|
| 140 |
+
.stTabs [data-testid="stTabBar"] button {
|
| 141 |
+
border-radius:var(--radius-sm) !important;
|
| 142 |
+
font-size:12px !important; font-weight:500 !important;
|
| 143 |
+
color:var(--text-secondary) !important;
|
| 144 |
+
background:transparent !important; border:none !important;
|
| 145 |
+
padding:8px 16px !important; white-space:nowrap !important;
|
| 146 |
+
transition:all 0.15s !important;
|
| 147 |
+
}
|
| 148 |
+
.stTabs [data-testid="stTabBar"] button:hover { color:var(--text-primary) !important; }
|
| 149 |
+
.stTabs [data-testid="stTabBar"] button[aria-selected="true"] {
|
| 150 |
+
background:var(--bg-secondary) !important;
|
| 151 |
+
color:var(--text-primary) !important;
|
| 152 |
+
font-weight:600 !important;
|
| 153 |
+
box-shadow:0 1px 4px rgba(0,0,0,0.1) !important;
|
| 154 |
+
}
|
| 155 |
+
[data-testid="stTabPanel"] { padding:0 !important; }
|
| 156 |
+
|
| 157 |
+
/* ─── Hide branding ─── */
|
| 158 |
+
#MainMenu { visibility:hidden; }
|
| 159 |
+
footer { visibility:hidden; }
|
| 160 |
+
[data-testid="stToolbar"] { display:none !important; }
|
| 161 |
+
[data-testid="stDecoration"] { display:none !important; }
|
| 162 |
+
|
| 163 |
+
/* ══════════════════════════════════
|
| 164 |
+
CUSTOM COMPONENTS
|
| 165 |
+
══════════════════════════════════ */
|
| 166 |
+
|
| 167 |
+
/* Sidebar header */
|
| 168 |
+
.fs-sb-header {
|
| 169 |
+
display:flex; align-items:center; gap:10px;
|
| 170 |
+
padding:16px 0 14px;
|
| 171 |
+
border-bottom:1px solid var(--border);
|
| 172 |
+
margin-bottom:16px;
|
| 173 |
+
}
|
| 174 |
+
.fs-sb-logo {
|
| 175 |
+
width:34px; height:34px; background:var(--green); border-radius:9px;
|
| 176 |
+
display:flex; align-items:center; justify-content:center;
|
| 177 |
+
font-size:17px; flex-shrink:0;
|
| 178 |
+
}
|
| 179 |
+
.fs-sb-title { font-size:14px; font-weight:700; color:var(--text-primary); line-height:1.2; }
|
| 180 |
+
.fs-sb-sub { font-size:10px; color:var(--text-muted); }
|
| 181 |
+
|
| 182 |
+
/* Sidebar section label */
|
| 183 |
+
.fs-sb-sec {
|
| 184 |
+
font-size:10px; text-transform:uppercase; letter-spacing:0.8px;
|
| 185 |
+
color:var(--text-muted); font-weight:600; margin:14px 0 6px;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
/* Sidebar match mini card */
|
| 189 |
+
.fs-match-mini {
|
| 190 |
+
background:var(--bg-active); border:1px solid rgba(37,112,212,0.3);
|
| 191 |
+
border-radius:var(--radius-md); padding:10px 12px; margin:10px 0;
|
| 192 |
+
}
|
| 193 |
+
.fs-match-mini .home { color:var(--blue); font-weight:700; font-size:12px; }
|
| 194 |
+
.fs-match-mini .away { color:var(--red); font-weight:700; font-size:12px; }
|
| 195 |
+
.fs-match-mini .vs { color:var(--text-muted); font-size:11px; padding:0 5px; }
|
| 196 |
+
.fs-match-mini .meta { font-size:10px; color:var(--text-muted); margin-top:5px; }
|
| 197 |
+
|
| 198 |
+
/* Sidebar stat pill */
|
| 199 |
+
.fs-sb-pills {
|
| 200 |
+
display:flex; gap:6px; flex-wrap:wrap; margin-top:8px;
|
| 201 |
+
}
|
| 202 |
+
.fs-sb-pill {
|
| 203 |
+
display:inline-flex; align-items:center; gap:3px;
|
| 204 |
+
background:var(--bg-hover); border:1px solid var(--border);
|
| 205 |
+
border-radius:20px; padding:3px 9px;
|
| 206 |
+
font-size:11px; color:var(--text-secondary);
|
| 207 |
+
}
|
| 208 |
+
.fs-sb-pill span { font-weight:700; color:var(--text-primary); }
|
| 209 |
+
|
| 210 |
+
/* Page header */
|
| 211 |
+
.fs-header {
|
| 212 |
+
display:flex; align-items:center; gap:14px;
|
| 213 |
+
padding-bottom:18px; border-bottom:1px solid var(--border); margin-bottom:20px;
|
| 214 |
+
}
|
| 215 |
+
.fs-header h1 {
|
| 216 |
+
font-size:22px !important; font-weight:700 !important;
|
| 217 |
+
color:var(--text-primary) !important; margin:0 !important;
|
| 218 |
+
padding:0 !important; line-height:1.2 !important;
|
| 219 |
+
}
|
| 220 |
+
.fs-header .sub { font-size:12px; color:var(--text-muted); margin-top:3px; }
|
| 221 |
+
|
| 222 |
+
/* Section title */
|
| 223 |
+
.fs-sec {
|
| 224 |
+
display:flex; align-items:center; gap:8px;
|
| 225 |
+
font-size:11px; text-transform:uppercase; letter-spacing:0.8px;
|
| 226 |
+
color:var(--text-muted); font-weight:600; margin:20px 0 12px;
|
| 227 |
+
}
|
| 228 |
+
.fs-sec::after { content:''; flex:1; height:1px; background:var(--border-light); }
|
| 229 |
+
|
| 230 |
+
/* Stats summary bar */
|
| 231 |
+
.fs-stats-bar {
|
| 232 |
+
display:flex; align-items:stretch;
|
| 233 |
+
background:var(--bg-card); border:1px solid var(--border);
|
| 234 |
+
border-radius:var(--radius-lg); overflow:hidden; margin-bottom:20px;
|
| 235 |
+
}
|
| 236 |
+
.fs-stats-bar .si {
|
| 237 |
+
flex:1; text-align:center; padding:14px 16px;
|
| 238 |
+
border-right:1px solid var(--border-light);
|
| 239 |
+
}
|
| 240 |
+
.fs-stats-bar .si:last-child { border-right:none; }
|
| 241 |
+
.fs-stats-bar .sn { font-size:24px; font-weight:700; color:var(--green); line-height:1; }
|
| 242 |
+
.fs-stats-bar .sn.blue { color:var(--blue); }
|
| 243 |
+
.fs-stats-bar .sn.red { color:var(--red); }
|
| 244 |
+
.fs-stats-bar .sn.yellow { color:var(--yellow); }
|
| 245 |
+
.fs-stats-bar .sl {
|
| 246 |
+
font-size:10px; color:var(--text-muted);
|
| 247 |
+
text-transform:uppercase; letter-spacing:0.5px; margin-top:4px;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
/* Team analysis panels */
|
| 251 |
+
.fs-panel-home {
|
| 252 |
+
background:var(--bg-card);
|
| 253 |
+
border:1px solid var(--border);
|
| 254 |
+
border-top:3px solid var(--blue);
|
| 255 |
+
border-radius:var(--radius-lg);
|
| 256 |
+
padding:16px 16px 4px;
|
| 257 |
+
margin-bottom:12px;
|
| 258 |
+
}
|
| 259 |
+
.fs-panel-away {
|
| 260 |
+
background:var(--bg-card);
|
| 261 |
+
border:1px solid var(--border);
|
| 262 |
+
border-top:3px solid var(--red);
|
| 263 |
+
border-radius:var(--radius-lg);
|
| 264 |
+
padding:16px 16px 4px;
|
| 265 |
+
margin-bottom:12px;
|
| 266 |
+
}
|
| 267 |
+
.fs-panel-title {
|
| 268 |
+
font-size:14px; font-weight:700; margin-bottom:12px;
|
| 269 |
+
display:flex; align-items:center; gap:8px;
|
| 270 |
+
}
|
| 271 |
+
.fs-panel-title.home { color:var(--blue); }
|
| 272 |
+
.fs-panel-title.away { color:var(--red); }
|
| 273 |
+
|
| 274 |
+
/* Probability bar */
|
| 275 |
+
.fs-prob {
|
| 276 |
+
background:var(--bg-card); border:1px solid var(--border);
|
| 277 |
+
border-radius:var(--radius-md); padding:16px; margin-bottom:14px;
|
| 278 |
+
}
|
| 279 |
+
.fs-prob .labels {
|
| 280 |
+
display:flex; justify-content:space-between;
|
| 281 |
+
font-size:12px; color:var(--text-secondary); margin-bottom:10px; font-weight:500;
|
| 282 |
+
}
|
| 283 |
+
.fs-prob .bar {
|
| 284 |
+
height:10px; border-radius:5px; background:var(--bg-hover); overflow:hidden; display:flex;
|
| 285 |
+
}
|
| 286 |
+
.fs-prob .seg { height:100%; transition:width 0.5s; }
|
| 287 |
+
.fs-prob .seg.h { background:#2570D4; }
|
| 288 |
+
.fs-prob .seg.d { background:#C0C6CE; }
|
| 289 |
+
.fs-prob .seg.a { background:#D93025; }
|
| 290 |
+
.fs-prob .vals {
|
| 291 |
+
display:flex; justify-content:space-between;
|
| 292 |
+
margin-top:10px; font-size:14px; font-weight:700;
|
| 293 |
+
}
|
| 294 |
+
.fs-prob .vh { color:#2570D4; }
|
| 295 |
+
.fs-prob .vd { color:var(--text-secondary); }
|
| 296 |
+
.fs-prob .va { color:#D93025; }
|
| 297 |
+
|
| 298 |
+
/* Odds cards (1X2) */
|
| 299 |
+
.fs-odds { display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; margin-bottom:16px; }
|
| 300 |
+
.fs-odds .oc {
|
| 301 |
+
background:var(--bg-card); border:1px solid var(--border);
|
| 302 |
+
border-radius:var(--radius-md); padding:16px 12px; text-align:center;
|
| 303 |
+
transition:border-color 0.15s;
|
| 304 |
+
}
|
| 305 |
+
.fs-odds .oc.best { border-color:var(--green); background:var(--green-dim); }
|
| 306 |
+
.fs-odds .oc .on { font-size:11px; color:var(--text-muted); margin-bottom:6px; font-weight:500; }
|
| 307 |
+
.fs-odds .oc .ov { font-size:26px; font-weight:700; color:var(--text-primary); line-height:1; }
|
| 308 |
+
.fs-odds .oc.best .ov { color:var(--green); }
|
| 309 |
+
.fs-odds .oc .op { font-size:11px; color:var(--text-secondary); margin-top:6px; }
|
| 310 |
+
.fs-odds .oc .ove { font-size:11px; margin-top:3px; font-weight:600; }
|
| 311 |
+
.fs-odds .oc .ove.pos { color:var(--green); }
|
| 312 |
+
.fs-odds .oc .ove.neg { color:var(--red); }
|
| 313 |
+
|
| 314 |
+
/* Kelly result cards */
|
| 315 |
+
.fs-kelly-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-bottom:20px; }
|
| 316 |
+
.fs-kelly-card {
|
| 317 |
+
background:var(--bg-card); border:1px solid var(--border);
|
| 318 |
+
border-top:4px solid var(--border);
|
| 319 |
+
border-radius:var(--radius-lg); padding:20px 14px; text-align:center;
|
| 320 |
+
}
|
| 321 |
+
.fs-kelly-card.positive { border-top-color:var(--green); background:var(--green-dim); border-color:rgba(13,158,110,0.2); }
|
| 322 |
+
.fs-kelly-card.yellow { border-top-color:var(--yellow); background:var(--yellow-dim); border-color:rgba(176,125,14,0.2); }
|
| 323 |
+
.fs-kelly-card.negative { border-top-color:var(--red); }
|
| 324 |
+
.fs-kelly-card .kc-result { font-size:10px; text-transform:uppercase; letter-spacing:0.5px; color:var(--text-muted); font-weight:600; margin-bottom:4px; }
|
| 325 |
+
.fs-kelly-card .kc-team { font-size:13px; font-weight:700; color:var(--text-primary); margin-bottom:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
| 326 |
+
.fs-kelly-card .kc-odd { font-size:32px; font-weight:700; color:var(--text-primary); line-height:1; margin-bottom:10px; }
|
| 327 |
+
.fs-kelly-card.positive .kc-odd { color:var(--green); }
|
| 328 |
+
.fs-kelly-card .kc-sep { width:32px; height:2px; background:var(--border); margin:0 auto 10px; border-radius:2px; }
|
| 329 |
+
.fs-kelly-card .kc-pct { font-size:20px; font-weight:700; line-height:1; }
|
| 330 |
+
.fs-kelly-card.positive .kc-pct { color:var(--green); }
|
| 331 |
+
.fs-kelly-card.yellow .kc-pct { color:var(--yellow); }
|
| 332 |
+
.fs-kelly-card.negative .kc-pct { color:var(--red); }
|
| 333 |
+
.fs-kelly-card .kc-label { font-size:10px; color:var(--text-muted); margin-top:3px; }
|
| 334 |
+
.fs-kelly-card .kc-meta { margin-top:10px; padding-top:10px; border-top:1px solid var(--border-light); font-size:11px; color:var(--text-secondary); }
|
| 335 |
+
.fs-kelly-card .kc-meta .pos { color:var(--green); font-weight:700; }
|
| 336 |
+
.fs-kelly-card .kc-meta .neg { color:var(--red); font-weight:700; }
|
| 337 |
+
|
| 338 |
+
/* Recommendation banner */
|
| 339 |
+
.fs-recommend {
|
| 340 |
+
display:flex; align-items:center; gap:14px;
|
| 341 |
+
background:var(--green-dim); border:1px solid rgba(13,158,110,0.25);
|
| 342 |
+
border-radius:var(--radius-md); padding:14px 18px; margin-top:4px;
|
| 343 |
+
}
|
| 344 |
+
.fs-recommend .rec-icon { font-size:24px; flex-shrink:0; }
|
| 345 |
+
.fs-recommend .rec-title { font-size:13px; font-weight:700; color:var(--green); }
|
| 346 |
+
.fs-recommend .rec-detail { font-size:12px; color:var(--text-secondary); margin-top:2px; }
|
| 347 |
+
.fs-no-bet {
|
| 348 |
+
display:flex; align-items:center; gap:14px;
|
| 349 |
+
background:var(--red-dim); border:1px solid rgba(217,48,37,0.2);
|
| 350 |
+
border-radius:var(--radius-md); padding:14px 18px; margin-top:4px;
|
| 351 |
+
}
|
| 352 |
+
.fs-no-bet .nb-icon { font-size:22px; flex-shrink:0; }
|
| 353 |
+
.fs-no-bet .nb-text { font-size:13px; font-weight:600; color:var(--red); }
|
| 354 |
+
|
| 355 |
+
/* AI panel */
|
| 356 |
+
.fs-ai {
|
| 357 |
+
background:linear-gradient(135deg,rgba(37,112,212,0.05),rgba(217,48,37,0.05));
|
| 358 |
+
border:1px solid rgba(37,112,212,0.2);
|
| 359 |
+
border-radius:var(--radius-md); padding:16px;
|
| 360 |
+
}
|
| 361 |
+
.fs-ai .ai-hdr { display:flex; align-items:center; gap:10px; margin-bottom:4px; }
|
| 362 |
+
.fs-ai .ai-ico {
|
| 363 |
+
width:32px; height:32px; background:var(--blue-dim); border:1px solid var(--blue);
|
| 364 |
+
border-radius:var(--radius-sm); display:flex; align-items:center; justify-content:center; font-size:16px;
|
| 365 |
+
}
|
| 366 |
+
.fs-ai .ai-title { font-size:14px; font-weight:700; color:var(--blue); }
|
| 367 |
+
.fs-ai .ai-sub { font-size:10px; color:var(--text-muted); }
|
| 368 |
+
|
| 369 |
+
/* Empty state */
|
| 370 |
+
.fs-empty {
|
| 371 |
+
text-align:center; padding:40px 20px;
|
| 372 |
+
color:var(--text-muted); font-size:13px;
|
| 373 |
+
}
|
| 374 |
+
.fs-empty .em-icon { font-size:32px; margin-bottom:8px; }
|
| 375 |
+
|
| 376 |
+
/* ─── Match selection cards ─── */
|
| 377 |
+
.fs-mc {
|
| 378 |
+
background:var(--bg-card); border:1px solid var(--border);
|
| 379 |
+
border-top:3px solid var(--border-light);
|
| 380 |
+
border-radius:var(--radius-lg); padding:14px 12px;
|
| 381 |
+
transition:all 0.15s; margin-bottom:4px;
|
| 382 |
+
}
|
| 383 |
+
.fs-mc.selected {
|
| 384 |
+
border-top-color:var(--green);
|
| 385 |
+
background:var(--green-dim); border-color:rgba(13,158,110,0.25);
|
| 386 |
+
}
|
| 387 |
+
.fs-mc .mc-league {
|
| 388 |
+
font-size:10px; font-weight:700; color:var(--text-muted);
|
| 389 |
+
text-transform:uppercase; letter-spacing:0.5px; margin-bottom:8px;
|
| 390 |
+
}
|
| 391 |
+
.fs-mc .mc-home { color:var(--blue); font-weight:700; font-size:13px; display:block; }
|
| 392 |
+
.fs-mc .mc-vs { color:var(--text-muted); font-size:11px; padding:2px 0; display:block; }
|
| 393 |
+
.fs-mc .mc-away { color:var(--red); font-weight:700; font-size:13px; display:block; }
|
| 394 |
+
.fs-mc .mc-meta { font-size:11px; color:var(--text-muted); margin-top:8px; }
|
| 395 |
+
|
| 396 |
+
/* date group label */
|
| 397 |
+
.fs-date-label {
|
| 398 |
+
font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0.6px;
|
| 399 |
+
color:var(--text-muted); padding:14px 0 8px; border-bottom:1px solid var(--border-light);
|
| 400 |
+
margin-bottom:10px;
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
/* Sidebar no-match state */
|
| 404 |
+
.fs-sb-welcome {
|
| 405 |
+
text-align:center; padding:20px 8px;
|
| 406 |
+
color:var(--text-muted); font-size:12px;
|
| 407 |
+
}
|
| 408 |
+
.fs-sb-welcome .sw-icon { font-size:28px; margin-bottom:8px; }
|
| 409 |
+
.fs-sb-welcome .sw-text { line-height:1.5; }
|
| 410 |
+
|
| 411 |
+
/* ─── Page navigation links ─── */
|
| 412 |
+
[data-testid="stPageLink"] {
|
| 413 |
+
margin:2px 0 !important;
|
| 414 |
+
}
|
| 415 |
+
[data-testid="stPageLink"] a {
|
| 416 |
+
border-radius:var(--radius-sm) !important;
|
| 417 |
+
padding:6px 10px !important;
|
| 418 |
+
font-size:12px !important;
|
| 419 |
+
color:var(--text-secondary) !important;
|
| 420 |
+
display:flex !important; align-items:center !important; gap:6px !important;
|
| 421 |
+
transition:all 0.15s !important;
|
| 422 |
+
text-decoration:none !important;
|
| 423 |
+
font-weight:500 !important;
|
| 424 |
+
white-space:nowrap !important;
|
| 425 |
+
}
|
| 426 |
+
[data-testid="stPageLink"] a:hover {
|
| 427 |
+
background:var(--bg-hover) !important;
|
| 428 |
+
color:var(--text-primary) !important;
|
| 429 |
+
}
|
| 430 |
+
[data-testid="stPageLink"] a[aria-current="page"] {
|
| 431 |
+
background:var(--bg-active) !important;
|
| 432 |
+
color:var(--blue) !important;
|
| 433 |
+
font-weight:600 !important;
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
/* ─── Topbar ─── */
|
| 437 |
+
.fs-topbar {
|
| 438 |
+
display:flex; align-items:center; gap:10px;
|
| 439 |
+
padding:8px 0 12px; border-bottom:1px solid var(--border); margin-bottom:16px;
|
| 440 |
+
}
|
| 441 |
+
.fs-topbar-logo {
|
| 442 |
+
display:flex; align-items:center; gap:8px; flex-shrink:0;
|
| 443 |
+
font-size:15px; font-weight:700; color:var(--text-primary);
|
| 444 |
+
}
|
| 445 |
+
.fs-topbar-logo .tl-icon {
|
| 446 |
+
width:30px; height:30px; background:var(--green); border-radius:8px;
|
| 447 |
+
display:flex; align-items:center; justify-content:center; font-size:15px;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
/* ─── Match context bar (shown when match is selected) ─── */
|
| 451 |
+
.fs-match-bar {
|
| 452 |
+
display:flex; align-items:center; gap:6px; flex-wrap:wrap;
|
| 453 |
+
background:var(--bg-active); border:1px solid rgba(37,112,212,0.2);
|
| 454 |
+
border-radius:var(--radius-md); padding:8px 14px; margin-bottom:14px;
|
| 455 |
+
font-size:13px;
|
| 456 |
+
}
|
| 457 |
+
.fs-match-bar .home { color:var(--blue); font-weight:700; }
|
| 458 |
+
.fs-match-bar .away { color:var(--red); font-weight:700; }
|
| 459 |
+
.fs-match-bar .vs { color:var(--text-muted); padding:0 4px; font-size:11px; }
|
| 460 |
+
.fs-match-bar .meta { color:var(--text-muted); font-size:11px; margin-left:4px; }
|
| 461 |
+
|
| 462 |
+
/* ─── Result pills (last X results) ─── */
|
| 463 |
+
.fs-result-pills {
|
| 464 |
+
display:flex; flex-wrap:wrap; gap:5px; margin-top:8px;
|
| 465 |
+
}
|
| 466 |
+
.rp {
|
| 467 |
+
min-width:28px; height:28px; border-radius:6px;
|
| 468 |
+
display:flex; align-items:center; justify-content:center;
|
| 469 |
+
font-size:12px; font-weight:700; color:#fff;
|
| 470 |
+
padding:0 5px;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
/* ─── Category stats header ─── */
|
| 474 |
+
.fs-cat-label {
|
| 475 |
+
font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.6px;
|
| 476 |
+
color:var(--text-muted); margin-bottom:6px;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
/* ─── Compact comparison cell ─── */
|
| 480 |
+
.fs-compare-cell {
|
| 481 |
+
background:var(--bg-card); border:1px solid var(--border);
|
| 482 |
+
border-radius:var(--radius-md); padding:14px 12px;
|
| 483 |
+
text-align:center; margin-bottom:10px;
|
| 484 |
+
}
|
| 485 |
+
.fs-compare-cell .cc-val { font-size:24px; font-weight:700; line-height:1; }
|
| 486 |
+
.fs-compare-cell .cc-lbl { font-size:10px; color:var(--text-muted); margin-top:4px; text-transform:uppercase; letter-spacing:0.4px; }
|
| 487 |
+
.fs-compare-cell .cc-delta { font-size:12px; font-weight:600; margin-top:6px; }
|
| 488 |
+
.fs-compare-cell .cc-delta.pos { color:var(--green); }
|
| 489 |
+
.fs-compare-cell .cc-delta.neg { color:var(--red); }
|
| 490 |
+
.fs-compare-cell .cc-delta.neu { color:var(--text-muted); }
|
| 491 |
+
|
| 492 |
+
/* ─── Date filter bar ─── */
|
| 493 |
+
.fs-date-filter {
|
| 494 |
+
display:flex; align-items:center; justify-content:flex-end; gap:10px;
|
| 495 |
+
margin-bottom:8px;
|
| 496 |
+
}
|
| 497 |
+
.fs-date-filter label {
|
| 498 |
+
font-size:11px; font-weight:600; text-transform:uppercase;
|
| 499 |
+
letter-spacing:0.5px; color:var(--text-muted);
|
| 500 |
+
}
|
| 501 |
+
</style>"""
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
def inject_css() -> None:
|
| 505 |
+
st.markdown(_CSS, unsafe_allow_html=True)
|
src/src/utils.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def sec(icon: str, title: str) -> None:
|
| 5 |
+
import streamlit as st
|
| 6 |
+
st.markdown(f'<div class="fs-sec"><span>{icon}</span> {title}</div>', unsafe_allow_html=True)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def med(df: pd.DataFrame, col: str) -> float:
|
| 10 |
+
s = pd.to_numeric(df[col], errors="coerce").dropna()
|
| 11 |
+
if s.empty:
|
| 12 |
+
return 0.0
|
| 13 |
+
mean, std = s.mean(), s.std()
|
| 14 |
+
if std == 0 or (mean != 0 and std / abs(mean) < 0.5):
|
| 15 |
+
return round(mean, 2)
|
| 16 |
+
return round(s.median(), 2)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def get_pred(df, stat: str) -> float:
|
| 20 |
+
if df is None or df.empty:
|
| 21 |
+
return 0.0
|
| 22 |
+
r = df[df["Estadística"] == stat]["Predicción RF"].values
|
| 23 |
+
return float(r[0]) if len(r) > 0 else 0.0
|
src/streamlit_app.py
CHANGED
|
@@ -1,40 +1,278 @@
|
|
| 1 |
-
import altair as alt
|
| 2 |
-
import numpy as np
|
| 3 |
-
import pandas as pd
|
| 4 |
import streamlit as st
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
#
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
|
| 11 |
-
forums](https://discuss.streamlit.io).
|
| 12 |
-
|
| 13 |
-
In the meantime, below is an example of what you can do with just a few lines of code:
|
| 14 |
-
"""
|
| 15 |
-
|
| 16 |
-
num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
|
| 17 |
-
num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
|
| 18 |
-
|
| 19 |
-
indices = np.linspace(0, 1, num_points)
|
| 20 |
-
theta = 2 * np.pi * num_turns * indices
|
| 21 |
-
radius = indices
|
| 22 |
-
|
| 23 |
-
x = radius * np.cos(theta)
|
| 24 |
-
y = radius * np.sin(theta)
|
| 25 |
-
|
| 26 |
-
df = pd.DataFrame({
|
| 27 |
-
"x": x,
|
| 28 |
-
"y": y,
|
| 29 |
-
"idx": indices,
|
| 30 |
-
"rand": np.random.randn(num_points),
|
| 31 |
-
})
|
| 32 |
-
|
| 33 |
-
st.altair_chart(alt.Chart(df, height=700, width=700)
|
| 34 |
-
.mark_point(filled=True)
|
| 35 |
-
.encode(
|
| 36 |
-
x=alt.X("x", axis=None),
|
| 37 |
-
y=alt.Y("y", axis=None),
|
| 38 |
-
color=alt.Color("idx", legend=None, scale=alt.Scale()),
|
| 39 |
-
size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
|
| 40 |
-
))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import streamlit as st
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
from src.config import (
|
| 6 |
+
URL_FIXTURES, PAGE_TITLE, PAGE_LAYOUT,
|
| 7 |
+
RF_N_ROLLING_DEFAULT, RF_N_ROLLING_MIN, RF_N_ROLLING_MAX,
|
| 8 |
+
RF_TARGETS_LOCAL, RF_TARGETS_VISIT,
|
| 9 |
+
TIPOS_APUESTA,
|
| 10 |
+
)
|
| 11 |
+
from src.data_loader import cargar_fixtures, cargar_historico, ultimos_n
|
| 12 |
+
from src.models import entrenar_rf_equipo
|
| 13 |
+
from src.betting import calc_probs, kelly
|
| 14 |
+
from src.styles import inject_css
|
| 15 |
+
from src.utils import med
|
| 16 |
+
|
| 17 |
+
st.set_page_config(page_title=PAGE_TITLE, layout=PAGE_LAYOUT)
|
| 18 |
+
inject_css()
|
| 19 |
+
|
| 20 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 21 |
+
# NAVEGACIÓN — debe definirse ANTES de renderizar cualquier st.page_link()
|
| 22 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 23 |
+
_p_inicio = st.Page("pages/inicio.py", title="Inicio", icon="🏠", default=True)
|
| 24 |
+
_p_stats = st.Page("pages/estadisticas.py", title="Estadísticas", icon="📊")
|
| 25 |
+
_p_comp = st.Page("pages/comparacion.py", title="Comparación", icon="⚔️")
|
| 26 |
+
_p_rf = st.Page("pages/prediccion_rf.py", title="Predicción RF", icon="🤖")
|
| 27 |
+
_p_kelly = st.Page("pages/cuotas_kelly.py", title="Cuotas & Kelly", icon="💰")
|
| 28 |
+
_p_ia = st.Page("pages/analisis_ia.py", title="Análisis IA", icon="🧠")
|
| 29 |
+
|
| 30 |
+
pg = st.navigation(
|
| 31 |
+
[_p_inicio, _p_stats, _p_comp, _p_rf, _p_kelly, _p_ia],
|
| 32 |
+
position="hidden",
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _to_utc6(t) -> str:
|
| 37 |
+
try:
|
| 38 |
+
h, m = map(int, str(t).split(":"))
|
| 39 |
+
total = (h * 60 + m - 360) % (24 * 60)
|
| 40 |
+
return f"{total // 60:02d}:{total % 60:02d}"
|
| 41 |
+
except Exception:
|
| 42 |
+
return ""
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ── Carga inicial ─────────────────────────────────────────────────────────────
|
| 46 |
+
df_proximos = cargar_fixtures(URL_FIXTURES)
|
| 47 |
+
if df_proximos is None:
|
| 48 |
+
st.error("No se pudo cargar la información de fixtures.")
|
| 49 |
+
st.stop()
|
| 50 |
+
|
| 51 |
+
df_proximos["Date"] = pd.to_datetime(df_proximos["Date"], dayfirst=True, errors="coerce")
|
| 52 |
+
df_proximos = df_proximos.dropna(subset=["Date"])
|
| 53 |
+
if "Time" in df_proximos.columns:
|
| 54 |
+
df_proximos["Time_local"] = df_proximos["Time"].apply(_to_utc6)
|
| 55 |
+
st.session_state["_df_proximos"] = df_proximos
|
| 56 |
+
st.session_state["_p_estadisticas"] = _p_stats
|
| 57 |
+
|
| 58 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 59 |
+
# TOPBAR
|
| 60 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 61 |
+
_col_logo, _col_nav = st.columns([2, 8])
|
| 62 |
+
|
| 63 |
+
with _col_logo:
|
| 64 |
+
st.markdown(
|
| 65 |
+
'<div class="fs-topbar-logo"><div class="tl-icon">⚽</div> FutStats</div>',
|
| 66 |
+
unsafe_allow_html=True,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
with _col_nav:
|
| 70 |
+
_nc = st.columns(6)
|
| 71 |
+
with _nc[0]: st.page_link(_p_inicio, label="Inicio", icon="🏠")
|
| 72 |
+
with _nc[1]: st.page_link(_p_stats, label="Estadísticas", icon="📊")
|
| 73 |
+
with _nc[2]: st.page_link(_p_comp, label="Comparación", icon="⚔️")
|
| 74 |
+
with _nc[3]: st.page_link(_p_rf, label="Predicción RF", icon="🤖")
|
| 75 |
+
with _nc[4]: st.page_link(_p_kelly, label="Cuotas & Kelly", icon="💰")
|
| 76 |
+
with _nc[5]: st.page_link(_p_ia, label="Análisis IA", icon="🧠")
|
| 77 |
+
|
| 78 |
+
st.markdown("<hr style='margin:4px 0 16px;border:none;border-top:1px solid var(--border)'>", unsafe_allow_html=True)
|
| 79 |
+
|
| 80 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 81 |
+
# MATCH SELECCIONADO
|
| 82 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 83 |
+
_sel = st.session_state.get("_selected_match")
|
| 84 |
+
|
| 85 |
+
df_hist = dl_all = dv_all = None
|
| 86 |
+
liga = eq_l = eq_v = hora_str = ""
|
| 87 |
+
fecha_sel = None
|
| 88 |
+
nl = nv = n_rf = 0
|
| 89 |
+
ventaja_pct = 0
|
| 90 |
+
ventaja_equipo = None
|
| 91 |
+
el = ev = "Todos"
|
| 92 |
+
_has_match = False
|
| 93 |
+
|
| 94 |
+
if _sel is not None:
|
| 95 |
+
liga = _sel.get("Div", "")
|
| 96 |
+
eq_l = _sel.get("HomeTeam", "")
|
| 97 |
+
eq_v = _sel.get("AwayTeam", "")
|
| 98 |
+
hora_str = _sel.get("Time_local", _sel.get("Time", ""))
|
| 99 |
+
fecha_raw = _sel.get("Date")
|
| 100 |
+
fecha_sel = pd.to_datetime(fecha_raw).date() if fecha_raw else None
|
| 101 |
+
|
| 102 |
+
hora_main = f" · {hora_str}" if hora_str else ""
|
| 103 |
+
fecha_str = fecha_sel.strftime("%d %b %Y") if fecha_sel else ""
|
| 104 |
+
|
| 105 |
+
st.markdown(
|
| 106 |
+
f'<div class="fs-match-bar">'
|
| 107 |
+
f'<span class="home">{eq_l}</span>'
|
| 108 |
+
f'<span class="vs">vs</span>'
|
| 109 |
+
f'<span class="away">{eq_v}</span>'
|
| 110 |
+
f'<span class="meta">🏆 {liga}'
|
| 111 |
+
f'{" · " + fecha_str if fecha_str else ""}'
|
| 112 |
+
f'{" · " + hora_str if hora_str else ""}'
|
| 113 |
+
f'</span></div>',
|
| 114 |
+
unsafe_allow_html=True,
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
df_hist = cargar_historico(liga)
|
| 118 |
+
if df_hist is None or df_hist.empty:
|
| 119 |
+
st.error(f"Sin datos históricos para **{liga}**.")
|
| 120 |
+
st.stop()
|
| 121 |
+
|
| 122 |
+
dl_all = df_hist[df_hist["HomeTeam"] == eq_l]
|
| 123 |
+
dv_all = df_hist[df_hist["AwayTeam"] == eq_v]
|
| 124 |
+
|
| 125 |
+
_match_id = f"{liga}:{eq_l}:{eq_v}"
|
| 126 |
+
if st.session_state.get("_match_id") != _match_id:
|
| 127 |
+
st.session_state.pop("n_both", None)
|
| 128 |
+
st.session_state["_match_id"] = _match_id
|
| 129 |
+
|
| 130 |
+
# ── Configuración específica por página ───────────────────────────────
|
| 131 |
+
_current_page = st.session_state.get("_current_page", "")
|
| 132 |
+
|
| 133 |
+
n_max = min(len(dl_all), len(dv_all), 20)
|
| 134 |
+
_opts = [0] + list(range(3, n_max + 1))
|
| 135 |
+
_def_n = 5
|
| 136 |
+
_n_stored = st.session_state.get("n_both")
|
| 137 |
+
_n_idx = (
|
| 138 |
+
_opts.index(_n_stored) if _n_stored in _opts else
|
| 139 |
+
(_opts.index(_def_n) if _def_n in _opts else
|
| 140 |
+
(1 if len(_opts) > 1 else 0))
|
| 141 |
+
)
|
| 142 |
+
_n_default = _opts[_n_idx]
|
| 143 |
+
|
| 144 |
+
# Defaults — each branch only overrides what it owns
|
| 145 |
+
nl = nv = _n_default
|
| 146 |
+
n_rf = st.session_state.get("nrf", RF_N_ROLLING_DEFAULT)
|
| 147 |
+
ventaja_pct = st.session_state.get("ventaja_ia", 0)
|
| 148 |
+
ventaja_equipo = st.session_state.get("ventaja_equipo", None)
|
| 149 |
+
bet_type = st.session_state.get("_bet_type", TIPOS_APUESTA[0])
|
| 150 |
+
|
| 151 |
+
if _current_page == "comparacion":
|
| 152 |
+
_cc, _ = st.columns([3, 7])
|
| 153 |
+
with _cc:
|
| 154 |
+
nl = nv = st.selectbox(
|
| 155 |
+
f"📊 Últimos partidos · 🏠 {len(dl_all)} · ✈️ {len(dv_all)} disp.",
|
| 156 |
+
_opts, index=_n_idx,
|
| 157 |
+
format_func=lambda x: "Todos" if x == 0 else f"Últimos {x}",
|
| 158 |
+
key="n_both",
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
elif _current_page == "analisis_ia":
|
| 162 |
+
_cv1, _cv2, _ = st.columns([2, 2, 6])
|
| 163 |
+
with _cv1:
|
| 164 |
+
_ventaja_opts = [0, 5, 10, 15, 20]
|
| 165 |
+
_ventaja_stored = st.session_state.get("ventaja_ia", 0)
|
| 166 |
+
_ventaja_idx = _ventaja_opts.index(_ventaja_stored) if _ventaja_stored in _ventaja_opts else 0
|
| 167 |
+
ventaja_pct = st.selectbox(
|
| 168 |
+
"📈 Ventaja informacional:",
|
| 169 |
+
_ventaja_opts,
|
| 170 |
+
index=_ventaja_idx,
|
| 171 |
+
format_func=lambda x: "Sin ventaja" if x == 0 else f"+{x}%",
|
| 172 |
+
key="ventaja_ia",
|
| 173 |
+
help="Porcentaje de ventaja sobre las cuotas del mercado.",
|
| 174 |
+
)
|
| 175 |
+
ventaja_equipo = None
|
| 176 |
+
if ventaja_pct > 0:
|
| 177 |
+
with _cv2:
|
| 178 |
+
ventaja_equipo = st.radio(
|
| 179 |
+
"Ventaja para:",
|
| 180 |
+
[f"🏠 {eq_l}", f"✈️ {eq_v}"],
|
| 181 |
+
horizontal=True,
|
| 182 |
+
key="ventaja_equipo",
|
| 183 |
+
)
|
| 184 |
+
# estadisticas, prediccion_rf, cuotas_kelly, inicio → all use defaults
|
| 185 |
+
|
| 186 |
+
_has_match = True
|
| 187 |
+
|
| 188 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 189 |
+
# DATOS COMPARTIDOS
|
| 190 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 191 |
+
if _has_match:
|
| 192 |
+
dl = ultimos_n(dl_all, nl)
|
| 193 |
+
dv = ultimos_n(dv_all, nv)
|
| 194 |
+
_periodo = "Todos" if nl == 0 else f"Últimos {nl}"
|
| 195 |
+
el = ev = _periodo
|
| 196 |
+
|
| 197 |
+
if _current_page == "prediccion_rf":
|
| 198 |
+
with st.spinner("Entrenando modelos RF..."):
|
| 199 |
+
rf_l = entrenar_rf_equipo(df_hist, eq_l, eq_v, n_rf, RF_TARGETS_LOCAL)
|
| 200 |
+
rf_v = entrenar_rf_equipo(df_hist, eq_l, eq_v, n_rf, RF_TARGETS_VISIT)
|
| 201 |
+
else:
|
| 202 |
+
rf_l = rf_v = None
|
| 203 |
+
|
| 204 |
+
cH = pd.to_numeric(_sel.get("B365H", np.nan), errors="coerce")
|
| 205 |
+
cD = pd.to_numeric(_sel.get("B365D", np.nan), errors="coerce")
|
| 206 |
+
cA = pd.to_numeric(_sel.get("B365A", np.nan), errors="coerce")
|
| 207 |
+
if np.isnan(cH) and "B365H" in dl.columns:
|
| 208 |
+
cH = pd.to_numeric(dl["B365H"], errors="coerce").mean()
|
| 209 |
+
if np.isnan(cD) and "B365D" in dl.columns:
|
| 210 |
+
cD = pd.to_numeric(dl["B365D"], errors="coerce").mean()
|
| 211 |
+
if np.isnan(cA) and "B365A" in dl.columns:
|
| 212 |
+
cA = pd.to_numeric(dl["B365A"], errors="coerce").mean()
|
| 213 |
+
|
| 214 |
+
probs = calc_probs(df_hist, eq_l, eq_v)
|
| 215 |
+
kelly_ok = probs is not None and not any(np.isnan([cH, cD, cA]))
|
| 216 |
+
|
| 217 |
+
if kelly_ok:
|
| 218 |
+
kH = kelly(probs["H"], cH)
|
| 219 |
+
kD = kelly(probs["D"], cD)
|
| 220 |
+
kA = kelly(probs["A"], cA)
|
| 221 |
+
veH = (probs["H"] * cH - 1) * 100
|
| 222 |
+
veD = (probs["D"] * cD - 1) * 100
|
| 223 |
+
veA = (probs["A"] * cA - 1) * 100
|
| 224 |
+
pH, pD, pA = probs["H"] * 100, probs["D"] * 100, probs["A"] * 100
|
| 225 |
+
else:
|
| 226 |
+
kH = kD = kA = veH = veD = veA = pH = pD = pA = 0.0
|
| 227 |
+
|
| 228 |
+
l_anota = med(dl, "FTHG") if not dl.empty else 0.0
|
| 229 |
+
v_recibe = med(dv, "FTHG") if not dv.empty else 0.0
|
| 230 |
+
v_anota = med(dv, "FTAG") if not dv.empty else 0.0
|
| 231 |
+
l_recibe = med(dl, "FTAG") if not dl.empty else 0.0
|
| 232 |
+
|
| 233 |
+
bet_type = st.session_state.get("_bet_type", TIPOS_APUESTA[0])
|
| 234 |
+
|
| 235 |
+
st.session_state["_ctx"] = {
|
| 236 |
+
"liga": liga, "eq_l": eq_l, "eq_v": eq_v,
|
| 237 |
+
"fecha_sel": fecha_sel, "hora_str": hora_str,
|
| 238 |
+
"el": el, "ev": ev,
|
| 239 |
+
"dl": dl, "dv": dv, "df_hist": df_hist,
|
| 240 |
+
"rf_l": rf_l, "rf_v": rf_v,
|
| 241 |
+
"probs": probs, "kelly_ok": kelly_ok,
|
| 242 |
+
"cH": cH, "cD": cD, "cA": cA,
|
| 243 |
+
"kH": kH, "kD": kD, "kA": kA,
|
| 244 |
+
"veH": veH, "veD": veD, "veA": veA,
|
| 245 |
+
"pH": pH, "pD": pD, "pA": pA,
|
| 246 |
+
"l_anota": l_anota, "v_recibe": v_recibe,
|
| 247 |
+
"v_anota": v_anota, "l_recibe": l_recibe,
|
| 248 |
+
"ventaja_pct": ventaja_pct, "ventaja_equipo": ventaja_equipo,
|
| 249 |
+
"bet_type": bet_type,
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
hora_main = f" · {hora_str}" if hora_str else ""
|
| 253 |
+
fecha_str_main = fecha_sel.strftime("%d %b %Y") if fecha_sel else ""
|
| 254 |
+
st.markdown(f"""<div class="fs-header">
|
| 255 |
+
<div>
|
| 256 |
+
<h1>
|
| 257 |
+
<span style="color:var(--blue)">{eq_l}</span>
|
| 258 |
+
<span style="color:var(--text-muted);font-weight:400;font-size:18px;padding:0 12px">vs</span>
|
| 259 |
+
<span style="color:var(--red)">{eq_v}</span>
|
| 260 |
+
</h1>
|
| 261 |
+
<div class="sub">🏆 {liga} · 📅 {fecha_str_main}{hora_main}</div>
|
| 262 |
+
</div>
|
| 263 |
+
</div>""", unsafe_allow_html=True)
|
| 264 |
+
|
| 265 |
+
st.markdown(f"""<div class="fs-stats-bar">
|
| 266 |
+
<div class="si"><div class="sn">{len(dl)}</div><div class="sl">🏠 {eq_l} local</div></div>
|
| 267 |
+
<div class="si"><div class="sn blue">{len(dv)}</div><div class="sl">✈️ {eq_v} visit.</div></div>
|
| 268 |
+
<div class="si"><div class="sn red">{len(df_hist)}</div><div class="sl">Histórico {liga}</div></div>
|
| 269 |
+
<div class="si"><div class="sn yellow">{len(df_proximos)}</div><div class="sl">Fixtures disponibles</div></div>
|
| 270 |
+
</div>""", unsafe_allow_html=True)
|
| 271 |
+
|
| 272 |
+
else:
|
| 273 |
+
st.session_state.pop("_ctx", None)
|
| 274 |
|
| 275 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 276 |
+
# EJECUTAR PÁGINA ACTIVA
|
| 277 |
+
# ════════════════════════════════════════════════════════════════════════════
|
| 278 |
+
pg.run()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|