Spaces:
Running
Running
Commit ·
888ef7f
1
Parent(s): cd4fb81
Release Safe Bet AI v2.2 Precision
Browse files- .dockerignore +6 -5
- .gitignore +7 -0
- CHECKSUMS.sha256 +28 -24
- Dockerfile +11 -4
- PRECISION_REVIEW.md +3 -0
- README.md +9 -3
- RELEASE_NOTES_v2.2.md +35 -0
- TUTORIAL.md +7 -5
- app/config.py +41 -9
- app/core/analyzer.py +34 -16
- app/core/calibration.py +2 -0
- app/core/history.py +44 -5
- app/core/market.py +16 -2
- app/core/stats.py +21 -10
- app/core/tickets.py +6 -3
- app/main.py +61 -11
- app/models.py +2 -0
- app/pipeline.py +85 -19
- app/providers/football_data.py +46 -9
- app/providers/http_client.py +26 -4
- app/providers/odds_api.py +29 -4
- app/static/app.js +439 -117
- app/static/style.css +2251 -49
- app/storage.py +146 -35
- app/templates/index.html +172 -31
- deploy_to_hf.py +9 -1
- tests/test_core.py +50 -3
- tests/test_pipeline.py +207 -0
- tests/test_robustness.py +217 -0
.dockerignore
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
-
.git
|
| 2 |
-
.github
|
| 3 |
-
__pycache__
|
| 4 |
-
*.
|
| 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 |
-
|
| 2 |
393d9f8ee564c3e985b671dfd34cfdeae5e290d27d483ae1dfd534855d27411d .env.example
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
| 6 |
0c05dd5ce39f36a555da3192cdbb785ae8f9c71bf5f94f91c14880f13b6a8e55 RELEASE_NOTES_v2.1.md
|
| 7 |
-
|
|
|
|
| 8 |
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/__init__.py
|
| 9 |
-
|
| 10 |
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/core/__init__.py
|
| 11 |
-
|
| 12 |
-
|
| 13 |
545b836f97ca6f9d847055ae2632f3dfefdee0749555e0e4622d7addc6f3b554 app/core/competitions.py
|
| 14 |
-
|
| 15 |
-
|
| 16 |
70922a2b322aeb7512ba9147e391687cb8d50083523d03fa3fd6ae9441a94fc2 app/core/names.py
|
| 17 |
-
|
| 18 |
-
|
| 19 |
8c76f9b59d601719dee412f494a964ea588ff5e6393af49c203765524fe26bd2 app/logging_config.py
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/providers/__init__.py
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 data/.gitkeep
|
| 32 |
-
|
| 33 |
37b7898dd0df68d7bacac4726b4836010200403a1abe9542fe229caebe39b8a3 pytest.ini
|
| 34 |
8636f9ab1a075be9f3039e2a6471837259c4f36b625bcaf7a3d9a1edd2419c6d requirements-dev.txt
|
| 35 |
27d9fc069a1ce60bdf78cf32226af023d57a63c5ab4076797edb3d05258a5397 requirements.txt
|
| 36 |
-
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
-
|
| 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 **
|
|
|
|
| 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 |
-
"
|
|
|
|
| 165 |
}
|
| 166 |
```
|
| 167 |
|
|
@@ -225,11 +226,12 @@ warnings
|
|
| 225 |
rejected_preview
|
| 226 |
```
|
| 227 |
|
| 228 |
-
|
| 229 |
|
| 230 |
```text
|
| 231 |
status = ok
|
| 232 |
-
|
|
|
|
| 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
|
| 462 |
|
| 463 |
```text
|
| 464 |
-
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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=
|
| 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 |
-
#
|
| 60 |
-
#
|
| 61 |
-
|
| 62 |
-
|
|
|
|
| 63 |
|
| 64 |
@property
|
| 65 |
def required_ready(self) -> bool:
|
| 66 |
-
return bool(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 186 |
tuning_skill = float(tuning.get("brier_skill", 0.0))
|
| 187 |
-
if tuning_samples >=
|
| 188 |
-
sample_validation = _clamp((tuning_samples -
|
| 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 <
|
| 307 |
-
reasons.append("preço de referência
|
| 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 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
best["conservative"],
|
| 370 |
best["score"],
|
| 371 |
best["ev"],
|
| 372 |
):
|
| 373 |
best = row
|
| 374 |
|
| 375 |
-
if
|
| 376 |
rejected.append({"event": event_name, "reason": "mercado incompleto"})
|
| 377 |
continue
|
| 378 |
|
| 379 |
-
if best
|
|
|
|
| 380 |
rejected.append({
|
| 381 |
"event": event_name,
|
| 382 |
-
"reason": "; ".join(
|
| 383 |
-
"safe_score": round(
|
| 384 |
-
"probability": round(
|
| 385 |
-
"conservative_probability": round(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 +=
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
age = _book_age_hours(bookmaker)
|
| 52 |
if age is not None and age > max_age_hours:
|
| 53 |
stale += 1
|
| 54 |
continue
|
| 55 |
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 356 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
pw, ew, fw = weights
|
| 440 |
total = 0.0
|
| 441 |
-
for pp, ep, fp, _clim, y in
|
| 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(
|
| 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
|
| 451 |
-
) / len(
|
| 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(
|
| 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
|
| 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 *
|
| 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(
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 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.
|
| 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")
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
previous["status"] = "configuration_error"
|
| 59 |
-
previous["last_error"] = "
|
| 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 =
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 127 |
"started_at": started.isoformat(),
|
| 128 |
-
"
|
|
|
|
| 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":
|
| 142 |
"matches": len(matches),
|
| 143 |
"detail": football_meta,
|
| 144 |
},
|
| 145 |
"odds_api": {
|
| 146 |
-
"ok":
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|
|
|
|
|
|
|
| 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")
|
|
|
|
|
|
|
| 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")
|
| 78 |
-
home = item.get("homeTeam")
|
| 79 |
-
away = item.get("awayTeam")
|
|
|
|
|
|
|
| 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
|
| 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/
|
| 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 =
|
| 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 |
-
|
|
|
|
| 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
|
| 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
|
| 3 |
-
const
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
function
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
return isNaN(d) ? value : d.toLocaleString("pt-BR");
|
| 10 |
}
|
| 11 |
|
| 12 |
-
function
|
| 13 |
-
|
| 14 |
}
|
| 15 |
|
| 16 |
-
function pickCard(
|
| 17 |
-
const
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
<
|
| 40 |
-
|
| 41 |
-
<div class="
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
<
|
| 45 |
-
|
| 46 |
-
<div class="
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
<
|
| 52 |
-
|
| 53 |
-
<div class="
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
</div>
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
</div>
|
|
|
|
| 67 |
</article>`;
|
| 68 |
}
|
| 69 |
|
| 70 |
-
|
| 71 |
-
const
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
const
|
| 75 |
-
const
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
<
|
| 87 |
-
<div class="
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
| 91 |
</article>`;
|
| 92 |
}
|
| 93 |
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
}
|
| 124 |
}
|
| 125 |
-
$("refresh-ui").addEventListener("click", load);
|
| 126 |
-
load();
|
| 127 |
-
setInterval(load, 60000);
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
await navigator.clipboard.writeText(text);
|
| 134 |
-
|
| 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 |
+
"&": "&",
|
| 38 |
+
"<": "<",
|
| 39 |
+
">": ">",
|
| 40 |
+
"'": "'",
|
| 41 |
+
'"': """
|
| 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:#
|
| 3 |
-
--
|
| 4 |
-
--
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
except Exception as exc:
|
| 55 |
logger.error("state.json inválido: %s", exc)
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
out: list[FinishedMatch] = []
|
| 82 |
-
|
|
|
|
| 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 |
-
|
| 151 |
-
|
| 152 |
-
(self.history_path,
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 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="#
|
| 7 |
-
<
|
|
|
|
| 8 |
<link rel="stylesheet" href="/static/style.css">
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
<header class="topbar">
|
| 13 |
-
<div>
|
| 14 |
-
<
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 24 |
-
<h1>
|
| 25 |
-
<p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
</div>
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
</section>
|
| 29 |
|
| 30 |
-
<section id="warnings"></section>
|
| 31 |
|
| 32 |
-
<section class="stats-grid">
|
| 33 |
-
<
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
</section>
|
| 38 |
|
| 39 |
-
<section class="section-
|
| 40 |
-
<div
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
</section>
|
| 43 |
-
<section id="picks" class="picks"></section>
|
| 44 |
|
| 45 |
-
<section class="section-
|
| 46 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
</section>
|
| 48 |
-
<section id="tickets" class="ticket-grid"></section>
|
| 49 |
|
| 50 |
-
<section class="section-
|
| 51 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
</section>
|
| 53 |
-
<section class="panel health" id="health"></section>
|
| 54 |
|
| 55 |
<footer>
|
| 56 |
-
|
|
|
|
|
|
|
| 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 só 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 & 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 & 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+ • 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=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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=
|
| 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":
|
| 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"]
|