spjasper commited on
Commit
4e172b0
·
1 Parent(s): 2271e8c
requirements.txt CHANGED
@@ -1,3 +1,9 @@
1
- altair
2
- pandas
3
- streamlit
 
 
 
 
 
 
 
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">&#x1F916;</div>
59
+ <div>
60
+ <div class="ai-title">Claude Analysis</div>
61
+ <div class="ai-sub">claude-opus-4-6 &middot; 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>&#x1F3E0; {eq_l}</span>
41
+ <span>Empate</span>
42
+ <span>&#x2708;&#xFE0F; {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 &mdash; {eq_l}", cH, pH, veH, probs["H"] > 1/cH)}
70
+ {_odds_card("X &mdash; Empate", cD, pD, veD, probs["D"] > 1/cD)}
71
+ {_odds_card(f"2 &mdash; {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 &nbsp;·&nbsp; 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">&#x2705;</div>
144
+ <div>
145
+ <div class="rec-title">Mejor apuesta: {mejor['Resultado']}</div>
146
+ <div class="rec-detail">
147
+ {mejor['Kelly (%)']:.2f}% del bankroll &nbsp;·&nbsp; Cuota {mejor['Cuota B365']} &nbsp;·&nbsp; 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">&#x26D4;</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>&#x1F3E0; {eq_l} &nbsp;<strong>{piH_n:.1f}%</strong> &nbsp;·&nbsp; Cuota {cH:.2f} &nbsp;<small style="opacity:.65">(hist. {pH:.1f}%)</small></span>
70
+ <span>Empate &nbsp;<strong>{piD_n:.1f}%</strong> &nbsp;·&nbsp; Cuota {cD:.2f} &nbsp;<small style="opacity:.65">(hist. {pD:.1f}%)</small></span>
71
+ <span>&#x2708;&#xFE0F; {eq_v} &nbsp;<strong>{piA_n:.1f}%</strong> &nbsp;·&nbsp; Cuota {cA:.2f} &nbsp;<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">&#x2705;</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 &nbsp;·&nbsp; Cuota {_best[2]:.2f} &nbsp;·&nbsp; 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">&#x26D4;</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">&#x1F3C6; {total_ligas} ligas &middot; &#x26BD; {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} &nbsp;·&nbsp; {ligas}'
175
+ f' &nbsp;·&nbsp; {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">&#x1F3E0; {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">&#x1F916;</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">&#x2708;&#xFE0F; {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">&#x1F916;</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
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
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">&#x1F3C6; {liga}'
111
+ f'{" &middot; " + fecha_str if fecha_str else ""}'
112
+ f'{" &middot; " + 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" &middot; {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">&#x1F3C6; {liga} &middot; &#x1F4C5; {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">&#x1F3E0; {eq_l} local</div></div>
267
+ <div class="si"><div class="sn blue">{len(dv)}</div><div class="sl">&#x2708;&#xFE0F; {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()