Compare commits
2 Commits
feature/HR
...
3079c2c6c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3079c2c6c6 | ||
|
|
52c0c95f22 |
@@ -22,6 +22,8 @@ Registers sub-blueprints:
|
|||||||
/api/v1/history — historique préd. ML (Free:7j, Premium:90j, Pro:illimité)
|
/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/org/ — organisations Pro (multi-compte, max 5 users)
|
||||||
/api/v1/docs — Swagger UI (via flasgger, registered on app)
|
/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
|
from flask import Blueprint
|
||||||
@@ -38,6 +40,7 @@ from .routes.user import user_bp
|
|||||||
from .routes.user_tokens import user_tokens_bp
|
from .routes.user_tokens import user_tokens_bp
|
||||||
from .routes.history import history_bp
|
from .routes.history import history_bp
|
||||||
from .routes.org import org_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
|
# Master blueprint that aggregates all sub-routes under /api/v1
|
||||||
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
||||||
@@ -57,3 +60,4 @@ def register_api_v1(app):
|
|||||||
app.register_blueprint(user_tokens_bp)
|
app.register_blueprint(user_tokens_bp)
|
||||||
app.register_blueprint(history_bp)
|
app.register_blueprint(history_bp)
|
||||||
app.register_blueprint(org_bp)
|
app.register_blueprint(org_bp)
|
||||||
|
app.register_blueprint(ml_feedback_bp)
|
||||||
|
|||||||
191
api_v1/routes/ml_feedback.py
Normal file
191
api_v1/routes/ml_feedback.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
#!/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()
|
||||||
600
ml_feedback_saas.py
Normal file
600
ml_feedback_saas.py
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
#!/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