feat(HRT-83): intégrer météo & terrain dans prédictions ML (Premium)

- scoring_v2.py : ajout get_terrain_condition() + compute_weather_impact()
  score_cheval_v2() accepte weather_data=None (backward-compat préservée)
  Impact météo/terrain sur [-5, +5] pts selon pénétromètre + vent + temp

- api_v1/routes/predictions.py : _fetch_ml_predictions() avec include_weather=True
  LEFT JOIN pmu_courses (pénétromètre) + pmu_meteo sur date+num_reunion
  /predictions/all → terrain_condition + weather_impact dans chaque row
  /predictions/top3 → inchangé (free tier, pas de champs météo)

- api_v1/routes/valuebets.py : même LEFT JOIN météo/terrain
  /valuebets → terrain_condition + weather_impact dans chaque value bet

Tests : 42/42 passent (pytest tests/test_api_v1.py)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DevOps Engineer
2026-04-29 15:35:15 +02:00
parent 225295030b
commit ec024d8236
3 changed files with 478 additions and 167 deletions

View File

@@ -22,8 +22,14 @@ from auth import jwt_required_middleware, plan_required, free_daily_limit_check
predictions_bp = Blueprint("v1_predictions", __name__, url_prefix="/api/v1/predictions") predictions_bp = Blueprint("v1_predictions", __name__, url_prefix="/api/v1/predictions")
def _fetch_ml_predictions(conn, date: str, limit: int = None, offset: int = 0): def _fetch_ml_predictions(
"""Shared helper — returns rows from ml_predictions_cache.""" conn, date: str, limit: int = None, offset: int = 0, include_weather: bool = False
):
"""Shared helper — returns rows from ml_predictions_cache.
include_weather=True adds terrain_condition and weather_impact columns
via LEFT JOIN on pmu_meteo (premium routes only).
"""
if not table_exists(conn, "ml_predictions_cache"): if not table_exists(conn, "ml_predictions_cache"):
return [], 0 return [], 0
@@ -33,13 +39,35 @@ def _fetch_ml_predictions(conn, date: str, limit: int = None, offset: int = 0):
).fetchone() ).fetchone()
total = count_row["cnt"] if count_row else 0 total = count_row["cnt"] if count_row else 0
sql = """SELECT if (
race_label, hippodrome, discipline, distance, heure, include_weather
horse_name, horse_number, odds, prob_top1, prob_top3, and table_exists(conn, "pmu_meteo")
ml_score, recommendation, is_value_bet, risque_label, risque_score and table_exists(conn, "pmu_courses")
FROM ml_predictions_cache ):
WHERE date = ? sql = """SELECT
ORDER BY ml_score DESC""" m.race_label, m.hippodrome, m.discipline, m.distance, m.heure,
m.horse_name, m.horse_number, m.odds, m.prob_top1, m.prob_top3,
m.ml_score, m.recommendation, m.is_value_bet, m.risque_label, m.risque_score,
c.penetrometre_intitule,
mt.nebulositecode, mt.nebulosite_court, mt.temperature, mt.force_vent
FROM ml_predictions_cache m
LEFT JOIN pmu_courses c
ON c.date_programme = m.date
AND c.num_reunion = m.num_reunion
AND c.num_course = m.num_course
LEFT JOIN pmu_meteo mt
ON mt.date_programme = m.date
AND mt.num_reunion = m.num_reunion
WHERE m.date = ?
ORDER BY m.ml_score DESC"""
else:
sql = """SELECT
race_label, hippodrome, discipline, distance, heure,
horse_name, horse_number, odds, prob_top1, prob_top3,
ml_score, recommendation, is_value_bet, risque_label, risque_score
FROM ml_predictions_cache
WHERE date = ?
ORDER BY ml_score DESC"""
params = [date] params = [date]
if limit is not None: if limit is not None:
@@ -47,7 +75,42 @@ def _fetch_ml_predictions(conn, date: str, limit: int = None, offset: int = 0):
params += [limit, offset] params += [limit, offset]
rows = conn.execute(sql, params).fetchall() rows = conn.execute(sql, params).fetchall()
return [dict(r) for r in rows], total
results = []
for r in rows:
row_dict = dict(r)
if include_weather:
# Compute derived fields from raw columns
penetrometre = row_dict.pop("penetrometre_intitule", None) or ""
# Import inline to avoid circular dependency at module level
from scoring_v2 import get_terrain_condition, compute_weather_impact
terrain_condition = (
get_terrain_condition(penetrometre) if penetrometre else "inconnu"
)
weather_data = None
if (
row_dict.get("nebulositecode") is not None
or row_dict.get("temperature") is not None
):
weather_data = {
"nebulositecode": row_dict.pop("nebulositecode", None),
"nebulosite_court": row_dict.pop("nebulosite_court", None),
"temperature": row_dict.pop("temperature", None),
"force_vent": row_dict.pop("force_vent", None),
}
else:
# Remove raw meteo columns even if NULL
row_dict.pop("nebulositecode", None)
row_dict.pop("nebulosite_court", None)
row_dict.pop("temperature", None)
row_dict.pop("force_vent", None)
weather_impact = compute_weather_impact(weather_data, terrain_condition)
row_dict["terrain_condition"] = terrain_condition
row_dict["weather_impact"] = weather_impact
results.append(row_dict)
return results, total
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
@@ -145,7 +208,7 @@ def predictions_all():
conn = get_db() conn = get_db()
try: try:
predictions, total = _fetch_ml_predictions( predictions, total = _fetch_ml_predictions(
conn, date_param, limit=limit, offset=offset conn, date_param, limit=limit, offset=offset, include_weather=True
) )
pagination = paginate_query(predictions, total, limit, offset) pagination = paginate_query(predictions, total, limit, offset)

View File

@@ -53,7 +53,7 @@ def valuebets():
default: 0 default: 0
responses: responses:
200: 200:
description: Value bets du jour description: Value bets du jour avec météo et terrain (HRT-83)
401: 401:
description: Token invalide description: Token invalide
403: 403:
@@ -69,7 +69,7 @@ def valuebets():
conn = get_db() conn = get_db()
try: try:
rows = [] rows_raw = []
total = 0 total = 0
if table_exists(conn, "ml_predictions_cache"): if table_exists(conn, "ml_predictions_cache"):
@@ -81,18 +81,73 @@ def valuebets():
).fetchone() ).fetchone()
total = count_row["cnt"] if count_row else 0 total = count_row["cnt"] if count_row else 0
rows = conn.execute( # LEFT JOIN pmu_courses (terrain) + pmu_meteo (météo) — HRT-83
"""SELECT race_label, hippodrome, discipline, distance, heure, has_courses = table_exists(conn, "pmu_courses")
horse_name, horse_number, odds, prob_top1, prob_top3, has_meteo = table_exists(conn, "pmu_meteo")
ml_score, recommendation, risque_label, risque_score
FROM ml_predictions_cache if has_courses and has_meteo:
WHERE date = ? AND is_value_bet = 1 AND odds >= ? rows_raw = conn.execute(
ORDER BY ml_score DESC """SELECT m.race_label, m.hippodrome, m.discipline, m.distance, m.heure,
LIMIT ? OFFSET ?""", m.horse_name, m.horse_number, m.odds, m.prob_top1, m.prob_top3,
(date_param, min_odds, limit, offset), m.ml_score, m.recommendation, m.risque_label, m.risque_score,
).fetchall() c.penetrometre_intitule,
mt.nebulositecode, mt.nebulosite_court,
mt.temperature, mt.force_vent
FROM ml_predictions_cache m
LEFT JOIN pmu_courses c
ON c.date_programme = m.date
AND c.num_reunion = m.num_reunion
AND c.num_course = m.num_course
LEFT JOIN pmu_meteo mt
ON mt.date_programme = m.date
AND mt.num_reunion = m.num_reunion
WHERE m.date = ? AND m.is_value_bet = 1 AND m.odds >= ?
ORDER BY m.ml_score DESC
LIMIT ? OFFSET ?""",
(date_param, min_odds, limit, offset),
).fetchall()
else:
rows_raw = conn.execute(
"""SELECT race_label, hippodrome, discipline, distance, heure,
horse_name, horse_number, odds, prob_top1, prob_top3,
ml_score, recommendation, risque_label, risque_score
FROM ml_predictions_cache
WHERE date = ? AND is_value_bet = 1 AND odds >= ?
ORDER BY ml_score DESC
LIMIT ? OFFSET ?""",
(date_param, min_odds, limit, offset),
).fetchall()
from scoring_v2 import get_terrain_condition, compute_weather_impact
valuebets_list = []
for r in rows_raw:
row_dict = dict(r)
penetrometre = row_dict.pop("penetrometre_intitule", None) or ""
terrain_condition = (
get_terrain_condition(penetrometre) if penetrometre else "inconnu"
)
weather_data = None
if (
row_dict.get("nebulositecode") is not None
or row_dict.get("temperature") is not None
):
weather_data = {
"nebulositecode": row_dict.pop("nebulositecode", None),
"nebulosite_court": row_dict.pop("nebulosite_court", None),
"temperature": row_dict.pop("temperature", None),
"force_vent": row_dict.pop("force_vent", None),
}
else:
row_dict.pop("nebulositecode", None)
row_dict.pop("nebulosite_court", None)
row_dict.pop("temperature", None)
row_dict.pop("force_vent", None)
weather_impact = compute_weather_impact(weather_data, terrain_condition)
row_dict["terrain_condition"] = terrain_condition
row_dict["weather_impact"] = weather_impact
valuebets_list.append(row_dict)
valuebets_list = [dict(r) for r in rows]
pagination = paginate_query(valuebets_list, total, limit, offset) pagination = paginate_query(valuebets_list, total, limit, offset)
return jsonify( return jsonify(

View File

@@ -11,29 +11,34 @@ import re
from datetime import datetime from datetime import datetime
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db" DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
HEADERS = {'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json'} HEADERS = {"User-Agent": "Mozilla/5.0", "Accept": "application/json"}
def get_cote_from_db(horse_name, date_course): def get_cote_from_db(horse_name, date_course):
"""Recupere la cote depuis la table predictions (plus recente et non nulle)""" """Recupere la cote depuis la table predictions (plus recente et non nulle)"""
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
c = conn.execute(""" c = conn.execute(
"""
SELECT odds FROM predictions SELECT odds FROM predictions
WHERE date=? AND horse_name LIKE ? AND odds > 0 WHERE date=? AND horse_name LIKE ? AND odds > 0
ORDER BY created_at DESC LIMIT 1 ORDER BY created_at DESC LIMIT 1
""", (date_course, f"%{horse_name}%")) """,
(date_course, f"%{horse_name}%"),
)
r = c.fetchone() r = c.fetchone()
conn.close() conn.close()
return r['odds'] if r else 0 return r["odds"] if r else 0
def parse_musique(musique): def parse_musique(musique):
if not musique: if not musique:
return {} return {}
clean = re.sub(r'\(\d+\)', '', musique) clean = re.sub(r"\(\d+\)", "", musique)
resultats = re.findall(r'(\d+|D|0)([amphsc]?)', clean) resultats = re.findall(r"(\d+|D|0)([amphsc]?)", clean)
positions = [] positions = []
for pos, disc in resultats[:10]: for pos, disc in resultats[:10]:
positions.append(99 if pos == 'D' else int(pos)) positions.append(99 if pos == "D" else int(pos))
if not positions: if not positions:
return {} return {}
nb_courses = len(positions) nb_courses = len(positions)
@@ -41,29 +46,102 @@ def parse_musique(musique):
nb_places = sum(1 for p in positions if 1 <= p <= 3) nb_places = sum(1 for p in positions if 1 <= p <= 3)
recentes = [p for p in positions[:3] if p != 99] recentes = [p for p in positions[:3] if p != 99]
forme_recente = sum(recentes) / len(recentes) if recentes else 99 forme_recente = sum(recentes) / len(recentes) if recentes else 99
tendance = (sum(positions[-4:]) / 4 - sum(positions[:4]) / 4) if len(positions) >= 4 else 0 tendance = (
(sum(positions[-4:]) / 4 - sum(positions[:4]) / 4) if len(positions) >= 4 else 0
)
return { return {
'forme_recente': round(forme_recente, 1), "forme_recente": round(forme_recente, 1),
'tendance': round(tendance, 1), "tendance": round(tendance, 1),
'tx_victoire': round(nb_victoires / nb_courses * 100, 1) if nb_courses else 0, "tx_victoire": round(nb_victoires / nb_courses * 100, 1) if nb_courses else 0,
'tx_place': round(nb_places / nb_courses * 100, 1) if nb_courses else 0, "tx_place": round(nb_places / nb_courses * 100, 1) if nb_courses else 0,
} }
def score_cheval_v2(p, all_participants, today):
def get_terrain_condition(penetrometre_intitule: str | None) -> str:
"""Normalise le pénétromètre PMU en condition terrain standardisée."""
if not penetrometre_intitule:
return "inconnu"
val = penetrometre_intitule.upper()
if any(k in val for k in ("TRES BON", "TRÈS BON", "FERME", "FIRM")):
return "bon"
if any(k in val for k in ("BON", "GOOD", "STANDARD")):
return "bon"
if any(k in val for k in ("SOUPLE", "YIELDING", "COLLANT")):
return "souple"
if any(k in val for k in ("LOURD", "HEAVY", "TRES SOUPLE", "TRÈS SOUPLE")):
return "lourd"
if any(k in val for k in ("SOFT", "MOU")):
return "souple"
return "inconnu"
def compute_weather_impact(weather_data: dict | None, terrain_condition: str) -> float:
"""
Calcule un score d'impact météo/terrain sur [5, +5].
weather_data keys attendues : nebulositecode, temperature, force_vent
terrain_condition : 'bon' | 'souple' | 'lourd' | 'inconnu'
Retourne un delta de score ML (positif = favorable, négatif = défavorable).
"""
if not weather_data:
return 0.0
delta = 0.0
# Terrain
if terrain_condition == "lourd":
delta -= 3.0
elif terrain_condition == "souple":
delta -= 1.5
elif terrain_condition == "bon":
delta += 1.0
# inconnu → 0
# Vent
force_vent = weather_data.get("force_vent") or 0
try:
force_vent = float(force_vent)
except (TypeError, ValueError):
force_vent = 0.0
if force_vent >= 50:
delta -= 2.0
elif force_vent >= 30:
delta -= 1.0
# Températures extrêmes
temperature = weather_data.get("temperature")
try:
temperature = float(temperature) if temperature is not None else None
except (TypeError, ValueError):
temperature = None
if temperature is not None:
if temperature <= 0:
delta -= 1.0
elif temperature >= 35:
delta -= 1.0
return round(max(-5.0, min(5.0, delta)), 2)
def score_cheval_v2(p, all_participants, today, weather_data=None):
"""
Score un cheval pour le modèle V2.
weather_data (optionnel) : dict issu de pmu_meteo pour cette réunion.
Backward-compatible : weather_data=None → comportement identique à avant HRT-83.
"""
score = 0 score = 0
details = {} details = {}
# 1. COTE - Essaye PMU API, sinon DB # 1. COTE - Essaye PMU API, sinon DB
horse_name = p.get('nom', '') horse_name = p.get("nom", "")
cote = 0 cote = 0
# Essayer d'abord depuis l'API PMU # Essayer d'abord depuis l'API PMU
rapport = p.get('dernierRapportDirect', {}) rapport = p.get("dernierRapportDirect", {})
if rapport: if rapport:
cote = rapport.get('rapport', 0) cote = rapport.get("rapport", 0)
if not cote: if not cote:
rapport_ref = p.get('dernierRapportReference', {}) rapport_ref = p.get("dernierRapportReference", {})
cote = rapport_ref.get('rapport', 0) if rapport_ref else 0 cote = rapport_ref.get("rapport", 0) if rapport_ref else 0
# Fallback: aller chercher dans la DB # Fallback: aller chercher dans la DB
if not cote or cote == 0: if not cote or cote == 0:
@@ -75,94 +153,136 @@ def score_cheval_v2(p, all_participants, today):
score_cote = max(2, min(10, 20 / (1 + cote * 0.15))) if cote > 0 else 2 score_cote = max(2, min(10, 20 / (1 + cote * 0.15))) if cote > 0 else 2
score += score_cote score += score_cote
details['cote'] = round(cote, 1) details["cote"] = round(cote, 1)
details['score_cote'] = round(score_cote, 1) details["score_cote"] = round(score_cote, 1)
# 2. FORME - AUGMENTE a 30 pts # 2. FORME - AUGMENTE a 30 pts
musique_stats = parse_musique(p.get('musique', '')) musique_stats = parse_musique(p.get("musique", ""))
forme = musique_stats.get('forme_recente', 99) forme = musique_stats.get("forme_recente", 99)
score_forme = 30 if forme <= 1 else 25 if forme <= 2 else 20 if forme <= 3 else 15 if forme <= 5 else 8 if forme <= 8 else 0 score_forme = (
30
if forme <= 1
else 25
if forme <= 2
else 20
if forme <= 3
else 15
if forme <= 5
else 8
if forme <= 8
else 0
)
score += score_forme score += score_forme
details['forme_recente'] = forme details["forme_recente"] = forme
details['score_forme'] = score_forme details["score_forme"] = score_forme
# 3. TAUX VICTOIRE (15 pts) # 3. TAUX VICTOIRE (15 pts)
nb_courses_total = p.get('nombreCourses', 0) nb_courses_total = p.get("nombreCourses", 0)
nb_victoires_total = p.get('nombreVictoires', 0) nb_victoires_total = p.get("nombreVictoires", 0)
tx_vic = (nb_victoires_total / nb_courses_total * 100) if nb_courses_total else 0 tx_vic = (nb_victoires_total / nb_courses_total * 100) if nb_courses_total else 0
score_vic = min(15, tx_vic * 0.5) score_vic = min(15, tx_vic * 0.5)
score += score_vic score += score_vic
details['tx_victoire'] = round(tx_vic, 1) details["tx_victoire"] = round(tx_vic, 1)
details['score_victoire'] = round(score_vic, 1) details["score_victoire"] = round(score_vic, 1)
# 4. TAUX PLACE (15 pts) # 4. TAUX PLACE (15 pts)
nb_places_total = p.get('nombrePlaces', 0) nb_places_total = p.get("nombrePlaces", 0)
tx_place = (nb_places_total / nb_courses_total * 100) if nb_courses_total else 0 tx_place = (nb_places_total / nb_courses_total * 100) if nb_courses_total else 0
score_place = min(15, tx_place * 0.2) score_place = min(15, tx_place * 0.2)
score += score_place score += score_place
details['tx_place'] = round(tx_place, 1) details["tx_place"] = round(tx_place, 1)
details['score_place'] = round(score_place, 1) details["score_place"] = round(score_place, 1)
# 5. REDUCTION KM (10 pts) # 5. REDUCTION KM (10 pts)
rk = p.get('reductionKilometrique', 0) rk = p.get("reductionKilometrique", 0)
all_rk = [x.get('reductionKilometrique', 0) for x in all_participants if x.get('reductionKilometrique', 0) > 0] all_rk = [
x.get("reductionKilometrique", 0)
for x in all_participants
if x.get("reductionKilometrique", 0) > 0
]
if rk > 0 and all_rk: if rk > 0 and all_rk:
score_rk = 10 * (1 - (rk - min(all_rk)) / (max(all_rk) - min(all_rk))) if max(all_rk) > min(all_rk) else 5 score_rk = (
10 * (1 - (rk - min(all_rk)) / (max(all_rk) - min(all_rk)))
if max(all_rk) > min(all_rk)
else 5
)
else: else:
score_rk = 0 score_rk = 0
score += score_rk score += score_rk
details['rk'] = rk details["rk"] = rk
details['score_rk'] = round(score_rk, 1) details["score_rk"] = round(score_rk, 1)
# 6. TENDANCE (10 pts) # 6. TENDANCE (10 pts)
tendance = musique_stats.get('tendance', 0) tendance = musique_stats.get("tendance", 0)
score_tendance = min(10, max(0, 5 + tendance)) score_tendance = min(10, max(0, 5 + tendance))
score += score_tendance score += score_tendance
details['tendance'] = tendance details["tendance"] = tendance
details['score_tendance'] = round(score_tendance, 1) details["score_tendance"] = round(score_tendance, 1)
# 7. AVIS ENTRAINEUR (5 pts) # 7. AVIS ENTRAINEUR (5 pts)
avis = p.get('avisEntraineur', 'NEUTRE') avis = p.get("avisEntraineur", "NEUTRE")
score_avis = {'POSITIF': 5, 'TRES_POSITIF': 5, 'NEUTRE': 2, 'NEGATIF': 0, 'TRES_NEGATIF': 0}.get(avis, 2) score_avis = {
"POSITIF": 5,
"TRES_POSITIF": 5,
"NEUTRE": 2,
"NEGATIF": 0,
"TRES_NEGATIF": 0,
}.get(avis, 2)
score += score_avis score += score_avis
details['avis_entraineur'] = avis details["avis_entraineur"] = avis
details['score_avis'] = score_avis details["score_avis"] = score_avis
# 8. BONUS OUTSIDER (5 pts) # 8. BONUS OUTSIDER (5 pts)
bonus_outsider = 5 if forme <= 3 and cote >= 10 else 0 bonus_outsider = 5 if forme <= 3 and cote >= 10 else 0
score += bonus_outsider score += bonus_outsider
details['bonus_outsider'] = bonus_outsider details["bonus_outsider"] = bonus_outsider
# Driver change penalty # Driver change penalty
if p.get('driverChange', False): if p.get("driverChange", False):
score -= 3 score -= 3
details['driver_change'] = True details["driver_change"] = True
details['score_total'] = round(score, 1) # 9. METEO & TERRAIN (HRT-83) — premium feature, weather_data=None → skip
details['musique'] = p.get('musique', '') penetrometre = p.get("penetrometre_intitule", "") or ""
details['nb_victoires'] = nb_victoires_total terrain_condition = (
details['nb_places'] = nb_places_total get_terrain_condition(penetrometre) if penetrometre else "inconnu"
details['nb_courses'] = nb_courses_total )
weather_impact = 0.0
if weather_data is not None:
weather_impact = compute_weather_impact(weather_data, terrain_condition)
score += weather_impact
details["terrain_condition"] = terrain_condition
details["weather_impact"] = weather_impact
details["score_total"] = round(score, 1)
details["musique"] = p.get("musique", "")
details["nb_victoires"] = nb_victoires_total
details["nb_places"] = nb_places_total
details["nb_courses"] = nb_courses_total
return round(score, 1), details return round(score, 1), details
def get_ze2sur4_combinaisons(top4): def get_ze2sur4_combinaisons(top4):
combinaisons = [] combinaisons = []
for i in range(4): for i in range(4):
for j in range(i+1, 4): for j in range(i + 1, 4):
c1 = top4[i] c1 = top4[i]
c2 = top4[j] c2 = top4[j]
combinaisons.append({ combinaisons.append(
'cheval1': c1['nom'], {
'numero1': c1['numero'], "cheval1": c1["nom"],
'cheval2': c2['nom'], "numero1": c1["numero"],
'numero2': c2['numero'], "cheval2": c2["nom"],
'mise': 1.0, "numero2": c2["numero"],
}) "mise": 1.0,
}
)
return combinaisons return combinaisons
def build_recommendations_v2(scored_horses): def build_recommendations_v2(scored_horses):
ranked = sorted(scored_horses, key=lambda x: x['score'], reverse=True) ranked = sorted(scored_horses, key=lambda x: x["score"], reverse=True)
if len(ranked) < 4: if len(ranked) < 4:
return None return None
@@ -170,39 +290,58 @@ def build_recommendations_v2(scored_horses):
top4_list = ranked[:4] top4_list = ranked[:4]
def confiance(s): def confiance(s):
return "FORTE" if s >= 55 else "BONNE" if s >= 45 else "MOYENNE" if s >= 35 else "FAIBLE" return (
"FORTE"
if s >= 55
else "BONNE"
if s >= 45
else "MOYENNE"
if s >= 35
else "FAIBLE"
)
ze2_combinaisons = get_ze2sur4_combinaisons(top4_list) ze2_combinaisons = get_ze2sur4_combinaisons(top4_list)
mise_ze2 = len(ze2_combinaisons) * 1.0 mise_ze2 = len(ze2_combinaisons) * 1.0
return { return {
'simple_gagnant': { "simple_gagnant": {
'cheval': top1['nom'], 'numero': top1['numero'], 'cote': top1['details']['cote'], "cheval": top1["nom"],
'score': top1['score'], 'confiance': confiance(top1['score']), "numero": top1["numero"],
'mise_suggeree': 2.0, 'gain_potentiel': round(2.0 * top1['details']['cote'], 2) "cote": top1["details"]["cote"],
"score": top1["score"],
"confiance": confiance(top1["score"]),
"mise_suggeree": 2.0,
"gain_potentiel": round(2.0 * top1["details"]["cote"], 2),
}, },
'ze2_sur_4': { "ze2_sur_4": {
'top4': [{'nom': h['nom'], 'numero': h['numero']} for h in top4_list], "top4": [{"nom": h["nom"], "numero": h["numero"]} for h in top4_list],
'combinaisons': ze2_combinaisons, "combinaisons": ze2_combinaisons,
'mise_totale': mise_ze2, "mise_totale": mise_ze2,
'nb_combinaisons': len(ze2_combinaisons), "nb_combinaisons": len(ze2_combinaisons),
'confiance': confiance((top1['score'] + top2['score'] + top3['score'] + top4['score']) / 4), "confiance": confiance(
'explication': 'Jouer les 6 combinaisons de 2 chevaux parmi les 4 premiers' (top1["score"] + top2["score"] + top3["score"] + top4["score"]) / 4
),
"explication": "Jouer les 6 combinaisons de 2 chevaux parmi les 4 premiers",
}, },
'outsider': _find_outsider(ranked), "outsider": _find_outsider(ranked),
'budget_total': 2.0 + mise_ze2, "budget_total": 2.0 + mise_ze2,
} }
def _find_outsider(ranked): def _find_outsider(ranked):
for h in ranked[3:7]: for h in ranked[3:7]:
d = h['details'] d = h["details"]
if d['cote'] >= 12 and d['forme_recente'] <= 4 and d['bonus_outsider'] == 5: if d["cote"] >= 12 and d["forme_recente"] <= 4 and d["bonus_outsider"] == 5:
return { return {
'cheval': h['nom'], 'numero': h['numero'], 'cote': d['cote'], "cheval": h["nom"],
'mise_suggeree': 1.0, 'gain_potentiel': round(1.0 * d['cote'], 2) "numero": h["numero"],
"cote": d["cote"],
"mise_suggeree": 1.0,
"gain_potentiel": round(1.0 * d["cote"], 2),
} }
return None return None
def save_to_db(scored_horses, date_course, hippodrome, libelle): def save_to_db(scored_horses, date_course, hippodrome, libelle):
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor() cursor = conn.cursor()
@@ -210,44 +349,72 @@ def save_to_db(scored_horses, date_course, hippodrome, libelle):
cursor.execute("DELETE FROM scoring WHERE date = ?", (date_course,)) cursor.execute("DELETE FROM scoring WHERE date = ?", (date_course,))
for i, h in enumerate(scored_horses, 1): for i, h in enumerate(scored_horses, 1):
d = h['details'] d = h["details"]
cursor.execute(""" cursor.execute(
"""
INSERT INTO scoring (date, race_name, horse_number, horse_name, score, INSERT INTO scoring (date, race_name, horse_number, horse_name, score,
score_cote, score_forme, score_victoire, score_place, score_rk, score_cote, score_forme, score_victoire, score_place, score_rk,
score_tendance, score_avis, cote, forme_recente, tx_victoire, tx_place, score_tendance, score_avis, cote, forme_recente, tx_victoire, tx_place,
avis_entraineur, musique, rang_scoring, scoring_version) avis_entraineur, musique, rang_scoring, scoring_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'v2') VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'v2')
""", (date_course, libelle, h['numero'], h['nom'], h['score'], """,
d.get('score_cote', 0), d.get('score_forme', 0), d.get('score_victoire', 0), (
d.get('score_place', 0), d.get('score_rk', 0), d.get('score_tendance', 0), date_course,
d.get('score_avis', 0), d.get('cote', 0), d.get('forme_recente', 0), libelle,
d.get('tx_victoire', 0), d.get('tx_place', 0), d.get('avis_entraineur', ''), h["numero"],
d.get('musique', ''), i)) h["nom"],
h["score"],
d.get("score_cote", 0),
d.get("score_forme", 0),
d.get("score_victoire", 0),
d.get("score_place", 0),
d.get("score_rk", 0),
d.get("score_tendance", 0),
d.get("score_avis", 0),
d.get("cote", 0),
d.get("forme_recente", 0),
d.get("tx_victoire", 0),
d.get("tx_place", 0),
d.get("avis_entraineur", ""),
d.get("musique", ""),
i,
),
)
conn.commit() conn.commit()
conn.close() conn.close()
print(f"💾 {len(scored_horses)} scores enregistres en BDD pour {date_course}") print(f"💾 {len(scored_horses)} scores enregistres en BDD pour {date_course}")
def main(): def main():
today = datetime.now().strftime('%Y-%m-%d') today = datetime.now().strftime("%Y-%m-%d")
date_pmu = datetime.now().strftime('%d%m%Y') date_pmu = datetime.now().strftime("%d%m%Y")
print(f"=== SCORING V2 - ZE2 SUR4 OPTIMISE === {datetime.now().strftime('%d/%m/%Y %H:%M')} ===") print(
f"=== SCORING V2 - ZE2 SUR4 OPTIMISE === {datetime.now().strftime('%d/%m/%Y %H:%M')} ==="
)
try: try:
url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_pmu}/reunions" url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_pmu}/reunions"
r = requests.get(url, headers=HEADERS, timeout=15) r = requests.get(url, headers=HEADERS, timeout=15)
reunions = r.json().get('programme', {}).get('reunions', []) reunions = r.json().get("programme", {}).get("reunions", [])
except Exception as e: except Exception as e:
print(f"Erreur: {e}") print(f"Erreur: {e}")
return return
quinte = None quinte = None
for reunion in reunions: for reunion in reunions:
for course in reunion.get('courses', []): for course in reunion.get("courses", []):
paris_types = [p["typePari"] for p in course.get("paris", [])] paris_types = [p["typePari"] for p in course.get("paris", [])]
if any("QUINTE" in p for p in paris_types) or "PARIS-TURF" in course.get('libelle', ''): if any("QUINTE" in p for p in paris_types) or "PARIS-TURF" in course.get(
quinte = (reunion['numOfficiel'], course['numOrdre'], course.get('libelle', ''), "libelle", ""
reunion['hippodrome']['libelleCourt'], course.get('heureDepart', 0)) ):
quinte = (
reunion["numOfficiel"],
course["numOrdre"],
course.get("libelle", ""),
reunion["hippodrome"]["libelleCourt"],
course.get("heureDepart", 0),
)
break break
if quinte: if quinte:
break break
@@ -256,7 +423,8 @@ def main():
# Fallback: utiliser la premiere reunion francaise avec predictions # Fallback: utiliser la premiere reunion francaise avec predictions
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
r = conn.execute(""" r = conn.execute(
"""
SELECT r.num_reunion, r.hippodrome_court, c.num_course, c.libelle SELECT r.num_reunion, r.hippodrome_court, c.num_course, c.libelle
FROM pmu_courses c FROM pmu_courses c
JOIN pmu_reunions r ON r.date_programme=c.date_programme AND r.num_reunion=c.num_reunion JOIN pmu_reunions r ON r.date_programme=c.date_programme AND r.num_reunion=c.num_reunion
@@ -264,22 +432,36 @@ def main():
AND EXISTS (SELECT 1 FROM predictions p WHERE p.date=? AND p.source='canalturf_partants' AND EXISTS (SELECT 1 FROM predictions p WHERE p.date=? AND p.source='canalturf_partants'
AND p.race_name LIKE '%' || c.libelle || '%') AND p.race_name LIKE '%' || c.libelle || '%')
ORDER BY c.heure_depart_str ASC LIMIT 1 ORDER BY c.heure_depart_str ASC LIMIT 1
""", (today, today)).fetchone() """,
(today, today),
).fetchone()
conn.close() conn.close()
if r: if r:
quinte = (r['num_reunion'], r['num_course'], r['libelle'], r['hippodrome_court'], 0) quinte = (
r["num_reunion"],
r["num_course"],
r["libelle"],
r["hippodrome_court"],
0,
)
else: else:
print("Aucune course trouvee") print("Aucune course trouvee")
return return
num_r, num_c, libelle, hippodrome, heure_ts = quinte num_r, num_c, libelle, hippodrome, heure_ts = quinte
heure = datetime.fromtimestamp(heure_ts/1000).strftime('%H:%M') if heure_ts else '13:55' heure = (
datetime.fromtimestamp(heure_ts / 1000).strftime("%H:%M")
if heure_ts
else "13:55"
)
print(f"Course: {libelle} - {hippodrome} {heure}") print(f"Course: {libelle} - {hippodrome} {heure}")
try: try:
url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_pmu}/R{num_r}/C{num_c}/participants" url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_pmu}/R{num_r}/C{num_c}/participants"
r = requests.get(url, headers=HEADERS, timeout=15) r = requests.get(url, headers=HEADERS, timeout=15)
participants = [p for p in r.json().get('participants', []) if p.get('statut') == 'PARTANT'] participants = [
p for p in r.json().get("participants", []) if p.get("statut") == "PARTANT"
]
except Exception as e: except Exception as e:
print(f"Erreur: {e}") print(f"Erreur: {e}")
return return
@@ -287,34 +469,45 @@ def main():
scored_horses = [] scored_horses = []
for p in participants: for p in participants:
score, details = score_cheval_v2(p, participants, today) score, details = score_cheval_v2(p, participants, today)
scored_horses.append({'nom': p['nom'], 'numero': p['numPmu'], 'score': score, 'details': details}) scored_horses.append(
{"nom": p["nom"], "numero": p["numPmu"], "score": score, "details": details}
)
ranked = sorted(scored_horses, key=lambda x: x['score'], reverse=True) ranked = sorted(scored_horses, key=lambda x: x["score"], reverse=True)
print(f"\n=== TOP 4 ===") print(f"\n=== TOP 4 ===")
for i, h in enumerate(ranked[:4], 1): for i, h in enumerate(ranked[:4], 1):
d = h['details'] d = h["details"]
print(f"{i}. #{h['numero']:>2} {h['nom']:<20} Score:{h['score']:.1f} Cote:{d['cote']:.1f}") print(
f"{i}. #{h['numero']:>2} {h['nom']:<20} Score:{h['score']:.1f} Cote:{d['cote']:.1f}"
)
save_to_db(ranked, today, hippodrome, libelle) save_to_db(ranked, today, hippodrome, libelle)
reco = build_recommendations_v2(scored_horses) reco = build_recommendations_v2(scored_horses)
if reco: if reco:
print(f"\n=== RECOMMANDATIONS ===") print(f"\n=== RECOMMANDATIONS ===")
sg = reco['simple_gagnant'] sg = reco["simple_gagnant"]
print(f"\n🎯 SIMPLE GAGNANT:") print(f"\n🎯 SIMPLE GAGNANT:")
print(f" #{sg['numero']} {sg['cheval']} @ {sg['cote']}/1 (mise {sg['mise_suggeree']}EUR)") print(
f" #{sg['numero']} {sg['cheval']} @ {sg['cote']}/1 (mise {sg['mise_suggeree']}EUR)"
)
ze2 = reco['ze2_sur_4'] ze2 = reco["ze2_sur_4"]
print(f"\n🎰 ZE 2 SUR 4 (TOP 4: {', '.join([h['nom'] for h in ze2['top4']])}") print(f"\n🎰 ZE 2 SUR 4 (TOP 4: {', '.join([h['nom'] for h in ze2['top4']])}")
print(f" Mise totale: {ze2['mise_totale']}EUR ({ze2['nb_combinaisons']} combis x 1EUR)") print(
f" Mise totale: {ze2['mise_totale']}EUR ({ze2['nb_combinaisons']} combis x 1EUR)"
)
print(f" Confiance: {ze2['confiance']}") print(f" Confiance: {ze2['confiance']}")
print(f" Combinaisons:") print(f" Combinaisons:")
for c in ze2['combinaisons']: for c in ze2["combinaisons"]:
print(f" {c['numero1']}-{c['cheval1']} + {c['numero2']}-{c['cheval2']}") print(
f" {c['numero1']}-{c['cheval1']} + {c['numero2']}-{c['cheval2']}"
)
print(f"\n💰 BUDGET TOTAL: {reco['budget_total']}EUR") print(f"\n💰 BUDGET TOTAL: {reco['budget_total']}EUR")
print(f" - Simple Gagnant: 2EUR") print(f" - Simple Gagnant: 2EUR")
print(f" - ZE 2 sur 4: {ze2['mise_totale']}EUR") print(f" - ZE 2 sur 4: {ze2['mise_totale']}EUR")
if __name__ == "__main__": if __name__ == "__main__":
main() main()