- 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>
227 lines
7.7 KiB
Python
227 lines
7.7 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Predictions routes for API v1.
|
|
|
|
GET /api/v1/predictions/top3 — Top 3 global du jour (free tier, 1/day limit)
|
|
GET /api/v1/predictions/all — Toutes prédictions (premium+)
|
|
"""
|
|
|
|
from datetime import datetime, timedelta
|
|
from flask import Blueprint, jsonify, request
|
|
|
|
from api_v1.utils import (
|
|
get_db,
|
|
table_exists,
|
|
internal_error,
|
|
not_found,
|
|
get_pagination_params,
|
|
paginate_query,
|
|
)
|
|
from auth import jwt_required_middleware, plan_required, free_daily_limit_check
|
|
|
|
predictions_bp = Blueprint("v1_predictions", __name__, url_prefix="/api/v1/predictions")
|
|
|
|
|
|
def _fetch_ml_predictions(
|
|
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"):
|
|
return [], 0
|
|
|
|
count_row = conn.execute(
|
|
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
|
(date,),
|
|
).fetchone()
|
|
total = count_row["cnt"] if count_row else 0
|
|
|
|
if (
|
|
include_weather
|
|
and table_exists(conn, "pmu_meteo")
|
|
and table_exists(conn, "pmu_courses")
|
|
):
|
|
sql = """SELECT
|
|
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]
|
|
|
|
if limit is not None:
|
|
sql += " LIMIT ? OFFSET ?"
|
|
params += [limit, offset]
|
|
|
|
rows = conn.execute(sql, params).fetchall()
|
|
|
|
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
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# GET /api/v1/predictions/top3
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@predictions_bp.route("/top3", methods=["GET"])
|
|
@jwt_required_middleware
|
|
@free_daily_limit_check
|
|
def predictions_top3():
|
|
"""
|
|
Top 3 prédictions du jour
|
|
---
|
|
tags:
|
|
- Prédictions
|
|
summary: Top 3 chevaux avec le meilleur score ML du jour (free tier inclus)
|
|
security:
|
|
- Bearer: []
|
|
parameters:
|
|
- name: date
|
|
in: query
|
|
type: string
|
|
format: date
|
|
description: Date au format YYYY-MM-DD (défaut aujourd'hui)
|
|
responses:
|
|
200:
|
|
description: Top 3 prédictions ML du jour
|
|
401:
|
|
description: Token invalide
|
|
429:
|
|
description: Limite quotidienne free tier atteinte
|
|
"""
|
|
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
|
|
|
|
conn = get_db()
|
|
try:
|
|
predictions, _ = _fetch_ml_predictions(conn, date_param, limit=3, offset=0)
|
|
|
|
return jsonify(
|
|
{
|
|
"status": "ok",
|
|
"date": date_param,
|
|
"top3": predictions,
|
|
}
|
|
), 200
|
|
except Exception as e:
|
|
return internal_error(str(e))
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# GET /api/v1/predictions/all
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@predictions_bp.route("/all", methods=["GET"])
|
|
@jwt_required_middleware
|
|
@plan_required("premium", "pro")
|
|
def predictions_all():
|
|
"""
|
|
Toutes les prédictions du jour
|
|
---
|
|
tags:
|
|
- Prédictions
|
|
summary: Toutes les prédictions ML du jour — accès premium et pro uniquement
|
|
security:
|
|
- Bearer: []
|
|
parameters:
|
|
- name: date
|
|
in: query
|
|
type: string
|
|
format: date
|
|
description: Date au format YYYY-MM-DD (défaut aujourd'hui)
|
|
- name: limit
|
|
in: query
|
|
type: integer
|
|
default: 20
|
|
- name: offset
|
|
in: query
|
|
type: integer
|
|
default: 0
|
|
responses:
|
|
200:
|
|
description: Toutes les prédictions ML
|
|
401:
|
|
description: Token invalide
|
|
403:
|
|
description: Plan insuffisant (premium ou pro requis)
|
|
"""
|
|
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
|
|
limit, offset = get_pagination_params(default_limit=50, max_limit=500)
|
|
|
|
conn = get_db()
|
|
try:
|
|
predictions, total = _fetch_ml_predictions(
|
|
conn, date_param, limit=limit, offset=offset, include_weather=True
|
|
)
|
|
pagination = paginate_query(predictions, total, limit, offset)
|
|
|
|
return jsonify(
|
|
{
|
|
"status": "ok",
|
|
"date": date_param,
|
|
"predictions": predictions,
|
|
**pagination,
|
|
}
|
|
), 200
|
|
except Exception as e:
|
|
return internal_error(str(e))
|
|
finally:
|
|
conn.close()
|