Compare commits
1 Commits
feature/HR
...
feature/HR
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0492f06bfd |
281
DOCUMENTATION.md
281
DOCUMENTATION.md
@@ -155,3 +155,284 @@ python app.py
|
||||
---
|
||||
|
||||
*Document généré automatiquement - Dépenses Trello*
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
# Turf SaaS — Documentation API v1
|
||||
|
||||
**Mise à jour** : 2026-04-30 (HRT-96 — ML Predictions + ROI + Feedback)
|
||||
**URL SaaS** : https://turf-saas-kolifee.duckdns.org
|
||||
**Port local** : 8792
|
||||
**Base de données** : `/home/h3r7/turf_saas/turf_saas.db`
|
||||
|
||||
---
|
||||
|
||||
## Stack Technique Turf SaaS
|
||||
|
||||
| Composant | Technologie |
|
||||
|---|---|
|
||||
| Backend | Python Flask + Blueprints |
|
||||
| Auth | JWT (access + refresh tokens) |
|
||||
| Base de données | SQLite (`turf_saas.db`) |
|
||||
| ML | XGBoost v1 (prédictions courses PMU) |
|
||||
| Frontend | HTML5 + Chart.js |
|
||||
| Hébergement | VPS Linux — https://turf-saas-kolifee.duckdns.org |
|
||||
|
||||
---
|
||||
|
||||
## Plans d'accès
|
||||
|
||||
| Plan | Accès |
|
||||
|---|---|
|
||||
| `free` | health, auth, courses/today, predictions/top3 (1/jour) |
|
||||
| `premium` | + predictions/all, valuebets, metrics, roi (complet), feedback/stats |
|
||||
| `pro` | + backtest, export/csv, historique illimité, orgs |
|
||||
|
||||
---
|
||||
|
||||
## Endpoints API v1
|
||||
|
||||
### Authentification
|
||||
|
||||
| Méthode | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/v1/auth/register` | Non | Créer un compte (plan=free) |
|
||||
| POST | `/api/v1/auth/login` | Non | Login — retourne access_token + refresh_token |
|
||||
| POST | `/api/v1/auth/refresh` | Non | Renouveler l'access token |
|
||||
| POST | `/api/v1/auth/logout` | Oui | Révoquer le refresh token |
|
||||
|
||||
### Système
|
||||
|
||||
| Méthode | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/health` | Non | Healthcheck public |
|
||||
| GET | `/api/v1/docs` | Non | Swagger UI (Flasgger) |
|
||||
|
||||
### Courses
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/courses/today` | free+ | Courses du jour (paginé) |
|
||||
| GET | `/api/v1/courses/{id}/predictions` | free+ | Prédictions ML pour une course |
|
||||
|
||||
`{id}` format : `{num_reunion}-{num_course}` ex: `1-3`
|
||||
Query params `courses/today` : `filter=[all|quinte|trot|plat]`, `limit`, `offset`
|
||||
|
||||
### Prédictions ML
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/predictions/top3` | free+ | Top 3 chevaux du jour |
|
||||
| GET | `/api/v1/predictions/all` | premium+ | Toutes les prédictions XGBoost |
|
||||
|
||||
Query params : `date=YYYY-MM-DD`, `limit`, `offset`
|
||||
|
||||
Source des données : table `ml_predictions_cache` (modèle `xgboost_v1`)
|
||||
|
||||
### Value Bets
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/valuebets` | premium+ | Value bets du jour (`is_value_bet=1`) |
|
||||
|
||||
Query params : `date`, `min_odds` (défaut 2.0), `limit`, `offset`
|
||||
|
||||
### Métriques ML
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/metrics` | premium+ | Métriques perf ML (precision, ROI, top-3 rate) |
|
||||
|
||||
Query params : `days` (int, défaut 30, max 365)
|
||||
|
||||
### ROI par Modèle/Stratégie (HRT-92)
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/roi/by-model` | premium+ | ROI calculé par stratégie ML XGBoost |
|
||||
|
||||
**Query params** :
|
||||
- `strategy` : filtrer par stratégie (`xgboost_sg`, `xgboost_value`, `xgboost_sp`, `xgboost_2sur4`)
|
||||
- `days` : période en jours (défaut 30, max 365)
|
||||
|
||||
**Réponse** :
|
||||
```json
|
||||
{
|
||||
"period": {"start": "2026-04-01", "end": "2026-04-30", "days": 30},
|
||||
"models": [
|
||||
{
|
||||
"model_source": "xgboost_sg",
|
||||
"nb_paris": 42,
|
||||
"mise": 42.0,
|
||||
"gain": 51.3,
|
||||
"roi_pct": 22.1,
|
||||
"win_rate": 28.6
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Jointures** : `paris` ← `pmu_partants` (résultats) ← `pmu_rapports` (dividendes)
|
||||
|
||||
**Accès plan** : Free = 1 stratégie max, Premium/Pro = complet + historique illimité
|
||||
|
||||
### ML Feedback Loop (HRT-93)
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/v1/ml/feedback/run` | Admin | Déclencher ml_feedback_saas.py manuellement |
|
||||
| GET | `/api/v1/ml/feedback/stats` | premium+ | Stats paris par stratégie XGBoost |
|
||||
|
||||
**POST `/api/v1/ml/feedback/run`** — Corps optionnel :
|
||||
```json
|
||||
{"date": "2026-04-29"}
|
||||
```
|
||||
ou
|
||||
```json
|
||||
{"backfill": "2026-04-20"}
|
||||
```
|
||||
|
||||
**GET `/api/v1/ml/feedback/stats`** — Réponse :
|
||||
```json
|
||||
{
|
||||
"stats": [
|
||||
{
|
||||
"source_reco": "xgboost_sg",
|
||||
"nb_paris": 42,
|
||||
"nb_gagnes": 12,
|
||||
"win_rate_pct": 28.6,
|
||||
"mise_totale": 42.0,
|
||||
"gain_total": 51.3,
|
||||
"roi_pct": 22.1
|
||||
}
|
||||
],
|
||||
"last_run": "2026-04-29T18:30:00"
|
||||
}
|
||||
```
|
||||
|
||||
**Stratégies XGBoost** :
|
||||
| Stratégie | Type pari | Condition | Mise |
|
||||
|---|---|---|---|
|
||||
| `xgboost_sg` | simple_gagnant | top1 ML, ml_score >= 70 | 1€ |
|
||||
| `xgboost_value` | simple_gagnant | is_value_bet = 1 | 1€ |
|
||||
| `xgboost_sp` | simple_place | top1 ML, ml_score >= 50 | 1€ |
|
||||
| `xgboost_2sur4` | deux_sur_quatre | top4 ML, 6 combos | 6€ |
|
||||
|
||||
### Backtest
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/backtest` | pro | Résultats historiques des paris |
|
||||
|
||||
Query params : `start`, `end` (YYYY-MM-DD), `limit`, `offset`
|
||||
|
||||
### Export
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/export/csv` | pro | Export CSV |
|
||||
|
||||
Query params : `type=[predictions|bets]`, `date`, `start`, `end`
|
||||
|
||||
### Historique
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/history` | free+ | Historique prédictions ML |
|
||||
|
||||
Limites : Free = 7j, Premium = 90j, Pro = illimité
|
||||
|
||||
### Organisations
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/org/` | pro | Détails de l'organisation |
|
||||
| POST | `/api/v1/org/` | pro | Créer une organisation |
|
||||
| POST | `/api/v1/org/invite` | pro | Inviter un membre (max 5) |
|
||||
| DELETE | `/api/v1/org/members/{id}` | pro | Retirer un membre |
|
||||
|
||||
### Utilisateur & Tokens
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/user/profile` | free+ | Profil utilisateur |
|
||||
| PUT | `/api/v1/user/alerts` | premium+ | Config alertes Telegram |
|
||||
| GET | `/api/v1/user/api-token` | pro | Token API personnel |
|
||||
| POST | `/api/v1/user/api-token` | pro | Générer/régénérer token API |
|
||||
| GET | `/api/v1/user/webhook` | pro | Config webhook |
|
||||
| PUT | `/api/v1/user/webhook` | pro | Modifier webhook |
|
||||
|
||||
### Billing (Stripe)
|
||||
|
||||
| Méthode | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/v1/billing/checkout` | Oui | Créer session Stripe Checkout |
|
||||
| POST | `/api/v1/billing/portal` | Oui | Portail Stripe (gestion abonnement) |
|
||||
| GET | `/api/v1/billing/status` | Oui | Statut abonnement actuel |
|
||||
| POST | `/api/v1/billing/webhook` | Non | Webhook Stripe (events) |
|
||||
|
||||
---
|
||||
|
||||
## Format de réponse uniforme
|
||||
|
||||
**Erreurs** :
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Description de l'erreur",
|
||||
"code": 400
|
||||
}
|
||||
```
|
||||
|
||||
**Listes paginées** :
|
||||
```json
|
||||
{
|
||||
"pagination": {
|
||||
"total": 150,
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"has_more": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture ML — Résumé
|
||||
|
||||
```
|
||||
ml_predictions_cache (XGBoost v1)
|
||||
→ ml_feedback_saas.py
|
||||
→ table paris (source_reco = xgboost_*)
|
||||
→ /api/v1/roi/by-model (ROI calculé)
|
||||
→ /api/v1/ml/feedback/stats (stats)
|
||||
→ dashboard_saas.html (Section Performance & ROI)
|
||||
```
|
||||
|
||||
Voir documentation complète : `POD/Intelligence/ML_Predictions_SaaS.md`
|
||||
|
||||
---
|
||||
|
||||
## Démarrage
|
||||
|
||||
```bash
|
||||
cd /home/h3r7/turf_saas
|
||||
source venv/bin/activate
|
||||
python app_v1.py
|
||||
# ou via gunicorn
|
||||
gunicorn -w 2 -b 0.0.0.0:8792 app_v1:app
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd /home/h3r7/turf_saas
|
||||
source venv/bin/activate
|
||||
python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Turf SaaS — H3R7Tech — Mise à jour 2026-04-30 (HRT-96)*
|
||||
|
||||
339
POD/Intelligence/ML_Predictions_SaaS.md
Normal file
339
POD/Intelligence/ML_Predictions_SaaS.md
Normal file
@@ -0,0 +1,339 @@
|
||||
# Note Intelligence — Système ML Prédictions dans turf_saas
|
||||
|
||||
**Date de création** : 2026-04-30
|
||||
**Auteur** : IngenieurDev (H3R7Tech)
|
||||
**Ticket de référence** : HRT-96 (sprint ML SaaS — HRT-90)
|
||||
**Scope** : `/home/h3r7/turf_saas/` — AUCUNE modification de `/home/h3r7/turf_scraper/`
|
||||
|
||||
---
|
||||
|
||||
## 1. Contexte & Décision architecturale
|
||||
|
||||
### 1.1 Deux systèmes, deux DB
|
||||
|
||||
H3R7Tech exploite deux dépôts séparés :
|
||||
|
||||
| Dépôt | Rôle | Base de données |
|
||||
|---|---|---|
|
||||
| `/home/h3r7/turf_scraper/` | Scraping PMU + entraînement XGBoost | `turf.db` |
|
||||
| `/home/h3r7/turf_saas/` | SaaS utilisateurs + API v1 + dashboard | `turf_saas.db` |
|
||||
|
||||
### 1.2 Décision de duplication (vs modification turf_scraper)
|
||||
|
||||
**Choix : dupliquer les tables et scripts ML dans turf_saas.db, sans toucher à turf_scraper.**
|
||||
|
||||
Justification :
|
||||
- `turf_scraper` est la source de vérité du scraping PMU et des modèles XGBoost — toute modification risque de casser la chaîne de collecte de données.
|
||||
- `turf_saas` doit fonctionner de manière autonome, avec ses propres utilisateurs, subscriptions et données.
|
||||
- La table `ml_predictions_cache` est *pré-peuplée* dans `turf_saas.db` par un processus de synchronisation (scheduler ou copie périodique depuis `turf.db`).
|
||||
- Le feedback loop (`ml_feedback_saas.py`) écrit dans `paris` de `turf_saas.db` uniquement.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture du système ML dans turf_saas
|
||||
|
||||
### 2.1 Vue d'ensemble du flow
|
||||
|
||||
```
|
||||
[turf_scraper/turf.db]
|
||||
└── ml_predictions_cache (XGBoost v1)
|
||||
│
|
||||
│ [sync périodique / scheduler]
|
||||
▼
|
||||
[turf_saas/turf_saas.db]
|
||||
├── ml_predictions_cache ← prédictions XGBoost importées
|
||||
├── pmu_partants ← données courses PMU
|
||||
├── pmu_rapports ← dividendes réels PMU
|
||||
├── paris ← paris virtuels ML (ml_feedback_saas.py)
|
||||
│
|
||||
└── API v1 ──┬── GET /api/v1/predictions/* (lecture ml_predictions_cache)
|
||||
├── GET /api/v1/roi/by-model (jointure paris + rapports)
|
||||
├── POST /api/v1/ml/feedback/run (déclenche ml_feedback_saas)
|
||||
└── GET /api/v1/ml/feedback/stats (stats par stratégie)
|
||||
│
|
||||
▼
|
||||
[dashboard_saas.html]
|
||||
Section "Performance & ROI"
|
||||
Chart.js — ROI par modèle / évolution
|
||||
```
|
||||
|
||||
### 2.2 Table `ml_predictions_cache` (turf_saas.db)
|
||||
|
||||
Table centrale du système ML. Contient les prédictions XGBoost pour chaque cheval/course.
|
||||
|
||||
| Colonne | Type | Description |
|
||||
|---|---|---|
|
||||
| `date` | TEXT | Date de la course (YYYY-MM-DD) |
|
||||
| `num_reunion` | INTEGER | Numéro de réunion |
|
||||
| `num_course` | INTEGER | Numéro de course |
|
||||
| `horse_name` | TEXT | Nom du cheval |
|
||||
| `horse_number` | INTEGER | Numéro du cheval |
|
||||
| `odds` | REAL | Cote au moment de la prédiction |
|
||||
| `prob_top1` | REAL | Probabilité XGBoost de finir 1er |
|
||||
| `prob_top3` | REAL | Probabilité XGBoost de finir top 3 |
|
||||
| `ml_score` | REAL | Score ML composite (0–100) |
|
||||
| `recommendation` | TEXT | `top1` / `top3` / `value_bet` |
|
||||
| `is_value_bet` | INTEGER | 1 si value bet détecté |
|
||||
| `is_outlier` | INTEGER | 1 si outlier de cote |
|
||||
| `race_label` | TEXT | Ex: `R1C3` |
|
||||
| `model_version` | TEXT | Version du modèle (ex: `xgboost_v1`) |
|
||||
| `risque_label` | TEXT | Niveau de risque (`low`/`neutral`/`high`) |
|
||||
| `risque_score` | INTEGER | Score risque (0–100) |
|
||||
|
||||
**Contrainte d'unicité** : `(date, num_reunion, num_course, horse_name)` — garantit l'idempotence des imports.
|
||||
|
||||
**Volume actuel** : ~1 000 entrées (2 dates de courses).
|
||||
|
||||
---
|
||||
|
||||
## 3. Feedback Loop ML — `ml_feedback_saas.py`
|
||||
|
||||
### 3.1 Rôle
|
||||
|
||||
Script Python autonome qui :
|
||||
1. Lit les prédictions XGBoost dans `ml_predictions_cache` de `turf_saas.db`
|
||||
2. Génère des paris virtuels selon 4 stratégies XGBoost
|
||||
3. Insère les paris dans la table `paris` de `turf_saas.db`
|
||||
4. Est **idempotent** : ne duplique pas les paris existants
|
||||
|
||||
### 3.2 Stratégies supportées
|
||||
|
||||
| Stratégie | Type pari | Condition sélection | Mise |
|
||||
|---|---|---|---|
|
||||
| `xgboost_sg` | `simple_gagnant` | top 1 ML par course, `ml_score >= 70` | 1€ |
|
||||
| `xgboost_value` | `simple_gagnant` | `is_value_bet = 1` | 1€ |
|
||||
| `xgboost_sp` | `simple_place` | top 1 ML par course, `ml_score >= 50` | 1€ |
|
||||
| `xgboost_2sur4` | `deux_sur_quatre` | top 4 ML par course, 6 combos générés | 6€ (1€/combo) |
|
||||
|
||||
### 3.3 Schéma d'idempotence
|
||||
|
||||
```python
|
||||
# Vérifie avant insertion
|
||||
SELECT id FROM paris
|
||||
WHERE date_course = ?
|
||||
AND source_reco = ? # ex: 'xgboost_sg'
|
||||
AND type_pari = ?
|
||||
AND numero1 = ?
|
||||
AND race_label = ?
|
||||
```
|
||||
|
||||
Si le pari existe déjà → skip (aucune duplication).
|
||||
|
||||
### 3.4 Table `paris` — colonnes clés pour le ML
|
||||
|
||||
| Colonne | Valeur ML |
|
||||
|---|---|
|
||||
| `source_reco` | `xgboost_sg` / `xgboost_value` / `xgboost_sp` / `xgboost_2sur4` |
|
||||
| `model_source` | `xgboost_v1` (héritée de ml_predictions_cache) |
|
||||
| `type_pari` | `simple_gagnant` / `simple_place` / `deux_sur_quatre` |
|
||||
| `statut` | `EN_ATTENTE` → `GAGNE` / `PERDU` (mise à jour par update_paris_results.py) |
|
||||
| `gain` | Dividende réel × mise (depuis pmu_rapports) |
|
||||
|
||||
### 3.5 Usage CLI
|
||||
|
||||
```bash
|
||||
# Traitement du jour
|
||||
python3 ml_feedback_saas.py
|
||||
|
||||
# Date spécifique
|
||||
python3 ml_feedback_saas.py --date 2026-04-29
|
||||
|
||||
# Backfill
|
||||
python3 ml_feedback_saas.py --backfill 2026-04-20
|
||||
```
|
||||
|
||||
**Différence avec `turf_scraper/ml_feedback.py`** :
|
||||
- `DB_PATH` = `/home/h3r7/turf_saas/turf_saas.db` (PAS `/home/h3r7/turf_scraper/turf.db`)
|
||||
- Logs dans `/home/h3r7/turf_saas/logs/`
|
||||
- AUCUNE référence à `turf_scraper`
|
||||
|
||||
---
|
||||
|
||||
## 4. API ROI — `/api/v1/roi/*`
|
||||
|
||||
### 4.1 Route principale
|
||||
|
||||
**`GET /api/v1/roi/by-model`** — Calcul du ROI par modèle/stratégie
|
||||
|
||||
Jointures SQL :
|
||||
|
||||
```sql
|
||||
-- paris ←→ pmu_partants (via race_label + date + numero)
|
||||
-- paris ←→ pmu_rapports (dividendes réels)
|
||||
|
||||
SELECT
|
||||
p.source_reco AS model_source,
|
||||
COUNT(p.id) AS nb_paris,
|
||||
SUM(p.mise) AS mise_totale,
|
||||
SUM(p.gain) AS gain_total,
|
||||
(SUM(p.gain) - SUM(p.mise)) / SUM(p.mise) * 100 AS roi_pct,
|
||||
COUNT(CASE WHEN p.statut='GAGNE' THEN 1 END) * 100.0 / COUNT(p.id) AS win_rate
|
||||
FROM paris p
|
||||
WHERE p.date_course BETWEEN :start AND :end
|
||||
AND (:strategy IS NULL OR p.source_reco = :strategy)
|
||||
GROUP BY p.source_reco
|
||||
```
|
||||
|
||||
**Paramètres query** :
|
||||
- `?strategy=xgboost_sg` — filtrer par stratégie (optionnel)
|
||||
- `?days=30` — fenêtre temporelle en jours (défaut : 30, max : 365)
|
||||
|
||||
**Réponse JSON** :
|
||||
```json
|
||||
{
|
||||
"period": {"start": "2026-04-01", "end": "2026-04-30", "days": 30},
|
||||
"models": [
|
||||
{
|
||||
"model_source": "xgboost_sg",
|
||||
"nb_paris": 42,
|
||||
"mise": 42.0,
|
||||
"gain": 51.3,
|
||||
"roi_pct": 22.1,
|
||||
"win_rate": 28.6
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Accès plan** :
|
||||
- `free` : 1 stratégie max
|
||||
- `premium` : complet
|
||||
- `pro` : complet + historique illimité
|
||||
|
||||
### 4.2 Blueprint `api_v1/routes/roi.py`
|
||||
|
||||
Enregistré dans `api_v1/__init__.py` avec :
|
||||
```python
|
||||
from .routes.roi import roi_bp
|
||||
app.register_blueprint(roi_bp)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API ML Feedback — `/api/v1/ml/feedback/*`
|
||||
|
||||
### 5.1 Routes
|
||||
|
||||
| Méthode | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/v1/ml/feedback/run` | Admin | Déclenche `ml_feedback_saas.py` manuellement |
|
||||
| `GET` | `/api/v1/ml/feedback/stats` | Premium+ | Stats paris par stratégie XGBoost |
|
||||
|
||||
### 5.2 `POST /api/v1/ml/feedback/run`
|
||||
|
||||
- Réservé aux admins (token admin requis)
|
||||
- Déclenche le script `ml_feedback_saas.py` en subprocess
|
||||
- Corps optionnel : `{"date": "2026-04-29"}` ou `{"backfill": "2026-04-20"}`
|
||||
|
||||
### 5.3 `GET /api/v1/ml/feedback/stats`
|
||||
|
||||
Retourne les statistiques agrégées par stratégie :
|
||||
|
||||
```json
|
||||
{
|
||||
"stats": [
|
||||
{
|
||||
"source_reco": "xgboost_sg",
|
||||
"nb_paris": 42,
|
||||
"nb_gagnes": 12,
|
||||
"win_rate_pct": 28.6,
|
||||
"mise_totale": 42.0,
|
||||
"gain_total": 51.3,
|
||||
"roi_pct": 22.1
|
||||
}
|
||||
],
|
||||
"last_run": "2026-04-29T18:30:00"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Blueprint `api_v1/routes/ml_feedback.py`
|
||||
|
||||
Enregistré dans `api_v1/__init__.py` avec :
|
||||
```python
|
||||
from .routes.ml_feedback import ml_feedback_bp
|
||||
app.register_blueprint(ml_feedback_bp)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Jointures de données — Schéma complet
|
||||
|
||||
```
|
||||
ml_predictions_cache
|
||||
date, num_reunion, num_course, horse_name, horse_number
|
||||
ml_score, recommendation, is_value_bet
|
||||
race_label, model_version
|
||||
│
|
||||
│ [ml_feedback_saas.py]
|
||||
▼
|
||||
paris
|
||||
date_course, race_label, numero1
|
||||
source_reco (= stratégie XGBoost)
|
||||
model_source (= xgboost_v1)
|
||||
type_pari, mise, statut, gain
|
||||
│
|
||||
├──── JOIN pmu_partants ──── date_programme + num_reunion + num_course + num_pmu
|
||||
│ ordre_arrivee (résultat réel)
|
||||
│
|
||||
└──── JOIN pmu_rapports ──── date_programme + num_reunion + num_course + type_pari
|
||||
dividende_euro (gain réel calculé)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Dashboard SaaS — Section ROI
|
||||
|
||||
Le dashboard `dashboard_saas.html` intègre une section **"Performance & ROI"** (implémentée dans HRT-94) :
|
||||
|
||||
- Graphique ROI par `model_source` (histogramme Chart.js)
|
||||
- Évolution ROI dans le temps (line chart, 7j/30j/90j)
|
||||
- Tableau : `model_source | nb paris | mise | gain | ROI% | win_rate%`
|
||||
- Filtre dropdown par stratégie
|
||||
- Gating plan : Free = 1 stratégie, Premium/Pro = complet
|
||||
|
||||
Appel API dashboard :
|
||||
```javascript
|
||||
fetch('/api/v1/roi/by-model?days=30')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Points d'attention & limites
|
||||
|
||||
1. **Données ML limitées** : actuellement 1 000 prédictions sur 2 dates (2026-04-24 et 2026-04-25). La pertinence du ROI augmentera avec le volume de données.
|
||||
|
||||
2. **Pas de paris XGBoost actifs** : la table `paris` contient des paris `manual`, `scoring_v2`, `canalturf` mais pas encore de paris `xgboost_*`. HRT-93 (ml_feedback_saas.py) doit être complété et exécuté.
|
||||
|
||||
3. **Modèle unique** : `model_version = 'xgboost_v1'`. L'évolution vers des versions de modèle multiples est prévue dans la roadmap.
|
||||
|
||||
4. **Sync turf_scraper → turf_saas** : le mécanisme de synchronisation de `ml_predictions_cache` n'est pas encore documenté formellement. À documenter dans une prochaine Note Intelligence.
|
||||
|
||||
5. **update_paris_results.py** : script de mise à jour des statuts paris (`EN_ATTENTE → GAGNE/PERDU`) à partir de `pmu_rapports` — dépendance critique pour le calcul du ROI réel.
|
||||
|
||||
---
|
||||
|
||||
## 9. Fichiers clés
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `turf_saas.db` | Base de données principale SaaS |
|
||||
| `ml_feedback_saas.py` | Feedback loop ML (à créer — HRT-93) |
|
||||
| `api_v1/routes/roi.py` | Routes API ROI (à créer — HRT-92) |
|
||||
| `api_v1/routes/ml_feedback.py` | Routes API feedback (à créer — HRT-93) |
|
||||
| `api_v1/__init__.py` | Enregistrement des blueprints |
|
||||
| `dashboard_saas.html` | Dashboard SaaS avec section ROI |
|
||||
| `update_paris_results.py` | MAJ statuts paris depuis résultats PMU |
|
||||
| `scoring_v2.py` | Scoring engine (stratégie scoring_v2) |
|
||||
|
||||
---
|
||||
|
||||
## 10. Références tickets
|
||||
|
||||
| Ticket | Description | Statut |
|
||||
|---|---|---|
|
||||
| HRT-90 | Orchestration ML SaaS (parent) | blocked |
|
||||
| HRT-92 | Backend: API ROI par modèle | in_progress |
|
||||
| HRT-93 | ML feedback loop ml_feedback_saas | in_progress |
|
||||
| HRT-94 | Frontend: Dashboard ROI | in_progress |
|
||||
| HRT-95 | QA: Tests end-to-end ML + ROI | in_progress |
|
||||
| HRT-96 | Note Intelligence ML + documentation (ce ticket) | in_progress |
|
||||
@@ -22,8 +22,6 @@ Registers sub-blueprints:
|
||||
/api/v1/history — historique préd. ML (Free:7j, Premium:90j, Pro:illimité)
|
||||
/api/v1/org/ — organisations Pro (multi-compte, max 5 users)
|
||||
/api/v1/docs — Swagger UI (via flasgger, registered on app)
|
||||
/api/v1/ml/feedback/run — trigger feedback loop ML (admin)
|
||||
/api/v1/ml/feedback/stats — stats par stratégie (premium+)
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
@@ -40,7 +38,6 @@ from .routes.user import user_bp
|
||||
from .routes.user_tokens import user_tokens_bp
|
||||
from .routes.history import history_bp
|
||||
from .routes.org import org_bp
|
||||
from .routes.ml_feedback import ml_feedback_bp
|
||||
|
||||
# Master blueprint that aggregates all sub-routes under /api/v1
|
||||
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
||||
@@ -60,4 +57,3 @@ def register_api_v1(app):
|
||||
app.register_blueprint(user_tokens_bp)
|
||||
app.register_blueprint(history_bp)
|
||||
app.register_blueprint(org_bp)
|
||||
app.register_blueprint(ml_feedback_bp)
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ml_feedback.py — API routes pour le feedback loop ML (turf_saas).
|
||||
|
||||
Routes:
|
||||
POST /api/v1/ml/feedback/run — Déclenche le feedback loop (admin uniquement)
|
||||
GET /api/v1/ml/feedback/stats — Stats performances par stratégie
|
||||
|
||||
Sécurité admin : token via variable d'environnement ML_ADMIN_TOKEN
|
||||
ou plan "pro" en fallback pour les stats.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, jsonify, request, g
|
||||
|
||||
# Ajoute le répertoire parent de api_v1 dans le path pour importer ml_feedback_saas
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||
|
||||
from api_v1.utils import get_db, internal_error, bad_request
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
|
||||
ml_feedback_bp = Blueprint("v1_ml_feedback", __name__, url_prefix="/api/v1/ml/feedback")
|
||||
|
||||
# Token admin interne — configurable via variable d'environnement
|
||||
ML_ADMIN_TOKEN = os.environ.get("ML_ADMIN_TOKEN", "")
|
||||
|
||||
|
||||
def _check_admin(req):
|
||||
"""Vérifie le token admin via header X-Admin-Token ou Authorization Bearer (plan pro)."""
|
||||
# 1. Token interne (scheduler/cron)
|
||||
admin_token = req.headers.get("X-Admin-Token", "").strip()
|
||||
if ML_ADMIN_TOKEN and admin_token == ML_ADMIN_TOKEN:
|
||||
return True, None
|
||||
|
||||
# 2. Pas de token admin configuré → autoriser les utilisateurs "pro" authentifiés
|
||||
user = getattr(g, "current_user", None)
|
||||
if user and user.get("plan") == "pro":
|
||||
return True, None
|
||||
|
||||
return False, jsonify({"error": "Accès admin requis", "code": 403}), 403
|
||||
|
||||
|
||||
@ml_feedback_bp.route("/run", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
def feedback_run():
|
||||
"""
|
||||
Déclenche le feedback loop ML pour une date donnée.
|
||||
---
|
||||
tags:
|
||||
- ML Feedback
|
||||
summary: Déclenche le feedback loop XGBoost (admin only)
|
||||
security:
|
||||
- Bearer: []
|
||||
- AdminToken: []
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
date:
|
||||
type: string
|
||||
description: Date YYYY-MM-DD (défaut aujourd'hui)
|
||||
example: "2026-04-25"
|
||||
mode:
|
||||
type: string
|
||||
description: "run (défaut) ou backfill"
|
||||
enum: [run, backfill]
|
||||
example: run
|
||||
responses:
|
||||
200:
|
||||
description: Feedback loop exécuté avec succès
|
||||
400:
|
||||
description: Paramètre invalide
|
||||
403:
|
||||
description: Accès refusé
|
||||
500:
|
||||
description: Erreur interne
|
||||
"""
|
||||
# Vérification admin
|
||||
user = getattr(g, "current_user", None)
|
||||
admin_token = request.headers.get("X-Admin-Token", "").strip()
|
||||
is_admin = (ML_ADMIN_TOKEN and admin_token == ML_ADMIN_TOKEN) or (
|
||||
user and user.get("plan") == "pro"
|
||||
)
|
||||
if not is_admin:
|
||||
return jsonify({"error": "Accès admin requis", "code": 403}), 403
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
date_str = body.get("date") or datetime.now().strftime("%Y-%m-%d")
|
||||
mode = body.get("mode", "run")
|
||||
|
||||
# Validation date
|
||||
try:
|
||||
datetime.strptime(date_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return bad_request(f"Date invalide : {date_str}. Format attendu : YYYY-MM-DD")
|
||||
|
||||
if mode not in ("run", "backfill"):
|
||||
return bad_request("mode doit être 'run' ou 'backfill'")
|
||||
|
||||
try:
|
||||
import ml_feedback_saas
|
||||
|
||||
if mode == "backfill":
|
||||
inseres, maj = ml_feedback_saas.backfill(date_str)
|
||||
total_inseres = inseres
|
||||
else:
|
||||
result = ml_feedback_saas.run(date_str)
|
||||
total_inseres = sum(result["inseres"].values())
|
||||
maj = result["maj"]
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": date_str,
|
||||
"mode": mode,
|
||||
"paris_inseres": total_inseres,
|
||||
"paris_mis_a_jour": maj,
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
|
||||
|
||||
@ml_feedback_bp.route("/stats", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def feedback_stats():
|
||||
"""
|
||||
Stats performances ML par stratégie.
|
||||
---
|
||||
tags:
|
||||
- ML Feedback
|
||||
summary: Stats paris ML par stratégie (premium+)
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: date_debut
|
||||
in: query
|
||||
type: string
|
||||
description: Date de début YYYY-MM-DD
|
||||
- name: date_fin
|
||||
in: query
|
||||
type: string
|
||||
description: Date de fin YYYY-MM-DD
|
||||
responses:
|
||||
200:
|
||||
description: Stats par stratégie
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant (premium ou pro requis)
|
||||
"""
|
||||
date_debut = request.args.get("date_debut")
|
||||
date_fin = request.args.get("date_fin")
|
||||
|
||||
# Validation optionnelle des dates
|
||||
for d_str, label in [(date_debut, "date_debut"), (date_fin, "date_fin")]:
|
||||
if d_str:
|
||||
try:
|
||||
datetime.strptime(d_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return bad_request(f"{label} invalide : {d_str}. Format : YYYY-MM-DD")
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
import ml_feedback_saas
|
||||
|
||||
stats = ml_feedback_saas.get_feedback_stats(conn, date_debut, date_fin)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"strategies": stats,
|
||||
"filters": {
|
||||
"date_debut": date_debut,
|
||||
"date_fin": date_fin,
|
||||
},
|
||||
"total_strategies": len(stats),
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1,600 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ml_feedback_saas.py — Feedback loop ML pour turf_saas.
|
||||
Enregistre les paris virtuels XGBoost depuis ml_predictions_cache
|
||||
et met à jour les résultats/dividendes depuis pmu_partants + pmu_rapports.
|
||||
|
||||
DB cible : /home/h3r7/turf_saas/turf_saas.db
|
||||
|
||||
Stratégies :
|
||||
A) xgboost_sg : simple_gagnant — top1 ML par course, ml_score >= 70, mise 1€
|
||||
B) xgboost_value : simple_gagnant — is_value_bet = 1, mise 1€
|
||||
C) xgboost_sp : simple_place — top1 ML par course, ml_score >= 50, mise 1€
|
||||
D) xgboost_2sur4 : deux_sur_quatre — top4 ML par course, 6 combos x 1€ = mise 6€
|
||||
|
||||
Usage :
|
||||
python3 ml_feedback_saas.py # Traite aujourd'hui
|
||||
python3 ml_feedback_saas.py --backfill 2026-04-25
|
||||
python3 ml_feedback_saas.py --date 2026-04-25
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
|
||||
|
||||
os.makedirs("/home/h3r7/turf_saas/logs", exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [ml_feedback_saas] %(levelname)s %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler("/home/h3r7/turf_saas/logs/ml_feedback_saas.log"),
|
||||
logging.StreamHandler(),
|
||||
],
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# UTILITAIRES
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def pari_existe(cursor, date, num_reunion, num_course, numero1, type_pari, source_reco):
|
||||
"""Vérifie si un pari identique existe déjà (idempotence)."""
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id FROM paris
|
||||
WHERE date_course = ? AND source_reco = ?
|
||||
AND type_pari = ? AND numero1 = ?
|
||||
AND race_label = ?
|
||||
""",
|
||||
(date, source_reco, type_pari, numero1, f"R{num_reunion}C{num_course}"),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
|
||||
def pari_2sur4_existe(cursor, date, num_reunion, num_course, source_reco):
|
||||
"""Vérifie si un pari 2sur4 existe déjà pour cette course."""
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id FROM paris
|
||||
WHERE date_course = ? AND source_reco = ?
|
||||
AND race_label = ?
|
||||
""",
|
||||
(date, source_reco, f"R{num_reunion}C{num_course}"),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
|
||||
def get_top_ml_par_course(cursor, date, n=4, min_score=0):
|
||||
"""Retourne les n meilleurs chevaux ML par course pour une date."""
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT num_reunion, num_course, horse_name, horse_number,
|
||||
ml_score, odds, recommendation, is_value_bet,
|
||||
race_label, race_name, hippodrome, heure,
|
||||
discipline, distance
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ?
|
||||
AND ml_score >= ?
|
||||
ORDER BY num_reunion, num_course, ml_score DESC
|
||||
""",
|
||||
(date, min_score),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
courses = {}
|
||||
for r in rows:
|
||||
key = (r["num_reunion"], r["num_course"])
|
||||
if key not in courses:
|
||||
courses[key] = []
|
||||
if len(courses[key]) < n:
|
||||
courses[key].append(dict(r))
|
||||
return courses
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# STRATÉGIE A — Simple Gagnant top1 ML (score >= 70)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def save_ml_paris_sg(conn, date):
|
||||
"""Insère 1 pari simple_gagnant par course : top1 ML avec ml_score >= 70."""
|
||||
cursor = conn.cursor()
|
||||
courses = get_top_ml_par_course(cursor, date, n=1, min_score=70)
|
||||
inseres = 0
|
||||
|
||||
for (num_reunion, num_course), chevaux in courses.items():
|
||||
cheval = chevaux[0]
|
||||
if pari_existe(
|
||||
cursor,
|
||||
date,
|
||||
num_reunion,
|
||||
num_course,
|
||||
cheval["horse_number"],
|
||||
"simple_gagnant",
|
||||
"xgboost_sg",
|
||||
):
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO paris
|
||||
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||
statut, gain, source_reco, model_source)
|
||||
VALUES (?, ?, ?, ?, ?, 'simple_gagnant', ?, ?, ?, ?, 1.0,
|
||||
'EN_ATTENTE', 0.0, 'xgboost_sg', 'xgboost_v1')
|
||||
""",
|
||||
(
|
||||
date,
|
||||
date,
|
||||
cheval.get("race_name") or "",
|
||||
f"R{num_reunion}C{num_course}",
|
||||
cheval.get("hippodrome") or "",
|
||||
cheval["horse_name"],
|
||||
cheval["horse_name"],
|
||||
cheval["horse_number"],
|
||||
cheval["odds"],
|
||||
),
|
||||
)
|
||||
inseres += 1
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[SG] {date} → {inseres} paris simple_gagnant insérés (score>=70)")
|
||||
return inseres
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# STRATÉGIE B — Value Bet (is_value_bet = 1)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def save_ml_paris_value(conn, date):
|
||||
"""Insère 1 pari simple_gagnant pour chaque cheval is_value_bet=1."""
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT num_reunion, num_course, horse_name, horse_number,
|
||||
ml_score, odds, race_label, race_name, hippodrome
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ? AND is_value_bet = 1
|
||||
ORDER BY num_reunion, num_course, ml_score DESC
|
||||
""",
|
||||
(date,),
|
||||
)
|
||||
rows = [dict(r) for r in cursor.fetchall()]
|
||||
inseres = 0
|
||||
|
||||
for r in rows:
|
||||
if pari_existe(
|
||||
cursor,
|
||||
date,
|
||||
r["num_reunion"],
|
||||
r["num_course"],
|
||||
r["horse_number"],
|
||||
"simple_gagnant",
|
||||
"xgboost_value",
|
||||
):
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO paris
|
||||
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||
statut, gain, source_reco, model_source)
|
||||
VALUES (?, ?, ?, ?, ?, 'simple_gagnant', ?, ?, ?, ?, 1.0,
|
||||
'EN_ATTENTE', 0.0, 'xgboost_value', 'xgboost_v1')
|
||||
""",
|
||||
(
|
||||
date,
|
||||
date,
|
||||
r.get("race_name") or "",
|
||||
r.get("race_label") or f"R{r['num_reunion']}C{r['num_course']}",
|
||||
r.get("hippodrome") or "",
|
||||
r["horse_name"],
|
||||
r["horse_name"],
|
||||
r["horse_number"],
|
||||
r["odds"],
|
||||
),
|
||||
)
|
||||
inseres += 1
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[VALUE] {date} → {inseres} paris value_bet insérés")
|
||||
return inseres
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# STRATÉGIE C — Simple Placé top1 ML (score >= 50)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def save_ml_paris_sp(conn, date):
|
||||
"""Insère 1 pari simple_place par course : top1 ML avec ml_score >= 50."""
|
||||
cursor = conn.cursor()
|
||||
courses = get_top_ml_par_course(cursor, date, n=1, min_score=50)
|
||||
inseres = 0
|
||||
|
||||
for (num_reunion, num_course), chevaux in courses.items():
|
||||
cheval = chevaux[0]
|
||||
if pari_existe(
|
||||
cursor,
|
||||
date,
|
||||
num_reunion,
|
||||
num_course,
|
||||
cheval["horse_number"],
|
||||
"simple_place",
|
||||
"xgboost_sp",
|
||||
):
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO paris
|
||||
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||
statut, gain, source_reco, model_source)
|
||||
VALUES (?, ?, ?, ?, ?, 'simple_place', ?, ?, ?, ?, 1.0,
|
||||
'EN_ATTENTE', 0.0, 'xgboost_sp', 'xgboost_v1')
|
||||
""",
|
||||
(
|
||||
date,
|
||||
date,
|
||||
cheval.get("race_name") or "",
|
||||
f"R{num_reunion}C{num_course}",
|
||||
cheval.get("hippodrome") or "",
|
||||
cheval["horse_name"],
|
||||
cheval["horse_name"],
|
||||
cheval["horse_number"],
|
||||
cheval["odds"],
|
||||
),
|
||||
)
|
||||
inseres += 1
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[SP] {date} → {inseres} paris simple_place insérés (score>=50)")
|
||||
return inseres
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# STRATÉGIE D — 2sur4 top4 ML (6 combinaisons x 1€)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def save_ml_paris_2sur4(conn, date):
|
||||
"""Insère 1 pari deux_sur_quatre par course : top4 ML, mise 6€."""
|
||||
cursor = conn.cursor()
|
||||
courses = get_top_ml_par_course(cursor, date, n=4, min_score=0)
|
||||
inseres = 0
|
||||
|
||||
for (num_reunion, num_course), chevaux in courses.items():
|
||||
if len(chevaux) < 4:
|
||||
continue
|
||||
if pari_2sur4_existe(cursor, date, num_reunion, num_course, "xgboost_2sur4"):
|
||||
continue
|
||||
|
||||
top4 = chevaux[:4]
|
||||
nums = [str(c["horse_number"]) for c in top4]
|
||||
noms = [c["horse_name"] for c in top4]
|
||||
chevaux_str = "/".join(noms)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO paris
|
||||
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||
statut, gain, source_reco, model_source, commentaire)
|
||||
VALUES (?, ?, ?, ?, ?, 'deux_sur_quatre', ?, ?, ?, 0.0, 6.0,
|
||||
'EN_ATTENTE', 0.0, 'xgboost_2sur4', 'xgboost_v1', ?)
|
||||
""",
|
||||
(
|
||||
date,
|
||||
date,
|
||||
top4[0].get("race_name") or "",
|
||||
f"R{num_reunion}C{num_course}",
|
||||
top4[0].get("hippodrome") or "",
|
||||
chevaux_str,
|
||||
top4[0]["horse_name"],
|
||||
top4[0]["horse_number"],
|
||||
f"top4 ML: {'/'.join(nums)}",
|
||||
),
|
||||
)
|
||||
inseres += 1
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[2S4] {date} → {inseres} paris deux_sur_quatre insérés")
|
||||
return inseres
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# UPDATE RÉSULTATS + DIVIDENDES
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def update_ml_paris_results(conn, date):
|
||||
"""
|
||||
Met à jour statut + gain (dividende PMU réel) pour tous les paris ML EN_ATTENTE.
|
||||
Sources: pmu_partants (ordre_arrivee) + pmu_rapports (dividende_euro).
|
||||
"""
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, race_label, type_pari, numero1, chevaux, mise, source_reco, commentaire
|
||||
FROM paris
|
||||
WHERE date_course = ? AND statut = 'EN_ATTENTE'
|
||||
AND source_reco LIKE 'xgboost%'
|
||||
""",
|
||||
(date,),
|
||||
)
|
||||
paris = [dict(r) for r in cursor.fetchall()]
|
||||
|
||||
if not paris:
|
||||
log.info(f"[UPDATE] {date} → aucun pari ML EN_ATTENTE")
|
||||
return 0
|
||||
|
||||
maj = 0
|
||||
for pari in paris:
|
||||
pari_id = pari["id"]
|
||||
race_label = pari["race_label"] or ""
|
||||
type_pari = pari["type_pari"]
|
||||
numero1 = pari["numero1"]
|
||||
mise = pari["mise"]
|
||||
|
||||
# Extraire num_reunion / num_course depuis le race_label "R{r}C{c}"
|
||||
try:
|
||||
parts = race_label.replace("R", "").split("C")
|
||||
num_reunion = int(parts[0])
|
||||
num_course = int(parts[1])
|
||||
except Exception:
|
||||
log.warning(f"[UPDATE] race_label invalide : {race_label}")
|
||||
continue
|
||||
|
||||
if type_pari == "simple_gagnant":
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT ordre_arrivee FROM pmu_partants
|
||||
WHERE date_programme = ? AND num_reunion = ?
|
||||
AND num_course = ? AND num_pmu = ?
|
||||
""",
|
||||
(date, num_reunion, num_course, numero1),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row or row["ordre_arrivee"] is None or row["ordre_arrivee"] == 0:
|
||||
continue
|
||||
|
||||
gagne = row["ordre_arrivee"] == 1
|
||||
gain = 0.0
|
||||
if gagne:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT dividende_euro FROM pmu_rapports
|
||||
WHERE date_programme = ? AND num_reunion = ?
|
||||
AND num_course = ? AND type_pari = 'SIMPLE_GAGNANT'
|
||||
AND CAST(combinaison AS INTEGER) = ?
|
||||
AND libelle NOT LIKE '%NP%'
|
||||
""",
|
||||
(date, num_reunion, num_course, numero1),
|
||||
)
|
||||
div = cursor.fetchone()
|
||||
gain = div["dividende_euro"] if div and div["dividende_euro"] else 0.0
|
||||
|
||||
cursor.execute(
|
||||
"UPDATE paris SET statut=?, gain=? WHERE id=?",
|
||||
("GAGNE" if gagne else "PERDU", gain, pari_id),
|
||||
)
|
||||
maj += 1
|
||||
|
||||
elif type_pari == "simple_place":
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT ordre_arrivee FROM pmu_partants
|
||||
WHERE date_programme = ? AND num_reunion = ?
|
||||
AND num_course = ? AND num_pmu = ?
|
||||
""",
|
||||
(date, num_reunion, num_course, numero1),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row or not row["ordre_arrivee"]:
|
||||
continue
|
||||
|
||||
gagne = 1 <= row["ordre_arrivee"] <= 3
|
||||
gain = 0.0
|
||||
if gagne:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT dividende_euro FROM pmu_rapports
|
||||
WHERE date_programme = ? AND num_reunion = ?
|
||||
AND num_course = ? AND type_pari = 'SIMPLE_PLACE'
|
||||
AND CAST(combinaison AS INTEGER) = ?
|
||||
AND libelle NOT LIKE '%NP%'
|
||||
""",
|
||||
(date, num_reunion, num_course, numero1),
|
||||
)
|
||||
div = cursor.fetchone()
|
||||
gain = div["dividende_euro"] if div and div["dividende_euro"] else 0.0
|
||||
|
||||
cursor.execute(
|
||||
"UPDATE paris SET statut=?, gain=? WHERE id=?",
|
||||
("GAGNE" if gagne else "PERDU", gain, pari_id),
|
||||
)
|
||||
maj += 1
|
||||
|
||||
elif type_pari == "deux_sur_quatre":
|
||||
# Récupère les 4 numéros depuis commentaire "top4 ML: n1/n2/n3/n4"
|
||||
try:
|
||||
nums_str = (
|
||||
pari["commentaire"].split(": ")[1]
|
||||
if pari.get("commentaire")
|
||||
else ""
|
||||
)
|
||||
nums_top4 = [int(n) for n in nums_str.split("/") if n.strip().isdigit()]
|
||||
except Exception:
|
||||
nums_top4 = []
|
||||
|
||||
if len(nums_top4) < 4:
|
||||
# Fallback : reconstituer top4 depuis ml_predictions_cache
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT horse_number FROM ml_predictions_cache
|
||||
WHERE date = ? AND num_reunion = ? AND num_course = ?
|
||||
ORDER BY ml_score DESC LIMIT 4
|
||||
""",
|
||||
(date, num_reunion, num_course),
|
||||
)
|
||||
nums_top4 = [r["horse_number"] for r in cursor.fetchall()]
|
||||
|
||||
if len(nums_top4) < 2:
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT combinaison, dividende_euro FROM pmu_rapports
|
||||
WHERE date_programme = ? AND num_reunion = ?
|
||||
AND num_course = ? AND type_pari = 'DEUX_SUR_QUATRE'
|
||||
AND libelle NOT LIKE '%NP%'
|
||||
""",
|
||||
(date, num_reunion, num_course),
|
||||
)
|
||||
rapports = [dict(r) for r in cursor.fetchall()]
|
||||
gain_total = 0.0
|
||||
|
||||
for rap in rapports:
|
||||
try:
|
||||
n1, n2 = [int(x) for x in rap["combinaison"].split("-")]
|
||||
except Exception:
|
||||
continue
|
||||
if n1 in nums_top4 and n2 in nums_top4:
|
||||
gain_total += rap["dividende_euro"]
|
||||
|
||||
gagne = gain_total > 0
|
||||
cursor.execute(
|
||||
"UPDATE paris SET statut=?, gain=? WHERE id=?",
|
||||
("GAGNE" if gagne else "PERDU", round(gain_total, 2), pari_id),
|
||||
)
|
||||
maj += 1
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[UPDATE] {date} → {maj}/{len(paris)} paris ML mis à jour")
|
||||
return maj
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# STATS PAR STRATÉGIE
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_feedback_stats(conn, date_debut=None, date_fin=None):
|
||||
"""Stats performances ML par stratégie (source_reco)."""
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT source_reco,
|
||||
COUNT(*) as n_paris,
|
||||
SUM(CASE WHEN statut='GAGNE' THEN 1 ELSE 0 END) as n_gagne,
|
||||
SUM(CASE WHEN statut='PERDU' THEN 1 ELSE 0 END) as n_perdu,
|
||||
SUM(CASE WHEN statut='EN_ATTENTE' THEN 1 ELSE 0 END) as n_attente,
|
||||
ROUND(100.0 * SUM(CASE WHEN statut='GAGNE' THEN 1 ELSE 0 END)
|
||||
/ NULLIF(SUM(CASE WHEN statut IN ('GAGNE','PERDU') THEN 1 ELSE 0 END), 0), 1) as win_rate_pct,
|
||||
ROUND(SUM(gain), 2) as gain_total,
|
||||
ROUND(SUM(mise), 2) as mise_totale,
|
||||
ROUND(SUM(gain) - SUM(mise), 2) as roi_net
|
||||
FROM paris
|
||||
WHERE source_reco LIKE 'xgboost%'
|
||||
AND (:debut IS NULL OR date_course >= :debut)
|
||||
AND (:fin IS NULL OR date_course <= :fin)
|
||||
GROUP BY source_reco
|
||||
ORDER BY source_reco
|
||||
""",
|
||||
{"debut": date_debut, "fin": date_fin},
|
||||
)
|
||||
return [dict(r) for r in cursor.fetchall()]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# PIPELINE COMPLET
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def run(date):
|
||||
"""Enregistre les paris ML du jour + met à jour les résultats de J-1."""
|
||||
conn = get_db()
|
||||
log.info(f"=== ml_feedback_saas.run({date}) ===")
|
||||
|
||||
# 1. Enregistre les paris ML pour la date (depuis le cache du jour)
|
||||
sg = save_ml_paris_sg(conn, date)
|
||||
vb = save_ml_paris_value(conn, date)
|
||||
sp = save_ml_paris_sp(conn, date)
|
||||
s4 = save_ml_paris_2sur4(conn, date)
|
||||
log.info(f"[SAVE] {date} → total insérés : SG={sg} VALUE={vb} SP={sp} 2S4={s4}")
|
||||
|
||||
# 2. Met à jour les résultats de J-1 (résultats PMU disponibles)
|
||||
yesterday = (datetime.strptime(date, "%Y-%m-%d") - timedelta(days=1)).strftime(
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
maj = update_ml_paris_results(conn, yesterday)
|
||||
log.info(f"[UPDATE] {yesterday} → {maj} paris mis à jour")
|
||||
|
||||
conn.close()
|
||||
return {"inseres": {"sg": sg, "value": vb, "sp": sp, "2sur4": s4}, "maj": maj}
|
||||
|
||||
|
||||
def backfill(date):
|
||||
"""Backfill : insère ET met à jour les résultats pour une date passée."""
|
||||
conn = get_db()
|
||||
log.info(f"=== ml_feedback_saas.backfill({date}) ===")
|
||||
|
||||
sg = save_ml_paris_sg(conn, date)
|
||||
vb = save_ml_paris_value(conn, date)
|
||||
sp = save_ml_paris_sp(conn, date)
|
||||
s4 = save_ml_paris_2sur4(conn, date)
|
||||
log.info(f"[SAVE] {date} → SG={sg} VALUE={vb} SP={sp} 2S4={s4}")
|
||||
|
||||
maj = update_ml_paris_results(conn, date)
|
||||
log.info(f"[UPDATE] {date} → {maj} paris mis à jour")
|
||||
|
||||
conn.close()
|
||||
return sg + vb + sp + s4, maj
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# MAIN
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--backfill" in sys.argv:
|
||||
idx = sys.argv.index("--backfill")
|
||||
date = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else None
|
||||
if not date:
|
||||
print("Usage: python3 ml_feedback_saas.py --backfill YYYY-MM-DD")
|
||||
sys.exit(1)
|
||||
inseres, maj = backfill(date)
|
||||
print(f"Backfill {date} : {inseres} paris insérés, {maj} mis à jour")
|
||||
|
||||
elif "--date" in sys.argv:
|
||||
idx = sys.argv.index("--date")
|
||||
date = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else None
|
||||
if not date:
|
||||
print("Usage: python3 ml_feedback_saas.py --date YYYY-MM-DD")
|
||||
sys.exit(1)
|
||||
result = run(date)
|
||||
total = sum(result["inseres"].values())
|
||||
print(f"Run {date} : {total} paris insérés, {result['maj']} mis à jour")
|
||||
|
||||
else:
|
||||
result = run(datetime.now().strftime("%Y-%m-%d"))
|
||||
total = sum(result["inseres"].values())
|
||||
print(f"Run today : {total} paris insérés, {result['maj']} mis à jour")
|
||||
Reference in New Issue
Block a user