Erinaldorodrigues commited on
Commit
888ef7f
·
1 Parent(s): cd4fb81

Release Safe Bet AI v2.2 Precision

Browse files
.dockerignore CHANGED
@@ -1,8 +1,9 @@
1
- .git
2
- .github
3
- __pycache__
4
- *.pyc
5
- .pytest_cache
 
6
  .env
7
  data/*
8
  !data/.gitkeep
 
1
+ .git/
2
+ .github/
3
+ **/__pycache__/
4
+ **/*.py[cod]
5
+ **/.pytest_cache/
6
+ .venv/
7
  .env
8
  data/*
9
  !data/.gitkeep
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ .env
2
+ .venv/
3
+ __pycache__/
4
+ *.py[cod]
5
+ .pytest_cache/
6
+ data/*.json
7
+ data/*.corrupt
CHECKSUMS.sha256 CHANGED
@@ -1,36 +1,40 @@
1
- 34b842a11bd1c675873df97d1c032d7dedeff0d93ae167244493574fb096e8c0 .dockerignore
2
  393d9f8ee564c3e985b671dfd34cfdeae5e290d27d483ae1dfd534855d27411d .env.example
3
- d423d2e687315a34b041fe690cd045ba162c06b0ae6260512709c236ff648797 Dockerfile
4
- c13f9e8f0e627f42cdaf11e674b456e4e1b03ab926f3be4ba274f982cca61ed6 PRECISION_REVIEW.md
5
- d7a6b6598800700c194c36e70cdef13ba049ed6ebc5ccd3415dbcd316ab1ba4a README.md
 
6
  0c05dd5ce39f36a555da3192cdbb785ae8f9c71bf5f94f91c14880f13b6a8e55 RELEASE_NOTES_v2.1.md
7
- 592367be07fd2634e33177b18842182227d16ea25207df66f5464f6d09e77238 TUTORIAL.md
 
8
  e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/__init__.py
9
- b679eb6936584d1b1bfc2c0162ffe1a2218648d7d981fb9a83067f6412704ce2 app/config.py
10
  e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/core/__init__.py
11
- f84db84500a6a4b1f8381888bd5b3820f464d96dce0a7815b5af0fa43e056d4c app/core/analyzer.py
12
- 7d66ed9ea74cd02dedef1029308509bcf46dd573adab66ff84f859ca902a16f9 app/core/calibration.py
13
  545b836f97ca6f9d847055ae2632f3dfefdee0749555e0e4622d7addc6f3b554 app/core/competitions.py
14
- 14fa22b0ba81c04443909de90d5bb0eb9a4832ce7528e5b99c3337fa503e58d6 app/core/history.py
15
- bec1a6922826ee7e19afaec136e3b8053ac2e0b52523eacfe5c255146e96c5c7 app/core/market.py
16
  70922a2b322aeb7512ba9147e391687cb8d50083523d03fa3fd6ae9441a94fc2 app/core/names.py
17
- 3c4a9fc6c716785459bd8aef41f777c7cc8be707fbef4c9d2ee14cbf7d7cf732 app/core/stats.py
18
- 56ab38e8c6deb1448df623c34950a8648401b1fcea3d6a6484ff32619ac579fe app/core/tickets.py
19
  8c76f9b59d601719dee412f494a964ea588ff5e6393af49c203765524fe26bd2 app/logging_config.py
20
- d92471ccb88b0ecc30148e7032b4fe447e86e154cb1571b48dd513a44002b6c0 app/main.py
21
- 9958bacf944a977369e7704ef7a61b54ee06446e062962b9044af47c1ab59694 app/models.py
22
- ac3bcce63058c79c9256c60f5dc257df3eeb143eb0a786379284b6df44673685 app/pipeline.py
23
  e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/providers/__init__.py
24
- deb29e44290232c6c898f8b3a80dcf273e80ad2a5350065dda85e4c5e2654494 app/providers/football_data.py
25
- 02a64da3890d1aa216caa51f50734c583196b5021335e888418bc983d136fcd9 app/providers/http_client.py
26
- 37b51de47ea1b9a3fe8a4c25c55c05ce8aedfa4fe845697c931434a3324741b0 app/providers/odds_api.py
27
- 8ba97c4e1dce9ce330bdb55209098f3ab8cb83ccd61a0da2f9cdabde36570031 app/static/app.js
28
- 5632fd8a1c655b73840a43319f8f915a8a052af0b8a8f8ce160cdfc5c9b354b8 app/static/style.css
29
- 56c31e2a8b1a12325daf1e6827a400d6a13bf0937143dacdfe0c9f124073585b app/storage.py
30
- cc14ab05dd7989f73643891e70d40489c0c2168314b30558b7f359478fd97cc4 app/templates/index.html
31
  e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 data/.gitkeep
32
- d3d70cc25a40e1e5c421e48d7644a87da128918f2699481d2bbf9e15854bcf52 deploy_to_hf.py
33
  37b7898dd0df68d7bacac4726b4836010200403a1abe9542fe229caebe39b8a3 pytest.ini
34
  8636f9ab1a075be9f3039e2a6471837259c4f36b625bcaf7a3d9a1edd2419c6d requirements-dev.txt
35
  27d9fc069a1ce60bdf78cf32226af023d57a63c5ab4076797edb3d05258a5397 requirements.txt
36
- 7edb53051f81e3b79d7a9b3a6fd0054124a9e489d7fb807a088f67fb0aded3d4 tests/test_core.py
 
 
 
1
+ e1ec325a22b4eb48475479b216b89793b73a4880ea65285af0350b29f6be7c5f .dockerignore
2
  393d9f8ee564c3e985b671dfd34cfdeae5e290d27d483ae1dfd534855d27411d .env.example
3
+ aee35c88b014bcd60a1feca85c7c6f69da4aaee7aa1cdea78761571aa79e1727 .gitignore
4
+ b4577ca0a14aae20e6e6cee3eb5e09fb94b29ae6d437774f4d5ec98845854372 Dockerfile
5
+ 9a5562772cc0ce8a95bef7fd7c3667dcf1c0aaf3b5523db750c23cce9ea22292 PRECISION_REVIEW.md
6
+ 95bca2162a9633bb3cbb02fd9dab054347320d07872abacf9d6ab483171d30e6 README.md
7
  0c05dd5ce39f36a555da3192cdbb785ae8f9c71bf5f94f91c14880f13b6a8e55 RELEASE_NOTES_v2.1.md
8
+ 83fe33e358a79959c3c5de0b6952f5eeb2e2d638ca48ef28b44489898a089df7 RELEASE_NOTES_v2.2.md
9
+ a68770f781b7acacfaaf56e5110d9846b7001e87d9936d433990b8393d5b86e5 TUTORIAL.md
10
  e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/__init__.py
11
+ c4891fc509f38fb94d9006be910c18b7573645c11da3532c38356302d99c7e55 app/config.py
12
  e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/core/__init__.py
13
+ ac21a5ed1bb8695b6dad694de61c2c85b5ad7c086f5ca9b9c41f484084a88caf app/core/analyzer.py
14
+ 48c155a14807a76713edb7579076fa877587e5cff9e35f8f7d0fef35d821396f app/core/calibration.py
15
  545b836f97ca6f9d847055ae2632f3dfefdee0749555e0e4622d7addc6f3b554 app/core/competitions.py
16
+ 0545a029d7488a10cc6d3dd8d2c69c38d5b6b3246ceb34b035ed0f2b77570e49 app/core/history.py
17
+ d2a9298d6d322fecb72184990c6c7bd720be5b3b12fb9245615f3687ee890aba app/core/market.py
18
  70922a2b322aeb7512ba9147e391687cb8d50083523d03fa3fd6ae9441a94fc2 app/core/names.py
19
+ 0dd37c9902bcb7e95e773af47d70dc970ba23124f237443889066e9a133574dc app/core/stats.py
20
+ abf7d9982399348a65b3f1e1b14ccdbe03e0b37c984eff790ed59248738fa56c app/core/tickets.py
21
  8c76f9b59d601719dee412f494a964ea588ff5e6393af49c203765524fe26bd2 app/logging_config.py
22
+ 2c30ffa77b5c69a67ab90a257504af4372909de8e95c4f61e32b752352f112d9 app/main.py
23
+ 34193815ff5356263a850c254ab48a2850b12f3b623831ea81b61708fced9519 app/models.py
24
+ c1af8e753cd3c63f2286719b2ae091ec3364d365f287bf7db9cb3747f2203ca0 app/pipeline.py
25
  e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/providers/__init__.py
26
+ bd060539c6d9bedb43d57aa7ff1e514bcf0a837b25d3db37212052fd23068465 app/providers/football_data.py
27
+ 1ffb376b0856a863a512d06ae36a736003692a8bbf5e74cfe23704c2c98f29d9 app/providers/http_client.py
28
+ fd692a949fd6c489203230cc6e2c894310cf5da4ed8f2ad625bf592945013bf5 app/providers/odds_api.py
29
+ adfcb95146b325af7cfddb67e1ab29f714f912736374c6d0d384f9ba93139659 app/static/app.js
30
+ 3e685cc5ab9c8c2e05edd8e60fbcb14079ef78dcb1ceb992f0a5c65a6854b2dd app/static/style.css
31
+ a6e1b0e654921c192f0af7cc05ec758ede5df2d64b64cc0eb4afe45665ec79c3 app/storage.py
32
+ 3bd44f894eaf87f049769fa47310497d789e11fc953e6826beb616e529c35e7c app/templates/index.html
33
  e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 data/.gitkeep
34
+ 7c898f5e3c33c6a50e18147f1eda9eb11daf7c6bdef9de8870530f9759b3e532 deploy_to_hf.py
35
  37b7898dd0df68d7bacac4726b4836010200403a1abe9542fe229caebe39b8a3 pytest.ini
36
  8636f9ab1a075be9f3039e2a6471837259c4f36b625bcaf7a3d9a1edd2419c6d requirements-dev.txt
37
  27d9fc069a1ce60bdf78cf32226af023d57a63c5ab4076797edb3d05258a5397 requirements.txt
38
+ 66b37f1efcf7bc1f3b2c2437dc991f489a0413b3de9268565fd17eca882880b2 tests/test_core.py
39
+ a0af163abacd7022e70f87b92e7da89a1b87aea4c34588287bd2e4c345d8228a tests/test_pipeline.py
40
+ a3a04237f3d8b574dd4aa394875d208ed019e5fce9fa51b3f760ea49a01e6f2e tests/test_robustness.py
Dockerfile CHANGED
@@ -5,14 +5,21 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
5
  PIP_NO_CACHE_DIR=1 \
6
  PORT=7860
7
 
8
- WORKDIR /app
9
 
10
- COPY requirements.txt .
 
 
 
 
 
11
  RUN pip install --upgrade pip && pip install -r requirements.txt
12
 
13
- COPY . .
 
 
14
 
15
- RUN mkdir -p /app/data && \
16
  python -m compileall -q app
17
 
18
  EXPOSE 7860
 
5
  PIP_NO_CACHE_DIR=1 \
6
  PORT=7860
7
 
8
+ RUN useradd --create-home --uid 1000 user
9
 
10
+ ENV HOME=/home/user \
11
+ PATH=/home/user/.local/bin:$PATH
12
+
13
+ WORKDIR $HOME/app
14
+
15
+ COPY --chown=user:user requirements.txt .
16
  RUN pip install --upgrade pip && pip install -r requirements.txt
17
 
18
+ COPY --chown=user:user . .
19
+
20
+ USER user
21
 
22
+ RUN mkdir -p $HOME/app/data && \
23
  python -m compileall -q app
24
 
25
  EXPOSE 7860
PRECISION_REVIEW.md CHANGED
@@ -1,5 +1,8 @@
1
  # Revisão profunda de precisão — v2.1-precision
2
 
 
 
 
3
  Esta revisão focou em erros silenciosos que podem produzir uma confiança artificialmente alta.
4
 
5
  ## 1. Correções críticas
 
1
  # Revisão profunda de precisão — v2.1-precision
2
 
3
+ > Documento histórico da revisão v2.1. Para as mudanças atuais, consulte
4
+ > `RELEASE_NOTES_v2.2.md`.
5
+
6
  Esta revisão focou em erros silenciosos que podem produzir uma confiança artificialmente alta.
7
 
8
  ## 1. Correções críticas
README.md CHANGED
@@ -8,12 +8,18 @@ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
- # SAFE BET AI — Precision v2.1
12
 
13
  Motor quantitativo de futebol para Docker Space, desenhado para **rejeitar apostas frágeis** em vez de forçar uma lista.
14
 
15
  ## O que mudou nesta versão
16
 
 
 
 
 
 
 
17
  - Histórico **separado por competição** para impedir misturas entre ligas.
18
  - Matching de times com IDs/aliases do football-data.org e detecção de ambiguidade.
19
  - Resultado histórico usa **tempo regulamentar** quando disponível (importante em mata-mata/1X2).
@@ -28,7 +34,7 @@ Motor quantitativo de futebol para Docker Space, desenhado para **rejeitar apost
28
  - The Odds API consulta primeiro `/sports`, que não consome quota, para ignorar ligas fora de temporada.
29
  - Forward tracking não duplica o mesmo evento caso a seleção mude em outro scan.
30
  - Bilhetes recebem stress de dependência quando há muitas pernas da mesma competição.
31
- - 24 testes automatizados cobrindo matemática, matching, mercado, segurança HTTP, histórico e tickets.
32
 
33
  > **SAFE não significa garantia.** O sistema estima risco e qualidade de dados; futebol continua sendo aleatório.
34
 
@@ -59,4 +65,4 @@ pytest -q
59
  - `/api/cron/daily` — cron protegido por `X-Cron-Secret`
60
  - `/api/admin/scan?wait=1` — scan manual protegido
61
 
62
- Leia **TUTORIAL.md** e **PRECISION_REVIEW.md** antes do deploy.
 
8
  pinned: false
9
  ---
10
 
11
+ # SAFE BET AI — Precision v2.2
12
 
13
  Motor quantitativo de futebol para Docker Space, desenhado para **rejeitar apostas frágeis** em vez de forçar uma lista.
14
 
15
  ## O que mudou nesta versão
16
 
17
+ - Painel completamente redesenhado, responsivo, acessível e sem dependências externas.
18
+ - Validação walk-forward agora separa tuning e holdout temporal para reduzir otimismo.
19
+ - Scans concorrentes compartilham uma única tarefa e sobrevivem à desconexão do cliente.
20
+ - Falhas totais de providers preservam o estado anterior; falhas parciais ficam como `degraded`.
21
+ - Liquidação usa IDs resolvidos de times e mantém trilha de publicação/partida liquidada.
22
+ - Storage Bucket montado em `/data` é usado automaticamente quando disponível.
23
  - Histórico **separado por competição** para impedir misturas entre ligas.
24
  - Matching de times com IDs/aliases do football-data.org e detecção de ambiguidade.
25
  - Resultado histórico usa **tempo regulamentar** quando disponível (importante em mata-mata/1X2).
 
34
  - The Odds API consulta primeiro `/sports`, que não consome quota, para ignorar ligas fora de temporada.
35
  - Forward tracking não duplica o mesmo evento caso a seleção mude em outro scan.
36
  - Bilhetes recebem stress de dependência quando há muitas pernas da mesma competição.
37
+ - Testes automatizados cobrindo matemática, matching, mercado, providers, segurança, histórico e tickets.
38
 
39
  > **SAFE não significa garantia.** O sistema estima risco e qualidade de dados; futebol continua sendo aleatório.
40
 
 
65
  - `/api/cron/daily` — cron protegido por `X-Cron-Secret`
66
  - `/api/admin/scan?wait=1` — scan manual protegido
67
 
68
+ Leia **TUTORIAL.md**, **PRECISION_REVIEW.md** e **RELEASE_NOTES_v2.2.md** antes do deploy.
RELEASE_NOTES_v2.2.md ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Safe Bet AI — Release Notes v2.2-precision
2
+
3
+ ## Precisão e integridade
4
+
5
+ - O ajuste de pesos usa uma janela temporal de tuning e um holdout posterior separado.
6
+ - A confiança do ensemble é regulada pela amostra e pelo Brier Skill do holdout.
7
+ - Eventos ainda abertos de outra versão não são republicados nem duplicados no histórico.
8
+ - Picks guardam os IDs resolvidos dos times; a liquidação prefere IDs exatos e registra o ID da partida.
9
+ - Eventos iniciados não entram na análise e partidas não são liquidadas antes do tempo plausível de término.
10
+
11
+ ## Robustez operacional
12
+
13
+ - Scans manuais e agendados compartilham uma única tarefa protegida contra cancelamento do cliente.
14
+ - Estado `scanning` interrompido por reinício é recuperado explicitamente.
15
+ - Falha total de um provider preserva o último estado; falha parcial gera estado `degraded`.
16
+ - Respostas malformadas, JSON não finito e registros históricos inválidos são isolados com segurança.
17
+ - Credenciais refletidas por providers são removidas das mensagens de erro.
18
+ - Regiões e ligas inválidas falham de forma fechada para não desperdiçar quota.
19
+ - Cada retry do football-data.org passa pelo rate guard.
20
+ - O Storage Bucket em `/data` é detectado automaticamente e o backup em Dataset usa um único commit.
21
+
22
+ ## Painel e deploy
23
+
24
+ - Interface profissional de apostas esportivas, responsiva e acessível.
25
+ - KPIs, cards de picks, auditoria dos modelos, comparador manual de odds e três perfis de bilhete.
26
+ - Estados de carregamento, scan, operação parcial, interrupção e erro tratados no frontend.
27
+ - CSP e cabeçalhos de segurança compatíveis com o iframe oficial do Hugging Face.
28
+ - Container executa como usuário não-root UID 1000, conforme recomendação do Hugging Face.
29
+
30
+ ## Validação local
31
+
32
+ - `pytest -q`: 44 testes aprovados.
33
+ - `python -m compileall -q app tests`: aprovado.
34
+ - `node --check app/static/app.js`: aprovado.
35
+ - Contratos HTML/JavaScript, CSP e diff Git: aprovados.
TUTORIAL.md CHANGED
@@ -161,7 +161,8 @@ Você deve receber JSON com:
161
  ```json
162
  {
163
  "ok": true,
164
- "version": "2.1-precision"
 
165
  }
166
  ```
167
 
@@ -225,11 +226,12 @@ warnings
225
  rejected_preview
226
  ```
227
 
228
- Estado normal:
229
 
230
  ```text
231
  status = ok
232
- model_version = 2.1-precision
 
233
  ```
234
 
235
  Se `picks` estiver vazio, veja `rejected_preview`.
@@ -458,10 +460,10 @@ python -m compileall -q app tests
458
  pytest -q
459
  ```
460
 
461
- A revisão entregue passou:
462
 
463
  ```text
464
- 24 passed
465
  ```
466
 
467
  Também foram testados:
 
161
  ```json
162
  {
163
  "ok": true,
164
+ "ready": true,
165
+ "version": "2.2-precision"
166
  }
167
  ```
168
 
 
226
  rejected_preview
227
  ```
228
 
229
+ Estados normais:
230
 
231
  ```text
232
  status = ok
233
+ status = degraded # scan concluído, mas algum provider teve falha parcial
234
+ model_version = 2.2-precision
235
  ```
236
 
237
  Se `picks` estiver vazio, veja `rejected_preview`.
 
460
  pytest -q
461
  ```
462
 
463
+ A revisão v2.2 passou:
464
 
465
  ```text
466
+ 44 passed
467
  ```
468
 
469
  Também foram testados:
app/config.py CHANGED
@@ -8,7 +8,20 @@ from app.core.competitions import COMPETITIONS
8
 
9
 
10
  DEFAULT_SPORT_KEYS = tuple(COMPETITIONS.keys())
11
- MODEL_VERSION = "2.1-precision"
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
 
14
  def _int(name: str, default: int, minimum: int, maximum: int) -> int:
@@ -27,13 +40,25 @@ def _float(name: str, default: float, minimum: float, maximum: float) -> float:
27
  return max(minimum, min(maximum, value))
28
 
29
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  @dataclass(frozen=True)
31
  class Settings:
32
  football_data_token: str = field(default_factory=lambda: os.getenv("FOOTBALL_DATA_TOKEN", "").strip())
33
  odds_api_key: str = field(default_factory=lambda: os.getenv("ODDS_API_KEY", "").strip())
34
  cron_secret: str = field(default_factory=lambda: os.getenv("CRON_SECRET", "").strip())
35
 
36
- odds_regions: str = field(default_factory=lambda: os.getenv("ODDS_REGIONS", "eu").strip() or "eu")
37
  history_days: int = field(default_factory=lambda: _int("HISTORY_DAYS", 240, 120, 730))
38
  scan_horizon_hours: int = field(default_factory=lambda: _int("SCAN_HORIZON_HOURS", 36, 6, 96))
39
  min_scan_interval_minutes: int = field(default_factory=lambda: _int("MIN_SCAN_INTERVAL_MINUTES", 180, 15, 1440))
@@ -48,22 +73,29 @@ class Settings:
48
  hf_token: str = field(default_factory=lambda: (os.getenv("HF_WRITE_TOKEN", "").strip() or os.getenv("HF_TOKEN", "").strip()))
49
  hf_dataset_repo: str = field(default_factory=lambda: os.getenv("HF_DATASET_REPO", "").strip())
50
 
51
- data_dir: Path = field(default_factory=lambda: Path(os.getenv("DATA_DIR", "/app/data" if Path("/app").exists() else "./data")))
52
 
53
  @property
54
  def sport_keys(self) -> tuple[str, ...]:
55
  raw = os.getenv("ODDS_SPORT_KEYS", "").strip()
56
  if not raw:
57
  return DEFAULT_SPORT_KEYS
58
- keys = tuple(x.strip() for x in raw.split(",") if x.strip())
59
- # Unknown leagues are ignored by the precision engine because we cannot
60
- # safely join them to football-data.org history.
61
- supported = tuple(k for k in keys if k in COMPETITIONS)
62
- return supported or DEFAULT_SPORT_KEYS
 
63
 
64
  @property
65
  def required_ready(self) -> bool:
66
- return bool(self.football_data_token and self.odds_api_key and self.cron_secret)
 
 
 
 
 
 
67
 
68
 
69
  settings = Settings()
 
8
 
9
 
10
  DEFAULT_SPORT_KEYS = tuple(COMPETITIONS.keys())
11
+ MODEL_VERSION = "2.2-precision"
12
+ VALID_ODDS_REGIONS = frozenset({"us", "us2", "uk", "au", "eu"})
13
+
14
+
15
+ def _default_data_dir() -> Path:
16
+ """Prefer the persistent volume mounted by Hugging Face Spaces."""
17
+ configured = os.getenv("DATA_DIR", "").strip()
18
+ if configured:
19
+ return Path(configured).expanduser()
20
+
21
+ persistent_root = Path("/data")
22
+ if persistent_root.is_dir():
23
+ return persistent_root / "safe-bet-ai"
24
+ return Path("/app/data") if Path("/app").is_dir() else Path("./data")
25
 
26
 
27
  def _int(name: str, default: int, minimum: int, maximum: int) -> int:
 
40
  return max(minimum, min(maximum, value))
41
 
42
 
43
+ def _odds_regions() -> str:
44
+ raw = os.getenv("ODDS_REGIONS")
45
+ if raw is None:
46
+ return "eu"
47
+ unique = list(dict.fromkeys(
48
+ part.strip().lower() for part in raw.split(",") if part.strip()
49
+ ))
50
+ if not unique or any(region not in VALID_ODDS_REGIONS for region in unique):
51
+ return ""
52
+ return ",".join(unique)
53
+
54
+
55
  @dataclass(frozen=True)
56
  class Settings:
57
  football_data_token: str = field(default_factory=lambda: os.getenv("FOOTBALL_DATA_TOKEN", "").strip())
58
  odds_api_key: str = field(default_factory=lambda: os.getenv("ODDS_API_KEY", "").strip())
59
  cron_secret: str = field(default_factory=lambda: os.getenv("CRON_SECRET", "").strip())
60
 
61
+ odds_regions: str = field(default_factory=_odds_regions)
62
  history_days: int = field(default_factory=lambda: _int("HISTORY_DAYS", 240, 120, 730))
63
  scan_horizon_hours: int = field(default_factory=lambda: _int("SCAN_HORIZON_HOURS", 36, 6, 96))
64
  min_scan_interval_minutes: int = field(default_factory=lambda: _int("MIN_SCAN_INTERVAL_MINUTES", 180, 15, 1440))
 
73
  hf_token: str = field(default_factory=lambda: (os.getenv("HF_WRITE_TOKEN", "").strip() or os.getenv("HF_TOKEN", "").strip()))
74
  hf_dataset_repo: str = field(default_factory=lambda: os.getenv("HF_DATASET_REPO", "").strip())
75
 
76
+ data_dir: Path = field(default_factory=_default_data_dir)
77
 
78
  @property
79
  def sport_keys(self) -> tuple[str, ...]:
80
  raw = os.getenv("ODDS_SPORT_KEYS", "").strip()
81
  if not raw:
82
  return DEFAULT_SPORT_KEYS
83
+ keys = tuple(dict.fromkeys(x.strip() for x in raw.split(",") if x.strip()))
84
+ # Fail closed when any requested league cannot be joined safely to
85
+ # football-data.org history; silently falling back would waste quota.
86
+ if not keys or any(key not in COMPETITIONS for key in keys):
87
+ return ()
88
+ return keys
89
 
90
  @property
91
  def required_ready(self) -> bool:
92
+ return bool(
93
+ self.football_data_token
94
+ and self.odds_api_key
95
+ and self.cron_secret
96
+ and self.odds_regions
97
+ and self.sport_keys
98
+ )
99
 
100
 
101
  settings = Settings()
app/core/analyzer.py CHANGED
@@ -132,6 +132,9 @@ def analyze_events(
132
  if kickoff is None:
133
  rejected.append({"event": event_name, "reason": "horário inválido"})
134
  continue
 
 
 
135
 
136
  market = market_consensus(event)
137
  if market.bookmakers < 1 or market.home_prob is None or market.away_prob is None or market.draw_prob is None:
@@ -182,10 +185,11 @@ def analyze_events(
182
  )
183
 
184
  tuning = tuning_by_comp[comp_code]
185
- tuning_samples = float(tuning["samples"])
 
186
  tuning_skill = float(tuning.get("brier_skill", 0.0))
187
- if tuning_samples >= 18:
188
- sample_validation = _clamp((tuning_samples - 18.0) / 38.0)
189
  skill_validation = _clamp((tuning_skill + 0.03) / 0.12)
190
  model_validation = 0.35 * sample_validation + 0.65 * skill_validation
191
  else:
@@ -229,6 +233,7 @@ def analyze_events(
229
  ]
230
 
231
  best = None
 
232
  previous = previous_by_event.get(str(event.get("id") or ""))
233
 
234
  for side, selection, idx, odd, mprob in candidate_rows:
@@ -276,6 +281,9 @@ def analyze_events(
276
 
277
  market_move = 0.0
278
  selection_changed = False
 
 
 
279
  if previous and previous.get("model_version") == MODEL_VERSION:
280
  if previous.get("side") == side and isinstance(previous.get("market_probability"), (int, float)):
281
  market_move = float(mprob) - float(previous["market_probability"])
@@ -303,12 +311,14 @@ def analyze_events(
303
  reasons.append("modelo interno diverge demais do mercado")
304
  if not 1.15 <= float(odd) <= 2.15:
305
  reasons.append("odd de referência fora da faixa SAFE")
306
- if ev < -0.025:
307
- reasons.append("preço de referência fraco")
308
  if market_move < -0.04:
309
  reasons.append("movimento de mercado relevante contra a seleção")
310
  if selection_changed:
311
  reasons.append("seleção mudou desde o último scan")
 
 
312
  if score < min_safe_score:
313
  reasons.append("SafeScore abaixo do mínimo")
314
 
@@ -354,6 +364,7 @@ def analyze_events(
354
  "weight_elo": float(model["weight_elo"]),
355
  "weight_form": float(model["weight_form"]),
356
  "tuning_samples": tuning_samples,
 
357
  "tuning_brier": float(tuning["brier"]),
358
  "tuning_climatology_brier": float(tuning.get("climatology_brier", 0.0)),
359
  "tuning_brier_skill": tuning_skill,
@@ -361,28 +372,33 @@ def analyze_events(
361
  },
362
  }
363
 
364
- if best is None or (
365
- row["conservative"],
366
- row["score"],
367
- row["ev"],
368
- ) > (
 
 
 
 
369
  best["conservative"],
370
  best["score"],
371
  best["ev"],
372
  ):
373
  best = row
374
 
375
- if not best:
376
  rejected.append({"event": event_name, "reason": "mercado incompleto"})
377
  continue
378
 
379
- if best["reasons"]:
 
380
  rejected.append({
381
  "event": event_name,
382
- "reason": "; ".join(best["reasons"]),
383
- "safe_score": round(best["score"], 1),
384
- "probability": round(best["p"], 4),
385
- "conservative_probability": round(best["conservative"], 4),
386
  })
387
  continue
388
 
@@ -417,6 +433,8 @@ def analyze_events(
417
  competition_code=comp_code,
418
  home=home_api,
419
  away=away_api,
 
 
420
  selection=best["selection"],
421
  side=best["side"],
422
  odd=round(best["odd"], 3),
 
132
  if kickoff is None:
133
  rejected.append({"event": event_name, "reason": "horário inválido"})
134
  continue
135
+ if kickoff <= datetime.now(timezone.utc):
136
+ rejected.append({"event": event_name, "reason": "evento já iniciado"})
137
+ continue
138
 
139
  market = market_consensus(event)
140
  if market.bookmakers < 1 or market.home_prob is None or market.away_prob is None or market.draw_prob is None:
 
185
  )
186
 
187
  tuning = tuning_by_comp[comp_code]
188
+ tuning_total_samples = float(tuning["samples"])
189
+ tuning_samples = float(tuning.get("validation_samples", tuning_total_samples))
190
  tuning_skill = float(tuning.get("brier_skill", 0.0))
191
+ if tuning_samples >= 10:
192
+ sample_validation = _clamp((tuning_samples - 10.0) / 12.0)
193
  skill_validation = _clamp((tuning_skill + 0.03) / 0.12)
194
  model_validation = 0.35 * sample_validation + 0.65 * skill_validation
195
  else:
 
233
  ]
234
 
235
  best = None
236
+ best_rejected = None
237
  previous = previous_by_event.get(str(event.get("id") or ""))
238
 
239
  for side, selection, idx, odd, mprob in candidate_rows:
 
281
 
282
  market_move = 0.0
283
  selection_changed = False
284
+ version_conflict = bool(
285
+ previous and previous.get("model_version") != MODEL_VERSION
286
+ )
287
  if previous and previous.get("model_version") == MODEL_VERSION:
288
  if previous.get("side") == side and isinstance(previous.get("market_probability"), (int, float)):
289
  market_move = float(mprob) - float(previous["market_probability"])
 
311
  reasons.append("modelo interno diverge demais do mercado")
312
  if not 1.15 <= float(odd) <= 2.15:
313
  reasons.append("odd de referência fora da faixa SAFE")
314
+ if ev < 0.0:
315
+ reasons.append("retorno esperado negativo no preço de referência")
316
  if market_move < -0.04:
317
  reasons.append("movimento de mercado relevante contra a seleção")
318
  if selection_changed:
319
  reasons.append("seleção mudou desde o último scan")
320
+ if version_conflict:
321
+ reasons.append("evento já rastreado por uma versão anterior")
322
  if score < min_safe_score:
323
  reasons.append("SafeScore abaixo do mínimo")
324
 
 
364
  "weight_elo": float(model["weight_elo"]),
365
  "weight_form": float(model["weight_form"]),
366
  "tuning_samples": tuning_samples,
367
+ "tuning_total_samples": tuning_total_samples,
368
  "tuning_brier": float(tuning["brier"]),
369
  "tuning_climatology_brier": float(tuning.get("climatology_brier", 0.0)),
370
  "tuning_brier_skill": tuning_skill,
 
372
  },
373
  }
374
 
375
+ rank = (row["conservative"], row["score"], row["ev"])
376
+ if row["reasons"]:
377
+ if best_rejected is None or rank > (
378
+ best_rejected["conservative"],
379
+ best_rejected["score"],
380
+ best_rejected["ev"],
381
+ ):
382
+ best_rejected = row
383
+ elif best is None or rank > (
384
  best["conservative"],
385
  best["score"],
386
  best["ev"],
387
  ):
388
  best = row
389
 
390
+ if best is None and best_rejected is None:
391
  rejected.append({"event": event_name, "reason": "mercado incompleto"})
392
  continue
393
 
394
+ if best is None:
395
+ assert best_rejected is not None
396
  rejected.append({
397
  "event": event_name,
398
+ "reason": "; ".join(best_rejected["reasons"]),
399
+ "safe_score": round(best_rejected["score"], 1),
400
+ "probability": round(best_rejected["p"], 4),
401
+ "conservative_probability": round(best_rejected["conservative"], 4),
402
  })
403
  continue
404
 
 
433
  competition_code=comp_code,
434
  home=home_api,
435
  away=away_api,
436
+ resolved_home_key=home_identity.key,
437
+ resolved_away_key=away_identity.key,
438
  selection=best["selection"],
439
  side=best["side"],
440
  odd=round(best["odd"], 3),
app/core/calibration.py CHANGED
@@ -20,6 +20,8 @@ def calibrate_probability(
20
  if r.get("result") in {"win", "loss"}
21
  and r.get("model_version") == model_version
22
  and isinstance(r.get("probability"), (int, float))
 
 
23
  ]
24
  if not records:
25
  return probability, {"effective_samples": 0.0, "delta": 0.0}
 
20
  if r.get("result") in {"win", "loss"}
21
  and r.get("model_version") == model_version
22
  and isinstance(r.get("probability"), (int, float))
23
+ and math.isfinite(float(r["probability"]))
24
+ and 0.0 <= float(r["probability"]) <= 1.0
25
  ]
26
  if not records:
27
  return probability, {"effective_samples": 0.0, "delta": 0.0}
app/core/history.py CHANGED
@@ -1,6 +1,6 @@
1
  from __future__ import annotations
2
 
3
- from datetime import datetime, timezone
4
  import math
5
 
6
  from app.core.names import similarity
@@ -18,6 +18,7 @@ def settle_history(history: list[dict], matches: list[FinishedMatch]) -> None:
18
  if not unresolved:
19
  return
20
 
 
21
  for record in unresolved:
22
  kickoff_raw = record.get("kickoff", "")
23
  try:
@@ -26,15 +27,27 @@ def settle_history(history: list[dict], matches: list[FinishedMatch]) -> None:
26
  kick = kick.replace(tzinfo=timezone.utc)
27
  except Exception:
28
  continue
 
 
29
 
30
  competition_code = str(record.get("competition_code") or "")
 
 
31
  best_match = None
32
  best_score = 0.0
33
 
34
  for m in matches:
35
  if competition_code and m.competition != competition_code:
36
  continue
37
- if abs((m.utc_date - kick).total_seconds()) > 30 * 3600:
 
 
 
 
 
 
 
 
38
  continue
39
 
40
  home_score = _team_match_score(
@@ -66,8 +79,14 @@ def settle_history(history: list[dict], matches: list[FinishedMatch]) -> None:
66
  won = winner == record.get("side")
67
  record["result"] = "win" if won else "loss"
68
  record["settled_at"] = datetime.now(timezone.utc).isoformat()
 
69
  record["score"] = f"{best_match.home_goals}-{best_match.away_goals}"
70
- odd = float(record.get("odd", 1.0))
 
 
 
 
 
71
  record["profit_units"] = round(odd - 1.0, 4) if won else -1.0
72
 
73
 
@@ -96,6 +115,12 @@ def append_new_picks(history: list[dict], picks: list[dict]) -> None:
96
  existing["competition_code"] = p.get(
97
  "competition_code", existing.get("competition_code")
98
  )
 
 
 
 
 
 
99
  continue
100
 
101
  history.append({
@@ -105,6 +130,8 @@ def append_new_picks(history: list[dict], picks: list[dict]) -> None:
105
  "competition_code": p.get("competition_code"),
106
  "home": p["home"],
107
  "away": p["away"],
 
 
108
  "selection": p["selection"],
109
  "side": p["side"],
110
  "odd": p["odd"],
@@ -114,6 +141,7 @@ def append_new_picks(history: list[dict], picks: list[dict]) -> None:
114
  "conservative_probability": p.get("conservative_probability"),
115
  "safe_score": p["safe_score"],
116
  "model_version": p.get("model_version"),
 
117
  "result": None,
118
  "profit_units": None,
119
  })
@@ -146,14 +174,25 @@ def performance_metrics(history: list[dict], model_version: str | None = None) -
146
  r for r in history
147
  if r.get("result") in {"win", "loss"}
148
  and isinstance(r.get("probability"), (int, float))
 
 
149
  ]
150
  settled = (
151
  [r for r in settled_all if r.get("model_version") == model_version]
152
  if model_version is not None
153
  else settled_all
154
  )
 
155
  wins = sum(1 for r in settled if r["result"] == "win")
156
- units = sum(float(r.get("profit_units") or 0.0) for r in settled)
 
 
 
 
 
 
 
 
157
  stake = len(settled)
158
 
159
  brier = None
@@ -181,7 +220,7 @@ def performance_metrics(history: list[dict], model_version: str | None = None) -
181
  peak = 0.0
182
  max_drawdown = 0.0
183
  for r in settled:
184
- bankroll += float(r.get("profit_units") or 0.0)
185
  peak = max(peak, bankroll)
186
  max_drawdown = max(max_drawdown, peak - bankroll)
187
 
 
1
  from __future__ import annotations
2
 
3
+ from datetime import datetime, timedelta, timezone
4
  import math
5
 
6
  from app.core.names import similarity
 
18
  if not unresolved:
19
  return
20
 
21
+ now = datetime.now(timezone.utc)
22
  for record in unresolved:
23
  kickoff_raw = record.get("kickoff", "")
24
  try:
 
27
  kick = kick.replace(tzinfo=timezone.utc)
28
  except Exception:
29
  continue
30
+ if now < kick + timedelta(hours=2):
31
+ continue
32
 
33
  competition_code = str(record.get("competition_code") or "")
34
+ resolved_home_key = str(record.get("resolved_home_key") or "")
35
+ resolved_away_key = str(record.get("resolved_away_key") or "")
36
  best_match = None
37
  best_score = 0.0
38
 
39
  for m in matches:
40
  if competition_code and m.competition != competition_code:
41
  continue
42
+ time_delta = abs((m.utc_date - kick).total_seconds())
43
+ if time_delta > 12 * 3600:
44
+ continue
45
+
46
+ if resolved_home_key and resolved_away_key:
47
+ if m.home_key == resolved_home_key and m.away_key == resolved_away_key:
48
+ score = 300.0 - time_delta / 3600.0
49
+ if score > best_score:
50
+ best_score, best_match = score, m
51
  continue
52
 
53
  home_score = _team_match_score(
 
79
  won = winner == record.get("side")
80
  record["result"] = "win" if won else "loss"
81
  record["settled_at"] = datetime.now(timezone.utc).isoformat()
82
+ record["settled_match_id"] = best_match.match_id
83
  record["score"] = f"{best_match.home_goals}-{best_match.away_goals}"
84
+ try:
85
+ odd = float(record.get("odd", 1.0))
86
+ except (TypeError, ValueError):
87
+ odd = 1.0
88
+ if not math.isfinite(odd) or odd < 1.0:
89
+ odd = 1.0
90
  record["profit_units"] = round(odd - 1.0, 4) if won else -1.0
91
 
92
 
 
115
  existing["competition_code"] = p.get(
116
  "competition_code", existing.get("competition_code")
117
  )
118
+ existing["resolved_home_key"] = p.get(
119
+ "resolved_home_key", existing.get("resolved_home_key")
120
+ )
121
+ existing["resolved_away_key"] = p.get(
122
+ "resolved_away_key", existing.get("resolved_away_key")
123
+ )
124
  continue
125
 
126
  history.append({
 
130
  "competition_code": p.get("competition_code"),
131
  "home": p["home"],
132
  "away": p["away"],
133
+ "resolved_home_key": p.get("resolved_home_key"),
134
+ "resolved_away_key": p.get("resolved_away_key"),
135
  "selection": p["selection"],
136
  "side": p["side"],
137
  "odd": p["odd"],
 
141
  "conservative_probability": p.get("conservative_probability"),
142
  "safe_score": p["safe_score"],
143
  "model_version": p.get("model_version"),
144
+ "published_at": datetime.now(timezone.utc).isoformat(),
145
  "result": None,
146
  "profit_units": None,
147
  })
 
174
  r for r in history
175
  if r.get("result") in {"win", "loss"}
176
  and isinstance(r.get("probability"), (int, float))
177
+ and math.isfinite(float(r["probability"]))
178
+ and 0.0 <= float(r["probability"]) <= 1.0
179
  ]
180
  settled = (
181
  [r for r in settled_all if r.get("model_version") == model_version]
182
  if model_version is not None
183
  else settled_all
184
  )
185
+ settled = sorted(settled, key=lambda row: str(row.get("kickoff") or ""))
186
  wins = sum(1 for r in settled if r["result"] == "win")
187
+
188
+ def profit_units(row: dict) -> float:
189
+ try:
190
+ value = float(row.get("profit_units") or 0.0)
191
+ return value if math.isfinite(value) else 0.0
192
+ except (TypeError, ValueError):
193
+ return 0.0
194
+
195
+ units = sum(profit_units(r) for r in settled)
196
  stake = len(settled)
197
 
198
  brier = None
 
220
  peak = 0.0
221
  max_drawdown = 0.0
222
  for r in settled:
223
+ bankroll += profit_units(r)
224
  peak = max(peak, bankroll)
225
  max_drawdown = max(max_drawdown, peak - bankroll)
226
 
app/core/market.py CHANGED
@@ -47,18 +47,32 @@ def market_consensus(event: dict, max_age_hours: float = 12.0) -> MarketConsensu
47
  rows: list[tuple[float, float, float, float, float, float, str]] = []
48
  stale = 0
49
 
50
- for bookmaker in event.get("bookmakers", []) or []:
 
 
 
 
 
 
51
  age = _book_age_hours(bookmaker)
52
  if age is not None and age > max_age_hours:
53
  stale += 1
54
  continue
55
 
56
- market = next((m for m in bookmaker.get("markets", []) if m.get("key") == "h2h"), None)
 
 
 
 
 
 
57
  if not market:
58
  continue
59
 
60
  found: dict[str, float | None] = {"home": None, "draw": None, "away": None}
61
  for outcome in market.get("outcomes", []) or []:
 
 
62
  name = str(outcome.get("name", ""))
63
  try:
64
  price = float(outcome.get("price"))
 
47
  rows: list[tuple[float, float, float, float, float, float, str]] = []
48
  stale = 0
49
 
50
+ bookmakers = event.get("bookmakers", [])
51
+ if not isinstance(bookmakers, list):
52
+ bookmakers = []
53
+
54
+ for bookmaker in bookmakers:
55
+ if not isinstance(bookmaker, dict):
56
+ continue
57
  age = _book_age_hours(bookmaker)
58
  if age is not None and age > max_age_hours:
59
  stale += 1
60
  continue
61
 
62
+ markets = bookmaker.get("markets", [])
63
+ if not isinstance(markets, list):
64
+ continue
65
+ market = next(
66
+ (m for m in markets if isinstance(m, dict) and m.get("key") == "h2h"),
67
+ None,
68
+ )
69
  if not market:
70
  continue
71
 
72
  found: dict[str, float | None] = {"home": None, "draw": None, "away": None}
73
  for outcome in market.get("outcomes", []) or []:
74
+ if not isinstance(outcome, dict):
75
+ continue
76
  name = str(outcome.get("name", ""))
77
  try:
78
  price = float(outcome.get("price"))
app/core/stats.py CHANGED
@@ -352,8 +352,9 @@ def tune_ensemble_weights(
352
  Time-aware competition-level weight tuning.
353
 
354
  Every evaluation match is predicted using only matches that happened before it.
355
- The selected weights minimize multiclass Brier score on the walk-forward slice,
356
- then are shrunk toward a conservative prior to reduce overfitting.
 
357
  """
358
  ordered = sorted(
359
  [m for m in matches if m.competition == competition],
@@ -363,6 +364,7 @@ def tune_ensemble_weights(
363
  return {
364
  "weights": (0.46, 0.34, 0.20),
365
  "samples": 0.0,
 
366
  "brier": 0.0,
367
  "default_brier": 0.0,
368
  "climatology_brier": 0.0,
@@ -428,6 +430,7 @@ def tune_ensemble_weights(
428
  return {
429
  "weights": (0.46, 0.34, 0.20),
430
  "samples": float(len(rows)),
 
431
  "brier": 0.0,
432
  "default_brier": 0.0,
433
  "climatology_brier": 0.0,
@@ -435,20 +438,28 @@ def tune_ensemble_weights(
435
  "gain": 0.0,
436
  }
437
 
438
- def brier(weights: tuple[float, float, float]) -> float:
 
 
 
 
 
 
 
 
439
  pw, ew, fw = weights
440
  total = 0.0
441
- for pp, ep, fp, _clim, y in rows:
442
  pred = tuple(pw * pp[i] + ew * ep[i] + fw * fp[i] for i in range(3))
443
  total += sum((pred[i] - y[i]) ** 2 for i in range(3)) / 3.0
444
- return total / len(rows)
445
 
446
  default = (0.46, 0.34, 0.20)
447
  default_brier = brier(default)
448
  climatology_brier = sum(
449
  sum((clim[i] - y[i]) ** 2 for i in range(3)) / 3.0
450
- for _pp, _ep, _fp, clim, y in rows
451
- ) / len(rows)
452
  candidates: list[tuple[float, float, float]] = [default]
453
  # Coarse grid is deliberate; a fine grid would overfit the short walk-forward
454
  # sample and create fake precision.
@@ -460,11 +471,10 @@ def tune_ensemble_weights(
460
  if 0.10 <= fw <= 0.40:
461
  candidates.append((pw, ew, fw))
462
 
463
- best = min(candidates, key=brier)
464
- best_brier = brier(best)
465
 
466
  # Empirical-Bayes style shrinkage toward prior weights.
467
- trust = min(0.70, len(rows) / (len(rows) + 45.0))
468
  shrunk = tuple(default[i] * (1.0 - trust) + best[i] * trust for i in range(3))
469
  total_w = sum(shrunk)
470
  shrunk = tuple(w / total_w for w in shrunk)
@@ -485,6 +495,7 @@ def tune_ensemble_weights(
485
  return {
486
  "weights": shrunk,
487
  "samples": float(len(rows)),
 
488
  "brier": float(shrunk_brier),
489
  "default_brier": float(default_brier),
490
  "climatology_brier": float(climatology_brier),
 
352
  Time-aware competition-level weight tuning.
353
 
354
  Every evaluation match is predicted using only matches that happened before it.
355
+ The selected weights minimize multiclass Brier score on the older part of the
356
+ walk-forward slice, are shrunk toward a conservative prior, and are accepted
357
+ only when they hold up on the newer validation part.
358
  """
359
  ordered = sorted(
360
  [m for m in matches if m.competition == competition],
 
364
  return {
365
  "weights": (0.46, 0.34, 0.20),
366
  "samples": 0.0,
367
+ "validation_samples": 0.0,
368
  "brier": 0.0,
369
  "default_brier": 0.0,
370
  "climatology_brier": 0.0,
 
430
  return {
431
  "weights": (0.46, 0.34, 0.20),
432
  "samples": float(len(rows)),
433
+ "validation_samples": 0.0,
434
  "brier": 0.0,
435
  "default_brier": 0.0,
436
  "climatology_brier": 0.0,
 
438
  "gain": 0.0,
439
  }
440
 
441
+ # Tune weights on the older part of the walk-forward predictions and report
442
+ # skill only on the newer holdout. The base predictions are time-safe, but
443
+ # selecting and scoring weights on the same slice would still be optimistic.
444
+ split_at = max(12, int(len(rows) * 0.70))
445
+ split_at = min(split_at, len(rows) - 6)
446
+ tuning_rows = rows[:split_at]
447
+ validation_rows = rows[split_at:]
448
+
449
+ def brier(weights: tuple[float, float, float], sample=validation_rows) -> float:
450
  pw, ew, fw = weights
451
  total = 0.0
452
+ for pp, ep, fp, _clim, y in sample:
453
  pred = tuple(pw * pp[i] + ew * ep[i] + fw * fp[i] for i in range(3))
454
  total += sum((pred[i] - y[i]) ** 2 for i in range(3)) / 3.0
455
+ return total / len(sample)
456
 
457
  default = (0.46, 0.34, 0.20)
458
  default_brier = brier(default)
459
  climatology_brier = sum(
460
  sum((clim[i] - y[i]) ** 2 for i in range(3)) / 3.0
461
+ for _pp, _ep, _fp, clim, y in validation_rows
462
+ ) / len(validation_rows)
463
  candidates: list[tuple[float, float, float]] = [default]
464
  # Coarse grid is deliberate; a fine grid would overfit the short walk-forward
465
  # sample and create fake precision.
 
471
  if 0.10 <= fw <= 0.40:
472
  candidates.append((pw, ew, fw))
473
 
474
+ best = min(candidates, key=lambda weights: brier(weights, tuning_rows))
 
475
 
476
  # Empirical-Bayes style shrinkage toward prior weights.
477
+ trust = min(0.70, len(tuning_rows) / (len(tuning_rows) + 45.0))
478
  shrunk = tuple(default[i] * (1.0 - trust) + best[i] * trust for i in range(3))
479
  total_w = sum(shrunk)
480
  shrunk = tuple(w / total_w for w in shrunk)
 
495
  return {
496
  "weights": shrunk,
497
  "samples": float(len(rows)),
498
+ "validation_samples": float(len(validation_rows)),
499
  "brier": float(shrunk_brier),
500
  "default_brier": float(default_brier),
501
  "climatology_brier": float(climatology_brier),
app/core/tickets.py CHANGED
@@ -58,9 +58,12 @@ def optimize_ticket(
58
  dependency_factor, same_comp_pairs = _dependency_penalty(combo)
59
  joint_p = joint_p_raw * dependency_factor
60
  joint_lower = joint_lower_raw * dependency_factor * 0.99
 
 
 
61
  avg_score = sum(float(p["safe_score"]) for p in combo) / size
62
 
63
- target_met = odd >= target_odd * 0.98
64
  if odd < target_odd:
65
  distance = math.log(max(target_odd / max(odd, 1e-9), 1.0))
66
  target_penalty = 38.0 * distance
@@ -73,7 +76,7 @@ def optimize_ticket(
73
  utility = (
74
  105.0 * joint_lower
75
  + 0.18 * avg_score
76
- + 8.0 * max(0.0, joint_p * odd - 1.0)
77
  - target_penalty
78
  - 0.7 * same_comp_pairs
79
  )
@@ -84,7 +87,7 @@ def optimize_ticket(
84
  "total_odd": round(odd, 2),
85
  "joint_probability": round(joint_p, 4),
86
  "joint_conservative": round(joint_lower, 4),
87
- "estimated_ev": round(joint_p * odd - 1.0, 4),
88
  "dependency_factor": round(dependency_factor, 4),
89
  "same_competition_pairs": same_comp_pairs,
90
  "legs": [
 
58
  dependency_factor, same_comp_pairs = _dependency_penalty(combo)
59
  joint_p = joint_p_raw * dependency_factor
60
  joint_lower = joint_lower_raw * dependency_factor * 0.99
61
+ estimated_ev = joint_p * odd - 1.0
62
+ if estimated_ev < 0.0:
63
+ continue
64
  avg_score = sum(float(p["safe_score"]) for p in combo) / size
65
 
66
+ target_met = odd >= target_odd
67
  if odd < target_odd:
68
  distance = math.log(max(target_odd / max(odd, 1e-9), 1.0))
69
  target_penalty = 38.0 * distance
 
76
  utility = (
77
  105.0 * joint_lower
78
  + 0.18 * avg_score
79
+ + 8.0 * estimated_ev
80
  - target_penalty
81
  - 0.7 * same_comp_pairs
82
  )
 
87
  "total_odd": round(odd, 2),
88
  "joint_probability": round(joint_p, 4),
89
  "joint_conservative": round(joint_lower, 4),
90
+ "estimated_ev": round(estimated_ev, 4),
91
  "dependency_factor": round(dependency_factor, 4),
92
  "same_competition_pairs": same_comp_pairs,
93
  "legs": [
app/main.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  from contextlib import asynccontextmanager
 
4
  import hmac
5
  import logging
6
  from pathlib import Path
@@ -26,15 +27,31 @@ pipeline = DailyPipeline(settings, store)
26
 
27
  def _authorized(secret: str | None) -> bool:
28
  expected = settings.cron_secret
29
- return bool(expected and secret and hmac.compare_digest(expected, secret))
 
 
 
 
30
 
31
 
32
  @asynccontextmanager
33
  async def lifespan(app: FastAPI):
34
  logger.info("Safe Bet AI %s iniciando", MODEL_VERSION)
35
  store.restore_from_hub_if_needed()
36
- yield
37
- logger.info("Safe Bet AI encerrando")
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
 
40
  app = FastAPI(
@@ -45,6 +62,30 @@ app = FastAPI(
45
  lifespan=lifespan,
46
  )
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
49
  templates = Jinja2Templates(directory=BASE_DIR / "templates")
50
 
@@ -64,17 +105,19 @@ async def index(request: Request):
64
  @app.get("/api/health")
65
  async def health():
66
  state = store.load_state()
 
 
 
 
 
 
67
  return {
68
  "ok": True,
 
69
  "service": "safe-bet-ai",
70
  "version": MODEL_VERSION,
71
  "pipeline_running": pipeline.running,
72
- "configured": {
73
- "football_data": bool(settings.football_data_token),
74
- "odds_api": bool(settings.odds_api_key),
75
- "cron_secret": bool(settings.cron_secret),
76
- "hub_backup": bool(settings.hf_token and settings.hf_dataset_repo),
77
- },
78
  "last_status": state.get("status"),
79
  "generated_at": state.get("generated_at"),
80
  "time": time.time(),
@@ -115,12 +158,19 @@ async def cron_daily(x_cron_secret: str | None = Header(default=None)):
115
 
116
  @app.post("/api/admin/scan")
117
  async def admin_scan(
118
- wait: int = 0,
 
119
  x_cron_secret: str | None = Header(default=None),
120
  ):
121
  if not _authorized(x_cron_secret):
122
  raise HTTPException(status_code=401, detail="X-Cron-Secret inválido")
 
 
 
 
 
 
123
  if wait:
124
- return await pipeline.scan()
125
  accepted = pipeline.trigger_background()
126
  return {"accepted": accepted, "model_version": MODEL_VERSION}
 
1
  from __future__ import annotations
2
 
3
  from contextlib import asynccontextmanager
4
+ from datetime import datetime, timezone
5
  import hmac
6
  import logging
7
  from pathlib import Path
 
27
 
28
  def _authorized(secret: str | None) -> bool:
29
  expected = settings.cron_secret
30
+ return bool(
31
+ expected
32
+ and secret
33
+ and hmac.compare_digest(expected.encode("utf-8"), secret.encode("utf-8"))
34
+ )
35
 
36
 
37
  @asynccontextmanager
38
  async def lifespan(app: FastAPI):
39
  logger.info("Safe Bet AI %s iniciando", MODEL_VERSION)
40
  store.restore_from_hub_if_needed()
41
+ state = store.load_state()
42
+ if state.get("status") == "scanning":
43
+ state["status"] = "interrupted"
44
+ state["last_error"] = "O processo anterior foi reiniciado durante um scan."
45
+ state["last_error_at"] = datetime.now(timezone.utc).isoformat()
46
+ state["warnings"] = list(state.get("warnings") or [])[-4:] + [
47
+ "Scan anterior interrompido por reinício; execute uma nova varredura."
48
+ ]
49
+ store.save_state(state)
50
+ try:
51
+ yield
52
+ finally:
53
+ await pipeline.shutdown()
54
+ logger.info("Safe Bet AI encerrando")
55
 
56
 
57
  app = FastAPI(
 
62
  lifespan=lifespan,
63
  )
64
 
65
+
66
+ @app.middleware("http")
67
+ async def harden_responses(request: Request, call_next):
68
+ response = await call_next(request)
69
+ response.headers.setdefault("X-Content-Type-Options", "nosniff")
70
+ response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
71
+ response.headers.setdefault(
72
+ "Permissions-Policy",
73
+ "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
74
+ )
75
+ if request.url.path.startswith(("/api/", "/static/")):
76
+ response.headers.setdefault("Cross-Origin-Resource-Policy", "same-origin")
77
+ if request.url.path.startswith("/api/"):
78
+ response.headers["Cache-Control"] = "no-store"
79
+ elif not request.url.path.startswith(("/docs", "/openapi.json")):
80
+ response.headers.setdefault(
81
+ "Content-Security-Policy",
82
+ "default-src 'self'; base-uri 'self'; "
83
+ "frame-ancestors https://huggingface.co https://*.huggingface.co; "
84
+ "form-action 'self'; img-src 'self' data:; style-src 'self'; "
85
+ "script-src 'self'; connect-src 'self'",
86
+ )
87
+ return response
88
+
89
  app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
90
  templates = Jinja2Templates(directory=BASE_DIR / "templates")
91
 
 
105
  @app.get("/api/health")
106
  async def health():
107
  state = store.load_state()
108
+ configured = {
109
+ "football_data": bool(settings.football_data_token),
110
+ "odds_api": bool(settings.odds_api_key),
111
+ "cron_secret": bool(settings.cron_secret),
112
+ "hub_backup": bool(settings.hf_token and settings.hf_dataset_repo),
113
+ }
114
  return {
115
  "ok": True,
116
+ "ready": settings.required_ready,
117
  "service": "safe-bet-ai",
118
  "version": MODEL_VERSION,
119
  "pipeline_running": pipeline.running,
120
+ "configured": configured,
 
 
 
 
 
121
  "last_status": state.get("status"),
122
  "generated_at": state.get("generated_at"),
123
  "time": time.time(),
 
158
 
159
  @app.post("/api/admin/scan")
160
  async def admin_scan(
161
+ wait: bool = False,
162
+ force: bool = False,
163
  x_cron_secret: str | None = Header(default=None),
164
  ):
165
  if not _authorized(x_cron_secret):
166
  raise HTTPException(status_code=401, detail="X-Cron-Secret inválido")
167
+ if not force and pipeline.recent_success(settings.min_scan_interval_minutes):
168
+ return {
169
+ "accepted": False,
170
+ "message": "scan recente já concluído; use force=1 somente se necessário",
171
+ "model_version": MODEL_VERSION,
172
+ }
173
  if wait:
174
+ return await pipeline.trigger_and_wait()
175
  accepted = pipeline.trigger_background()
176
  return {"accepted": accepted, "model_version": MODEL_VERSION}
app/models.py CHANGED
@@ -60,6 +60,8 @@ class Pick:
60
  competition_code: str
61
  home: str
62
  away: str
 
 
63
  selection: str
64
  side: str
65
  odd: float
 
60
  competition_code: str
61
  home: str
62
  away: str
63
+ resolved_home_key: str
64
+ resolved_away_key: str
65
  selection: str
66
  side: str
67
  odd: float
app/pipeline.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
 
3
  import asyncio
4
  from datetime import datetime, timezone
 
5
  import logging
6
 
7
  from app.config import MODEL_VERSION, Settings
@@ -25,11 +26,11 @@ class DailyPipeline:
25
 
26
  @property
27
  def running(self) -> bool:
28
- return bool(self._task and not self._task.done())
29
 
30
  def recent_success(self, minutes: int) -> bool:
31
  state = self.store.load_state()
32
- if state.get("status") != "ok" or not state.get("generated_at"):
33
  return False
34
  try:
35
  generated = datetime.fromisoformat(str(state["generated_at"]).replace("Z", "+00:00"))
@@ -46,6 +47,26 @@ class DailyPipeline:
46
  self._task = asyncio.create_task(self.scan())
47
  return True
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  async def scan(self) -> dict:
50
  if self._lock.locked():
51
  return {"accepted": False, "reason": "scan já em execução"}
@@ -54,12 +75,32 @@ class DailyPipeline:
54
  started = datetime.now(timezone.utc)
55
  previous = self.store.load_state()
56
 
57
- if not self.settings.football_data_token or not self.settings.odds_api_key:
 
 
 
 
 
 
 
 
 
 
58
  previous["status"] = "configuration_error"
59
- previous["last_error"] = "Configure FOOTBALL_DATA_TOKEN e ODDS_API_KEY nos Secrets."
60
  self.store.save_state(previous)
61
  return previous
62
 
 
 
 
 
 
 
 
 
 
 
63
  http = ResilientHTTP(timeout=24.0, retries=3)
64
  football = FootballDataProvider(self.settings.football_data_token, http)
65
  odds = OddsAPIProvider(self.settings.odds_api_key, http, self.settings.odds_regions)
@@ -76,22 +117,34 @@ class DailyPipeline:
76
  history = self.store.load_history()
77
  settle_history(history, matches)
78
 
 
 
 
 
 
 
 
 
 
79
  events = await odds.fetch_events(
80
  self.settings.sport_keys,
81
  self.settings.scan_horizon_hours,
82
  )
83
 
84
- picks, rejected = analyze_events(
85
- events,
86
- matches,
87
- min_safe_score=self.settings.min_safe_score,
88
- limit=self.settings.top_picks_limit,
89
- calibration_history=history,
90
- previous_picks=previous.get("picks") or [],
91
- min_probability=self.settings.min_probability,
92
- min_conservative_probability=self.settings.min_conservative_probability,
93
- min_bookmakers=self.settings.min_bookmakers,
94
- min_name_score=self.settings.min_name_score,
 
 
 
95
  )
96
  pick_dicts = [p.as_dict() for p in picks]
97
 
@@ -121,11 +174,18 @@ class DailyPipeline:
121
  warnings.append(
122
  f"{len(odds.inactive_keys)} liga(s) estavam fora de temporada e foram ignoradas sem gastar consulta de odds."
123
  )
 
 
 
 
124
 
 
 
125
  state = {
126
- "generated_at": datetime.now(timezone.utc).isoformat(),
127
  "started_at": started.isoformat(),
128
- "status": "ok",
 
129
  "model_version": MODEL_VERSION,
130
  "summary": {
131
  "events": len(events),
@@ -138,15 +198,19 @@ class DailyPipeline:
138
  "performance": performance,
139
  "providers": {
140
  "football_data": {
141
- "ok": True,
142
  "matches": len(matches),
143
  "detail": football_meta,
144
  },
145
  "odds_api": {
146
- "ok": True,
147
  "quota": odds.quota,
 
 
148
  "sport_keys": list(self.settings.sport_keys),
 
149
  "inactive_keys": odds.inactive_keys,
 
150
  },
151
  },
152
  "warnings": warnings,
@@ -179,6 +243,8 @@ class DailyPipeline:
179
  except Exception as exc:
180
  logger.exception("Falha no scan")
181
  previous["status"] = "error"
 
 
182
  previous["last_error"] = f"{type(exc).__name__}: {exc}"
183
  previous["last_error_at"] = datetime.now(timezone.utc).isoformat()
184
  previous.setdefault("warnings", [])
 
2
 
3
  import asyncio
4
  from datetime import datetime, timezone
5
+ from functools import partial
6
  import logging
7
 
8
  from app.config import MODEL_VERSION, Settings
 
26
 
27
  @property
28
  def running(self) -> bool:
29
+ return self._lock.locked() or bool(self._task and not self._task.done())
30
 
31
  def recent_success(self, minutes: int) -> bool:
32
  state = self.store.load_state()
33
+ if state.get("status") not in {"ok", "degraded"} or not state.get("generated_at"):
34
  return False
35
  try:
36
  generated = datetime.fromisoformat(str(state["generated_at"]).replace("Z", "+00:00"))
 
47
  self._task = asyncio.create_task(self.scan())
48
  return True
49
 
50
+ async def trigger_and_wait(self) -> dict:
51
+ if self._task is not None and not self._task.done():
52
+ return await asyncio.shield(self._task)
53
+ if self._lock.locked():
54
+ return {"accepted": False, "reason": "scan já em execução"}
55
+ self._task = asyncio.create_task(self.scan())
56
+ # A client disconnect must not cancel a scan after provider quota has
57
+ # already been consumed. The shared task continues and persists state.
58
+ return await asyncio.shield(self._task)
59
+
60
+ async def shutdown(self) -> None:
61
+ task = self._task
62
+ if task is None or task.done():
63
+ return
64
+ task.cancel()
65
+ try:
66
+ await task
67
+ except asyncio.CancelledError:
68
+ logger.info("Scan em segundo plano cancelado durante o encerramento")
69
+
70
  async def scan(self) -> dict:
71
  if self._lock.locked():
72
  return {"accepted": False, "reason": "scan já em execução"}
 
75
  started = datetime.now(timezone.utc)
76
  previous = self.store.load_state()
77
 
78
+ configuration_errors: list[str] = []
79
+ if not self.settings.football_data_token:
80
+ configuration_errors.append("FOOTBALL_DATA_TOKEN")
81
+ if not self.settings.odds_api_key:
82
+ configuration_errors.append("ODDS_API_KEY")
83
+ if not self.settings.odds_regions:
84
+ configuration_errors.append("ODDS_REGIONS inválido")
85
+ if not self.settings.sport_keys:
86
+ configuration_errors.append("ODDS_SPORT_KEYS sem liga suportada")
87
+
88
+ if configuration_errors:
89
  previous["status"] = "configuration_error"
90
+ previous["last_error"] = "Configuração pendente: " + ", ".join(configuration_errors)
91
  self.store.save_state(previous)
92
  return previous
93
 
94
+ scanning_state = dict(previous)
95
+ scanning_state.update({
96
+ "status": "scanning",
97
+ "started_at": started.isoformat(),
98
+ "model_version": MODEL_VERSION,
99
+ })
100
+ scanning_state.pop("last_error", None)
101
+ scanning_state.pop("last_error_at", None)
102
+ self.store.save_state(scanning_state)
103
+
104
  http = ResilientHTTP(timeout=24.0, retries=3)
105
  football = FootballDataProvider(self.settings.football_data_token, http)
106
  odds = OddsAPIProvider(self.settings.odds_api_key, http, self.settings.odds_regions)
 
117
  history = self.store.load_history()
118
  settle_history(history, matches)
119
 
120
+ # Keep the first published recommendation as the event anchor even
121
+ # if an intermediate scan temporarily removed it from the dashboard.
122
+ # This prevents a later opposite selection from looking "new".
123
+ tracked_open = [
124
+ row for row in history
125
+ if row.get("result") is None and row.get("event_id")
126
+ ]
127
+ previous_context = list(previous.get("picks") or []) + tracked_open
128
+
129
  events = await odds.fetch_events(
130
  self.settings.sport_keys,
131
  self.settings.scan_horizon_hours,
132
  )
133
 
134
+ picks, rejected = await asyncio.to_thread(
135
+ partial(
136
+ analyze_events,
137
+ events,
138
+ matches,
139
+ min_safe_score=self.settings.min_safe_score,
140
+ limit=self.settings.top_picks_limit,
141
+ calibration_history=history,
142
+ previous_picks=previous_context,
143
+ min_probability=self.settings.min_probability,
144
+ min_conservative_probability=self.settings.min_conservative_probability,
145
+ min_bookmakers=self.settings.min_bookmakers,
146
+ min_name_score=self.settings.min_name_score,
147
+ )
148
  )
149
  pick_dicts = [p.as_dict() for p in picks]
150
 
 
174
  warnings.append(
175
  f"{len(odds.inactive_keys)} liga(s) estavam fora de temporada e foram ignoradas sem gastar consulta de odds."
176
  )
177
+ if odds.errors:
178
+ warnings.append(
179
+ f"{len(odds.errors)} consulta(s) de odds falharam; as demais ligas foram processadas."
180
+ )
181
 
182
+ finished = datetime.now(timezone.utc)
183
+ degraded = bool(football_meta.get("errors") or odds.errors)
184
  state = {
185
+ "generated_at": finished.isoformat(),
186
  "started_at": started.isoformat(),
187
+ "duration_seconds": round((finished - started).total_seconds(), 2),
188
+ "status": "degraded" if degraded else "ok",
189
  "model_version": MODEL_VERSION,
190
  "summary": {
191
  "events": len(events),
 
198
  "performance": performance,
199
  "providers": {
200
  "football_data": {
201
+ "ok": not bool(football_meta.get("errors")),
202
  "matches": len(matches),
203
  "detail": football_meta,
204
  },
205
  "odds_api": {
206
+ "ok": not bool(odds.errors),
207
  "quota": odds.quota,
208
+ "regions": self.settings.odds_regions.split(","),
209
+ "estimated_cost_per_league": odds.region_count,
210
  "sport_keys": list(self.settings.sport_keys),
211
+ "queried_keys": odds.queried_keys,
212
  "inactive_keys": odds.inactive_keys,
213
+ "errors": odds.errors,
214
  },
215
  },
216
  "warnings": warnings,
 
243
  except Exception as exc:
244
  logger.exception("Falha no scan")
245
  previous["status"] = "error"
246
+ previous["started_at"] = started.isoformat()
247
+ previous["model_version"] = MODEL_VERSION
248
  previous["last_error"] = f"{type(exc).__name__}: {exc}"
249
  previous["last_error_at"] = datetime.now(timezone.utc).isoformat()
250
  previous.setdefault("warnings", [])
app/providers/football_data.py CHANGED
@@ -37,16 +37,20 @@ class FootballDataProvider:
37
  self._request_times.append(time.monotonic())
38
 
39
  async def _get(self, url: str, params: dict) -> dict:
40
- await self._rate_limit()
41
  payload, _ = await self.http.get_json(
42
  url,
43
  params=params,
44
  headers={"X-Auth-Token": self.token},
 
45
  )
46
- return payload or {}
 
 
47
 
48
  @staticmethod
49
  def _team_aliases(team: dict) -> tuple[str, ...]:
 
 
50
  aliases = {
51
  str(team.get("name") or "").strip(),
52
  str(team.get("shortName") or "").strip(),
@@ -56,13 +60,21 @@ class FootballDataProvider:
56
 
57
  @classmethod
58
  def _parse_match(cls, item: dict) -> FinishedMatch | None:
59
- score_node = item.get("score") or {}
 
 
 
 
60
  # 1X2 is a regulation-time market. For knockout games, football-data
61
  # exposes regularTime separately; fullTime can include extra time.
62
- regular = score_node.get("regularTime") or {}
 
 
63
  hg, ag = regular.get("home"), regular.get("away")
64
  if not isinstance(hg, int) or not isinstance(ag, int):
65
- full = score_node.get("fullTime") or {}
 
 
66
  hg, ag = full.get("home"), full.get("away")
67
  if not isinstance(hg, int) or not isinstance(ag, int):
68
  return None
@@ -74,9 +86,11 @@ class FootballDataProvider:
74
  except Exception:
75
  return None
76
 
77
- comp = item.get("competition") or {}
78
- home = item.get("homeTeam") or {}
79
- away = item.get("awayTeam") or {}
 
 
80
  home_name = str(home.get("name") or "").strip()
81
  away_name = str(away.get("name") or "").strip()
82
  if not home_name or not away_name:
@@ -103,15 +117,29 @@ class FootballDataProvider:
103
  {"season": season, "status": "FINISHED"},
104
  )
105
  except ProviderError as exc:
 
 
 
 
 
 
 
 
106
  return [], f"{competition_code}/{season}: {exc}"
107
  except Exception as exc:
108
  return [], f"{competition_code}/{season}: {type(exc).__name__}: {exc}"
109
 
 
 
 
 
110
  out: list[FinishedMatch] = []
111
- for item in payload.get("matches", []) or []:
112
  match = self._parse_match(item)
113
  if match:
114
  out.append(match)
 
 
115
  return out, None
116
 
117
  @staticmethod
@@ -141,6 +169,7 @@ class FootballDataProvider:
141
  if m.utc_date >= cutoff and any(m.competition == s.football_data_code for s in specs)
142
  ]
143
  meta = {"competitions": {}, "errors": [], "cache_loaded": len(all_matches)}
 
144
 
145
  for spec in specs:
146
  current_season = season_start_year(spec, now.date())
@@ -149,6 +178,8 @@ class FootballDataProvider:
149
  current, error = await self._fetch_season(spec.football_data_code, current_season)
150
  if error:
151
  meta["errors"].append(error)
 
 
152
  all_matches.extend(current)
153
 
154
  recent_comp = [
@@ -182,6 +213,12 @@ class FootballDataProvider:
182
  "previous_loaded": previous_loaded,
183
  }
184
 
 
 
 
 
 
 
185
  final = [
186
  m for m in self._merge(all_matches)
187
  if cutoff <= m.utc_date < now
 
37
  self._request_times.append(time.monotonic())
38
 
39
  async def _get(self, url: str, params: dict) -> dict:
 
40
  payload, _ = await self.http.get_json(
41
  url,
42
  params=params,
43
  headers={"X-Auth-Token": self.token},
44
+ before_attempt=self._rate_limit,
45
  )
46
+ if not isinstance(payload, dict):
47
+ raise ProviderError("football-data.org retornou JSON fora do formato esperado")
48
+ return payload
49
 
50
  @staticmethod
51
  def _team_aliases(team: dict) -> tuple[str, ...]:
52
+ if not isinstance(team, dict):
53
+ return ()
54
  aliases = {
55
  str(team.get("name") or "").strip(),
56
  str(team.get("shortName") or "").strip(),
 
60
 
61
  @classmethod
62
  def _parse_match(cls, item: dict) -> FinishedMatch | None:
63
+ if not isinstance(item, dict):
64
+ return None
65
+ score_node = item.get("score")
66
+ if not isinstance(score_node, dict):
67
+ return None
68
  # 1X2 is a regulation-time market. For knockout games, football-data
69
  # exposes regularTime separately; fullTime can include extra time.
70
+ regular = score_node.get("regularTime")
71
+ if not isinstance(regular, dict):
72
+ regular = {}
73
  hg, ag = regular.get("home"), regular.get("away")
74
  if not isinstance(hg, int) or not isinstance(ag, int):
75
+ full = score_node.get("fullTime")
76
+ if not isinstance(full, dict):
77
+ full = {}
78
  hg, ag = full.get("home"), full.get("away")
79
  if not isinstance(hg, int) or not isinstance(ag, int):
80
  return None
 
86
  except Exception:
87
  return None
88
 
89
+ comp = item.get("competition")
90
+ home = item.get("homeTeam")
91
+ away = item.get("awayTeam")
92
+ if not isinstance(comp, dict) or not isinstance(home, dict) or not isinstance(away, dict):
93
+ return None
94
  home_name = str(home.get("name") or "").strip()
95
  away_name = str(away.get("name") or "").strip()
96
  if not home_name or not away_name:
 
117
  {"season": season, "status": "FINISHED"},
118
  )
119
  except ProviderError as exc:
120
+ message = str(exc).lower()
121
+ invalid_token = (
122
+ exc.status_code == 400
123
+ and "token" in message
124
+ and any(word in message for word in ("invalid", "missing", "invalido"))
125
+ )
126
+ if exc.status_code == 401 or invalid_token:
127
+ raise
128
  return [], f"{competition_code}/{season}: {exc}"
129
  except Exception as exc:
130
  return [], f"{competition_code}/{season}: {type(exc).__name__}: {exc}"
131
 
132
+ items = payload.get("matches")
133
+ if not isinstance(items, list):
134
+ return [], f"{competition_code}/{season}: resposta sem lista de partidas"
135
+
136
  out: list[FinishedMatch] = []
137
+ for item in items:
138
  match = self._parse_match(item)
139
  if match:
140
  out.append(match)
141
+ if items and not out:
142
+ return [], f"{competition_code}/{season}: nenhuma partida válida na resposta"
143
  return out, None
144
 
145
  @staticmethod
 
169
  if m.utc_date >= cutoff and any(m.competition == s.football_data_code for s in specs)
170
  ]
171
  meta = {"competitions": {}, "errors": [], "cache_loaded": len(all_matches)}
172
+ successful_current_requests = 0
173
 
174
  for spec in specs:
175
  current_season = season_start_year(spec, now.date())
 
178
  current, error = await self._fetch_season(spec.football_data_code, current_season)
179
  if error:
180
  meta["errors"].append(error)
181
+ else:
182
+ successful_current_requests += 1
183
  all_matches.extend(current)
184
 
185
  recent_comp = [
 
213
  "previous_loaded": previous_loaded,
214
  }
215
 
216
+ if specs and successful_current_requests == 0 and meta["errors"]:
217
+ raise ProviderError(
218
+ "Todas as consultas atuais ao football-data.org falharam; "
219
+ "o estado anterior foi preservado"
220
+ )
221
+
222
  final = [
223
  m for m in self._merge(all_matches)
224
  if cutoff <= m.utc_date < now
app/providers/http_client.py CHANGED
@@ -5,10 +5,12 @@ from email.utils import parsedate_to_datetime
5
  import logging
6
  import random
7
  from datetime import datetime, timezone
8
- from typing import Any
9
 
10
  import httpx
11
 
 
 
12
  logger = logging.getLogger(__name__)
13
 
14
 
@@ -37,7 +39,7 @@ class ResilientHTTP:
37
  timeout=timeout,
38
  follow_redirects=True,
39
  limits=httpx.Limits(max_connections=8, max_keepalive_connections=4),
40
- headers={"User-Agent": "SafeBetAI/2.1-precision"},
41
  )
42
  return self._client
43
 
@@ -63,6 +65,23 @@ class ResilientHTTP:
63
  pass
64
  return min(1.2 * (2 ** attempt) + random.uniform(0.05, 0.55), 12.0)
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  async def get_json(
67
  self,
68
  url: str,
@@ -70,6 +89,7 @@ class ResilientHTTP:
70
  params: dict[str, Any] | None = None,
71
  headers: dict[str, str] | None = None,
72
  allow_status: set[int] | None = None,
 
73
  ) -> tuple[Any, httpx.Headers]:
74
  allow_status = allow_status or set()
75
  last_exc: Exception | None = None
@@ -77,11 +97,13 @@ class ResilientHTTP:
77
 
78
  for attempt in range(self.retries):
79
  try:
 
 
80
  response = await client.get(url, params=params, headers=headers)
81
  if response.status_code in allow_status:
82
  return None, response.headers
83
 
84
- if response.status_code in (429, 500, 502, 503, 504):
85
  if attempt < self.retries - 1:
86
  delay = self._retry_delay(response, attempt)
87
  logger.warning(
@@ -96,7 +118,7 @@ class ResilientHTTP:
96
  continue
97
 
98
  if response.status_code >= 400:
99
- detail = response.text[:500].replace("\n", " ")
100
  raise ProviderError(
101
  f"HTTP {response.status_code} em {url}: {detail}",
102
  response.status_code,
 
5
  import logging
6
  import random
7
  from datetime import datetime, timezone
8
+ from typing import Any, Awaitable, Callable
9
 
10
  import httpx
11
 
12
+ from app.config import MODEL_VERSION
13
+
14
  logger = logging.getLogger(__name__)
15
 
16
 
 
39
  timeout=timeout,
40
  follow_redirects=True,
41
  limits=httpx.Limits(max_connections=8, max_keepalive_connections=4),
42
+ headers={"User-Agent": f"SafeBetAI/{MODEL_VERSION}"},
43
  )
44
  return self._client
45
 
 
65
  pass
66
  return min(1.2 * (2 ** attempt) + random.uniform(0.05, 0.55), 12.0)
67
 
68
+ @staticmethod
69
+ def _safe_error_detail(
70
+ response: httpx.Response,
71
+ params: dict[str, Any] | None,
72
+ headers: dict[str, str] | None,
73
+ ) -> str:
74
+ detail = response.text[:500].replace("\n", " ")
75
+ for mapping in (params or {}, headers or {}):
76
+ for key, value in mapping.items():
77
+ key_lower = str(key).lower()
78
+ if not any(marker in key_lower for marker in ("key", "token", "auth", "secret")):
79
+ continue
80
+ secret = str(value)
81
+ if len(secret) >= 4:
82
+ detail = detail.replace(secret, "[redacted]")
83
+ return detail
84
+
85
  async def get_json(
86
  self,
87
  url: str,
 
89
  params: dict[str, Any] | None = None,
90
  headers: dict[str, str] | None = None,
91
  allow_status: set[int] | None = None,
92
+ before_attempt: Callable[[], Awaitable[None]] | None = None,
93
  ) -> tuple[Any, httpx.Headers]:
94
  allow_status = allow_status or set()
95
  last_exc: Exception | None = None
 
97
 
98
  for attempt in range(self.retries):
99
  try:
100
+ if before_attempt is not None:
101
+ await before_attempt()
102
  response = await client.get(url, params=params, headers=headers)
103
  if response.status_code in allow_status:
104
  return None, response.headers
105
 
106
+ if response.status_code in (408, 425, 429, 500, 502, 503, 504):
107
  if attempt < self.retries - 1:
108
  delay = self._retry_delay(response, attempt)
109
  logger.warning(
 
118
  continue
119
 
120
  if response.status_code >= 400:
121
+ detail = self._safe_error_detail(response, params, headers)
122
  raise ProviderError(
123
  f"HTTP {response.status_code} em {url}: {detail}",
124
  response.status_code,
app/providers/odds_api.py CHANGED
@@ -17,8 +17,11 @@ class OddsAPIProvider:
17
  self.api_key = api_key
18
  self.http = http
19
  self.regions = regions
 
20
  self.quota = {"remaining": None, "used": None, "last": None}
21
  self.inactive_keys: list[str] = []
 
 
22
 
23
  async def _active_sport_keys(self) -> set[str] | None:
24
  try:
@@ -47,13 +50,16 @@ class OddsAPIProvider:
47
  now = datetime.now(timezone.utc)
48
  end = now + timedelta(hours=horizon_hours)
49
  out: list[dict[str, Any]] = []
 
50
 
51
  for key in sport_keys:
52
  remaining = self.quota.get("remaining")
53
- if isinstance(remaining, int) and remaining <= 25:
 
54
  logger.warning(
55
- "Quota Guardian: apenas %d créditos restantes; interrompendo novas ligas",
56
  remaining,
 
57
  )
58
  break
59
 
@@ -66,25 +72,44 @@ class OddsAPIProvider:
66
  "commenceTimeFrom": now.isoformat().replace("+00:00", "Z"),
67
  "commenceTimeTo": end.isoformat().replace("+00:00", "Z"),
68
  }
 
69
  try:
70
  payload, headers = await self.http.get_json(
71
  f"{self.BASE}/sports/{key}/odds/",
72
  params=params,
73
- allow_status={404, 422},
74
  )
75
  except ProviderError as exc:
76
- if exc.status_code in {401, 403}:
77
  raise
78
  logger.warning("Odds API falhou para %s: %s", key, exc)
 
79
  continue
80
 
81
  self._read_quota(headers)
 
 
 
 
 
 
 
 
 
 
82
  if payload:
83
  for event in payload:
 
 
84
  event["_sport_key"] = key
85
  out.append(event)
86
  await asyncio.sleep(0.10)
87
 
 
 
 
 
 
88
  unique: dict[str, dict[str, Any]] = {}
89
  for event in out:
90
  event_id = str(event.get("id") or "")
 
17
  self.api_key = api_key
18
  self.http = http
19
  self.regions = regions
20
+ self.region_count = max(1, len([part for part in regions.split(",") if part]))
21
  self.quota = {"remaining": None, "used": None, "last": None}
22
  self.inactive_keys: list[str] = []
23
+ self.queried_keys: list[str] = []
24
+ self.errors: list[str] = []
25
 
26
  async def _active_sport_keys(self) -> set[str] | None:
27
  try:
 
50
  now = datetime.now(timezone.utc)
51
  end = now + timedelta(hours=horizon_hours)
52
  out: list[dict[str, Any]] = []
53
+ successful_requests = 0
54
 
55
  for key in sport_keys:
56
  remaining = self.quota.get("remaining")
57
+ reserve = max(25, self.region_count * 3)
58
+ if isinstance(remaining, int) and remaining <= reserve:
59
  logger.warning(
60
+ "Quota Guardian: apenas %d créditos restantes (reserva %d); interrompendo novas ligas",
61
  remaining,
62
+ reserve,
63
  )
64
  break
65
 
 
72
  "commenceTimeFrom": now.isoformat().replace("+00:00", "Z"),
73
  "commenceTimeTo": end.isoformat().replace("+00:00", "Z"),
74
  }
75
+ self.queried_keys.append(key)
76
  try:
77
  payload, headers = await self.http.get_json(
78
  f"{self.BASE}/sports/{key}/odds/",
79
  params=params,
80
+ allow_status={404},
81
  )
82
  except ProviderError as exc:
83
+ if exc.status_code in {401, 403, 429}:
84
  raise
85
  logger.warning("Odds API falhou para %s: %s", key, exc)
86
+ self.errors.append(f"{key}: {exc}")
87
  continue
88
 
89
  self._read_quota(headers)
90
+ if payload is None:
91
+ if key not in self.inactive_keys:
92
+ self.inactive_keys.append(key)
93
+ successful_requests += 1
94
+ continue
95
+ if not isinstance(payload, list):
96
+ self.errors.append(f"{key}: resposta de odds fora do formato esperado")
97
+ continue
98
+
99
+ successful_requests += 1
100
  if payload:
101
  for event in payload:
102
+ if not isinstance(event, dict):
103
+ continue
104
  event["_sport_key"] = key
105
  out.append(event)
106
  await asyncio.sleep(0.10)
107
 
108
+ if self.queried_keys and successful_requests == 0 and self.errors:
109
+ raise ProviderError(
110
+ "Todas as consultas de odds falharam; o estado anterior foi preservado"
111
+ )
112
+
113
  unique: dict[str, dict[str, Any]] = {}
114
  for event in out:
115
  event_id = str(event.get("id") or "")
app/static/app.js CHANGED
@@ -1,140 +1,462 @@
 
 
1
  const $ = (id) => document.getElementById(id);
2
- const pct = (v) => v == null ? "—" : (Number(v) * 100).toFixed(1) + "%";
3
- const num = (v, d=1) => v == null ? "—" : Number(v).toFixed(d);
4
- const safe = (s) => String(s ?? "").replace(/[<>&"]/g, c => ({"<":"&lt;",">":"&gt;","&":"&amp;",'"':"&quot;"}[c]));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- function localDate(value){
7
- if(!value) return "";
8
- const d = new Date(value);
9
- return isNaN(d) ? value : d.toLocaleString("pt-BR");
10
  }
11
 
12
- function warnings(items){
13
- $("warnings").innerHTML = (items || []).map(x => `<div class="warning">${safe(x)}</div>`).join("");
14
  }
15
 
16
- function pickCard(p, idx){
17
- const reasons = (p.reasons || []).map(r => `<span class="tag">${safe(r)}</span>`).join("");
18
- return `<article class="pick panel">
19
- <div class="pick-top">
20
- <div>
21
- <div class="rank">#${String(idx+1).padStart(2,"0")} • ${safe(p.label)}</div>
22
- <div class="match">${safe(p.home)} <span class="muted">x</span> ${safe(p.away)}</div>
23
- <div class="competition">${safe(p.competition)} ${localDate(p.kickoff)}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  </div>
25
- <div class="pill ok">${num(p.safe_score,1)}</div>
26
- </div>
27
- <div class="selection">${safe(p.selection)} vence</div>
28
- <div class="metrics">
29
- <div class="metric"><span>PROB.</span><strong>${pct(p.probability)}</strong></div>
30
- <div class="metric"><span>CONSERV.</span><strong>${pct(p.conservative_probability)}</strong></div>
31
- <div class="metric"><span>ODD MED.</span><strong>${num(p.odd,2)}</strong></div>
32
- <div class="metric"><span>ODD JUSTA</span><strong>${num(p.fair_odd,2)}</strong></div>
33
  </div>
34
- <div class="bar"><div style="width:${Math.max(0,Math.min(100,p.safe_score))}%"></div></div>
35
- <div class="reasons">${reasons}</div>
36
- <details class="models-detail">
37
- <summary>Ver modelos e qualidade</summary>
38
- <div class="model-row"><span>Dixon-Coles/Poisson</span><b>${pct(p.model_detail?.poisson)}</b></div>
39
- <div class="model-row"><span>Elo</span><b>${pct(p.model_detail?.elo)}</b></div>
40
- <div class="model-row"><span>Forma ponderada</span><b>${pct(p.model_detail?.form)}</b></div>
41
- <div class="model-row"><span>Modelo interno</span><b>${pct(p.raw_model_probability)}</b></div>
42
- <div class="model-row"><span>Mercado de-vigado</span><b>${pct(p.market_probability)}</b></div>
43
- <div class="model-row"><span>Peso do modelo interno</span><b>${pct(p.model_detail?.internal_weight)}</b></div>
44
- <div class="model-row"><span>Pesos P/E/F</span><b>${pct(p.model_detail?.weight_poisson)} / ${pct(p.model_detail?.weight_elo)} / ${pct(p.model_detail?.weight_form)}</b></div>
45
- <div class="model-row"><span>Amostra walk-forward</span><b>${num(p.model_detail?.tuning_samples,0)}</b></div>
46
- <div class="model-row"><span>Brier Skill vs climatologia</span><b>${p.model_detail?.tuning_brier_skill == null ? "—" : pct(p.model_detail.tuning_brier_skill)}</b></div>
47
- <div class="model-row"><span>Validação do modelo</span><b>${p.model_detail?.model_validation == null ? "—" : pct(p.model_detail.model_validation)}</b></div>
48
- <div class="model-row"><span>Piso Poisson/Elo</span><b>${p.model_detail?.core_model_floor == null ? "—" : pct(p.model_detail.core_model_floor)}</b></div>
49
- <div class="model-row"><span>Ganho Brier tuning</span><b>${p.model_detail?.tuning_gain == null ? "—" : Number(p.model_detail.tuning_gain).toFixed(4)}</b></div>
50
- <div class="model-row"><span>Qualidade dos dados</span><b>${pct(p.quality)}</b></div>
51
- <div class="model-row"><span>Confiabilidade composta</span><b>${pct(p.reliability)}</b></div>
52
- <div class="model-row"><span>Casas no consenso</span><b>${p.market_bookmakers ?? "—"}</b></div>
53
- <div class="model-row"><span>Dispersão do mercado</span><b>${pct(p.market_dispersion)}</b></div>
54
- <div class="model-row"><span>Confiança no nome do time</span><b>${pct(p.name_confidence)}</b></div>
55
- <div class="model-row"><span>Calibração forward</span><b>${p.calibration_delta == null ? "" : ((Number(p.calibration_delta)*100).toFixed(1)+" pp")}</b></div>
56
- <div class="model-row"><span>Edge vs. mercado</span><b>${pct(p.edge)}</b></div>
57
- </details>
58
- <div class="copyline">
59
- <button class="copybtn" data-copy="${safe(p.selection)}" onclick="copyText(this)">Copiar time</button>
60
- <button class="copybtn" data-copy="${safe(p.home)} x ${safe(p.away)}" onclick="copyText(this)">Copiar jogo</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  </div>
62
- <div class="oddbox">
63
- <strong>Odd Betano:</strong>
64
- <input type="number" min="1.01" step="0.01" placeholder="ex. 1.31" oninput="evalOdd(this, ${Number(p.probability)})">
65
- <span class="oddresult">Digite sua odd para comparar.</span>
 
 
 
 
 
66
  </div>
 
67
  </article>`;
68
  }
69
 
70
- window.evalOdd = function(input, p){
71
- const out = input.parentElement.querySelector(".oddresult");
72
- const odd = Number(input.value);
73
- if(!odd || odd <= 1){ out.textContent = "Digite uma odd válida."; return; }
74
- const ev = p * odd - 1;
75
- const fair = 1/p;
76
- if(ev >= .03) out.innerHTML = `🟢 EV estimado <b>+${(ev*100).toFixed(1)}%</b> • justa ${fair.toFixed(2)}`;
77
- else if(ev >= -.02) out.innerHTML = `🟡 preço próximo do justo • EV ${(ev*100).toFixed(1)}%`;
78
- else out.innerHTML = `🔴 preço fraco • EV ${(ev*100).toFixed(1)}% • justa ${fair.toFixed(2)}`;
79
- }
80
-
81
- function ticketCard(name, t){
82
- const labels = {safe:"🛡️ SAFE",balanced:"⚖️ BALANCEADO",freebet:"🎁 FREEBET"};
83
- if(!t) return `<article class="ticket panel"><h3>${labels[name]}</h3><div class="empty">Não há seleções suficientes.</div></article>`;
84
- const legs = t.legs.map(l => `<div class="ticket-leg"><b>${safe(l.selection)} vence @ ${num(l.odd,2)}</b><span>${safe(l.match)} • score ${num(l.safe_score,1)}</span></div>`).join("");
85
- return `<article class="ticket panel">
86
- <h3>${labels[name]}</h3>
87
- <div class="muted">alvo ${num(t.target_odd,1)}</div>
88
- <div class="big">@ ${num(t.total_odd,2)}</div>
89
- <div class="muted">${t.target_met ? "✅ alvo atingido" : "⚠️ abaixo do alvo"} • Prob. estimada: ${pct(t.joint_probability)} • stress: ${pct(t.joint_conservative)} • EV ${pct(t.estimated_ev)}</div>
90
- <div style="margin-top:14px">${legs}</div>
 
 
 
91
  </article>`;
92
  }
93
 
94
- async function load(){
95
- try{
96
- const r = await fetch("/api/state", {cache:"no-store"});
97
- const s = await r.json();
98
- $("system-pill").className = "pill " + (s.status === "ok" ? "ok" : s.status === "error" ? "bad" : "neutral");
99
- $("system-pill").textContent = String(s.status || "WAITING").toUpperCase();
100
- $("s-events").textContent = s.summary?.events ?? "";
101
- $("s-approved").textContent = s.summary?.approved ?? 0;
102
- $("s-best").textContent = s.picks?.length ? num(Math.max(...s.picks.map(x=>x.safe_score)),1) : "—";
103
- $("s-winrate").textContent = s.performance?.win_rate == null ? "—" : pct(s.performance.win_rate);
104
- $("last-update").textContent = "Atualizado: " + localDate(s.generated_at);
105
- warnings(s.warnings);
106
- $("picks").innerHTML = s.picks?.length ? s.picks.map(pickCard).join("") : `<div class="panel empty">Nenhum palpite aprovado. Isso é permitido: o bot não força seleções.</div>`;
107
- $("tickets").innerHTML = ["safe","balanced","freebet"].map(k => ticketCard(k, s.tickets?.[k])).join("");
108
-
109
- const q = s.providers?.odds_api?.quota || {};
110
- $("health").innerHTML = `
111
- <div class="health-card"><span>FOOTBALL DATA</span><strong>${s.providers?.football_data?.ok ? "🟢 ONLINE" : "⚪ AGUARDANDO"}</strong></div>
112
- <div class="health-card"><span>ODDS API</span><strong>${s.providers?.odds_api?.ok ? "🟢 ONLINE" : "⚪ AGUARDANDO"}</strong></div>
113
- <div class="health-card"><span>CRÉDITOS ODDS RESTANTES</span><strong>${q.remaining ?? ""}</strong></div>
114
- <div class="health-card"><span>HISTÓRICO</span><strong>${s.summary?.historical_matches ?? "—"} jogos</strong></div>
115
- <div class="health-card"><span>PALPITES LIQUIDADOS</span><strong>${s.performance?.settled ?? 0}</strong></div>
116
- <div class="health-card"><span>ROI FORWARD</span><strong>${s.performance?.roi == null ? "—" : pct(s.performance.roi)}</strong></div>
117
- <div class="health-card"><span>BRIER SCORE</span><strong>${s.performance?.brier_score ?? "—"}</strong></div>
118
- <div class="health-card"><span>CALIBRAÇÃO ECE</span><strong>${s.performance?.ece == null ? "" : pct(s.performance.ece)}</strong></div>
119
- <div class="health-card"><span>MODELO</span><strong>${safe(s.model_version || "")}</strong></div>`;
120
- }catch(e){
121
- $("system-pill").className="pill bad"; $("system-pill").textContent="ERRO";
122
- warnings(["Falha ao carregar o painel: " + e.message]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  }
124
  }
125
- $("refresh-ui").addEventListener("click", load);
126
- load();
127
- setInterval(load, 60000);
128
 
 
 
 
 
 
 
 
129
 
130
- window.copyText = async function(button){
131
- const text = button.dataset.copy || "";
132
- try{
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  await navigator.clipboard.writeText(text);
134
- const old = button.textContent;
135
- button.textContent = "Copiado ✓";
136
- setTimeout(()=>button.textContent=old, 1200);
137
- }catch(e){
138
- button.textContent = text;
139
  }
 
 
 
 
 
 
 
 
 
140
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use strict";
2
+
3
  const $ = (id) => document.getElementById(id);
4
+ const REFRESH_INTERVAL = 60_000;
5
+ const REQUEST_TIMEOUT = 15_000;
6
+
7
+ const ICONS = Object.freeze({
8
+ alert: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4 3 20h18L12 4Z"/><path d="M12 10v4m0 3h.01"/></svg>`,
9
+ copy: `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="11" height="11" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg>`,
10
+ shield: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 4 6v5.7c0 4.7 3.2 8.3 8 9.8 4.8-1.5 8-5.1 8-9.8V6l-8-3Z"/><path d="m8.8 12 2.1 2.1 4.5-4.6"/></svg>`,
11
+ balance: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v18M5 6h14M7 6l-4 8h8L7 6Zm10 0-4 8h8l-4-8ZM7 21h10"/></svg>`,
12
+ gift: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 10h16v10H4zM3 6h18v4H3zM12 6v14M12 6H8.5A2.5 2.5 0 1 1 11 3.5L12 6Zm0 0h3.5A2.5 2.5 0 1 0 13 3.5L12 6Z"/></svg>`
13
+ });
14
+
15
+ const TICKET_PROFILES = Object.freeze({
16
+ safe: {
17
+ title: "SAFE",
18
+ subtitle: "Exposição controlada",
19
+ risk: "RISCO MENOR",
20
+ icon: ICONS.shield
21
+ },
22
+ balanced: {
23
+ title: "BALANCEADO",
24
+ subtitle: "Risco e retorno ajustados",
25
+ risk: "RISCO MÉDIO",
26
+ icon: ICONS.balance
27
+ },
28
+ freebet: {
29
+ title: "FREEBET",
30
+ subtitle: "Retorno potencial elevado",
31
+ risk: "RISCO MAIOR",
32
+ icon: ICONS.gift
33
+ }
34
+ });
35
+
36
+ const safe = (value) => String(value ?? "").replace(/[&<>'"]/g, (char) => ({
37
+ "&": "&amp;",
38
+ "<": "&lt;",
39
+ ">": "&gt;",
40
+ "'": "&#39;",
41
+ '"': "&quot;"
42
+ })[char]);
43
+
44
+ const finite = (value) => {
45
+ if (value === null || value === undefined || value === "") return null;
46
+ const parsed = Number(value);
47
+ return Number.isFinite(parsed) ? parsed : null;
48
+ };
49
+
50
+ const num = (value, digits = 1) => {
51
+ const parsed = finite(value);
52
+ return parsed === null ? "—" : parsed.toLocaleString("pt-BR", {
53
+ minimumFractionDigits: digits,
54
+ maximumFractionDigits: digits
55
+ });
56
+ };
57
+
58
+ const pct = (value) => {
59
+ const parsed = finite(value);
60
+ return parsed === null ? "—" : (parsed * 100).toLocaleString("pt-BR", {
61
+ minimumFractionDigits: 1,
62
+ maximumFractionDigits: 1
63
+ }) + "%";
64
+ };
65
+
66
+ const integer = (value) => {
67
+ const parsed = finite(value);
68
+ return parsed === null ? "—" : Math.round(parsed).toLocaleString("pt-BR");
69
+ };
70
+
71
+ function localDate(value) {
72
+ if (!value) return "—";
73
+ const date = new Date(value);
74
+ if (Number.isNaN(date.getTime())) return String(value);
75
+ return date.toLocaleString("pt-BR", {
76
+ day: "2-digit",
77
+ month: "2-digit",
78
+ year: "numeric",
79
+ hour: "2-digit",
80
+ minute: "2-digit"
81
+ });
82
+ }
83
+
84
+ function teamInitials(name) {
85
+ const words = String(name || "?").trim().split(/\s+/).filter(Boolean);
86
+ const initials = words.length > 1 ? words[0][0] + words[words.length - 1][0] : words[0]?.slice(0, 2);
87
+ return String(initials || "?").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toUpperCase();
88
+ }
89
+
90
+ function boundedScore(value) {
91
+ const parsed = finite(value);
92
+ return parsed === null ? 0 : Math.max(0, Math.min(100, parsed));
93
+ }
94
+
95
+ function setStatus(status) {
96
+ const pill = $("system-pill");
97
+ const statuses = {
98
+ ok: { tone: "ok", pill: "SISTEMA ONLINE", engine: "MOTOR DE PRECISÃO ONLINE", console: "ONLINE", flow: "" },
99
+ degraded: { tone: "warning", pill: "OPERAÇÃO PARCIAL", engine: "MOTOR ONLINE COM ALERTAS", console: "ATENÇÃO", flow: "paused" },
100
+ scanning: { tone: "ok", pill: "ANALISANDO", engine: "VARREDURA EM ANDAMENTO", console: "PROCESSANDO", flow: "" },
101
+ waiting: { tone: "neutral", pill: "AGUARDANDO", engine: "AGUARDANDO PRIMEIRO SCAN", console: "STANDBY", flow: "paused" },
102
+ interrupted: { tone: "bad", pill: "INTERROMPIDO", engine: "VARREDURA INTERROMPIDA", console: "ATENÇÃO", flow: "failed" },
103
+ configuration_error: { tone: "bad", pill: "CONFIGURAÇÃO", engine: "CONFIGURAÇÃO NECESSÁRIA", console: "ATENÇÃO", flow: "failed" },
104
+ error: { tone: "bad", pill: "INDISPONÍVEL", engine: "MOTOR INDISPONÍVEL", console: "ERRO", flow: "failed" }
105
+ };
106
+ const current = statuses[status] || {
107
+ tone: "neutral",
108
+ pill: String(status || "AGUARDANDO").toUpperCase(),
109
+ engine: "MOTOR DE PRECISÃO EM ESPERA",
110
+ console: "STANDBY",
111
+ flow: "paused"
112
+ };
113
+ pill.className = `pill ${current.tone}`;
114
+ pill.innerHTML = `<span class="status-dot" aria-hidden="true"></span><span>${safe(current.pill)}</span>`;
115
+
116
+ const engineLabel = $("engine-label");
117
+ const engineState = $("engine-state");
118
+ const engineFlow = $("engine-flow");
119
+ const stateClass = current.tone === "bad"
120
+ ? "danger"
121
+ : current.tone === "neutral" || current.tone === "warning"
122
+ ? "paused"
123
+ : "";
124
+ if (engineLabel) {
125
+ engineLabel.className = `live-label ${stateClass}`.trim();
126
+ engineLabel.innerHTML = `<span aria-hidden="true"></span><b>${safe(current.engine)}</b>`;
127
+ }
128
+ if (engineState) {
129
+ engineState.className = `console-state ${stateClass}`.trim();
130
+ engineState.innerHTML = `<i aria-hidden="true"></i><b>${safe(current.console)}</b>`;
131
+ }
132
+ if (engineFlow) engineFlow.className = `engine-flow ${current.flow}`.trim();
133
+ }
134
 
135
+ function warnings(items) {
136
+ const values = Array.isArray(items) ? items.filter((item) => item !== null && item !== undefined && item !== "") : [];
137
+ $("warnings").innerHTML = values.map((item) => `<div class="warning">${ICONS.alert}<span>${safe(item)}</span></div>`).join("");
 
138
  }
139
 
140
+ function modelRow(label, value) {
141
+ return `<div class="model-row"><span>${label}</span><b>${value}</b></div>`;
142
  }
143
 
144
+ function pickCard(pick, index) {
145
+ const p = pick && typeof pick === "object" ? pick : {};
146
+ const detail = p.model_detail && typeof p.model_detail === "object" ? p.model_detail : {};
147
+ const score = boundedScore(p.safe_score);
148
+ const reasons = Array.isArray(p.reasons)
149
+ ? p.reasons.map((reason) => `<span class="tag">${safe(reason)}</span>`).join("")
150
+ : "";
151
+ const oddInputId = `market-odd-${index}`;
152
+ const selection = p.selection ? `${p.selection} vence` : "Seleção indisponível";
153
+ const probability = finite(p.probability);
154
+ const modelRows = [
155
+ modelRow("Dixon-Coles / Poisson", pct(detail.poisson)),
156
+ modelRow("Elo", pct(detail.elo)),
157
+ modelRow("Forma ponderada", pct(detail.form)),
158
+ modelRow("Modelo interno", pct(p.raw_model_probability)),
159
+ modelRow("Mercado sem margem", pct(p.market_probability)),
160
+ modelRow("Peso do modelo interno", pct(detail.internal_weight)),
161
+ modelRow("Pesos Poisson / Elo / Forma", `${pct(detail.weight_poisson)} / ${pct(detail.weight_elo)} / ${pct(detail.weight_form)}`),
162
+ modelRow("Amostra walk-forward / holdout", `${integer(detail.tuning_total_samples)} / ${integer(detail.tuning_samples)}`),
163
+ modelRow("Brier Skill vs. climatologia", detail.tuning_brier_skill == null ? "—" : pct(detail.tuning_brier_skill)),
164
+ modelRow("Validação do modelo", detail.model_validation == null ? "—" : pct(detail.model_validation)),
165
+ modelRow("Piso Poisson / Elo", detail.core_model_floor == null ? "—" : pct(detail.core_model_floor)),
166
+ modelRow("Ganho Brier tuning", detail.tuning_gain == null ? "—" : num(detail.tuning_gain, 4)),
167
+ modelRow("Qualidade dos dados", pct(p.quality)),
168
+ modelRow("Confiabilidade composta", pct(p.reliability)),
169
+ modelRow("Casas no consenso", integer(p.market_bookmakers)),
170
+ modelRow("Dispersão do mercado", pct(p.market_dispersion)),
171
+ modelRow("Confiança no nome do time", pct(p.name_confidence)),
172
+ modelRow("Calibração forward", p.calibration_delta == null ? "—" : `${num(Number(p.calibration_delta) * 100, 1)} pp`),
173
+ modelRow("Edge vs. mercado", pct(p.edge))
174
+ ].join("");
175
+
176
+ return `<article class="pick panel" aria-label="${safe(p.home)} contra ${safe(p.away)}">
177
+ <div class="pick-head">
178
+ <div class="pick-meta">
179
+ <span class="rank">#${String(index + 1).padStart(2, "0")} · ${safe(p.label || "SAFE")}</span>
180
+ <span class="competition-chip">${safe(p.competition || "Competição")}</span>
181
+ <span class="kickoff">Início · ${safe(localDate(p.kickoff))}</span>
182
+ </div>
183
+ <div class="score-ring" role="img" aria-label="Score de confiança ${num(score, 1)} de 100">
184
+ <svg viewBox="0 0 44 44" aria-hidden="true"><circle class="score-track" cx="22" cy="22" r="19" pathLength="100"/><circle class="score-value" cx="22" cy="22" r="19" pathLength="100" stroke-dasharray="${score} 100"/></svg>
185
+ <span><strong>${num(p.safe_score, 1)}</strong><small>SCORE</small></span>
186
  </div>
 
 
 
 
 
 
 
 
187
  </div>
188
+ <div class="pick-body">
189
+ <div class="matchup">
190
+ <div class="team"><span class="team-badge" aria-hidden="true">${safe(teamInitials(p.home))}</span><strong>${safe(p.home || "Mandante")}</strong></div>
191
+ <span class="versus">VS</span>
192
+ <div class="team"><span class="team-badge" aria-hidden="true">${safe(teamInitials(p.away))}</span><strong>${safe(p.away || "Visitante")}</strong></div>
193
+ </div>
194
+
195
+ <div class="selection-box">
196
+ <div><span>SELEÇÃO DO MODELO</span><strong>${safe(selection)}</strong></div>
197
+ <span class="selection-signal"><i aria-hidden="true"></i> APROVADA</span>
198
+ </div>
199
+
200
+ <div class="metrics" aria-label="Métricas da seleção">
201
+ <div class="metric"><span>PROBABILIDADE</span><strong>${pct(p.probability)}</strong></div>
202
+ <div class="metric"><span>CONSERVADORA</span><strong>${pct(p.conservative_probability)}</strong></div>
203
+ <div class="metric"><span>ODD MERCADO</span><strong>${num(p.odd, 2)}</strong></div>
204
+ <div class="metric"><span>ODD JUSTA</span><strong>${num(p.fair_odd, 2)}</strong></div>
205
+ </div>
206
+
207
+ <div class="confidence-line">
208
+ <span>FORÇA DO SINAL</span>
209
+ <progress class="bar" aria-label="Força do sinal" value="${score}" max="100">${num(score, 1)}%</progress>
210
+ <span>${num(score, 1)}/100</span>
211
+ </div>
212
+ ${reasons ? `<div class="reasons">${reasons}</div>` : ""}
213
+
214
+ <details class="models-detail">
215
+ <summary>Auditoria de modelos e qualidade</summary>
216
+ <div class="model-table">${modelRows}</div>
217
+ </details>
218
+
219
+ <div class="pick-tools">
220
+ <div class="copyline">
221
+ <button class="copybtn" type="button" data-copy="${safe(p.selection)}" aria-label="Copiar seleção ${safe(p.selection)}">${ICONS.copy} Copiar seleção</button>
222
+ <button class="copybtn" type="button" data-copy="${safe(p.home)} x ${safe(p.away)}" aria-label="Copiar nome do jogo">${ICONS.copy} Copiar jogo</button>
223
+ </div>
224
+ </div>
225
+
226
+ <div class="oddbox">
227
+ <div class="odd-input-row">
228
+ <label for="${oddInputId}">COMPARE A ODD ENCONTRADA NA SUA CASA</label>
229
+ <div class="odd-input-wrap"><input id="${oddInputId}" class="market-odd-input" type="number" inputmode="decimal" min="1.01" step="0.01" placeholder="1,31" data-probability="${probability ?? ""}"></div>
230
+ </div>
231
+ <span class="oddresult" aria-live="polite">Digite uma odd para estimar o valor esperado.</span>
232
+ </div>
233
  </div>
234
+ </article>`;
235
+ }
236
+
237
+ function emptyTicket(profileName) {
238
+ const profile = TICKET_PROFILES[profileName];
239
+ return `<article class="ticket panel" data-profile="${profileName}">
240
+ <div class="ticket-head">
241
+ <div class="ticket-profile"><span class="ticket-icon">${profile.icon}</span><div><h3>${profile.title}</h3><span>${profile.subtitle}</span></div></div>
242
+ <span class="risk-chip">${profile.risk}</span>
243
  </div>
244
+ <div class="empty"><div class="empty-content"><span class="empty-icon">—</span><strong>Bilhete ainda indisponível</strong><small>São necessárias mais seleções aprovadas para montar este perfil.</small></div></div>
245
  </article>`;
246
  }
247
 
248
+ function ticketCard(profileName, ticket) {
249
+ const profile = TICKET_PROFILES[profileName];
250
+ if (!ticket || typeof ticket !== "object") return emptyTicket(profileName);
251
+
252
+ const legs = Array.isArray(ticket.legs) ? ticket.legs : [];
253
+ const legMarkup = legs.map((leg) => `<div class="ticket-leg">
254
+ <b>${safe(leg?.selection)} vence <span aria-label="com odd">@</span> ${num(leg?.odd, 2)}</b>
255
+ <span title="${safe(leg?.match)}">${safe(leg?.match)} · score ${num(leg?.safe_score, 1)}</span>
256
+ </div>`).join("");
257
+ const targetMet = ticket.target_met === true;
258
+
259
+ return `<article class="ticket panel" data-profile="${profileName}">
260
+ <div class="ticket-head">
261
+ <div class="ticket-profile"><span class="ticket-icon">${profile.icon}</span><div><h3>${profile.title}</h3><span>${profile.subtitle}</span></div></div>
262
+ <span class="risk-chip">${profile.risk}</span>
263
+ </div>
264
+ <div class="ticket-odd"><span>ODD COMBINADA</span><strong>@ ${num(ticket.total_odd, 2)}</strong></div>
265
+ <div class="ticket-summary">
266
+ <div><span>PROB. EST.</span><strong>${pct(ticket.joint_probability)}</strong></div>
267
+ <div><span>STRESS</span><strong>${pct(ticket.joint_conservative)}</strong></div>
268
+ <div><span>EV EST.</span><strong>${pct(ticket.estimated_ev)}</strong></div>
269
+ </div>
270
+ <div class="target-state ${targetMet ? "met" : ""}"><i aria-hidden="true"></i>${targetMet ? "Meta atingida" : "Abaixo da meta"} · alvo @ ${num(ticket.target_odd, 1)}</div>
271
+ <div class="ticket-legs">${legMarkup || `<div class="empty">Nenhuma perna disponível.</div>`}</div>
272
  </article>`;
273
  }
274
 
275
+ function healthCard(label, value, note = "") {
276
+ return `<div class="health-card"><span>${label}</span><strong>${value}</strong>${note ? `<small>${note}</small>` : ""}</div>`;
277
+ }
278
+
279
+ function providerValue(isOnline, isDegraded) {
280
+ const tone = isOnline ? "online" : isDegraded ? "partial" : "";
281
+ const label = isOnline ? "ONLINE" : isDegraded ? "PARCIAL" : "AGUARDANDO";
282
+ return `<span class="provider-status ${tone}"><i aria-hidden="true"></i>${label}</span>`;
283
+ }
284
+
285
+ function renderHealth(state) {
286
+ const quota = state.providers?.odds_api?.quota || {};
287
+ const degraded = state.status === "degraded";
288
+ const cards = [
289
+ healthCard("FOOTBALL DATA", providerValue(Boolean(state.providers?.football_data?.ok), degraded), "dados de jogos"),
290
+ healthCard("ODDS API", providerValue(Boolean(state.providers?.odds_api?.ok), degraded), "consenso de mercado"),
291
+ healthCard("CRÉDITOS ODDS", safe(integer(quota.remaining)), "consultas restantes"),
292
+ healthCard("BASE HISTÓRICA", `${safe(integer(state.summary?.historical_matches))} jogos`, "amostra disponível"),
293
+ healthCard("PALPITES LIQUIDADOS", safe(integer(state.performance?.settled)), "forward tracking"),
294
+ healthCard("ROI FORWARD", safe(pct(state.performance?.roi)), "retorno observado"),
295
+ healthCard("BRIER SCORE", safe(num(state.performance?.brier_score, 3)), "erro probabilístico"),
296
+ healthCard("CALIBRAÇÃO ECE", safe(pct(state.performance?.ece)), "desvio de calibração"),
297
+ healthCard("VERSÃO DO MODELO", safe(state.model_version || "—"), "motor em execução")
298
+ ];
299
+ $("health").innerHTML = cards.join("");
300
+ $("health").setAttribute("aria-busy", "false");
301
+ }
302
+
303
+ function renderState(payload) {
304
+ const state = payload && typeof payload === "object" ? payload : {};
305
+ const picks = Array.isArray(state.picks) ? state.picks : [];
306
+
307
+ setStatus(state.status || "waiting");
308
+ $("s-events").textContent = integer(state.summary?.events);
309
+ $("s-approved").textContent = integer(state.summary?.approved ?? picks.length);
310
+ $("s-best").textContent = picks.length ? num(Math.max(...picks.map((item) => boundedScore(item?.safe_score))), 1) : "—";
311
+ $("s-winrate").textContent = state.performance?.win_rate == null ? "—" : pct(state.performance.win_rate);
312
+ $("last-update").textContent = `Atualizado · ${localDate(state.generated_at)}`;
313
+ warnings(state.warnings);
314
+
315
+ $("picks").innerHTML = picks.length
316
+ ? picks.map(pickCard).join("")
317
+ : `<div class="panel empty"><div class="empty-content"><span class="empty-icon">✓</span><strong>Nenhuma seleção foi liberada</strong><small>O Risk Gate não força palpites. O painel será atualizado quando houver uma oportunidade dentro do padrão SAFE.</small></div></div>`;
318
+ $("picks").setAttribute("aria-busy", "false");
319
+
320
+ const tickets = state.tickets && typeof state.tickets === "object" ? state.tickets : {};
321
+ $("tickets").innerHTML = ["safe", "balanced", "freebet"].map((key) => ticketCard(key, tickets[key])).join("");
322
+ $("tickets").setAttribute("aria-busy", "false");
323
+ renderHealth(state);
324
+ }
325
+
326
+ function setRegionsBusy(isBusy) {
327
+ ["picks", "tickets", "health"].forEach((id) => $(id)?.setAttribute("aria-busy", String(isBusy)));
328
+ }
329
+
330
+ let activeController = null;
331
+ let toastTimer = null;
332
+
333
+ function showToast(message) {
334
+ const toast = $("toast");
335
+ if (!toast) return;
336
+ toast.textContent = message;
337
+ toast.classList.add("show");
338
+ clearTimeout(toastTimer);
339
+ toastTimer = setTimeout(() => toast.classList.remove("show"), 2300);
340
+ }
341
+
342
+ async function load({ force = false, announce = false } = {}) {
343
+ if (activeController && !force) return;
344
+ if (activeController) activeController.abort();
345
+
346
+ const controller = new AbortController();
347
+ activeController = controller;
348
+ const refreshButton = $("refresh-ui");
349
+ setRegionsBusy(true);
350
+ refreshButton.classList.add("loading");
351
+ refreshButton.disabled = true;
352
+ refreshButton.setAttribute("aria-label", "Atualizando painel");
353
+
354
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
355
+ try {
356
+ const response = await fetch("/api/state", {
357
+ cache: "no-store",
358
+ headers: { Accept: "application/json" },
359
+ signal: controller.signal
360
+ });
361
+ if (!response.ok) throw new Error(`servidor respondeu ${response.status}`);
362
+ const state = await response.json();
363
+ renderState(state);
364
+ if (announce) showToast("Painel atualizado com sucesso.");
365
+ } catch (error) {
366
+ if (error.name === "AbortError" && activeController !== controller) return;
367
+ setStatus("error");
368
+ const message = error.name === "AbortError"
369
+ ? "A atualização demorou mais que o esperado. Tente novamente."
370
+ : `Falha ao carregar o painel: ${error.message}`;
371
+ warnings([message]);
372
+ setRegionsBusy(false);
373
+ if (announce) showToast("Não foi possível atualizar o painel.");
374
+ } finally {
375
+ clearTimeout(timeout);
376
+ if (activeController === controller) {
377
+ activeController = null;
378
+ refreshButton.classList.remove("loading");
379
+ refreshButton.disabled = false;
380
+ refreshButton.setAttribute("aria-label", "Atualizar painel");
381
+ }
382
  }
383
  }
 
 
 
384
 
385
+ window.evalOdd = function evalOdd(input, probability) {
386
+ if (!input) return;
387
+ const result = input.closest(".oddbox")?.querySelector(".oddresult");
388
+ if (!result) return;
389
+ const odd = finite(input.value);
390
+ const p = finite(probability);
391
+ result.className = "oddresult";
392
 
393
+ if (odd === null || odd <= 1) {
394
+ result.textContent = input.value ? "Digite uma odd válida, acima de 1,00." : "Digite uma odd para estimar o valor esperado.";
395
+ return;
396
+ }
397
+ if (p === null || p <= 0) {
398
+ result.textContent = "Probabilidade indisponível para esta comparação.";
399
+ return;
400
+ }
401
+
402
+ const expectedValue = p * odd - 1;
403
+ const fairOdd = 1 / p;
404
+ if (expectedValue >= .03) {
405
+ result.classList.add("good");
406
+ result.textContent = `Valor positivo · EV +${num(expectedValue * 100, 1)}% · odd justa ${num(fairOdd, 2)}`;
407
+ } else if (expectedValue >= -.02) {
408
+ result.classList.add("fair");
409
+ result.textContent = `Preço próximo do justo · EV ${num(expectedValue * 100, 1)}% · justa ${num(fairOdd, 2)}`;
410
+ } else {
411
+ result.classList.add("weak");
412
+ result.textContent = `Preço abaixo do modelo · EV ${num(expectedValue * 100, 1)}% · justa ${num(fairOdd, 2)}`;
413
+ }
414
+ };
415
+
416
+ async function writeClipboard(text) {
417
+ if (navigator.clipboard && window.isSecureContext) {
418
  await navigator.clipboard.writeText(text);
419
+ return;
 
 
 
 
420
  }
421
+ const field = document.createElement("textarea");
422
+ field.value = text;
423
+ field.setAttribute("readonly", "");
424
+ field.className = "clipboard-fallback";
425
+ document.body.appendChild(field);
426
+ field.select();
427
+ const copied = document.execCommand("copy");
428
+ field.remove();
429
+ if (!copied) throw new Error("cópia indisponível");
430
  }
431
+
432
+ window.copyText = async function copyText(button) {
433
+ if (!button) return;
434
+ const text = button.dataset.copy || "";
435
+ if (!text) return;
436
+ try {
437
+ await writeClipboard(text);
438
+ showToast("Copiado para a área de transferência.");
439
+ } catch (_error) {
440
+ showToast("Não foi possível copiar automaticamente.");
441
+ }
442
+ };
443
+
444
+ document.addEventListener("click", (event) => {
445
+ const copyButton = event.target.closest(".copybtn");
446
+ if (copyButton) window.copyText(copyButton);
447
+
448
+ const scrollButton = event.target.closest("[data-scroll-target]");
449
+ if (scrollButton) $(scrollButton.dataset.scrollTarget)?.scrollIntoView({ behavior: "smooth", block: "start" });
450
+ });
451
+
452
+ document.addEventListener("input", (event) => {
453
+ const input = event.target.closest(".market-odd-input");
454
+ if (input) window.evalOdd(input, input.dataset.probability);
455
+ });
456
+
457
+ $("refresh-ui").addEventListener("click", () => load({ force: true, announce: true }));
458
+
459
+ load();
460
+ setInterval(() => {
461
+ if (!document.hidden) load();
462
+ }, REFRESH_INTERVAL);
app/static/style.css CHANGED
@@ -1,49 +1,2251 @@
1
- :root{
2
- --bg:#07111f;--panel:#0d1a2b;--panel2:#102238;--line:#1b3350;
3
- --text:#edf6ff;--muted:#8ea5bd;--green:#3ee39a;--yellow:#ffc857;
4
- --red:#ff6b7a;--cyan:#57d8ff;--shadow:0 18px 60px rgba(0,0,0,.28)
5
- }
6
- *{box-sizing:border-box}
7
- html{background:var(--bg);color-scheme:dark}
8
- body{margin:0;background:
9
- radial-gradient(circle at 12% 0%,rgba(62,227,154,.10),transparent 28rem),
10
- radial-gradient(circle at 88% 12%,rgba(87,216,255,.08),transparent 24rem),
11
- var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;min-height:100vh}
12
- .noise{position:fixed;inset:0;pointer-events:none;opacity:.02;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E")}
13
- .topbar{max-width:1180px;margin:auto;padding:24px 20px 8px;display:flex;justify-content:space-between;align-items:center;gap:16px}
14
- .brand{font-weight:900;letter-spacing:.08em}.shield{color:var(--green);margin-right:8px}.subtitle{font-size:12px;color:var(--muted);margin-top:4px}
15
- .container{max-width:1180px;margin:auto;padding:18px 20px 70px}
16
- .panel{background:linear-gradient(180deg,rgba(16,34,56,.92),rgba(10,24,41,.92));border:1px solid var(--line);border-radius:20px;box-shadow:var(--shadow)}
17
- .hero{padding:28px;display:flex;justify-content:space-between;align-items:flex-end;gap:24px}
18
- .hero h1{font-size:clamp(28px,5vw,52px);line-height:1;margin:8px 0 12px;letter-spacing:-.045em}
19
- .hero p{max-width:720px;color:var(--muted);line-height:1.6;margin:0}
20
- .eyebrow{font-size:11px;font-weight:900;letter-spacing:.16em;color:var(--green)}
21
- .btn{border:1px solid #2f765c;background:#123d31;color:#dffff1;padding:12px 16px;border-radius:12px;font-weight:800;cursor:pointer;white-space:nowrap}
22
- .btn:hover{filter:brightness(1.15)}
23
- .pill{border-radius:999px;padding:8px 12px;font-size:11px;font-weight:900;letter-spacing:.08em;border:1px solid var(--line)}
24
- .pill.ok{color:var(--green);border-color:#23684f;background:#0c2a22}.pill.bad{color:var(--red);border-color:#63303a;background:#2d151a}.pill.neutral{color:var(--muted)}
25
- .stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:18px 0 36px}
26
- .stat{padding:18px}.stat span,.stat small{display:block;color:var(--muted);font-size:11px;font-weight:800;letter-spacing:.08em}.stat strong{display:block;font-size:32px;margin:6px 0}
27
- .section-head{display:flex;justify-content:space-between;align-items:flex-end;margin:36px 2px 14px;gap:16px}.section-head h2{margin:5px 0 0;font-size:24px}.muted{color:var(--muted);font-size:12px}
28
- .picks{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}
29
- .pick{padding:20px;position:relative;overflow:hidden}.pick:before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--green)}
30
- .pick-top{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.rank{font-size:11px;color:var(--muted);font-weight:900;letter-spacing:.12em}.match{font-size:19px;font-weight:850;margin:5px 0 4px}.competition{font-size:11px;color:var(--muted)}
31
- .selection{margin:18px 0 12px;font-size:15px;font-weight:850;color:#dfffee}
32
- .metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}.metric{background:#091725;border:1px solid #172d47;border-radius:12px;padding:10px}.metric span{display:block;color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.06em}.metric strong{display:block;margin-top:5px;font-size:15px}
33
- .bar{height:7px;background:#07111f;border-radius:999px;overflow:hidden;margin:14px 0}.bar>div{height:100%;background:linear-gradient(90deg,var(--cyan),var(--green));border-radius:999px}
34
- .reasons{display:flex;flex-wrap:wrap;gap:7px}.tag{font-size:10px;color:#a8c6dd;background:#0a1b2b;border:1px solid #1a3650;padding:6px 8px;border-radius:999px}
35
- .oddbox{margin-top:14px;padding-top:14px;border-top:1px solid #19324c;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.oddbox input{width:112px;background:#071522;border:1px solid #25415b;border-radius:9px;padding:9px;color:white}.oddresult{font-size:11px;color:var(--muted)}
36
- .ticket-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.ticket{padding:20px}.ticket h3{margin:0 0 3px}.ticket .big{font-size:30px;font-weight:900;margin:10px 0}.ticket-leg{padding:10px 0;border-top:1px solid #19324c}.ticket-leg:first-of-type{border-top:0}.ticket-leg b{display:block;font-size:13px}.ticket-leg span{font-size:11px;color:var(--muted)}
37
- .warning{border:1px solid #614d24;background:#2b2412;color:#ffe0a0;padding:12px 14px;border-radius:12px;margin:10px 0;font-size:13px}
38
- .empty{padding:26px;text-align:center;color:var(--muted)}
39
- .health{padding:20px;display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.health-card{background:#091725;border:1px solid #172d47;border-radius:14px;padding:14px}.health-card span{display:block;color:var(--muted);font-size:10px}.health-card strong{display:block;margin-top:6px}
40
- footer{text-align:center;color:#6f879e;font-size:11px;margin-top:40px;line-height:1.6}
41
- @media(max-width:800px){.stats-grid{grid-template-columns:repeat(2,1fr)}.picks,.ticket-grid{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}.health{grid-template-columns:1fr 1fr}.hero{align-items:flex-start;flex-direction:column}.topbar{padding-top:16px}}
42
- @media(max-width:480px){.container,.topbar{padding-left:12px;padding-right:12px}.hero{padding:20px}.health{grid-template-columns:1fr}.stat strong{font-size:28px}}
43
-
44
- .models-detail{margin-top:13px;border-top:1px solid #19324c;padding-top:12px}
45
- .models-detail summary{cursor:pointer;color:#bcd2e6;font-size:11px;font-weight:800}
46
- .model-row{display:flex;justify-content:space-between;gap:12px;padding:7px 0;border-bottom:1px dashed #173049;font-size:11px;color:var(--muted)}
47
- .model-row b{color:var(--text)}
48
- .copyline{display:flex;gap:8px;margin-top:12px}
49
- .copybtn{background:#0a1b2b;border:1px solid #24415d;color:#bcd2e6;border-radius:9px;padding:7px 9px;font-size:10px;font-weight:800;cursor:pointer}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #060b09;
3
+ --bg-soft: #0a110e;
4
+ --surface: #0d1713;
5
+ --surface-2: #111e19;
6
+ --surface-3: #14241d;
7
+ --line: rgba(179, 211, 196, .12);
8
+ --line-strong: rgba(179, 211, 196, .2);
9
+ --text: #f2f8f5;
10
+ --muted: #91a69b;
11
+ --muted-strong: #b6c7be;
12
+ --green: #38e991;
13
+ --green-bright: #75ffb4;
14
+ --green-dark: #123b2a;
15
+ --blue: #54b8ff;
16
+ --violet: #a98aff;
17
+ --amber: #ffca64;
18
+ --red: #ff7184;
19
+ --shadow: 0 24px 70px rgba(0, 0, 0, .35);
20
+ --radius-lg: 22px;
21
+ --radius-md: 16px;
22
+ --radius-sm: 11px;
23
+ --header-height: 76px;
24
+ }
25
+
26
+ * {
27
+ box-sizing: border-box;
28
+ }
29
+
30
+ html {
31
+ background: var(--bg);
32
+ color-scheme: dark;
33
+ scroll-behavior: smooth;
34
+ scroll-padding-top: calc(var(--header-height) + 24px);
35
+ }
36
+
37
+ body {
38
+ min-height: 100vh;
39
+ margin: 0;
40
+ overflow-x: hidden;
41
+ background:
42
+ radial-gradient(circle at 8% 3%, rgba(56, 233, 145, .08), transparent 25rem),
43
+ radial-gradient(circle at 94% 30%, rgba(84, 184, 255, .055), transparent 28rem),
44
+ linear-gradient(180deg, #07100d 0, var(--bg) 32rem);
45
+ color: var(--text);
46
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
47
+ -webkit-font-smoothing: antialiased;
48
+ text-rendering: optimizeLegibility;
49
+ }
50
+
51
+ body::selection {
52
+ background: rgba(56, 233, 145, .3);
53
+ color: #fff;
54
+ }
55
+
56
+ a {
57
+ color: inherit;
58
+ text-decoration: none;
59
+ }
60
+
61
+ button,
62
+ input {
63
+ font: inherit;
64
+ }
65
+
66
+ button {
67
+ color: inherit;
68
+ }
69
+
70
+ svg {
71
+ display: block;
72
+ }
73
+
74
+ .skip-link {
75
+ position: fixed;
76
+ z-index: 1000;
77
+ top: 10px;
78
+ left: 10px;
79
+ padding: 10px 14px;
80
+ transform: translateY(-150%);
81
+ border-radius: 8px;
82
+ background: var(--green);
83
+ color: #032014;
84
+ font-size: 13px;
85
+ font-weight: 800;
86
+ transition: transform .2s ease;
87
+ }
88
+
89
+ .skip-link:focus {
90
+ transform: translateY(0);
91
+ }
92
+
93
+ :focus-visible {
94
+ outline: 2px solid var(--green-bright);
95
+ outline-offset: 3px;
96
+ }
97
+
98
+ .ambient {
99
+ position: fixed;
100
+ z-index: -2;
101
+ width: 30rem;
102
+ height: 30rem;
103
+ border-radius: 50%;
104
+ pointer-events: none;
105
+ filter: blur(110px);
106
+ opacity: .06;
107
+ }
108
+
109
+ .ambient-one {
110
+ top: 18rem;
111
+ left: -16rem;
112
+ background: var(--green);
113
+ }
114
+
115
+ .ambient-two {
116
+ right: -18rem;
117
+ bottom: 4rem;
118
+ background: var(--blue);
119
+ }
120
+
121
+ .noise {
122
+ position: fixed;
123
+ z-index: -1;
124
+ inset: 0;
125
+ pointer-events: none;
126
+ opacity: .018;
127
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.95' numOctaves='3' stitchTiles='stitchTiles'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
128
+ }
129
+
130
+ .topbar {
131
+ position: sticky;
132
+ z-index: 100;
133
+ top: 0;
134
+ min-height: var(--header-height);
135
+ border-bottom: 1px solid rgba(185, 219, 202, .08);
136
+ background: rgba(6, 12, 9, .82);
137
+ backdrop-filter: blur(20px) saturate(140%);
138
+ }
139
+
140
+ .topbar-inner {
141
+ display: flex;
142
+ align-items: center;
143
+ justify-content: space-between;
144
+ width: min(100% - 40px, 1240px);
145
+ min-height: var(--header-height);
146
+ margin: auto;
147
+ gap: 28px;
148
+ }
149
+
150
+ .brand {
151
+ display: inline-flex;
152
+ flex: 0 0 auto;
153
+ align-items: center;
154
+ gap: 11px;
155
+ }
156
+
157
+ .brand-mark {
158
+ display: grid;
159
+ width: 37px;
160
+ height: 41px;
161
+ place-items: center;
162
+ color: var(--green);
163
+ filter: drop-shadow(0 0 16px rgba(56, 233, 145, .22));
164
+ }
165
+
166
+ .brand-mark svg {
167
+ width: 30px;
168
+ height: 34px;
169
+ }
170
+
171
+ .brand-copy {
172
+ display: flex;
173
+ flex-direction: column;
174
+ gap: 2px;
175
+ }
176
+
177
+ .brand-copy strong {
178
+ font-size: 15px;
179
+ font-weight: 900;
180
+ letter-spacing: .055em;
181
+ line-height: 1;
182
+ }
183
+
184
+ .brand-copy em {
185
+ color: var(--green);
186
+ font-style: normal;
187
+ }
188
+
189
+ .brand-copy small {
190
+ color: var(--muted);
191
+ font-size: 9px;
192
+ font-weight: 700;
193
+ letter-spacing: .14em;
194
+ text-transform: uppercase;
195
+ }
196
+
197
+ .main-nav {
198
+ display: flex;
199
+ align-self: stretch;
200
+ align-items: center;
201
+ gap: 8px;
202
+ }
203
+
204
+ .main-nav a {
205
+ position: relative;
206
+ display: grid;
207
+ height: 100%;
208
+ padding: 0 13px;
209
+ place-items: center;
210
+ color: var(--muted-strong);
211
+ font-size: 12px;
212
+ font-weight: 700;
213
+ transition: color .2s ease;
214
+ }
215
+
216
+ .main-nav a::after {
217
+ position: absolute;
218
+ right: 13px;
219
+ bottom: -1px;
220
+ left: 13px;
221
+ height: 2px;
222
+ transform: scaleX(0);
223
+ border-radius: 2px;
224
+ background: var(--green);
225
+ content: "";
226
+ transition: transform .2s ease;
227
+ }
228
+
229
+ .main-nav a:hover,
230
+ .main-nav a:focus-visible {
231
+ color: var(--text);
232
+ }
233
+
234
+ .main-nav a:hover::after {
235
+ transform: scaleX(1);
236
+ }
237
+
238
+ .topbar-actions {
239
+ display: flex;
240
+ flex: 0 0 auto;
241
+ align-items: center;
242
+ gap: 9px;
243
+ }
244
+
245
+ .pill {
246
+ display: inline-flex;
247
+ min-height: 33px;
248
+ align-items: center;
249
+ gap: 8px;
250
+ padding: 7px 11px;
251
+ border: 1px solid var(--line);
252
+ border-radius: 999px;
253
+ background: rgba(255, 255, 255, .025);
254
+ color: var(--muted);
255
+ font-size: 9px;
256
+ font-weight: 900;
257
+ letter-spacing: .1em;
258
+ white-space: nowrap;
259
+ }
260
+
261
+ .status-dot {
262
+ width: 7px;
263
+ height: 7px;
264
+ border-radius: 50%;
265
+ background: currentColor;
266
+ box-shadow: 0 0 0 4px currentColor;
267
+ opacity: .7;
268
+ }
269
+
270
+ .pill.ok {
271
+ border-color: rgba(56, 233, 145, .25);
272
+ background: rgba(56, 233, 145, .075);
273
+ color: var(--green);
274
+ }
275
+
276
+ .pill.ok .status-dot {
277
+ animation: status-pulse 2s ease-out infinite;
278
+ }
279
+
280
+ .pill.bad {
281
+ border-color: rgba(255, 113, 132, .28);
282
+ background: rgba(255, 113, 132, .08);
283
+ color: var(--red);
284
+ }
285
+
286
+ .pill.warning {
287
+ border-color: rgba(255, 202, 100, .28);
288
+ background: rgba(255, 202, 100, .08);
289
+ color: var(--amber);
290
+ }
291
+
292
+ .clipboard-fallback {
293
+ position: fixed;
294
+ width: 1px;
295
+ height: 1px;
296
+ opacity: 0;
297
+ pointer-events: none;
298
+ }
299
+
300
+ .icon-button {
301
+ display: grid;
302
+ width: 36px;
303
+ height: 36px;
304
+ padding: 0;
305
+ cursor: pointer;
306
+ border: 1px solid var(--line);
307
+ border-radius: 10px;
308
+ background: rgba(255, 255, 255, .035);
309
+ place-items: center;
310
+ transition: border-color .2s ease, background .2s ease, transform .2s ease;
311
+ }
312
+
313
+ .icon-button:hover {
314
+ transform: translateY(-1px);
315
+ border-color: rgba(56, 233, 145, .35);
316
+ background: rgba(56, 233, 145, .08);
317
+ }
318
+
319
+ .icon-button svg {
320
+ width: 17px;
321
+ fill: none;
322
+ stroke: currentColor;
323
+ stroke-width: 1.8;
324
+ stroke-linecap: round;
325
+ stroke-linejoin: round;
326
+ }
327
+
328
+ .icon-button.loading svg {
329
+ animation: spin .8s linear infinite;
330
+ }
331
+
332
+ .container {
333
+ width: min(100% - 40px, 1240px);
334
+ margin: 0 auto;
335
+ padding: 26px 0 60px;
336
+ }
337
+
338
+ .panel {
339
+ border: 1px solid var(--line);
340
+ border-radius: var(--radius-lg);
341
+ background: linear-gradient(145deg, rgba(17, 30, 25, .96), rgba(10, 18, 15, .97));
342
+ box-shadow: var(--shadow);
343
+ }
344
+
345
+ .hero {
346
+ position: relative;
347
+ display: grid;
348
+ min-height: 440px;
349
+ overflow: hidden;
350
+ grid-template-columns: minmax(0, 1.25fr) minmax(330px, .75fr);
351
+ background:
352
+ linear-gradient(100deg, rgba(12, 27, 21, .98) 0%, rgba(9, 19, 15, .95) 52%, rgba(8, 17, 14, .96) 100%);
353
+ }
354
+
355
+ .hero::before {
356
+ position: absolute;
357
+ width: 600px;
358
+ height: 600px;
359
+ top: -360px;
360
+ left: -130px;
361
+ border: 1px solid rgba(56, 233, 145, .08);
362
+ border-radius: 50%;
363
+ box-shadow:
364
+ 0 0 0 80px rgba(56, 233, 145, .018),
365
+ 0 0 0 160px rgba(56, 233, 145, .012);
366
+ content: "";
367
+ }
368
+
369
+ .hero::after {
370
+ position: absolute;
371
+ inset: 0;
372
+ pointer-events: none;
373
+ background:
374
+ linear-gradient(90deg, transparent 49.8%, rgba(56, 233, 145, .025) 50%, transparent 50.2%),
375
+ linear-gradient(0deg, transparent 49.8%, rgba(56, 233, 145, .02) 50%, transparent 50.2%);
376
+ background-size: 120px 120px;
377
+ content: "";
378
+ mask-image: linear-gradient(90deg, transparent 25%, #000 80%);
379
+ }
380
+
381
+ .hero-copy {
382
+ position: relative;
383
+ z-index: 2;
384
+ display: flex;
385
+ flex-direction: column;
386
+ justify-content: center;
387
+ padding: clamp(34px, 5vw, 64px);
388
+ }
389
+
390
+ .live-label {
391
+ display: inline-flex;
392
+ width: fit-content;
393
+ align-items: center;
394
+ gap: 9px;
395
+ color: var(--green);
396
+ font-size: 10px;
397
+ font-weight: 900;
398
+ letter-spacing: .16em;
399
+ }
400
+
401
+ .live-label b,
402
+ .console-state b {
403
+ font: inherit;
404
+ }
405
+
406
+ .live-label > span {
407
+ width: 7px;
408
+ height: 7px;
409
+ border-radius: 50%;
410
+ background: var(--green);
411
+ box-shadow: 0 0 0 5px rgba(56, 233, 145, .1), 0 0 16px rgba(56, 233, 145, .6);
412
+ }
413
+
414
+ .hero h1 {
415
+ max-width: 700px;
416
+ margin: 18px 0 18px;
417
+ font-size: clamp(37px, 5vw, 67px);
418
+ font-weight: 850;
419
+ letter-spacing: -.055em;
420
+ line-height: .98;
421
+ }
422
+
423
+ .hero h1 span {
424
+ color: var(--green);
425
+ }
426
+
427
+ .hero-copy > p {
428
+ max-width: 650px;
429
+ margin: 0;
430
+ color: var(--muted-strong);
431
+ font-size: 15px;
432
+ line-height: 1.7;
433
+ }
434
+
435
+ .hero-actions {
436
+ display: flex;
437
+ flex-wrap: wrap;
438
+ gap: 10px;
439
+ margin-top: 28px;
440
+ }
441
+
442
+ .btn {
443
+ display: inline-flex;
444
+ min-height: 44px;
445
+ align-items: center;
446
+ justify-content: center;
447
+ gap: 8px;
448
+ padding: 0 17px;
449
+ cursor: pointer;
450
+ border: 1px solid transparent;
451
+ border-radius: 11px;
452
+ font-size: 12px;
453
+ font-weight: 850;
454
+ transition: transform .2s ease, filter .2s ease, border-color .2s ease, background .2s ease;
455
+ }
456
+
457
+ .btn:hover {
458
+ transform: translateY(-2px);
459
+ }
460
+
461
+ .btn.primary {
462
+ background: var(--green);
463
+ box-shadow: 0 10px 30px rgba(56, 233, 145, .16);
464
+ color: #042116;
465
+ }
466
+
467
+ .btn.primary:hover {
468
+ filter: brightness(1.08);
469
+ }
470
+
471
+ .btn.primary svg {
472
+ width: 16px;
473
+ fill: none;
474
+ stroke: currentColor;
475
+ stroke-width: 2;
476
+ stroke-linecap: round;
477
+ stroke-linejoin: round;
478
+ }
479
+
480
+ .btn.secondary {
481
+ border-color: var(--line-strong);
482
+ background: rgba(255, 255, 255, .035);
483
+ color: var(--muted-strong);
484
+ }
485
+
486
+ .btn.secondary:hover {
487
+ border-color: rgba(56, 233, 145, .3);
488
+ background: rgba(56, 233, 145, .06);
489
+ color: var(--text);
490
+ }
491
+
492
+ .hero-footnote {
493
+ display: flex;
494
+ align-items: center;
495
+ gap: 8px;
496
+ margin-top: 24px;
497
+ color: var(--muted);
498
+ font-size: 10px;
499
+ line-height: 1.5;
500
+ }
501
+
502
+ .hero-footnote svg {
503
+ width: 16px;
504
+ flex: 0 0 auto;
505
+ fill: none;
506
+ stroke: var(--green);
507
+ stroke-width: 1.7;
508
+ stroke-linecap: round;
509
+ stroke-linejoin: round;
510
+ }
511
+
512
+ .hero-console {
513
+ position: relative;
514
+ z-index: 2;
515
+ align-self: center;
516
+ margin: 32px 32px 32px 0;
517
+ overflow: hidden;
518
+ border: 1px solid rgba(189, 222, 205, .14);
519
+ border-radius: 18px;
520
+ background: rgba(4, 11, 8, .66);
521
+ box-shadow: 0 24px 60px rgba(0, 0, 0, .3);
522
+ backdrop-filter: blur(10px);
523
+ }
524
+
525
+ .console-head,
526
+ .console-footer {
527
+ display: flex;
528
+ align-items: center;
529
+ justify-content: space-between;
530
+ padding: 15px 17px;
531
+ border-bottom: 1px solid var(--line);
532
+ color: var(--muted-strong);
533
+ font-size: 10px;
534
+ font-weight: 800;
535
+ letter-spacing: .08em;
536
+ text-transform: uppercase;
537
+ }
538
+
539
+ .console-state {
540
+ display: flex;
541
+ align-items: center;
542
+ gap: 6px;
543
+ color: var(--green);
544
+ font-size: 8px;
545
+ letter-spacing: .12em;
546
+ }
547
+
548
+ .live-label.paused,
549
+ .console-state.paused {
550
+ color: var(--amber);
551
+ }
552
+
553
+ .live-label.danger,
554
+ .console-state.danger {
555
+ color: var(--red);
556
+ }
557
+
558
+ .console-state i,
559
+ .secure-label i {
560
+ width: 6px;
561
+ height: 6px;
562
+ border-radius: 50%;
563
+ background: currentColor;
564
+ box-shadow: 0 0 10px currentColor;
565
+ }
566
+
567
+ .engine-flow {
568
+ margin: 0;
569
+ padding: 7px 17px;
570
+ list-style: none;
571
+ }
572
+
573
+ .engine-flow li {
574
+ position: relative;
575
+ display: grid;
576
+ align-items: center;
577
+ gap: 12px;
578
+ padding: 14px 0;
579
+ grid-template-columns: 27px 1fr 20px;
580
+ }
581
+
582
+ .engine-flow li:not(:last-child) {
583
+ border-bottom: 1px dashed rgba(185, 219, 202, .1);
584
+ }
585
+
586
+ .flow-number {
587
+ display: grid;
588
+ width: 26px;
589
+ height: 26px;
590
+ border: 1px solid var(--line);
591
+ border-radius: 8px;
592
+ place-items: center;
593
+ color: var(--muted);
594
+ font-size: 8px;
595
+ font-weight: 900;
596
+ }
597
+
598
+ .engine-flow strong,
599
+ .engine-flow small {
600
+ display: block;
601
+ }
602
+
603
+ .engine-flow strong {
604
+ color: #deebe4;
605
+ font-size: 11px;
606
+ line-height: 1.3;
607
+ }
608
+
609
+ .engine-flow small {
610
+ margin-top: 3px;
611
+ color: #71867b;
612
+ font-size: 9px;
613
+ }
614
+
615
+ .flow-check {
616
+ color: var(--green);
617
+ font-size: 13px;
618
+ font-weight: 900;
619
+ text-align: center;
620
+ }
621
+
622
+ .engine-flow .active .flow-number {
623
+ border-color: rgba(56, 233, 145, .35);
624
+ background: rgba(56, 233, 145, .09);
625
+ color: var(--green);
626
+ }
627
+
628
+ .flow-pulse {
629
+ justify-self: center;
630
+ width: 8px;
631
+ height: 8px;
632
+ border-radius: 50%;
633
+ background: var(--green);
634
+ box-shadow: 0 0 0 5px rgba(56, 233, 145, .08), 0 0 12px rgba(56, 233, 145, .4);
635
+ }
636
+
637
+ .engine-flow.paused .flow-pulse {
638
+ background: var(--amber);
639
+ box-shadow: 0 0 0 5px rgba(255, 202, 100, .08), 0 0 12px rgba(255, 202, 100, .35);
640
+ }
641
+
642
+ .engine-flow.failed .flow-pulse {
643
+ background: var(--red);
644
+ box-shadow: 0 0 0 5px rgba(255, 113, 132, .08), 0 0 12px rgba(255, 113, 132, .35);
645
+ }
646
+
647
+ .console-footer {
648
+ border-top: 1px solid var(--line);
649
+ border-bottom: 0;
650
+ background: rgba(255, 255, 255, .018);
651
+ }
652
+
653
+ .console-footer strong {
654
+ color: var(--green);
655
+ font-size: 9px;
656
+ }
657
+
658
+ .warnings:empty {
659
+ display: none;
660
+ }
661
+
662
+ .warnings {
663
+ display: grid;
664
+ gap: 8px;
665
+ margin-top: 16px;
666
+ }
667
+
668
+ .warning {
669
+ display: flex;
670
+ align-items: flex-start;
671
+ gap: 11px;
672
+ padding: 12px 14px;
673
+ border: 1px solid rgba(255, 202, 100, .24);
674
+ border-radius: 12px;
675
+ background: rgba(255, 202, 100, .07);
676
+ color: #ead5a8;
677
+ font-size: 11px;
678
+ line-height: 1.55;
679
+ }
680
+
681
+ .warning svg {
682
+ width: 16px;
683
+ flex: 0 0 auto;
684
+ fill: none;
685
+ stroke: var(--amber);
686
+ stroke-width: 1.8;
687
+ stroke-linecap: round;
688
+ stroke-linejoin: round;
689
+ }
690
+
691
+ .stats-grid {
692
+ display: grid;
693
+ gap: 12px;
694
+ margin: 16px 0 58px;
695
+ grid-template-columns: repeat(4, minmax(0, 1fr));
696
+ }
697
+
698
+ .stat {
699
+ position: relative;
700
+ display: flex;
701
+ min-height: 134px;
702
+ align-items: center;
703
+ gap: 15px;
704
+ overflow: hidden;
705
+ padding: 20px;
706
+ box-shadow: 0 14px 40px rgba(0, 0, 0, .2);
707
+ }
708
+
709
+ .stat::after {
710
+ position: absolute;
711
+ top: -38px;
712
+ right: -38px;
713
+ width: 100px;
714
+ height: 100px;
715
+ border-radius: 50%;
716
+ background: rgba(255, 255, 255, .02);
717
+ content: "";
718
+ }
719
+
720
+ .stat.featured {
721
+ border-color: rgba(56, 233, 145, .2);
722
+ background: linear-gradient(145deg, rgba(19, 47, 35, .98), rgba(9, 21, 16, .98));
723
+ }
724
+
725
+ .stat-icon {
726
+ display: grid;
727
+ width: 40px;
728
+ height: 40px;
729
+ flex: 0 0 auto;
730
+ border: 1px solid currentColor;
731
+ border-radius: 12px;
732
+ place-items: center;
733
+ background: color-mix(in srgb, currentColor 9%, transparent);
734
+ }
735
+
736
+ .stat-icon svg {
737
+ width: 19px;
738
+ fill: none;
739
+ stroke: currentColor;
740
+ stroke-width: 1.7;
741
+ stroke-linecap: round;
742
+ stroke-linejoin: round;
743
+ }
744
+
745
+ .stat-icon.blue { color: var(--blue); }
746
+ .stat-icon.green { color: var(--green); }
747
+ .stat-icon.violet { color: var(--violet); }
748
+ .stat-icon.amber { color: var(--amber); }
749
+
750
+ .stat-copy {
751
+ min-width: 0;
752
+ }
753
+
754
+ .stat-copy > span,
755
+ .stat-copy > small {
756
+ display: block;
757
+ overflow: hidden;
758
+ color: var(--muted);
759
+ font-size: 9px;
760
+ font-weight: 800;
761
+ letter-spacing: .08em;
762
+ line-height: 1.35;
763
+ text-overflow: ellipsis;
764
+ }
765
+
766
+ .stat-copy > span {
767
+ white-space: nowrap;
768
+ }
769
+
770
+ .stat-copy > small {
771
+ font-weight: 600;
772
+ letter-spacing: 0;
773
+ white-space: normal;
774
+ }
775
+
776
+ .stat-copy strong {
777
+ display: block;
778
+ margin: 5px 0 4px;
779
+ font-size: clamp(26px, 2.7vw, 38px);
780
+ font-variant-numeric: tabular-nums;
781
+ font-weight: 850;
782
+ letter-spacing: -.04em;
783
+ line-height: 1;
784
+ }
785
+
786
+ .content-section {
787
+ margin-top: 58px;
788
+ scroll-margin-top: calc(var(--header-height) + 24px);
789
+ }
790
+
791
+ .section-head {
792
+ display: flex;
793
+ align-items: flex-end;
794
+ justify-content: space-between;
795
+ gap: 24px;
796
+ margin: 0 2px 18px;
797
+ }
798
+
799
+ .eyebrow,
800
+ .panel-kicker {
801
+ display: inline-flex;
802
+ align-items: center;
803
+ gap: 8px;
804
+ color: var(--green);
805
+ font-size: 9px;
806
+ font-weight: 900;
807
+ letter-spacing: .16em;
808
+ }
809
+
810
+ .eyebrow i {
811
+ width: 18px;
812
+ height: 1px;
813
+ background: var(--green);
814
+ }
815
+
816
+ .section-head h2 {
817
+ margin: 7px 0 0;
818
+ font-size: clamp(26px, 3vw, 35px);
819
+ font-weight: 800;
820
+ letter-spacing: -.035em;
821
+ line-height: 1.1;
822
+ }
823
+
824
+ .section-head p {
825
+ margin: 8px 0 0;
826
+ color: var(--muted);
827
+ font-size: 12px;
828
+ line-height: 1.5;
829
+ }
830
+
831
+ .update-time,
832
+ .section-badge {
833
+ flex: 0 0 auto;
834
+ padding: 8px 11px;
835
+ border: 1px solid var(--line);
836
+ border-radius: 9px;
837
+ background: rgba(255, 255, 255, .025);
838
+ color: var(--muted);
839
+ font-size: 9px;
840
+ font-weight: 750;
841
+ letter-spacing: .05em;
842
+ white-space: nowrap;
843
+ }
844
+
845
+ .section-badge {
846
+ color: var(--muted-strong);
847
+ font-weight: 850;
848
+ letter-spacing: .1em;
849
+ }
850
+
851
+ .picks {
852
+ display: grid;
853
+ gap: 14px;
854
+ grid-template-columns: repeat(2, minmax(0, 1fr));
855
+ }
856
+
857
+ .pick {
858
+ position: relative;
859
+ overflow: hidden;
860
+ padding: 0;
861
+ box-shadow: 0 18px 48px rgba(0, 0, 0, .23);
862
+ transition: transform .25s ease, border-color .25s ease, box-shadow .25s ease;
863
+ }
864
+
865
+ .pick:hover {
866
+ transform: translateY(-3px);
867
+ border-color: rgba(56, 233, 145, .25);
868
+ box-shadow: 0 24px 56px rgba(0, 0, 0, .3);
869
+ }
870
+
871
+ .pick::before {
872
+ position: absolute;
873
+ z-index: 2;
874
+ top: 0;
875
+ right: 0;
876
+ left: 0;
877
+ height: 2px;
878
+ background: linear-gradient(90deg, transparent, var(--green), transparent);
879
+ content: "";
880
+ opacity: .8;
881
+ }
882
+
883
+ .pick-head {
884
+ display: flex;
885
+ align-items: flex-start;
886
+ justify-content: space-between;
887
+ gap: 16px;
888
+ padding: 18px 20px 15px;
889
+ border-bottom: 1px solid var(--line);
890
+ background: rgba(255, 255, 255, .014);
891
+ }
892
+
893
+ .pick-meta {
894
+ display: flex;
895
+ min-width: 0;
896
+ flex-wrap: wrap;
897
+ align-items: center;
898
+ gap: 7px;
899
+ }
900
+
901
+ .rank,
902
+ .competition-chip {
903
+ display: inline-flex;
904
+ min-height: 24px;
905
+ align-items: center;
906
+ padding: 4px 8px;
907
+ border: 1px solid var(--line);
908
+ border-radius: 7px;
909
+ color: var(--muted);
910
+ font-size: 8px;
911
+ font-weight: 850;
912
+ letter-spacing: .09em;
913
+ line-height: 1.2;
914
+ }
915
+
916
+ .rank {
917
+ border-color: rgba(56, 233, 145, .25);
918
+ background: rgba(56, 233, 145, .07);
919
+ color: var(--green);
920
+ }
921
+
922
+ .kickoff {
923
+ width: 100%;
924
+ margin-top: 3px;
925
+ color: var(--muted);
926
+ font-size: 10px;
927
+ }
928
+
929
+ .score-ring {
930
+ position: relative;
931
+ display: grid;
932
+ width: 58px;
933
+ height: 58px;
934
+ flex: 0 0 auto;
935
+ border-radius: 50%;
936
+ place-items: center;
937
+ }
938
+
939
+ .score-ring > svg {
940
+ position: absolute;
941
+ inset: 0;
942
+ width: 58px;
943
+ height: 58px;
944
+ transform: rotate(-90deg);
945
+ fill: none;
946
+ stroke-width: 3.5;
947
+ }
948
+
949
+ .score-track {
950
+ stroke: rgba(255, 255, 255, .07);
951
+ }
952
+
953
+ .score-value {
954
+ stroke: var(--green);
955
+ stroke-linecap: round;
956
+ filter: drop-shadow(0 0 4px rgba(56, 233, 145, .35));
957
+ }
958
+
959
+ .score-ring > span {
960
+ position: relative;
961
+ z-index: 1;
962
+ text-align: center;
963
+ }
964
+
965
+ .score-ring strong,
966
+ .score-ring small {
967
+ display: block;
968
+ }
969
+
970
+ .score-ring strong {
971
+ color: var(--green-bright);
972
+ font-size: 15px;
973
+ font-variant-numeric: tabular-nums;
974
+ font-weight: 900;
975
+ line-height: 1;
976
+ }
977
+
978
+ .score-ring small {
979
+ margin-top: 2px;
980
+ color: var(--muted);
981
+ font-size: 6px;
982
+ font-weight: 900;
983
+ letter-spacing: .1em;
984
+ }
985
+
986
+ .pick-body {
987
+ padding: 20px;
988
+ }
989
+
990
+ .matchup {
991
+ display: grid;
992
+ align-items: center;
993
+ gap: 11px;
994
+ grid-template-columns: minmax(0, 1fr) 30px minmax(0, 1fr);
995
+ }
996
+
997
+ .team {
998
+ display: flex;
999
+ min-width: 0;
1000
+ align-items: center;
1001
+ gap: 9px;
1002
+ }
1003
+
1004
+ .team:last-child {
1005
+ flex-direction: row-reverse;
1006
+ text-align: right;
1007
+ }
1008
+
1009
+ .team-badge {
1010
+ display: grid;
1011
+ width: 35px;
1012
+ height: 35px;
1013
+ flex: 0 0 auto;
1014
+ border: 1px solid var(--line-strong);
1015
+ border-radius: 11px;
1016
+ background: linear-gradient(145deg, rgba(255, 255, 255, .07), rgba(255, 255, 255, .02));
1017
+ place-items: center;
1018
+ color: var(--muted-strong);
1019
+ font-size: 10px;
1020
+ font-weight: 900;
1021
+ letter-spacing: .02em;
1022
+ }
1023
+
1024
+ .team strong {
1025
+ overflow: hidden;
1026
+ font-size: 13px;
1027
+ font-weight: 750;
1028
+ line-height: 1.3;
1029
+ text-overflow: ellipsis;
1030
+ }
1031
+
1032
+ .versus {
1033
+ display: grid;
1034
+ width: 29px;
1035
+ height: 29px;
1036
+ border: 1px solid var(--line);
1037
+ border-radius: 50%;
1038
+ background: rgba(255, 255, 255, .025);
1039
+ place-items: center;
1040
+ color: #6f8278;
1041
+ font-size: 7px;
1042
+ font-weight: 900;
1043
+ }
1044
+
1045
+ .selection-box {
1046
+ display: flex;
1047
+ align-items: center;
1048
+ justify-content: space-between;
1049
+ gap: 14px;
1050
+ margin: 18px 0 14px;
1051
+ padding: 13px 14px;
1052
+ border: 1px solid rgba(56, 233, 145, .18);
1053
+ border-radius: 12px;
1054
+ background: linear-gradient(90deg, rgba(56, 233, 145, .09), rgba(56, 233, 145, .025));
1055
+ }
1056
+
1057
+ .selection-box > div span,
1058
+ .selection-box > div strong {
1059
+ display: block;
1060
+ }
1061
+
1062
+ .selection-box > div span {
1063
+ color: var(--muted);
1064
+ font-size: 8px;
1065
+ font-weight: 850;
1066
+ letter-spacing: .1em;
1067
+ }
1068
+
1069
+ .selection-box > div strong {
1070
+ margin-top: 4px;
1071
+ color: #dffbed;
1072
+ font-size: 14px;
1073
+ font-weight: 800;
1074
+ }
1075
+
1076
+ .selection-signal {
1077
+ display: inline-flex;
1078
+ flex: 0 0 auto;
1079
+ align-items: center;
1080
+ gap: 6px;
1081
+ color: var(--green);
1082
+ font-size: 8px;
1083
+ font-weight: 900;
1084
+ letter-spacing: .1em;
1085
+ }
1086
+
1087
+ .selection-signal i {
1088
+ width: 6px;
1089
+ height: 6px;
1090
+ border-radius: 50%;
1091
+ background: currentColor;
1092
+ box-shadow: 0 0 9px currentColor;
1093
+ }
1094
+
1095
+ .metrics {
1096
+ display: grid;
1097
+ gap: 8px;
1098
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1099
+ }
1100
+
1101
+ .metric {
1102
+ min-width: 0;
1103
+ padding: 10px;
1104
+ border: 1px solid var(--line);
1105
+ border-radius: 10px;
1106
+ background: rgba(2, 8, 5, .26);
1107
+ }
1108
+
1109
+ .metric span,
1110
+ .metric strong {
1111
+ display: block;
1112
+ }
1113
+
1114
+ .metric span {
1115
+ overflow: hidden;
1116
+ color: var(--muted);
1117
+ font-size: 7px;
1118
+ font-weight: 850;
1119
+ letter-spacing: .08em;
1120
+ text-overflow: ellipsis;
1121
+ white-space: nowrap;
1122
+ }
1123
+
1124
+ .metric strong {
1125
+ margin-top: 5px;
1126
+ font-size: 13px;
1127
+ font-variant-numeric: tabular-nums;
1128
+ font-weight: 800;
1129
+ }
1130
+
1131
+ .confidence-line {
1132
+ display: flex;
1133
+ align-items: center;
1134
+ gap: 10px;
1135
+ margin: 15px 0 12px;
1136
+ }
1137
+
1138
+ .confidence-line > span {
1139
+ flex: 0 0 auto;
1140
+ color: var(--muted);
1141
+ font-size: 8px;
1142
+ font-weight: 800;
1143
+ letter-spacing: .07em;
1144
+ }
1145
+
1146
+ .bar {
1147
+ appearance: none;
1148
+ flex: 1;
1149
+ height: 5px;
1150
+ overflow: hidden;
1151
+ padding: 0;
1152
+ border: 0;
1153
+ border-radius: 999px;
1154
+ background: rgba(255, 255, 255, .06);
1155
+ }
1156
+
1157
+ .bar::-webkit-progress-bar {
1158
+ border-radius: inherit;
1159
+ background: rgba(255, 255, 255, .06);
1160
+ }
1161
+
1162
+ .bar::-webkit-progress-value {
1163
+ border-radius: inherit;
1164
+ background: linear-gradient(90deg, #20b96c, var(--green-bright));
1165
+ box-shadow: 0 0 12px rgba(56, 233, 145, .35);
1166
+ }
1167
+
1168
+ .bar::-moz-progress-bar {
1169
+ border-radius: inherit;
1170
+ background: linear-gradient(90deg, #20b96c, var(--green-bright));
1171
+ box-shadow: 0 0 12px rgba(56, 233, 145, .35);
1172
+ }
1173
+
1174
+ .reasons {
1175
+ display: flex;
1176
+ flex-wrap: wrap;
1177
+ gap: 6px;
1178
+ }
1179
+
1180
+ .tag {
1181
+ padding: 5px 8px;
1182
+ border: 1px solid var(--line);
1183
+ border-radius: 999px;
1184
+ background: rgba(255, 255, 255, .024);
1185
+ color: #9fb3a8;
1186
+ font-size: 8px;
1187
+ font-weight: 650;
1188
+ line-height: 1.2;
1189
+ }
1190
+
1191
+ .models-detail {
1192
+ margin-top: 14px;
1193
+ border-top: 1px solid var(--line);
1194
+ }
1195
+
1196
+ .models-detail summary {
1197
+ display: flex;
1198
+ align-items: center;
1199
+ justify-content: space-between;
1200
+ padding: 13px 0 0;
1201
+ cursor: pointer;
1202
+ color: var(--muted-strong);
1203
+ font-size: 9px;
1204
+ font-weight: 800;
1205
+ letter-spacing: .04em;
1206
+ list-style: none;
1207
+ }
1208
+
1209
+ .models-detail summary::-webkit-details-marker {
1210
+ display: none;
1211
+ }
1212
+
1213
+ .models-detail summary::after {
1214
+ display: grid;
1215
+ width: 19px;
1216
+ height: 19px;
1217
+ border: 1px solid var(--line);
1218
+ border-radius: 6px;
1219
+ content: "+";
1220
+ place-items: center;
1221
+ color: var(--green);
1222
+ font-size: 13px;
1223
+ transition: transform .2s ease;
1224
+ }
1225
+
1226
+ .models-detail[open] summary::after {
1227
+ transform: rotate(45deg);
1228
+ }
1229
+
1230
+ .model-table {
1231
+ display: grid;
1232
+ margin-top: 12px;
1233
+ padding: 5px 12px;
1234
+ border: 1px solid var(--line);
1235
+ border-radius: 10px;
1236
+ background: rgba(0, 0, 0, .14);
1237
+ }
1238
+
1239
+ .model-row {
1240
+ display: flex;
1241
+ align-items: center;
1242
+ justify-content: space-between;
1243
+ gap: 14px;
1244
+ padding: 8px 0;
1245
+ border-bottom: 1px dashed rgba(185, 219, 202, .09);
1246
+ color: var(--muted);
1247
+ font-size: 9px;
1248
+ line-height: 1.3;
1249
+ }
1250
+
1251
+ .model-row:last-child {
1252
+ border-bottom: 0;
1253
+ }
1254
+
1255
+ .model-row b {
1256
+ flex: 0 0 auto;
1257
+ color: var(--text);
1258
+ font-variant-numeric: tabular-nums;
1259
+ }
1260
+
1261
+ .pick-tools {
1262
+ display: flex;
1263
+ align-items: stretch;
1264
+ gap: 8px;
1265
+ margin-top: 14px;
1266
+ }
1267
+
1268
+ .copyline {
1269
+ display: flex;
1270
+ gap: 6px;
1271
+ }
1272
+
1273
+ .copybtn {
1274
+ display: inline-flex;
1275
+ min-height: 36px;
1276
+ align-items: center;
1277
+ gap: 6px;
1278
+ padding: 0 10px;
1279
+ cursor: pointer;
1280
+ border: 1px solid var(--line);
1281
+ border-radius: 9px;
1282
+ background: rgba(255, 255, 255, .025);
1283
+ color: var(--muted-strong);
1284
+ font-size: 8px;
1285
+ font-weight: 800;
1286
+ transition: border-color .2s ease, background .2s ease, color .2s ease;
1287
+ }
1288
+
1289
+ .copybtn:hover {
1290
+ border-color: rgba(56, 233, 145, .27);
1291
+ background: rgba(56, 233, 145, .06);
1292
+ color: var(--green-bright);
1293
+ }
1294
+
1295
+ .copybtn svg {
1296
+ width: 13px;
1297
+ fill: none;
1298
+ stroke: currentColor;
1299
+ stroke-width: 1.8;
1300
+ stroke-linecap: round;
1301
+ stroke-linejoin: round;
1302
+ }
1303
+
1304
+ .oddbox {
1305
+ margin-top: 16px;
1306
+ padding: 14px;
1307
+ border: 1px solid var(--line);
1308
+ border-radius: 12px;
1309
+ background: rgba(3, 10, 7, .34);
1310
+ }
1311
+
1312
+ .odd-input-row {
1313
+ display: flex;
1314
+ align-items: center;
1315
+ gap: 10px;
1316
+ }
1317
+
1318
+ .odd-input-row label {
1319
+ min-width: 0;
1320
+ flex: 1;
1321
+ color: var(--muted-strong);
1322
+ font-size: 9px;
1323
+ font-weight: 800;
1324
+ letter-spacing: .04em;
1325
+ }
1326
+
1327
+ .odd-input-wrap {
1328
+ position: relative;
1329
+ width: 116px;
1330
+ flex: 0 0 auto;
1331
+ }
1332
+
1333
+ .odd-input-wrap::before {
1334
+ position: absolute;
1335
+ top: 50%;
1336
+ left: 11px;
1337
+ transform: translateY(-50%);
1338
+ color: var(--green);
1339
+ content: "@";
1340
+ font-size: 10px;
1341
+ font-weight: 900;
1342
+ }
1343
+
1344
+ .oddbox input {
1345
+ width: 100%;
1346
+ height: 37px;
1347
+ padding: 0 9px 0 27px;
1348
+ border: 1px solid var(--line-strong);
1349
+ border-radius: 8px;
1350
+ outline: 0;
1351
+ background: #07110c;
1352
+ color: var(--text);
1353
+ font-size: 12px;
1354
+ font-variant-numeric: tabular-nums;
1355
+ font-weight: 800;
1356
+ transition: border-color .2s ease, box-shadow .2s ease;
1357
+ }
1358
+
1359
+ .oddbox input:focus {
1360
+ border-color: rgba(56, 233, 145, .55);
1361
+ box-shadow: 0 0 0 3px rgba(56, 233, 145, .08);
1362
+ }
1363
+
1364
+ .oddresult {
1365
+ display: block;
1366
+ min-height: 16px;
1367
+ margin-top: 9px;
1368
+ color: var(--muted);
1369
+ font-size: 9px;
1370
+ line-height: 1.5;
1371
+ }
1372
+
1373
+ .oddresult.good { color: var(--green); }
1374
+ .oddresult.fair { color: var(--amber); }
1375
+ .oddresult.weak { color: var(--red); }
1376
+
1377
+ .ticket-grid {
1378
+ display: grid;
1379
+ gap: 14px;
1380
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1381
+ }
1382
+
1383
+ .ticket {
1384
+ position: relative;
1385
+ min-height: 350px;
1386
+ overflow: hidden;
1387
+ padding: 20px;
1388
+ box-shadow: 0 18px 48px rgba(0, 0, 0, .22);
1389
+ transition: transform .25s ease, border-color .25s ease;
1390
+ }
1391
+
1392
+ .ticket:hover {
1393
+ transform: translateY(-3px);
1394
+ }
1395
+
1396
+ .ticket::before {
1397
+ position: absolute;
1398
+ top: 0;
1399
+ right: 0;
1400
+ left: 0;
1401
+ height: 2px;
1402
+ background: var(--profile-color, var(--green));
1403
+ box-shadow: 0 0 18px var(--profile-color, var(--green));
1404
+ content: "";
1405
+ opacity: .8;
1406
+ }
1407
+
1408
+ .ticket[data-profile="safe"] { --profile-color: var(--green); }
1409
+ .ticket[data-profile="balanced"] { --profile-color: var(--blue); }
1410
+ .ticket[data-profile="freebet"] { --profile-color: var(--violet); }
1411
+
1412
+ .ticket-head {
1413
+ display: flex;
1414
+ align-items: flex-start;
1415
+ justify-content: space-between;
1416
+ gap: 12px;
1417
+ }
1418
+
1419
+ .ticket-profile {
1420
+ display: flex;
1421
+ align-items: center;
1422
+ gap: 10px;
1423
+ }
1424
+
1425
+ .ticket-icon {
1426
+ display: grid;
1427
+ width: 36px;
1428
+ height: 36px;
1429
+ border: 1px solid color-mix(in srgb, var(--profile-color) 35%, transparent);
1430
+ border-radius: 11px;
1431
+ background: color-mix(in srgb, var(--profile-color) 9%, transparent);
1432
+ place-items: center;
1433
+ color: var(--profile-color);
1434
+ }
1435
+
1436
+ .ticket-icon svg {
1437
+ width: 18px;
1438
+ fill: none;
1439
+ stroke: currentColor;
1440
+ stroke-width: 1.7;
1441
+ stroke-linecap: round;
1442
+ stroke-linejoin: round;
1443
+ }
1444
+
1445
+ .ticket-profile h3,
1446
+ .ticket-profile span {
1447
+ display: block;
1448
+ }
1449
+
1450
+ .ticket-profile h3 {
1451
+ margin: 0;
1452
+ font-size: 13px;
1453
+ font-weight: 850;
1454
+ letter-spacing: -.01em;
1455
+ }
1456
+
1457
+ .ticket-profile span {
1458
+ margin-top: 3px;
1459
+ color: var(--muted);
1460
+ font-size: 8px;
1461
+ font-weight: 700;
1462
+ letter-spacing: .08em;
1463
+ }
1464
+
1465
+ .risk-chip {
1466
+ padding: 5px 7px;
1467
+ border: 1px solid color-mix(in srgb, var(--profile-color) 25%, transparent);
1468
+ border-radius: 999px;
1469
+ background: color-mix(in srgb, var(--profile-color) 6%, transparent);
1470
+ color: var(--profile-color);
1471
+ font-size: 7px;
1472
+ font-weight: 900;
1473
+ letter-spacing: .08em;
1474
+ }
1475
+
1476
+ .ticket-odd {
1477
+ margin: 23px 0 19px;
1478
+ }
1479
+
1480
+ .ticket-odd span,
1481
+ .ticket-odd strong {
1482
+ display: block;
1483
+ }
1484
+
1485
+ .ticket-odd span {
1486
+ color: var(--muted);
1487
+ font-size: 8px;
1488
+ font-weight: 800;
1489
+ letter-spacing: .08em;
1490
+ }
1491
+
1492
+ .ticket-odd strong {
1493
+ margin-top: 4px;
1494
+ color: var(--profile-color);
1495
+ font-size: 34px;
1496
+ font-variant-numeric: tabular-nums;
1497
+ font-weight: 900;
1498
+ letter-spacing: -.045em;
1499
+ line-height: 1;
1500
+ }
1501
+
1502
+ .ticket-summary {
1503
+ display: grid;
1504
+ gap: 6px;
1505
+ padding: 11px 12px;
1506
+ border: 1px solid var(--line);
1507
+ border-radius: 10px;
1508
+ background: rgba(0, 0, 0, .13);
1509
+ grid-template-columns: repeat(3, 1fr);
1510
+ }
1511
+
1512
+ .ticket-summary div:not(:last-child) {
1513
+ border-right: 1px solid var(--line);
1514
+ }
1515
+
1516
+ .ticket-summary span,
1517
+ .ticket-summary strong {
1518
+ display: block;
1519
+ text-align: center;
1520
+ }
1521
+
1522
+ .ticket-summary span {
1523
+ color: var(--muted);
1524
+ font-size: 6px;
1525
+ font-weight: 800;
1526
+ letter-spacing: .07em;
1527
+ }
1528
+
1529
+ .ticket-summary strong {
1530
+ margin-top: 4px;
1531
+ font-size: 10px;
1532
+ font-variant-numeric: tabular-nums;
1533
+ }
1534
+
1535
+ .target-state {
1536
+ display: flex;
1537
+ align-items: center;
1538
+ gap: 6px;
1539
+ margin: 13px 0 7px;
1540
+ color: var(--muted);
1541
+ font-size: 8px;
1542
+ font-weight: 700;
1543
+ }
1544
+
1545
+ .target-state i {
1546
+ width: 6px;
1547
+ height: 6px;
1548
+ border-radius: 50%;
1549
+ background: var(--amber);
1550
+ }
1551
+
1552
+ .target-state.met {
1553
+ color: var(--green);
1554
+ }
1555
+
1556
+ .target-state.met i {
1557
+ background: currentColor;
1558
+ }
1559
+
1560
+ .ticket-legs {
1561
+ margin-top: 9px;
1562
+ }
1563
+
1564
+ .ticket-leg {
1565
+ position: relative;
1566
+ display: grid;
1567
+ gap: 3px;
1568
+ padding: 10px 0 10px 17px;
1569
+ border-top: 1px dashed rgba(185, 219, 202, .1);
1570
+ }
1571
+
1572
+ .ticket-leg::before {
1573
+ position: absolute;
1574
+ top: 15px;
1575
+ left: 2px;
1576
+ width: 6px;
1577
+ height: 6px;
1578
+ border: 1px solid var(--profile-color);
1579
+ border-radius: 50%;
1580
+ background: color-mix(in srgb, var(--profile-color) 25%, #06100b);
1581
+ content: "";
1582
+ }
1583
+
1584
+ .ticket-leg b {
1585
+ font-size: 10px;
1586
+ font-weight: 750;
1587
+ line-height: 1.35;
1588
+ }
1589
+
1590
+ .ticket-leg span {
1591
+ overflow: hidden;
1592
+ color: var(--muted);
1593
+ font-size: 8px;
1594
+ line-height: 1.4;
1595
+ text-overflow: ellipsis;
1596
+ white-space: nowrap;
1597
+ }
1598
+
1599
+ .ticket .empty {
1600
+ min-height: 235px;
1601
+ padding: 36px 12px;
1602
+ }
1603
+
1604
+ .system-layout {
1605
+ display: grid;
1606
+ align-items: stretch;
1607
+ gap: 14px;
1608
+ grid-template-columns: minmax(0, 1.65fr) minmax(290px, .75fr);
1609
+ }
1610
+
1611
+ .health-panel {
1612
+ padding: 20px;
1613
+ box-shadow: 0 18px 48px rgba(0, 0, 0, .22);
1614
+ }
1615
+
1616
+ .panel-heading {
1617
+ display: flex;
1618
+ align-items: flex-start;
1619
+ justify-content: space-between;
1620
+ gap: 16px;
1621
+ padding: 2px 2px 17px;
1622
+ border-bottom: 1px solid var(--line);
1623
+ }
1624
+
1625
+ .panel-heading h3,
1626
+ .methodology h3 {
1627
+ margin: 5px 0 0;
1628
+ font-size: 17px;
1629
+ font-weight: 800;
1630
+ letter-spacing: -.025em;
1631
+ }
1632
+
1633
+ .secure-label {
1634
+ display: flex;
1635
+ align-items: center;
1636
+ gap: 7px;
1637
+ padding: 6px 8px;
1638
+ border: 1px solid rgba(56, 233, 145, .18);
1639
+ border-radius: 999px;
1640
+ background: rgba(56, 233, 145, .05);
1641
+ color: var(--green);
1642
+ font-size: 7px;
1643
+ font-weight: 900;
1644
+ letter-spacing: .08em;
1645
+ }
1646
+
1647
+ .health {
1648
+ display: grid;
1649
+ gap: 8px;
1650
+ padding-top: 16px;
1651
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1652
+ }
1653
+
1654
+ .health-card {
1655
+ position: relative;
1656
+ min-height: 79px;
1657
+ overflow: hidden;
1658
+ padding: 13px;
1659
+ border: 1px solid var(--line);
1660
+ border-radius: 11px;
1661
+ background: rgba(2, 8, 5, .22);
1662
+ }
1663
+
1664
+ .health-card > span,
1665
+ .health-card > strong,
1666
+ .health-card > small {
1667
+ display: block;
1668
+ }
1669
+
1670
+ .health-card > span {
1671
+ overflow: hidden;
1672
+ color: var(--muted);
1673
+ font-size: 7px;
1674
+ font-weight: 850;
1675
+ letter-spacing: .08em;
1676
+ text-overflow: ellipsis;
1677
+ white-space: nowrap;
1678
+ }
1679
+
1680
+ .health-card > strong {
1681
+ overflow: hidden;
1682
+ margin-top: 7px;
1683
+ font-size: 13px;
1684
+ font-variant-numeric: tabular-nums;
1685
+ font-weight: 800;
1686
+ line-height: 1.25;
1687
+ text-overflow: ellipsis;
1688
+ white-space: nowrap;
1689
+ }
1690
+
1691
+ .health-card > small {
1692
+ margin-top: 4px;
1693
+ color: #6f8278;
1694
+ font-size: 7px;
1695
+ }
1696
+
1697
+ .provider-status {
1698
+ display: inline-flex !important;
1699
+ align-items: center;
1700
+ gap: 6px;
1701
+ }
1702
+
1703
+ .provider-status i {
1704
+ width: 6px;
1705
+ height: 6px;
1706
+ border-radius: 50%;
1707
+ background: var(--muted);
1708
+ }
1709
+
1710
+ .provider-status.online {
1711
+ color: var(--green);
1712
+ }
1713
+
1714
+ .provider-status.online i {
1715
+ background: currentColor;
1716
+ box-shadow: 0 0 8px currentColor;
1717
+ }
1718
+
1719
+ .provider-status.partial {
1720
+ color: var(--amber);
1721
+ }
1722
+
1723
+ .provider-status.partial i {
1724
+ background: currentColor;
1725
+ box-shadow: 0 0 8px currentColor;
1726
+ }
1727
+
1728
+ .methodology {
1729
+ position: relative;
1730
+ overflow: hidden;
1731
+ padding: 25px;
1732
+ border-color: rgba(56, 233, 145, .16);
1733
+ background: linear-gradient(145deg, rgba(18, 42, 31, .97), rgba(9, 20, 15, .98));
1734
+ }
1735
+
1736
+ .methodology::after {
1737
+ position: absolute;
1738
+ right: -70px;
1739
+ bottom: -70px;
1740
+ width: 180px;
1741
+ height: 180px;
1742
+ border: 1px solid rgba(56, 233, 145, .08);
1743
+ border-radius: 50%;
1744
+ box-shadow: 0 0 0 34px rgba(56, 233, 145, .018), 0 0 0 68px rgba(56, 233, 145, .01);
1745
+ content: "";
1746
+ }
1747
+
1748
+ .methodology > p {
1749
+ margin: 11px 0 0;
1750
+ color: var(--muted);
1751
+ font-size: 10px;
1752
+ line-height: 1.6;
1753
+ }
1754
+
1755
+ .methodology ul {
1756
+ position: relative;
1757
+ z-index: 1;
1758
+ display: grid;
1759
+ gap: 12px;
1760
+ margin: 19px 0;
1761
+ padding: 0;
1762
+ list-style: none;
1763
+ }
1764
+
1765
+ .methodology li {
1766
+ display: grid;
1767
+ align-items: center;
1768
+ gap: 10px;
1769
+ grid-template-columns: 28px 1fr;
1770
+ }
1771
+
1772
+ .methodology li > span {
1773
+ display: grid;
1774
+ width: 27px;
1775
+ height: 27px;
1776
+ border: 1px solid rgba(56, 233, 145, .2);
1777
+ border-radius: 8px;
1778
+ background: rgba(56, 233, 145, .06);
1779
+ place-items: center;
1780
+ color: var(--green);
1781
+ font-size: 7px;
1782
+ font-weight: 900;
1783
+ }
1784
+
1785
+ .methodology li strong,
1786
+ .methodology li small {
1787
+ display: block;
1788
+ }
1789
+
1790
+ .methodology li strong {
1791
+ font-size: 10px;
1792
+ }
1793
+
1794
+ .methodology li small {
1795
+ margin-top: 3px;
1796
+ color: var(--muted);
1797
+ font-size: 8px;
1798
+ line-height: 1.4;
1799
+ }
1800
+
1801
+ .methodology-note {
1802
+ position: relative;
1803
+ z-index: 1;
1804
+ display: flex;
1805
+ align-items: flex-start;
1806
+ gap: 8px;
1807
+ padding-top: 14px;
1808
+ border-top: 1px solid var(--line);
1809
+ color: #91a99d;
1810
+ font-size: 8px;
1811
+ line-height: 1.5;
1812
+ }
1813
+
1814
+ .methodology-note svg {
1815
+ width: 14px;
1816
+ flex: 0 0 auto;
1817
+ fill: none;
1818
+ stroke: var(--green);
1819
+ stroke-width: 1.8;
1820
+ stroke-linecap: round;
1821
+ }
1822
+
1823
+ .empty {
1824
+ display: flex;
1825
+ min-height: 160px;
1826
+ align-items: center;
1827
+ justify-content: center;
1828
+ gap: 10px;
1829
+ padding: 28px;
1830
+ color: var(--muted);
1831
+ font-size: 11px;
1832
+ line-height: 1.6;
1833
+ text-align: center;
1834
+ }
1835
+
1836
+ .picks > .empty {
1837
+ grid-column: 1 / -1;
1838
+ }
1839
+
1840
+ .empty-content {
1841
+ max-width: 420px;
1842
+ }
1843
+
1844
+ .empty-icon {
1845
+ display: grid;
1846
+ width: 42px;
1847
+ height: 42px;
1848
+ margin: 0 auto 11px;
1849
+ border: 1px solid var(--line);
1850
+ border-radius: 12px;
1851
+ background: rgba(255, 255, 255, .025);
1852
+ place-items: center;
1853
+ color: var(--green);
1854
+ font-size: 16px;
1855
+ }
1856
+
1857
+ .empty strong,
1858
+ .empty small {
1859
+ display: block;
1860
+ }
1861
+
1862
+ .empty strong {
1863
+ color: var(--muted-strong);
1864
+ font-size: 12px;
1865
+ }
1866
+
1867
+ .empty small {
1868
+ margin-top: 5px;
1869
+ font-size: 9px;
1870
+ }
1871
+
1872
+ .loading-state {
1873
+ min-height: 220px;
1874
+ }
1875
+
1876
+ .loader {
1877
+ width: 16px;
1878
+ height: 16px;
1879
+ border: 2px solid rgba(56, 233, 145, .18);
1880
+ border-top-color: var(--green);
1881
+ border-radius: 50%;
1882
+ animation: spin .75s linear infinite;
1883
+ }
1884
+
1885
+ .skeleton {
1886
+ position: relative;
1887
+ min-height: 120px;
1888
+ overflow: hidden;
1889
+ }
1890
+
1891
+ .skeleton::after {
1892
+ position: absolute;
1893
+ inset: 0;
1894
+ transform: translateX(-100%);
1895
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .035), transparent);
1896
+ content: "";
1897
+ animation: shimmer 1.6s infinite;
1898
+ }
1899
+
1900
+ .toast {
1901
+ position: fixed;
1902
+ z-index: 200;
1903
+ right: 22px;
1904
+ bottom: 22px;
1905
+ max-width: min(360px, calc(100vw - 32px));
1906
+ padding: 11px 14px;
1907
+ transform: translateY(20px);
1908
+ border: 1px solid rgba(56, 233, 145, .27);
1909
+ border-radius: 11px;
1910
+ background: rgba(13, 29, 22, .96);
1911
+ box-shadow: var(--shadow);
1912
+ color: #dcf8e9;
1913
+ font-size: 10px;
1914
+ font-weight: 750;
1915
+ opacity: 0;
1916
+ pointer-events: none;
1917
+ transition: opacity .2s ease, transform .2s ease;
1918
+ }
1919
+
1920
+ .toast.show {
1921
+ transform: translateY(0);
1922
+ opacity: 1;
1923
+ }
1924
+
1925
+ footer {
1926
+ display: grid;
1927
+ align-items: center;
1928
+ gap: 16px;
1929
+ margin-top: 64px;
1930
+ padding: 25px 2px 0;
1931
+ border-top: 1px solid var(--line);
1932
+ grid-template-columns: auto 1fr auto;
1933
+ color: #657a6f;
1934
+ font-size: 8px;
1935
+ line-height: 1.5;
1936
+ }
1937
+
1938
+ .footer-brand {
1939
+ display: flex;
1940
+ align-items: center;
1941
+ gap: 8px;
1942
+ color: var(--muted-strong);
1943
+ font-size: 9px;
1944
+ letter-spacing: .06em;
1945
+ }
1946
+
1947
+ .brand-mark.small {
1948
+ width: 24px;
1949
+ height: 24px;
1950
+ border: 1px solid rgba(56, 233, 145, .2);
1951
+ border-radius: 8px;
1952
+ background: rgba(56, 233, 145, .06);
1953
+ font-size: 11px;
1954
+ }
1955
+
1956
+ footer p {
1957
+ margin: 0;
1958
+ text-align: center;
1959
+ }
1960
+
1961
+ footer > span {
1962
+ text-align: right;
1963
+ }
1964
+
1965
+ @keyframes spin {
1966
+ to { transform: rotate(360deg); }
1967
+ }
1968
+
1969
+ @keyframes shimmer {
1970
+ 100% { transform: translateX(100%); }
1971
+ }
1972
+
1973
+ @keyframes status-pulse {
1974
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(56, 233, 145, .38); }
1975
+ 50% { box-shadow: 0 0 0 5px rgba(56, 233, 145, 0); }
1976
+ }
1977
+
1978
+ @media (max-width: 1050px) {
1979
+ .hero {
1980
+ grid-template-columns: minmax(0, 1.15fr) minmax(300px, .85fr);
1981
+ }
1982
+
1983
+ .hero-copy {
1984
+ padding: 40px;
1985
+ }
1986
+
1987
+ .hero-console {
1988
+ margin: 28px 28px 28px 0;
1989
+ }
1990
+
1991
+ .stats-grid {
1992
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1993
+ }
1994
+
1995
+ .stat-copy strong {
1996
+ font-size: 32px;
1997
+ }
1998
+
1999
+ .metrics {
2000
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2001
+ }
2002
+
2003
+ .system-layout {
2004
+ grid-template-columns: 1fr;
2005
+ }
2006
+
2007
+ .methodology ul {
2008
+ grid-template-columns: repeat(3, minmax(0, 1fr));
2009
+ }
2010
+ }
2011
+
2012
+ @media (max-width: 820px) {
2013
+ :root {
2014
+ --header-height: 66px;
2015
+ }
2016
+
2017
+ .topbar-inner,
2018
+ .container {
2019
+ width: min(100% - 28px, 1240px);
2020
+ }
2021
+
2022
+ .brand-copy small,
2023
+ .main-nav {
2024
+ display: none;
2025
+ }
2026
+
2027
+ .hero {
2028
+ min-height: 0;
2029
+ grid-template-columns: 1fr;
2030
+ }
2031
+
2032
+ .hero-copy {
2033
+ padding: 38px 28px 28px;
2034
+ }
2035
+
2036
+ .hero h1 {
2037
+ max-width: 640px;
2038
+ }
2039
+
2040
+ .hero-console {
2041
+ margin: 0 28px 28px;
2042
+ }
2043
+
2044
+ .picks,
2045
+ .ticket-grid {
2046
+ grid-template-columns: 1fr;
2047
+ }
2048
+
2049
+ .ticket {
2050
+ min-height: auto;
2051
+ }
2052
+
2053
+ .health {
2054
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2055
+ }
2056
+
2057
+ .methodology ul {
2058
+ grid-template-columns: 1fr;
2059
+ }
2060
+
2061
+ footer {
2062
+ grid-template-columns: 1fr;
2063
+ justify-items: center;
2064
+ text-align: center;
2065
+ }
2066
+
2067
+ footer > span {
2068
+ text-align: center;
2069
+ }
2070
+ }
2071
+
2072
+ @media (max-width: 560px) {
2073
+ .topbar-inner,
2074
+ .container {
2075
+ width: min(100% - 20px, 1240px);
2076
+ }
2077
+
2078
+ .topbar-actions .pill {
2079
+ max-width: 120px;
2080
+ overflow: hidden;
2081
+ }
2082
+
2083
+ .brand-mark {
2084
+ width: 30px;
2085
+ }
2086
+
2087
+ .brand-copy strong {
2088
+ font-size: 13px;
2089
+ }
2090
+
2091
+ .container {
2092
+ padding-top: 14px;
2093
+ }
2094
+
2095
+ .hero-copy {
2096
+ padding: 30px 20px 24px;
2097
+ }
2098
+
2099
+ .hero h1 {
2100
+ margin-top: 14px;
2101
+ font-size: clamp(34px, 11vw, 48px);
2102
+ }
2103
+
2104
+ .hero-copy > p {
2105
+ font-size: 13px;
2106
+ }
2107
+
2108
+ .hero-actions {
2109
+ align-items: stretch;
2110
+ flex-direction: column;
2111
+ }
2112
+
2113
+ .btn {
2114
+ width: 100%;
2115
+ }
2116
+
2117
+ .hero-console {
2118
+ margin: 0 14px 14px;
2119
+ }
2120
+
2121
+ .hero-footnote {
2122
+ align-items: flex-start;
2123
+ }
2124
+
2125
+ .stats-grid {
2126
+ gap: 9px;
2127
+ margin-bottom: 48px;
2128
+ }
2129
+
2130
+ .stat {
2131
+ min-height: 126px;
2132
+ align-items: flex-start;
2133
+ flex-direction: column;
2134
+ gap: 11px;
2135
+ padding: 15px;
2136
+ }
2137
+
2138
+ .stat-icon {
2139
+ width: 34px;
2140
+ height: 34px;
2141
+ }
2142
+
2143
+ .stat-copy strong {
2144
+ font-size: 27px;
2145
+ }
2146
+
2147
+ .content-section {
2148
+ margin-top: 48px;
2149
+ }
2150
+
2151
+ .section-head {
2152
+ align-items: flex-start;
2153
+ flex-direction: column;
2154
+ gap: 12px;
2155
+ }
2156
+
2157
+ .section-head h2 {
2158
+ font-size: 27px;
2159
+ }
2160
+
2161
+ .update-time,
2162
+ .section-badge {
2163
+ align-self: flex-start;
2164
+ }
2165
+
2166
+ .pick-head,
2167
+ .pick-body {
2168
+ padding-right: 15px;
2169
+ padding-left: 15px;
2170
+ }
2171
+
2172
+ .matchup {
2173
+ gap: 7px;
2174
+ }
2175
+
2176
+ .team {
2177
+ align-items: flex-start;
2178
+ flex-direction: column;
2179
+ }
2180
+
2181
+ .team:last-child {
2182
+ align-items: flex-end;
2183
+ flex-direction: column;
2184
+ }
2185
+
2186
+ .team strong {
2187
+ font-size: 11px;
2188
+ }
2189
+
2190
+ .metrics {
2191
+ gap: 6px;
2192
+ }
2193
+
2194
+ .metric {
2195
+ padding: 9px;
2196
+ }
2197
+
2198
+ .selection-box {
2199
+ align-items: flex-start;
2200
+ flex-direction: column;
2201
+ gap: 9px;
2202
+ }
2203
+
2204
+ .odd-input-row {
2205
+ align-items: stretch;
2206
+ flex-direction: column;
2207
+ }
2208
+
2209
+ .odd-input-wrap {
2210
+ width: 100%;
2211
+ }
2212
+
2213
+ .copyline {
2214
+ width: 100%;
2215
+ }
2216
+
2217
+ .copybtn {
2218
+ flex: 1;
2219
+ justify-content: center;
2220
+ }
2221
+
2222
+ .health-panel,
2223
+ .methodology,
2224
+ .ticket {
2225
+ padding: 16px;
2226
+ }
2227
+
2228
+ .health {
2229
+ grid-template-columns: 1fr;
2230
+ }
2231
+
2232
+ .panel-heading {
2233
+ align-items: flex-start;
2234
+ flex-direction: column;
2235
+ }
2236
+ }
2237
+
2238
+ @media (prefers-reduced-motion: reduce) {
2239
+ html {
2240
+ scroll-behavior: auto;
2241
+ }
2242
+
2243
+ *,
2244
+ *::before,
2245
+ *::after {
2246
+ scroll-behavior: auto !important;
2247
+ animation-duration: .01ms !important;
2248
+ animation-iteration-count: 1 !important;
2249
+ transition-duration: .01ms !important;
2250
+ }
2251
+ }
app/storage.py CHANGED
@@ -1,8 +1,9 @@
1
  from __future__ import annotations
2
 
3
- from datetime import datetime
4
  import json
5
  import logging
 
6
  import os
7
  from pathlib import Path
8
  import shutil
@@ -15,6 +16,14 @@ from app.models import FinishedMatch
15
  logger = logging.getLogger(__name__)
16
 
17
 
 
 
 
 
 
 
 
 
18
  class StateStore:
19
  def __init__(self, data_dir: Path, hf_token: str = "", hf_dataset_repo: str = ""):
20
  self.data_dir = data_dir
@@ -29,7 +38,7 @@ class StateStore:
29
  fd, tmp = tempfile.mkstemp(prefix=path.name, dir=str(path.parent))
30
  try:
31
  with os.fdopen(fd, "w", encoding="utf-8") as f:
32
- json.dump(payload, f, ensure_ascii=False, indent=2)
33
  f.flush()
34
  os.fsync(f.fileno())
35
  os.replace(tmp, path)
@@ -37,23 +46,58 @@ class StateStore:
37
  if os.path.exists(tmp):
38
  os.unlink(tmp)
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  def load_state(self) -> dict:
41
  if not self.state_path.exists():
42
- return {
43
- "generated_at": None,
44
- "status": "waiting",
45
- "summary": {"events": 0, "approved": 0, "rejected": 0},
46
- "picks": [],
47
- "tickets": {},
48
- "performance": {},
49
- "providers": {},
50
- "warnings": ["Faça o primeiro scan depois de configurar os Secrets."],
51
- }
52
  try:
53
- return json.loads(self.state_path.read_text(encoding="utf-8"))
 
 
 
 
 
 
54
  except Exception as exc:
55
  logger.error("state.json inválido: %s", exc)
56
- return {"status": "error", "picks": [], "tickets": {}, "warnings": [str(exc)]}
 
 
 
 
57
 
58
  def save_state(self, state: dict) -> None:
59
  self._atomic_json(self.state_path, state)
@@ -62,10 +106,59 @@ class StateStore:
62
  if not self.history_path.exists():
63
  return []
64
  try:
65
- value = json.loads(self.history_path.read_text(encoding="utf-8"))
66
- return value if isinstance(value, list) else []
67
- except Exception:
68
- return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  def save_history(self, history: list[dict]) -> None:
71
  self._atomic_json(self.history_path, history)
@@ -74,14 +167,27 @@ class StateStore:
74
  if not self.matches_path.exists():
75
  return []
76
  try:
77
- rows = json.loads(self.matches_path.read_text(encoding="utf-8"))
78
- except Exception:
79
- return []
 
 
 
 
 
 
 
 
 
 
80
 
81
  out: list[FinishedMatch] = []
82
- for row in rows if isinstance(rows, list) else []:
 
83
  try:
84
  dt = datetime.fromisoformat(str(row["utc_date"]).replace("Z", "+00:00"))
 
 
85
  out.append(FinishedMatch(
86
  match_id=str(row.get("match_id") or ""),
87
  competition=str(row["competition"]),
@@ -96,7 +202,11 @@ class StateStore:
96
  away_aliases=tuple(row.get("away_aliases") or ()),
97
  ))
98
  except Exception:
 
99
  continue
 
 
 
100
  return out
101
 
102
  def save_matches(self, matches: list[FinishedMatch]) -> None:
@@ -147,18 +257,19 @@ class StateStore:
147
  api = HfApi(token=self.hf_token)
148
  try:
149
  api.create_repo(self.hf_dataset_repo, repo_type="dataset", exist_ok=True, private=True)
150
- for local, remote in (
151
- (self.state_path, "state/state.json"),
152
- (self.history_path, "state/history.json"),
153
- (self.matches_path, "state/matches.json"),
154
- ):
155
- if local.exists():
156
- api.upload_file(
157
- path_or_fileobj=str(local),
158
- path_in_repo=remote,
159
- repo_id=self.hf_dataset_repo,
160
- repo_type="dataset",
161
- commit_message="Update Safe Bet precision state",
162
- )
 
163
  except Exception as exc:
164
  logger.warning("Backup HF falhou (não interrompe o bot): %s", exc)
 
1
  from __future__ import annotations
2
 
3
+ from datetime import datetime, timezone
4
  import json
5
  import logging
6
+ import math
7
  import os
8
  from pathlib import Path
9
  import shutil
 
16
  logger = logging.getLogger(__name__)
17
 
18
 
19
+ def _reject_non_finite(value: str):
20
+ raise ValueError(f"constante JSON não finita: {value}")
21
+
22
+
23
+ class StorageCorruptionError(RuntimeError):
24
+ pass
25
+
26
+
27
  class StateStore:
28
  def __init__(self, data_dir: Path, hf_token: str = "", hf_dataset_repo: str = ""):
29
  self.data_dir = data_dir
 
38
  fd, tmp = tempfile.mkstemp(prefix=path.name, dir=str(path.parent))
39
  try:
40
  with os.fdopen(fd, "w", encoding="utf-8") as f:
41
+ json.dump(payload, f, ensure_ascii=False, indent=2, allow_nan=False)
42
  f.flush()
43
  os.fsync(f.fileno())
44
  os.replace(tmp, path)
 
46
  if os.path.exists(tmp):
47
  os.unlink(tmp)
48
 
49
+ @staticmethod
50
+ def _quarantine(path: Path) -> Path | None:
51
+ if not path.exists():
52
+ return None
53
+ try:
54
+ marker = path.stat().st_mtime_ns
55
+ target = path.with_name(f"{path.name}.{marker}.corrupt")
56
+ if not target.exists():
57
+ shutil.copy2(path, target)
58
+ logger.error("Cópia de segurança do arquivo inválido criada em %s", target)
59
+ return target
60
+ except Exception as exc:
61
+ logger.error("Não foi possível preservar o arquivo inválido %s: %s", path, exc)
62
+ return None
63
+
64
+ @staticmethod
65
+ def _empty_state(*, status: str = "waiting", warning: str | None = None) -> dict:
66
+ warnings = [warning] if warning else ["Faça o primeiro scan depois de configurar os Secrets."]
67
+ return {
68
+ "generated_at": None,
69
+ "status": status,
70
+ "summary": {
71
+ "events": 0,
72
+ "historical_matches": 0,
73
+ "approved": 0,
74
+ "rejected": 0,
75
+ },
76
+ "picks": [],
77
+ "tickets": {},
78
+ "performance": {},
79
+ "providers": {},
80
+ "warnings": warnings,
81
+ }
82
+
83
  def load_state(self) -> dict:
84
  if not self.state_path.exists():
85
+ return self._empty_state()
 
 
 
 
 
 
 
 
 
86
  try:
87
+ value = json.loads(
88
+ self.state_path.read_text(encoding="utf-8"),
89
+ parse_constant=_reject_non_finite,
90
+ )
91
+ if not isinstance(value, dict):
92
+ raise ValueError("a raiz precisa ser um objeto JSON")
93
+ return value
94
  except Exception as exc:
95
  logger.error("state.json inválido: %s", exc)
96
+ self._quarantine(self.state_path)
97
+ return self._empty_state(
98
+ status="error",
99
+ warning="O estado persistido estava inválido e foi ignorado.",
100
+ )
101
 
102
  def save_state(self, state: dict) -> None:
103
  self._atomic_json(self.state_path, state)
 
106
  if not self.history_path.exists():
107
  return []
108
  try:
109
+ value = json.loads(
110
+ self.history_path.read_text(encoding="utf-8"),
111
+ parse_constant=_reject_non_finite,
112
+ )
113
+ if not isinstance(value, list):
114
+ raise ValueError("a raiz não é uma lista")
115
+ valid: list[dict] = []
116
+ invalid_rows = 0
117
+ for item in value:
118
+ if not isinstance(item, dict):
119
+ invalid_rows += 1
120
+ continue
121
+ row = dict(item)
122
+ try:
123
+ for field in (
124
+ "probability",
125
+ "raw_model_probability",
126
+ "market_probability",
127
+ "conservative_probability",
128
+ ):
129
+ if row.get(field) is None:
130
+ continue
131
+ number = float(row[field])
132
+ if not math.isfinite(number) or not 0.0 <= number <= 1.0:
133
+ raise ValueError(field)
134
+ row[field] = number
135
+ if row.get("odd") is not None:
136
+ odd = float(row["odd"])
137
+ if not math.isfinite(odd) or not 1.0 <= odd <= 1000.0:
138
+ raise ValueError("odd")
139
+ row["odd"] = odd
140
+ if row.get("profit_units") is not None:
141
+ profit = float(row["profit_units"])
142
+ if not math.isfinite(profit):
143
+ raise ValueError("profit_units")
144
+ row["profit_units"] = profit
145
+ if row.get("result") not in {None, "win", "loss"}:
146
+ raise ValueError("result")
147
+ except (TypeError, ValueError):
148
+ invalid_rows += 1
149
+ continue
150
+ valid.append(row)
151
+ if invalid_rows:
152
+ self._quarantine(self.history_path)
153
+ logger.warning(
154
+ "history.json: %d registro(s) inválido(s) ignorado(s)",
155
+ invalid_rows,
156
+ )
157
+ return valid
158
+ except Exception as exc:
159
+ logger.error("history.json inválido: %s", exc)
160
+ self._quarantine(self.history_path)
161
+ raise StorageCorruptionError("history.json inválido; original preservado") from exc
162
 
163
  def save_history(self, history: list[dict]) -> None:
164
  self._atomic_json(self.history_path, history)
 
167
  if not self.matches_path.exists():
168
  return []
169
  try:
170
+ rows = json.loads(
171
+ self.matches_path.read_text(encoding="utf-8"),
172
+ parse_constant=_reject_non_finite,
173
+ )
174
+ except Exception as exc:
175
+ logger.error("matches.json inválido: %s", exc)
176
+ self._quarantine(self.matches_path)
177
+ raise StorageCorruptionError("matches.json inválido; original preservado") from exc
178
+
179
+ if not isinstance(rows, list):
180
+ logger.error("matches.json inválido: a raiz não é uma lista")
181
+ self._quarantine(self.matches_path)
182
+ raise StorageCorruptionError("matches.json inválido; original preservado")
183
 
184
  out: list[FinishedMatch] = []
185
+ invalid_rows = 0
186
+ for row in rows:
187
  try:
188
  dt = datetime.fromisoformat(str(row["utc_date"]).replace("Z", "+00:00"))
189
+ if dt.tzinfo is None:
190
+ dt = dt.replace(tzinfo=timezone.utc)
191
  out.append(FinishedMatch(
192
  match_id=str(row.get("match_id") or ""),
193
  competition=str(row["competition"]),
 
202
  away_aliases=tuple(row.get("away_aliases") or ()),
203
  ))
204
  except Exception:
205
+ invalid_rows += 1
206
  continue
207
+ if invalid_rows:
208
+ self._quarantine(self.matches_path)
209
+ logger.warning("matches.json: %d registro(s) inválido(s) ignorado(s)", invalid_rows)
210
  return out
211
 
212
  def save_matches(self, matches: list[FinishedMatch]) -> None:
 
257
  api = HfApi(token=self.hf_token)
258
  try:
259
  api.create_repo(self.hf_dataset_repo, repo_type="dataset", exist_ok=True, private=True)
260
+ available = [
261
+ path.name
262
+ for path in (self.state_path, self.history_path, self.matches_path)
263
+ if path.exists()
264
+ ]
265
+ if available:
266
+ api.upload_folder(
267
+ folder_path=str(self.data_dir),
268
+ path_in_repo="state",
269
+ allow_patterns=available,
270
+ repo_id=self.hf_dataset_repo,
271
+ repo_type="dataset",
272
+ commit_message="Update Safe Bet precision state",
273
+ )
274
  except Exception as exc:
275
  logger.warning("Backup HF falhou (não interrompe o bot): %s", exc)
app/templates/index.html CHANGED
@@ -3,59 +3,200 @@
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
6
- <meta name="theme-color" content="#07111f">
7
- <title>Safe Bet AI</title>
 
8
  <link rel="stylesheet" href="/static/style.css">
9
  </head>
10
  <body>
11
- <div class="noise"></div>
 
 
 
 
12
  <header class="topbar">
13
- <div>
14
- <div class="brand"><span class="shield">◆</span> SAFE BET AI</div>
15
- <div class="subtitle">Precision Football Risk Engine • {{ model_version }}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  </div>
17
- <div id="system-pill" class="pill neutral">CARREGANDO</div>
18
  </header>
19
 
20
- <main class="container">
21
- <section class="hero panel">
22
- <div>
23
- <span class="eyebrow">PAINEL PROFISSIONAL</span>
24
- <h1>Precisão primeiro. Palpite quando passa.</h1>
25
- <p>Dixon-Coles, Elo, forma ponderada, consenso de odds sem margem, calibração forward e Risk Gate. SAFE é menor risco relativo nunca garantia.</p>
 
 
 
 
 
 
 
 
 
 
 
26
  </div>
27
- <button id="refresh-ui" class="btn">Atualizar painel</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  </section>
29
 
30
- <section id="warnings"></section>
31
 
32
- <section class="stats-grid">
33
- <div class="stat panel"><span>EVENTOS</span><strong id="s-events">—</strong><small>analisados</small></div>
34
- <div class="stat panel"><span>SAFE</span><strong id="s-approved">—</strong><small>aprovados</small></div>
35
- <div class="stat panel"><span>MELHOR SCORE</span><strong id="s-best">—</strong><small>/100</small></div>
36
- <div class="stat panel"><span>WIN RATE</span><strong id="s-winrate">—</strong><small>forward tracking</small></div>
 
 
 
 
 
 
 
 
 
 
 
 
37
  </section>
38
 
39
- <section class="section-head">
40
- <div><span class="eyebrow">TOP SAFE DO DIA</span><h2>Seleções aprovadas</h2></div>
41
- <div id="last-update" class="muted">—</div>
 
 
 
 
 
 
 
 
 
42
  </section>
43
- <section id="picks" class="picks"></section>
44
 
45
- <section class="section-head">
46
- <div><span class="eyebrow">OTIMIZADOR</span><h2>Bilhetes automáticos</h2></div>
 
 
 
 
 
 
 
 
 
 
 
 
47
  </section>
48
- <section id="tickets" class="ticket-grid"></section>
49
 
50
- <section class="section-head">
51
- <div><span class="eyebrow">SISTEMA</span><h2>Saúde e metodologia</h2></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  </section>
53
- <section class="panel health" id="health"></section>
54
 
55
  <footer>
56
- Safe Bet AI análise estatística, não recomendação garantida • controle sua banca e nunca aposte dinheiro necessário.
 
 
57
  </footer>
58
  </main>
 
 
59
  <script src="/static/app.js"></script>
60
  </body>
61
  </html>
 
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
6
+ <meta name="theme-color" content="#07100d">
7
+ <meta name="description" content="Safe Bet AI: inteligência estatística e gestão de risco para futebol.">
8
+ <title>Safe Bet AI — Painel de precisão</title>
9
  <link rel="stylesheet" href="/static/style.css">
10
  </head>
11
  <body>
12
+ <a class="skip-link" href="#main-content">Ir para o conteúdo</a>
13
+ <div class="ambient ambient-one" aria-hidden="true"></div>
14
+ <div class="ambient ambient-two" aria-hidden="true"></div>
15
+ <div class="noise" aria-hidden="true"></div>
16
+
17
  <header class="topbar">
18
+ <div class="topbar-inner">
19
+ <a class="brand" href="#overview" aria-label="Safe Bet AI — início">
20
+ <span class="brand-mark" aria-hidden="true">
21
+ <svg viewBox="0 0 28 32" role="img">
22
+ <path d="M14 1.7 26 6.5v8.7c0 7.2-4.8 12.8-12 15.1C6.8 28 2 22.4 2 15.2V6.5L14 1.7Z" fill="currentColor" opacity=".2"/>
23
+ <path d="m8.3 16 3.6 3.6 7.9-8" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
24
+ </svg>
25
+ </span>
26
+ <span class="brand-copy">
27
+ <strong>SAFE BET <em>AI</em></strong>
28
+ <small>Football intelligence</small>
29
+ </span>
30
+ </a>
31
+
32
+ <nav class="main-nav" aria-label="Navegação principal">
33
+ <a href="#overview">Visão geral</a>
34
+ <a href="#selections">Seleções</a>
35
+ <a href="#bet-builder">Bilhetes</a>
36
+ <a href="#system">Sistema</a>
37
+ </nav>
38
+
39
+ <div class="topbar-actions">
40
+ <div id="system-pill" class="pill neutral" role="status" aria-live="polite">
41
+ <span class="status-dot" aria-hidden="true"></span>
42
+ <span>CARREGANDO</span>
43
+ </div>
44
+ <button id="refresh-ui" class="icon-button" type="button" aria-label="Atualizar painel" title="Atualizar painel">
45
+ <svg viewBox="0 0 24 24" aria-hidden="true">
46
+ <path d="M20 11a8.1 8.1 0 0 0-14.9-4M4 4v5h5M4 13a8.1 8.1 0 0 0 14.9 4M20 20v-5h-5"/>
47
+ </svg>
48
+ </button>
49
+ </div>
50
  </div>
 
51
  </header>
52
 
53
+ <main id="main-content" class="container">
54
+ <section id="overview" class="hero panel" aria-labelledby="hero-title">
55
+ <div class="hero-copy">
56
+ <div id="engine-label" class="live-label"><span aria-hidden="true"></span><b>MOTOR DE PRECISÃO CARREGANDO</b></div>
57
+ <h1 id="hero-title">Decisões melhores começam com <span>risco calculado.</span></h1>
58
+ <p>Probabilidade, valor de mercado e confiabilidade reunidos em uma leitura objetiva. O motor libera uma seleção quando ela atravessa todo o Risk Gate.</p>
59
+ <div class="hero-actions">
60
+ <a class="btn primary" href="#selections">
61
+ Ver seleções do dia
62
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
63
+ </a>
64
+ <button class="btn secondary" type="button" data-scroll-target="system">Como funciona</button>
65
+ </div>
66
+ <div class="hero-footnote">
67
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 4 6v5.7c0 4.7 3.2 8.3 8 9.8 4.8-1.5 8-5.1 8-9.8V6l-8-3Z"/><path d="m8.8 12 2.1 2.1 4.5-4.6"/></svg>
68
+ SAFE representa menor risco relativo, nunca garantia de retorno.
69
+ </div>
70
  </div>
71
+
72
+ <aside class="hero-console" aria-label="Etapas do motor de análise">
73
+ <div class="console-head">
74
+ <span>Análise multicamada</span>
75
+ <span id="engine-state" class="console-state"><i aria-hidden="true"></i><b>CARREGANDO</b></span>
76
+ </div>
77
+ <ol id="engine-flow" class="engine-flow">
78
+ <li class="complete">
79
+ <span class="flow-number">01</span>
80
+ <div><strong>Dados &amp; forma</strong><small>Histórico ponderado por recência</small></div>
81
+ <span class="flow-check" aria-label="Concluído">✓</span>
82
+ </li>
83
+ <li class="complete">
84
+ <span class="flow-number">02</span>
85
+ <div><strong>Consenso de mercado</strong><small>Odds agregadas sem margem</small></div>
86
+ <span class="flow-check" aria-label="Concluído">✓</span>
87
+ </li>
88
+ <li class="complete">
89
+ <span class="flow-number">03</span>
90
+ <div><strong>Modelos probabilísticos</strong><small>Dixon-Coles, Elo e calibração</small></div>
91
+ <span class="flow-check" aria-label="Concluído">✓</span>
92
+ </li>
93
+ <li class="active">
94
+ <span class="flow-number">04</span>
95
+ <div><strong>Risk Gate</strong><small>Filtro final de confiabilidade</small></div>
96
+ <span class="flow-pulse" aria-hidden="true"></span>
97
+ </li>
98
+ </ol>
99
+ <div class="console-footer"><span>MODELO</span><strong>{{ model_version }}</strong></div>
100
+ </aside>
101
  </section>
102
 
103
+ <section id="warnings" class="warnings" aria-live="polite" aria-label="Avisos do sistema"></section>
104
 
105
+ <section class="stats-grid" aria-label="Resumo da análise">
106
+ <article class="stat panel">
107
+ <div class="stat-icon blue" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M4 19V9m5 10V5m6 14v-7m5 7V3"/></svg></div>
108
+ <div class="stat-copy"><span>EVENTOS ANALISADOS</span><strong id="s-events">—</strong><small>na leitura mais recente</small></div>
109
+ </article>
110
+ <article class="stat panel featured">
111
+ <div class="stat-icon green" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3 4 6v5.7c0 4.7 3.2 8.3 8 9.8 4.8-1.5 8-5.1 8-9.8V6l-8-3Z"/><path d="m8.8 12 2.1 2.1 4.5-4.6"/></svg></div>
112
+ <div class="stat-copy"><span>SELEÇÕES SAFE</span><strong id="s-approved">—</strong><small>aprovadas pelo Risk Gate</small></div>
113
+ </article>
114
+ <article class="stat panel">
115
+ <div class="stat-icon violet" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3v18M3 12h18"/><circle cx="12" cy="12" r="8"/></svg></div>
116
+ <div class="stat-copy"><span>MELHOR SCORE</span><strong id="s-best">—</strong><small>pontos de confiança / 100</small></div>
117
+ </article>
118
+ <article class="stat panel">
119
+ <div class="stat-icon amber" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="m4 16 5-5 4 3 7-8"/><path d="M15 6h5v5"/></svg></div>
120
+ <div class="stat-copy"><span>WIN RATE FORWARD</span><strong id="s-winrate">—</strong><small>somente palpites liquidados</small></div>
121
+ </article>
122
  </section>
123
 
124
+ <section id="selections" class="content-section" aria-labelledby="picks-title">
125
+ <div class="section-head">
126
+ <div>
127
+ <span class="eyebrow"><i aria-hidden="true"></i> TOP SAFE DO DIA</span>
128
+ <h2 id="picks-title">Seleções aprovadas</h2>
129
+ <p>Oportunidades que passaram pelos critérios de probabilidade, preço e qualidade.</p>
130
+ </div>
131
+ <div id="last-update" class="update-time">—</div>
132
+ </div>
133
+ <div id="picks" class="picks" aria-live="polite" aria-busy="true">
134
+ <div class="panel empty loading-state"><span class="loader" aria-hidden="true"></span>Carregando seleções...</div>
135
+ </div>
136
  </section>
 
137
 
138
+ <section id="bet-builder" class="content-section" aria-labelledby="tickets-title">
139
+ <div class="section-head">
140
+ <div>
141
+ <span class="eyebrow"><i aria-hidden="true"></i> BET BUILDER</span>
142
+ <h2 id="tickets-title">Bilhetes inteligentes</h2>
143
+ <p>Combinações automáticas com diferentes perfis de exposição e meta de odd.</p>
144
+ </div>
145
+ <div class="section-badge">3 PERFIS DE RISCO</div>
146
+ </div>
147
+ <div id="tickets" class="ticket-grid" aria-live="polite" aria-busy="true">
148
+ <div class="ticket panel skeleton"></div>
149
+ <div class="ticket panel skeleton"></div>
150
+ <div class="ticket panel skeleton"></div>
151
+ </div>
152
  </section>
 
153
 
154
+ <section id="system" class="content-section" aria-labelledby="system-title">
155
+ <div class="section-head">
156
+ <div>
157
+ <span class="eyebrow"><i aria-hidden="true"></i> INTELIGÊNCIA DO SISTEMA</span>
158
+ <h2 id="system-title">Saúde e metodologia</h2>
159
+ <p>Transparência operacional e métricas de validação do modelo.</p>
160
+ </div>
161
+ </div>
162
+ <div class="system-layout">
163
+ <section class="panel health-panel" aria-label="Indicadores de saúde">
164
+ <div class="panel-heading">
165
+ <div><span class="panel-kicker">STATUS</span><h3>Infraestrutura &amp; performance</h3></div>
166
+ <span class="secure-label"><i aria-hidden="true"></i> MONITORADO</span>
167
+ </div>
168
+ <div class="health" id="health" aria-live="polite" aria-busy="true">
169
+ <div class="health-card skeleton"></div>
170
+ <div class="health-card skeleton"></div>
171
+ <div class="health-card skeleton"></div>
172
+ </div>
173
+ </section>
174
+
175
+ <aside class="panel methodology" aria-label="Metodologia de análise">
176
+ <span class="panel-kicker">NOSSO PROCESSO</span>
177
+ <h3>Disciplina antes da aposta.</h3>
178
+ <p>Cada oportunidade é comparada ao mercado e estressada antes de aparecer no painel.</p>
179
+ <ul>
180
+ <li><span>01</span><div><strong>Probabilidade calibrada</strong><small>Modelos independentes combinados por desempenho.</small></div></li>
181
+ <li><span>02</span><div><strong>Preço justo</strong><small>Margem das casas removida do consenso.</small></div></li>
182
+ <li><span>03</span><div><strong>Controle de exposição</strong><small>Qualidade e incerteza limitam a confiança.</small></div></li>
183
+ </ul>
184
+ <div class="methodology-note">
185
+ <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 11v5m0-8h.01"/></svg>
186
+ Use as análises como apoio. Gestão de banca continua essencial.
187
+ </div>
188
+ </aside>
189
+ </div>
190
  </section>
 
191
 
192
  <footer>
193
+ <div class="footer-brand"><span class="brand-mark small" aria-hidden="true">✓</span><strong>SAFE BET AI</strong></div>
194
+ <p>Análise estatística, não recomendação garantida. Aposte com responsabilidade e nunca use dinheiro necessário.</p>
195
+ <span>18+ &nbsp;•&nbsp; Jogue com responsabilidade</span>
196
  </footer>
197
  </main>
198
+
199
+ <div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
200
  <script src="/static/app.js"></script>
201
  </body>
202
  </html>
deploy_to_hf.py CHANGED
@@ -22,7 +22,15 @@ api.upload_folder(
22
  folder_path=str(Path(__file__).resolve().parent),
23
  repo_id=repo,
24
  repo_type="space",
25
- ignore_patterns=[".git/*", ".env", "__pycache__/*", ".pytest_cache/*", "data/*.json"],
 
 
 
 
 
 
 
 
26
  commit_message="Deploy Safe Bet AI",
27
  )
28
  print(f"OK: arquivos enviados para https://huggingface.co/spaces/{repo}")
 
22
  folder_path=str(Path(__file__).resolve().parent),
23
  repo_id=repo,
24
  repo_type="space",
25
+ ignore_patterns=[
26
+ ".git/**",
27
+ ".env",
28
+ "**/__pycache__/**",
29
+ "**/*.py[cod]",
30
+ "**/.pytest_cache/**",
31
+ ".venv/**",
32
+ "data/**",
33
+ ],
34
  commit_message="Deploy Safe Bet AI",
35
  )
36
  print(f"OK: arquivos enviados para https://huggingface.co/spaces/{repo}")
tests/test_core.py CHANGED
@@ -215,20 +215,21 @@ def test_append_history_does_not_duplicate_same_event_with_new_side():
215
 
216
 
217
  def test_settlement_respects_competition_and_pair():
 
218
  matches = [
219
  FinishedMatch(
220
- match_id="1", competition="PL", utc_date=NOW,
221
  home="Alpha FC", away="Beta FC", home_goals=2, away_goals=0,
222
  home_aliases=("Alpha",), away_aliases=("Beta",),
223
  ),
224
  FinishedMatch(
225
- match_id="2", competition="SA", utc_date=NOW,
226
  home="Alpha FC", away="Beta FC", home_goals=0, away_goals=2,
227
  home_aliases=("Alpha",), away_aliases=("Beta",),
228
  ),
229
  ]
230
  history = [{
231
- "event_id": "e", "kickoff": NOW.isoformat(), "competition_code": "PL",
232
  "home": "Alpha", "away": "Beta", "side": "home", "odd": 1.5,
233
  "probability": .7, "result": None,
234
  }]
@@ -267,6 +268,25 @@ def test_ticket_optimizer_respects_max_legs_and_stresses_dependency():
267
  assert isinstance(ticket["target_met"], bool)
268
 
269
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  def _strong_favorite_fixture():
271
  matches = []
272
  ids = {"Alpha FC": "1", "Beta FC": "2", "Gamma FC": "3", "Delta FC": "4"}
@@ -313,6 +333,7 @@ def test_analyzer_market_prior_reduces_extreme_internal_probability():
313
  posterior = pick.probability
314
  assert min(internal, market) <= posterior <= max(internal, market)
315
  assert pick.conservative_probability <= pick.probability
 
316
 
317
 
318
  def test_analyzer_rejects_single_bookmaker_for_safe_mode():
@@ -334,6 +355,30 @@ def test_analyzer_rejects_single_bookmaker_for_safe_mode():
334
  assert any("poucas casas" in row["reason"] for row in rejected)
335
 
336
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  def test_stale_bookmakers_are_removed_from_consensus():
338
  event = make_event()
339
  for bookmaker in event["bookmakers"]:
@@ -433,6 +478,8 @@ def test_walk_forward_reports_time_safe_brier_skill():
433
 
434
  result = tune_ensemble_weights(sample_matches("PL", 180), "PL", evaluation_matches=40)
435
  assert result["samples"] >= 18
 
 
436
  assert result["climatology_brier"] > 0
437
  assert result["brier"] > 0
438
  assert -2.0 < result["brier_skill"] < 1.0
 
215
 
216
 
217
  def test_settlement_respects_competition_and_pair():
218
+ finished_at = NOW - timedelta(hours=3)
219
  matches = [
220
  FinishedMatch(
221
+ match_id="1", competition="PL", utc_date=finished_at,
222
  home="Alpha FC", away="Beta FC", home_goals=2, away_goals=0,
223
  home_aliases=("Alpha",), away_aliases=("Beta",),
224
  ),
225
  FinishedMatch(
226
+ match_id="2", competition="SA", utc_date=finished_at,
227
  home="Alpha FC", away="Beta FC", home_goals=0, away_goals=2,
228
  home_aliases=("Alpha",), away_aliases=("Beta",),
229
  ),
230
  ]
231
  history = [{
232
+ "event_id": "e", "kickoff": finished_at.isoformat(), "competition_code": "PL",
233
  "home": "Alpha", "away": "Beta", "side": "home", "odd": 1.5,
234
  "probability": .7, "result": None,
235
  }]
 
268
  assert isinstance(ticket["target_met"], bool)
269
 
270
 
271
+ def test_ticket_target_requires_reaching_the_full_advertised_odd():
272
+ picks = [
273
+ {
274
+ "event_id": "1", "home": "A", "away": "B", "selection": "A",
275
+ "odd": 1.4, "probability": .72, "conservative_probability": .63,
276
+ "safe_score": 85, "competition_code": "PL", "kickoff": NOW.isoformat(),
277
+ },
278
+ {
279
+ "event_id": "2", "home": "C", "away": "D", "selection": "C",
280
+ "odd": 1.75, "probability": .72, "conservative_probability": .63,
281
+ "safe_score": 85, "competition_code": "SA", "kickoff": NOW.isoformat(),
282
+ },
283
+ ]
284
+ ticket = optimize_ticket(picks, target_odd=2.5, max_legs=2)
285
+ assert ticket is not None
286
+ assert ticket["total_odd"] == 2.45
287
+ assert ticket["target_met"] is False
288
+
289
+
290
  def _strong_favorite_fixture():
291
  matches = []
292
  ids = {"Alpha FC": "1", "Beta FC": "2", "Gamma FC": "3", "Delta FC": "4"}
 
333
  posterior = pick.probability
334
  assert min(internal, market) <= posterior <= max(internal, market)
335
  assert pick.conservative_probability <= pick.probability
336
+ assert pick.model_ev >= 0
337
 
338
 
339
  def test_analyzer_rejects_single_bookmaker_for_safe_mode():
 
355
  assert any("poucas casas" in row["reason"] for row in rejected)
356
 
357
 
358
+ def test_analyzer_does_not_republish_open_event_from_previous_version():
359
+ from app.core.analyzer import analyze_events
360
+
361
+ matches, event = _strong_favorite_fixture()
362
+ picks, rejected = analyze_events(
363
+ [event],
364
+ matches,
365
+ min_safe_score=70,
366
+ limit=10,
367
+ previous_picks=[{
368
+ "event_id": "evt1",
369
+ "model_version": "2.1-precision",
370
+ "side": "home",
371
+ "market_probability": 0.65,
372
+ }],
373
+ min_probability=.60,
374
+ min_conservative_probability=.53,
375
+ min_bookmakers=3,
376
+ min_name_score=82,
377
+ )
378
+ assert picks == []
379
+ assert any("versão anterior" in row["reason"] for row in rejected)
380
+
381
+
382
  def test_stale_bookmakers_are_removed_from_consensus():
383
  event = make_event()
384
  for bookmaker in event["bookmakers"]:
 
478
 
479
  result = tune_ensemble_weights(sample_matches("PL", 180), "PL", evaluation_matches=40)
480
  assert result["samples"] >= 18
481
+ assert result["validation_samples"] >= 6
482
+ assert result["validation_samples"] < result["samples"]
483
  assert result["climatology_brier"] > 0
484
  assert result["brier"] > 0
485
  assert -2.0 < result["brier_skill"] < 1.0
tests/test_pipeline.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from datetime import datetime, timedelta, timezone
3
+ from types import SimpleNamespace
4
+
5
+ import app.pipeline as pipeline_module
6
+ from app.models import FinishedMatch
7
+ from app.pipeline import DailyPipeline
8
+ from app.storage import StateStore
9
+
10
+
11
+ def _settings(**overrides):
12
+ values = {
13
+ "football_data_token": "football-token",
14
+ "odds_api_key": "odds-key",
15
+ "odds_regions": "eu",
16
+ "sport_keys": ("soccer_epl",),
17
+ "history_days": 240,
18
+ "scan_horizon_hours": 36,
19
+ "min_safe_score": 76.0,
20
+ "top_picks_limit": 10,
21
+ "min_probability": 0.64,
22
+ "min_conservative_probability": 0.57,
23
+ "min_bookmakers": 3,
24
+ "min_name_score": 82.0,
25
+ }
26
+ values.update(overrides)
27
+ return SimpleNamespace(**values)
28
+
29
+
30
+ def _matches(count=45):
31
+ now = datetime.now(timezone.utc)
32
+ return [
33
+ FinishedMatch(
34
+ match_id=str(index),
35
+ competition="PL",
36
+ utc_date=now - timedelta(days=index + 1),
37
+ home="Alpha FC",
38
+ away="Beta FC",
39
+ home_goals=1,
40
+ away_goals=0,
41
+ home_id="1",
42
+ away_id="2",
43
+ )
44
+ for index in range(count)
45
+ ]
46
+
47
+
48
+ class RecordingStore(StateStore):
49
+ def __init__(self, data_dir):
50
+ super().__init__(data_dir)
51
+ self.saved_statuses = []
52
+ self.backups = 0
53
+
54
+ def save_state(self, state):
55
+ self.saved_statuses.append(state.get("status"))
56
+ super().save_state(state)
57
+
58
+ def backup_to_hub(self):
59
+ self.backups += 1
60
+
61
+
62
+ def test_pipeline_happy_path_persists_complete_state_and_closes_http(monkeypatch, tmp_path):
63
+ closed = []
64
+
65
+ class FakeHTTP:
66
+ def __init__(self, *args, **kwargs):
67
+ pass
68
+
69
+ async def aclose(self):
70
+ closed.append(True)
71
+
72
+ class FakeFootball:
73
+ def __init__(self, token, http):
74
+ assert token == "football-token"
75
+
76
+ async def fetch_finished(self, history_days, sport_keys, cached):
77
+ assert history_days == 240
78
+ assert sport_keys == ("soccer_epl",)
79
+ assert cached == []
80
+ return _matches(), {
81
+ "errors": [],
82
+ "competitions": {"PL": {"matches": 45}},
83
+ }
84
+
85
+ class FakeOdds:
86
+ def __init__(self, api_key, http, regions):
87
+ assert api_key == "odds-key"
88
+ assert regions == "eu"
89
+ self.region_count = 1
90
+ self.quota = {"remaining": 499, "used": 1, "last": 1}
91
+ self.inactive_keys = []
92
+ self.queried_keys = ["soccer_epl"]
93
+ self.errors = []
94
+
95
+ async def fetch_events(self, sport_keys, horizon_hours):
96
+ assert sport_keys == ("soccer_epl",)
97
+ assert horizon_hours == 36
98
+ return []
99
+
100
+ monkeypatch.setattr(pipeline_module, "ResilientHTTP", FakeHTTP)
101
+ monkeypatch.setattr(pipeline_module, "FootballDataProvider", FakeFootball)
102
+ monkeypatch.setattr(pipeline_module, "OddsAPIProvider", FakeOdds)
103
+
104
+ store = RecordingStore(tmp_path)
105
+ pipeline = DailyPipeline(_settings(), store)
106
+ state = asyncio.run(pipeline.scan())
107
+
108
+ assert store.saved_statuses == ["scanning", "ok"]
109
+ assert state["status"] == "ok"
110
+ assert state["summary"] == {
111
+ "events": 0,
112
+ "historical_matches": 45,
113
+ "approved": 0,
114
+ "rejected": 0,
115
+ }
116
+ assert state["providers"]["football_data"]["ok"] is True
117
+ assert state["providers"]["odds_api"]["ok"] is True
118
+ assert state["providers"]["odds_api"]["estimated_cost_per_league"] == 1
119
+ assert store.load_state()["generated_at"] == state["generated_at"]
120
+ assert len(store.load_matches()) == 45
121
+ assert store.load_history() == []
122
+ assert store.backups == 1
123
+ assert closed == [True]
124
+
125
+
126
+ def test_pipeline_provider_failure_preserves_previous_picks(monkeypatch, tmp_path):
127
+ closed = []
128
+
129
+ class FakeHTTP:
130
+ def __init__(self, *args, **kwargs):
131
+ pass
132
+
133
+ async def aclose(self):
134
+ closed.append(True)
135
+
136
+ class FailingFootball:
137
+ def __init__(self, token, http):
138
+ pass
139
+
140
+ async def fetch_finished(self, *args, **kwargs):
141
+ raise RuntimeError("football provider unavailable")
142
+
143
+ class UnusedOdds:
144
+ def __init__(self, *args, **kwargs):
145
+ pass
146
+
147
+ monkeypatch.setattr(pipeline_module, "ResilientHTTP", FakeHTTP)
148
+ monkeypatch.setattr(pipeline_module, "FootballDataProvider", FailingFootball)
149
+ monkeypatch.setattr(pipeline_module, "OddsAPIProvider", UnusedOdds)
150
+
151
+ store = RecordingStore(tmp_path)
152
+ previous = store.load_state()
153
+ previous.update({
154
+ "status": "ok",
155
+ "generated_at": "2026-08-14T10:00:00+00:00",
156
+ "picks": [{"event_id": "keep-me"}],
157
+ })
158
+ store.save_state(previous)
159
+ store.saved_statuses.clear()
160
+
161
+ pipeline = DailyPipeline(_settings(), store)
162
+ state = asyncio.run(pipeline.scan())
163
+
164
+ assert store.saved_statuses == ["scanning", "error"]
165
+ assert state["status"] == "error"
166
+ assert state["picks"] == [{"event_id": "keep-me"}]
167
+ assert "football provider unavailable" in state["last_error"]
168
+ assert store.load_state()["picks"] == [{"event_id": "keep-me"}]
169
+ assert store.backups == 0
170
+ assert closed == [True]
171
+
172
+
173
+ def test_pipeline_concurrent_waiters_share_one_background_scan():
174
+ pipeline = DailyPipeline(_settings(), store=None)
175
+
176
+ async def scenario():
177
+ started = asyncio.Event()
178
+ release = asyncio.Event()
179
+ calls = 0
180
+
181
+ async def slow_scan():
182
+ nonlocal calls
183
+ calls += 1
184
+ started.set()
185
+ await release.wait()
186
+ return {"status": "ok", "call": calls}
187
+
188
+ pipeline.scan = slow_scan
189
+ assert pipeline.trigger_background() is True
190
+ await started.wait()
191
+ assert pipeline.running is True
192
+ assert pipeline.trigger_background() is False
193
+
194
+ first = asyncio.create_task(pipeline.trigger_and_wait())
195
+ second = asyncio.create_task(pipeline.trigger_and_wait())
196
+ await asyncio.sleep(0)
197
+ release.set()
198
+ results = await asyncio.gather(first, second)
199
+
200
+ assert calls == 1
201
+ assert results == [
202
+ {"status": "ok", "call": 1},
203
+ {"status": "ok", "call": 1},
204
+ ]
205
+ assert pipeline.running is False
206
+
207
+ asyncio.run(scenario())
tests/test_robustness.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from datetime import datetime, timedelta, timezone
3
+ import math
4
+ from types import SimpleNamespace
5
+
6
+ import httpx
7
+ import pytest
8
+
9
+ from app.config import MODEL_VERSION, Settings
10
+ from app.core.calibration import calibrate_probability
11
+ from app.core.history import performance_metrics, settle_history
12
+ from app.core.market import market_consensus
13
+ from app.models import FinishedMatch
14
+ from app.pipeline import DailyPipeline
15
+ from app.providers.http_client import ProviderError, ResilientHTTP
16
+ from app.providers.odds_api import OddsAPIProvider
17
+ from app.storage import StateStore
18
+
19
+
20
+ def test_state_store_rejects_non_finite_json(tmp_path):
21
+ store = StateStore(tmp_path)
22
+ with pytest.raises(ValueError):
23
+ store.save_state({"probability": math.nan})
24
+ assert not store.state_path.exists()
25
+ assert list(tmp_path.iterdir()) == []
26
+
27
+
28
+ def test_state_store_recovers_from_non_object_json(tmp_path):
29
+ store = StateStore(tmp_path)
30
+ store.state_path.write_text("[]", encoding="utf-8")
31
+ state = store.load_state()
32
+ assert state["status"] == "error"
33
+ assert state["picks"] == []
34
+
35
+
36
+ def test_performance_ignores_invalid_probabilities():
37
+ history = [
38
+ {"result": "win", "probability": 0.70, "profit_units": 0.5},
39
+ {"result": "loss", "probability": float("nan"), "profit_units": -1.0},
40
+ {"result": "win", "probability": 1.5, "profit_units": 0.5},
41
+ ]
42
+ metrics = performance_metrics(history)
43
+ assert metrics["settled"] == 1
44
+ assert metrics["wins"] == 1
45
+
46
+
47
+ def test_performance_treats_invalid_profit_as_zero():
48
+ history = [
49
+ {"result": "win", "probability": 0.70, "profit_units": "oops"},
50
+ {"result": "loss", "probability": 0.65, "profit_units": float("inf")},
51
+ ]
52
+ metrics = performance_metrics(history)
53
+ assert metrics["settled"] == 2
54
+ assert metrics["profit_units"] == 0.0
55
+ assert math.isfinite(metrics["max_drawdown_units"])
56
+
57
+
58
+ def test_calibration_ignores_non_finite_probability_history():
59
+ history = [{
60
+ "result": "win",
61
+ "probability": float("nan"),
62
+ "model_version": MODEL_VERSION,
63
+ "competition_code": "PL",
64
+ }] * 30
65
+ probability, metadata = calibrate_probability(
66
+ 0.70,
67
+ history,
68
+ model_version=MODEL_VERSION,
69
+ competition_code="PL",
70
+ )
71
+ assert probability == 0.70
72
+ assert metadata["effective_samples"] == 0.0
73
+
74
+
75
+ def test_provider_error_detail_redacts_credentials():
76
+ secret = "private-api-key-value"
77
+ response = httpx.Response(401, text=f"invalid api key: {secret}")
78
+ detail = ResilientHTTP._safe_error_detail(
79
+ response,
80
+ {"apiKey": secret, "regions": "eu"},
81
+ {"X-Auth-Token": "another-private-token"},
82
+ )
83
+ assert secret not in detail
84
+ assert "[redacted]" in detail
85
+
86
+
87
+ def test_market_consensus_ignores_malformed_nested_rows():
88
+ event = {
89
+ "home_team": "Alpha",
90
+ "away_team": "Beta",
91
+ "bookmakers": [None, "bad", {"markets": [None, {"key": "h2h", "outcomes": [None]}]}],
92
+ }
93
+ market = market_consensus(event)
94
+ assert market.bookmakers == 0
95
+
96
+
97
+ def test_odds_provider_does_not_report_total_failure_as_empty_success():
98
+ class FailingHTTP:
99
+ async def get_json(self, *args, **kwargs):
100
+ raise ProviderError("provider unavailable", 503)
101
+
102
+ provider = OddsAPIProvider("test-key", FailingHTTP())
103
+ with pytest.raises(ProviderError, match="Todas as consultas"):
104
+ asyncio.run(provider.fetch_events(("soccer_epl",), 24))
105
+ assert provider.queried_keys == ["soccer_epl"]
106
+ assert len(provider.errors) == 1
107
+
108
+
109
+ def test_pipeline_running_includes_direct_lock_holder():
110
+ pipeline = DailyPipeline(settings=None, store=None)
111
+
112
+ async def check():
113
+ await pipeline._lock.acquire()
114
+ try:
115
+ assert pipeline.running is True
116
+ assert pipeline.trigger_background() is False
117
+ finally:
118
+ pipeline._lock.release()
119
+
120
+ asyncio.run(check())
121
+
122
+
123
+ def test_settings_fail_closed_on_invalid_regions_or_leagues(monkeypatch):
124
+ monkeypatch.setenv("ODDS_REGIONS", "eu,invalid")
125
+ monkeypatch.setenv("ODDS_SPORT_KEYS", "soccer_epl,soccer_typo")
126
+ configured = Settings()
127
+ assert configured.odds_regions == ""
128
+ assert configured.sport_keys == ()
129
+ assert configured.required_ready is False
130
+
131
+
132
+ def test_unicode_cron_secret_is_compared_without_server_error(monkeypatch):
133
+ import app.main as main_module
134
+
135
+ monkeypatch.setattr(main_module, "settings", SimpleNamespace(cron_secret="segredo-ç"))
136
+ assert main_module._authorized("segredo-ç") is True
137
+ assert main_module._authorized("segredo-c") is False
138
+
139
+
140
+ def test_settlement_prefers_resolved_ids_and_records_audit_fields():
141
+ kickoff = datetime.now(timezone.utc) - timedelta(hours=4)
142
+ history = [{
143
+ "event_id": "event-1",
144
+ "kickoff": kickoff.isoformat(),
145
+ "competition_code": "PL",
146
+ "home": "Nome divergente",
147
+ "away": "Outro nome",
148
+ "resolved_home_key": "id:10",
149
+ "resolved_away_key": "id:20",
150
+ "side": "home",
151
+ "odd": 1.8,
152
+ "probability": 0.7,
153
+ "result": None,
154
+ }]
155
+ matches = [FinishedMatch(
156
+ match_id="match-99",
157
+ competition="PL",
158
+ utc_date=kickoff,
159
+ home="Canonical Home",
160
+ away="Canonical Away",
161
+ home_goals=2,
162
+ away_goals=1,
163
+ home_id="10",
164
+ away_id="20",
165
+ )]
166
+ settle_history(history, matches)
167
+ assert history[0]["result"] == "win"
168
+ assert history[0]["settled_match_id"] == "match-99"
169
+
170
+
171
+ def test_settlement_never_runs_before_match_can_finish():
172
+ kickoff = datetime.now(timezone.utc) - timedelta(minutes=15)
173
+ history = [{
174
+ "event_id": "event-live",
175
+ "kickoff": kickoff.isoformat(),
176
+ "competition_code": "PL",
177
+ "home": "Alpha",
178
+ "away": "Beta",
179
+ "side": "home",
180
+ "odd": 1.5,
181
+ "probability": 0.7,
182
+ "result": None,
183
+ }]
184
+ matches = [FinishedMatch("m", "PL", kickoff, "Alpha", "Beta", 1, 0)]
185
+ settle_history(history, matches)
186
+ assert history[0]["result"] is None
187
+
188
+
189
+ def test_api_security_headers_allow_hugging_face_embedding():
190
+ from fastapi.testclient import TestClient
191
+ from app.main import app
192
+
193
+ with TestClient(app) as client:
194
+ page = client.get("/")
195
+ api = client.get("/api/health")
196
+
197
+ csp = page.headers["content-security-policy"]
198
+ assert "frame-ancestors https://huggingface.co https://*.huggingface.co" in csp
199
+ assert "x-frame-options" not in page.headers
200
+ assert api.headers["cache-control"] == "no-store"
201
+ assert api.json()["version"] == MODEL_VERSION
202
+
203
+
204
+ def test_admin_scan_blocks_recent_duplicate_by_default(monkeypatch):
205
+ from fastapi.testclient import TestClient
206
+ import app.main as main_module
207
+
208
+ monkeypatch.setattr(main_module, "_authorized", lambda _secret: True)
209
+ monkeypatch.setattr(main_module.pipeline, "recent_success", lambda _minutes: True)
210
+ with TestClient(main_module.app) as client:
211
+ response = client.post(
212
+ "/api/admin/scan?wait=1",
213
+ headers={"X-Cron-Secret": "test"},
214
+ )
215
+ assert response.status_code == 200
216
+ assert response.json()["accepted"] is False
217
+ assert "scan recente" in response.json()["message"]