Files
turf_saas/combined_api.py
DevOps Engineer 0e7bcff6b0 feat(ml): add ensemble XGBoost+LightGBM+MLP with Optuna optimization
- train_ensemble.py: full training pipeline with 100-trial Optuna studies
  for XGBoost and LightGBM, MLP (256-128-64), SHAP feature selection,
  weighted soft-voting ensemble, benchmark report generation
- predict_v2.py: production prediction module with model cache invalidation
- combined_api.py: add /api/v1/predictions, /api/v1/model/status,
  /api/v1/model/invalidate-cache endpoints using ensemble model
- tests/test_ml_ensemble.py: regression, latency and API tests

Baseline XGBoost Precision@3: 0.5287 (holdout 20% temporal)
Deploy threshold: +5% = 0.5551

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 18:18:48 +02:00

3815 lines
126 KiB
Python
Executable File

#!/usr/bin/env python3
"""Combined API - Turf + Ideas"""
from flask import Flask, jsonify, request, send_file, send_from_directory
from flask_cors import CORS
import sqlite3
import json
import os
import requests
from datetime import datetime, timedelta
app = Flask(__name__)
CORS(app, origins=["*"], methods=["GET", "POST", "OPTIONS"])
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
IDEAS_FILE = "/home/h3r7/boite_a_idees/idees.json"
# Basic Auth decorator
def require_auth(f):
from functools import wraps
from flask import request
@wraps(f)
def decorated(*args, **kwargs):
from flask import make_response
import base64
auth = request.headers.get("Authorization", "")
expected = "Basic " + base64.b64encode("h3r7:h3r7".encode("utf-8")).decode(
"utf-8"
)
if not auth or auth != expected:
return make_response("Unauthorized", 401)
return f(*args, **kwargs)
return decorated
LLM_API_KEY = os.environ.get(
"OPENROUTER_API_KEY",
os.environ.get("OPENAI_API_KEY", os.environ.get("1MINAI_API_KEY", "")),
)
LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://openrouter.ai/v1")
LLM_MODEL = os.environ.get("LLM_MODEL", "liquid/lfm-2.5-1.2b-instruct:free")
# === API KEYS ===
RESEND_API_KEY = os.environ.get("RESEND_API", "")
BRAVE_SEARCH_API_KEY = os.environ.get("BRAVE_SEARCH_API", "")
SQL_SCHEMA = """
Tables:
- pmu_partants: date_programme, num_reunion, num_course, num_pmu, id_cheval, nom, age, sexe, race, robe, pays, place_corde, nom_pere, nom_mere, nom_pere_mere, driver, driver_change, entraineur, proprietaire, eleveur, oeilleres, supplement, handicap_valeur, handicap_poids, musique, nombre_courses, nombre_victoires, nombre_places, cote_direct, cote_reference, tendance_cote, favoris, ordre_arrivee, tx_victoire, tx_place, forme_recente
- pmu_courses: date_programme, num_reunion, num_course, libelle, discipline, distance, hippodrome,px_type
Tables relations:
- pmu_partants.date_programme = pmu_courses.date_programme
- pmu_partants.num_reunion = pmu_courses.num_reunion
- pmu_partants.num_course = pmu_courses.num_course
Key fields:
- ordre_arrivee: 1=winner, 2=2nd, 3=3rd, 0=not finished
- favoris: 1 if favorite (cote < 5), 0 otherwise
- date_programme: YYYY-MM-DD format
- discipline: 'Plat', 'Trot', 'Attele', 'Monte'
"""
def generate_sql_with_llm(question: str) -> str:
"""Generate SQL query from natural language using LLM"""
if not LLM_API_KEY:
return None
try:
import litellm
litellm.drop_params = True
system_prompt = f"""Tu es un expert SQL. Génère SEULEMENT la requête SQL (pas d'explication).
Schéma STRICT:
- Table pmu_partants: date_programme, num_reunion, num_course, nom, driver, entraineur, cote_direct, favoris, ordre_arrivee, tx_victoire, tx_place, forme_recente
- Table pmu_courses: date_programme, num_reunion, num_course, libelle, discipline, distance, hippodrome
Règles OBLIGATOIRES:
- Utilise les noms de colonnes EXACTS ci-dessus
- Utilise date('now', '-X days') pour les dates relatives
- Pour "aujourd'hui", utilise date('now')
- Pour "hier", utilise date('now', '-1 day')
- Pour "cette semaine", utilise date('now', '-7 days')
- Pour "ce mois", utilise date('now', '-30 days')
- JOIN requis entre pmu_partants et pmu_courses
- Limite à 15 résultats
- Only SELECT queries allowed
Question: {question}
SQL:"""
response = litellm.completion(
model=f"openrouter/{LLM_MODEL}",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": question},
],
api_key=LLM_API_KEY,
max_tokens=300,
)
sql = response.choices[0].message.content.strip()
if sql.upper().startswith("SQL:"):
sql = sql[4:].strip()
if sql.upper().startswith("```SQL"):
sql = sql[6:].strip()
if sql.upper().startswith("```"):
sql = sql[3:].strip()
if sql.endswith("```"):
sql = sql[:-3].strip()
return sql if sql.upper().startswith("SELECT") else None
except Exception as e:
print(f"LLM SQL generation error: {e}")
return None
# === TURF API ===
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
@app.route("/")
def index():
return send_file("/home/h3r7/turf_saas/dashboard.html")
@app.route("/dashboard")
def dashboard():
return send_file("/home/h3r7/turf_saas/dashboard.html")
@app.route("/n8n-chat")
@app.route("/turf/n8n-chat")
def n8n_chat():
return send_file("/home/h3r7/turf_saas/n8n-chat.html")
@app.route("/turf/api/n8n-proxy", methods=["GET", "POST", "OPTIONS"])
@app.route("/api/n8n-proxy", methods=["GET", "POST", "OPTIONS"])
def n8n_proxy():
"""Proxy pour éviter CORS vers n8n interne"""
if request.method == "OPTIONS":
response = make_response("", 200)
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "Content-Type"
return response
data = request.json or {}
try:
r = requests.post(
"http://10.0.1.7:5678/webhook/openclaw",
json=data,
timeout=30,
headers={"Content-Type": "application/json"},
)
try:
return jsonify(r.json()), 200
except:
return jsonify({"message": r.text}), 200
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api")
@app.route("/turf/api")
@app.route("/api/today")
def api_today():
conn = get_db()
today = datetime.now().strftime("%Y-%m-%d")
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
# Parametre de selection des courses
race_filter = request.args.get("race", "") # Nom de la course ou vide pour toutes
data = {
"date": today,
"races": [],
"predictions": {},
"results": [],
"scores": {},
"weather": {},
}
# Construire la condition de filtre - si un nom de course est fourni, l'utiliser directement
if race_filter:
race_condition = "AND race_name = ?" # Utilise le nom exact de la course
race_params = (race_filter,)
else:
race_condition = ""
race_params = ()
# Filtre France uniquement - matcher avec LIKE sur nom hippodrome (désactivé car pas de données aujourd'hui)
# france_condition = "AND EXISTS (SELECT 1 FROM pmu_reunions r WHERE r.pays_code='FRA' AND r.date_programme=date AND (r.hippodrome_long LIKE '%' || race_hippodrome || '%' OR r.hippodrome_court LIKE '%' || race_hippodrome || '%' OR r.hippodrome_code LIKE '%' || race_hippodrome || '%'))"
france_condition = ""
# Filtre données suffisantes (min 10 partants)
min_partants_condition = "AND (SELECT COUNT(*) FROM predictions p2 WHERE p2.race_name=predictions.race_name AND p2.date=predictions.date AND p2.source='canalturf_partants') >= 10"
# Recuperer toutes les courses du jour
try:
# Get full race name format: R1 ANGERS - C1 Grand prix ... (13:55)
full_race_name = get_full_race_name(conn, today)
c = conn.execute(
f"""
SELECT DISTINCT
c.num_reunion,
c.num_course,
c.libelle,
c.libelle_court,
c.heure_depart_str,
r.hippodrome_court,
r.hippodrome_long,
r.nature
FROM pmu_courses c
JOIN pmu_reunions r
ON r.date_programme = c.date_programme AND r.num_reunion = c.num_reunion
WHERE c.date_programme=? {france_condition}
ORDER BY c.num_reunion ASC, c.num_course ASC
""",
(today,),
)
races = c.fetchall()
if not races:
c = conn.execute(
f"""
SELECT DISTINCT race_name, race_hippodrome, race_time
FROM predictions
WHERE date=? AND source='canalturf_partants' {france_condition}
ORDER BY race_time ASC
""",
(today,),
)
races = c.fetchall()
def infer_race_type(libelle="", libelle_court="", nature=""):
text = f"{libelle or ''} {libelle_court or ''} {nature or ''}".upper()
if "QUINTE" in text:
return "Quinté+"
if "TROT" in text:
return "Trot"
if "PLAT" in text:
return "Plat"
return (nature or "").strip().title()
def fmt_race(row):
if "num_reunion" not in row.keys():
race_name = row["race_name"] or ""
hippo = row["race_hippodrome"] or ""
time = row["race_time"] or ""
race_type = infer_race_type(race_name)
display = f"{hippo} - {race_name} - {time}".strip()
if race_type:
display = f"{display} : {race_type}"
return {
"name": race_name,
"filter": race_name,
"display_label": display,
"hippodrome": hippo,
"time": time,
"num_reunion": None,
"num_course": None,
"race_type": "",
}
num_reunion = row["num_reunion"]
num_course = row["num_course"]
hippo = row["hippodrome_court"] or row["hippodrome_long"] or ""
course = row["libelle"] or row["libelle_court"] or ""
time = row["heure_depart_str"] or ""
race_type = infer_race_type(
row["libelle"], row["libelle_court"], row["nature"]
)
display = f"R{num_reunion} {hippo} - {course} - {time}".strip(" -")
if race_type:
display = f"{display} : {race_type}"
return {
"name": row["libelle"] or course,
"filter": row["libelle"] or course,
"display_label": display,
"hippodrome": hippo,
"time": time,
"num_reunion": num_reunion,
"num_course": num_course,
"race_type": row["nature"] or "",
}
data["races"] = [fmt_race(r) for r in races]
# Première course comme course principale, ou course correspondant au filtre
if races:
selected_race = None
if race_filter:
import re
race_filter_name = race_filter
match = re.search(r"-\s+C\d+\s+(.+?)(?:\s*\(|$)", race_filter)
if match:
race_filter_name = match.group(1).strip()
selected_race = next(
(
fmt_race(r)
for r in races
if (r["libelle"] or "") == race_filter_name
or (r["libelle_court"] or "") == race_filter_name
),
None,
)
first_race = selected_race or fmt_race(races[0])
data["race"] = {
"name": first_race["display_label"] or full_race_name,
"display_label": first_race["display_label"] or full_race_name,
"filter_name": first_race["filter"],
"hippodrome": first_race["hippodrome"],
"time": first_race["time"],
"race_type": first_race["race_type"],
}
except Exception as e:
print(f"Erreur races: {e}")
data["race"] = {}
# Partants avec cotes — 1 ligne par cheval (dédoublonnage)
try:
if race_filter:
# Extract just the race name part (after time) for better matching
race_name_part = race_filter
# Try to extract the actual race name (after " - HH:MM ")
import re
match = re.search(r"-\s+\d{1,2}:\d{2}\s+(.+)$", race_filter)
if match:
race_name_part = match.group(1).strip()
c = conn.execute(
"""
SELECT horse_name, horse_number, AVG(odds) as odds, prediction_rank, jockey
FROM predictions
WHERE date=? AND source='canalturf_partants' AND odds > 0 AND (race_name LIKE ? OR ? LIKE '%' || race_name || '%')
GROUP BY horse_name
ORDER BY odds ASC
""",
(today, "%" + race_name_part + "%", race_filter),
)
else:
c = conn.execute(
"""
SELECT horse_name, horse_number, AVG(odds) as odds, prediction_rank, jockey
FROM predictions
WHERE date=? AND source='canalturf_partants' AND odds > 0
GROUP BY horse_name
ORDER BY odds ASC
""",
(today,),
)
data["predictions"]["partants"] = [dict(r) for r in c.fetchall()]
except:
data["predictions"]["partants"] = []
# Pronostic bases/chances/outsiders - aussi filtré France
for cat, src in [
("bases", "canalturf_prono_bases"),
("chances", "canalturf_prono_chances"),
("outsiders", "canalturf_prono_outsiders"),
]:
try:
if race_filter:
# Extract just the race name part (after time) for better matching
import re
match = re.search(r"-\s+\d{1,2}:\d{2}\s+(.+)$", race_filter)
race_name_part = race_filter
if match:
race_name_part = match.group(1).strip()
c = conn.execute(
"""
SELECT horse_name, horse_number, MIN(prediction_rank) as prediction_rank
FROM predictions WHERE date=? AND source=? AND (race_name LIKE ? OR ? LIKE '%' || race_name || '%')
GROUP BY horse_name
ORDER BY prediction_rank
""",
(today, src, "%" + race_name_part + "%", race_filter),
)
else:
c = conn.execute(
"""
SELECT horse_name, horse_number, MIN(prediction_rank) as prediction_rank
FROM predictions WHERE date=? AND source=?
GROUP BY horse_name
ORDER BY prediction_rank
""",
(today, src),
)
data["predictions"][cat] = [dict(r) for r in c.fetchall()]
except:
data["predictions"][cat] = []
# Resultats du jour - depuis pmu_partants (toutes les courses France terminées)
try:
# Add race filter if provided
race_condition = ""
race_params = [today]
if race_filter:
import re
match = re.search(r"-\s+\d{1,2}:\d{2}\s+(.+)$", race_filter)
race_name_part = race_filter
if match:
race_name_part = match.group(1).strip()
race_condition = "AND (c.libelle LIKE ? OR ? LIKE '%' || c.libelle || '%')"
race_params.append("%" + race_name_part + "%")
race_params.append(race_filter)
c = conn.execute(
f"""
SELECT
pp.nom as horse_name,
pp.ordre_arrivee as position,
pp.cote_direct as odds,
pr.num_reunion,
pr.hippodrome_court,
c.heure_depart_str,
c.libelle as race_name,
c.num_course
FROM pmu_partants pp
JOIN pmu_reunions pr ON pr.date_programme=pp.date_programme AND pr.num_reunion=pp.num_reunion
JOIN pmu_courses c ON c.date_programme=pp.date_programme AND c.num_reunion=pp.num_reunion AND c.num_course=pp.num_course
WHERE pp.date_programme=? AND pp.ordre_arrivee > 0 AND pp.ordre_arrivee <= 5
AND pr.pays_code='FRA' {race_condition}
ORDER BY c.heure_depart_str, pp.ordre_arrivee
""",
race_params,
)
results = c.fetchall()
if results:
# Grouper par reunion
current_reunion = None
grouped = []
for r in results:
rd = dict(r)
reunion_key = f"R{rd['num_reunion']} {rd['hippodrome_court']}"
rd["race_label"] = reunion_key
rd["race_type"] = rd["race_name"][:30] if rd["race_name"] else ""
grouped.append(rd)
data["results"] = grouped
else:
c = conn.execute(
"SELECT horse_name, position, odds FROM results WHERE date=? ORDER BY position LIMIT 5",
(today,),
)
data["results"] = [dict(r) for r in c.fetchall()]
except Exception as e:
print(f"Erreur résultats: {e}")
data["results"] = []
# Fallback: utiliser v_resultats_complets si results table est vide
if not data["results"]:
try:
c = conn.execute(
"""
SELECT date_programme as date, course as race_name, cheval as horse_name,
position_finale as position, cote_direct as odds,
num_reunion, num_course
FROM v_resultats_complets
WHERE date_programme=? AND position_finale IS NOT NULL AND position_finale > 0
ORDER BY course, position_finale
""",
(today,),
)
rows = c.fetchall()
if rows:
data["results"] = [dict(r) for r in rows]
except Exception as e:
print(f"Erreur fallback résultats: {e}")
# Fallback 2: afficher les résultats d'hier si pas de résultats aujourd'hui
if not data["results"]:
try:
c = conn.execute(
"""
SELECT date_programme as date, course as race_name, cheval as horse_name,
position_finale as position, cote_direct as odds,
num_reunion, num_course
FROM v_resultats_complets
WHERE date_programme=? AND position_finale IS NOT NULL AND position_finale > 0
ORDER BY course, position_finale
""",
(yesterday,),
)
rows = c.fetchall()
if rows:
data["results"] = [dict(r) for r in rows]
except:
pass
# Score hier
try:
c = conn.execute(
"SELECT horse_name FROM results WHERE date=? AND position<=3", (yesterday,)
)
result_names = [r[0] for r in c.fetchall()]
c = conn.execute(
"SELECT DISTINCT horse_name FROM predictions WHERE date=? AND source='canalturf_prono_bases'",
(yesterday,),
)
bases_yest = [r[0] for r in c.fetchall()]
score = sum(
1
for p in bases_yest
if any(
p.upper() in r.upper() or r.upper() in p.upper() for r in result_names
)
)
data["scores"] = {
"bases": f"{score}/{len(bases_yest)}" if bases_yest else "-",
"date": yesterday,
}
except:
data["scores"] = {}
# Weather
try:
c = conn.execute("SELECT * FROM weather ORDER BY id DESC LIMIT 1")
w = c.fetchone()
if w:
data["weather"] = dict(w)
except:
pass
conn.close()
return jsonify(data)
@app.route("/api/races")
@app.route("/turf/api/races")
def api_races():
"""Liste des courses du jour avec selection"""
conn = get_db()
today = datetime.now().strftime("%Y-%m-%d")
race_filter = request.args.get(
"filter", "quinte"
) # 'quinte', 'all', 'trot', 'plat'
if race_filter == "quinte":
condition = "AND (race_name LIKE '%Quinte%' OR race_name LIKE '%Quinté%')"
elif race_filter == "trot":
condition = "AND race_name LIKE '%TROT%'"
elif race_filter == "plat":
condition = "AND race_name LIKE '%PLAT%'"
else:
condition = ""
try:
c = conn.execute(
f"""
SELECT DISTINCT race_name, race_hippodrome, race_time
FROM predictions
WHERE date=? AND source='canalturf_partants' {condition}
ORDER BY race_time ASC
""",
(today,),
)
races = []
for r in c.fetchall():
# Compter les partants
c2 = conn.execute(
"""
SELECT COUNT(DISTINCT horse_name)
FROM predictions
WHERE date=? AND race_name=? AND source='canalturf_partants'
""",
(today, r[0]),
)
partants = c2.fetchone()[0]
races.append(
{
"name": r[0],
"hippodrome": r[1],
"time": r[2],
"partants": partants,
"is_quinte": "Quinte" in (r[0] or ""),
}
)
return jsonify({"date": today, "races": races, "filter": race_filter})
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
# === PREDICTIONS PAR COURSE ===
@app.route("/api/race/<race_name>")
@app.route("/turf/api/race/<race_name>")
def api_race_predictions(race_name):
"""Predictions pour une course specifique"""
conn = get_db()
today = datetime.now().strftime("%Y-%m-%d")
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
# Decoder le nom de la course
import urllib.parse
race_name = urllib.parse.unquote(race_name)
data = {
"date": today,
"race": {"name": race_name},
"predictions": {},
"results": [],
"scores": {},
}
# Infos de la course
try:
c = conn.execute(
"""
SELECT DISTINCT race_hippodrome, race_time
FROM predictions
WHERE date=? AND race_name=? AND source='canalturf_partants'
""",
(today, race_name),
)
row = c.fetchone()
if row:
data["race"]["hippodrome"] = row[0]
data["race"]["time"] = row[1]
except:
pass
# Partants
try:
c = conn.execute(
"""
SELECT horse_name, horse_number, AVG(odds) as odds, prediction_rank, jockey
FROM predictions
WHERE date=? AND race_name=? AND source='canalturf_partants' AND odds > 0
GROUP BY horse_name
ORDER BY odds ASC
""",
(today, race_name),
)
data["predictions"]["partants"] = [dict(r) for r in c.fetchall()]
except:
data["predictions"]["partants"] = []
# Pronostics
for cat, src in [
("bases", "canalturf_prono_bases"),
("chances", "canalturf_prono_chances"),
("outsiders", "canalturf_prono_outsiders"),
]:
try:
c = conn.execute(
"""
SELECT horse_name, horse_number, MIN(prediction_rank) as prediction_rank
FROM predictions
WHERE date=? AND race_name=? AND source=?
GROUP BY horse_name
ORDER BY prediction_rank
""",
(today, race_name, src),
)
data["predictions"][cat] = [dict(r) for r in c.fetchall()]
except:
data["predictions"][cat] = []
# Resultats
try:
c = conn.execute(
"""
SELECT horse_name, position, odds
FROM results
WHERE date=? AND race_name LIKE ?
ORDER BY position
""",
(today, f"%{race_name[:20]}%"),
)
data["results"] = [dict(r) for r in c.fetchall()]
except:
data["results"] = []
conn.close()
return jsonify(data)
# Partants avec cotes — 1 ligne par cheval (dédoublonnage)
try:
c = conn.execute(
"""
SELECT horse_name, horse_number, AVG(odds) as odds, prediction_rank, jockey
FROM predictions
WHERE date=? AND source='canalturf_partants' AND odds > 0
GROUP BY horse_name
ORDER BY odds ASC
""",
(today,),
)
data["predictions"]["partants"] = [dict(r) for r in c.fetchall()]
except:
data["predictions"]["partants"] = []
# Pronostic bases/chances/outsiders — 1 ligne par cheval
for cat, src in [
("bases", "canalturf_prono_bases"),
("chances", "canalturf_prono_chances"),
("outsiders", "canalturf_prono_outsiders"),
]:
try:
c = conn.execute(
"""
SELECT horse_name, horse_number, MIN(prediction_rank) as prediction_rank
FROM predictions WHERE date=? AND source=?
GROUP BY horse_name
ORDER BY prediction_rank
""",
(today, src),
)
data["predictions"][cat] = [dict(r) for r in c.fetchall()]
except:
data["predictions"][cat] = []
# Legacy ours (compatibilité)
try:
c = conn.execute(
"SELECT horse_name, odds, prediction_rank FROM predictions WHERE date=?",
(today,),
)
data["predictions"]["ours"] = [
{"horse_name": r[0], "odds": r[1], "prediction_rank": r[2]}
for r in c.fetchall()
]
except:
data["predictions"]["ours"] = []
# Résultats du jour
try:
c = conn.execute(
"SELECT horse_name, position, odds FROM results WHERE date=? ORDER BY position LIMIT 5",
(today,),
)
data["results"] = [dict(r) for r in c.fetchall()]
except:
data["results"] = []
# Score hier
try:
c = conn.execute(
"SELECT horse_name FROM results WHERE date=? AND position<=3", (yesterday,)
)
result_names = [r[0] for r in c.fetchall()]
c = conn.execute(
"SELECT DISTINCT horse_name FROM predictions WHERE date=? AND source='canalturf_prono_bases'",
(yesterday,),
)
bases_yest = [r[0] for r in c.fetchall()]
score = sum(
1
for p in bases_yest
if any(
p.upper() in r.upper() or r.upper() in p.upper() for r in result_names
)
)
data["scores"] = {
"bases": f"{score}/{len(bases_yest)}" if bases_yest else "-",
"date": yesterday,
}
except:
data["scores"] = {}
# Weather
try:
c = conn.execute("SELECT * FROM weather ORDER BY id DESC LIMIT 1")
w = c.fetchone()
if w:
data["weather"] = dict(w)
except:
pass
conn.close()
return jsonify(data)
@app.route("/api/odds_history")
@app.route("/turf/api/odds_history")
def api_odds_history():
conn = get_db()
today = datetime.now().strftime("%Y-%m-%d")
try:
c = conn.execute(
"""
SELECT horse_name, horse_number, odds, MIN(scraped_at) as scraped_at
FROM odds_history WHERE date=?
GROUP BY horse_name, DATE(scraped_at), SUBSTR(scraped_at, 12, 5)
ORDER BY horse_name, scraped_at ASC
""",
(today,),
)
rows = c.fetchall()
except:
conn.close()
return jsonify({"date": today, "horses": []})
horses = {}
for row in rows:
h = row["horse_name"]
if h not in horses:
horses[h] = {
"horse_name": h,
"horse_number": row["horse_number"],
"snapshots": [],
}
horses[h]["snapshots"].append(
{"odds": row["odds"], "time": row["scraped_at"][11:16]}
)
result = []
for h, data in horses.items():
snaps = data["snapshots"]
debut = snaps[0]["odds"] if snaps else 0
actuel = snaps[-1]["odds"] if snaps else 0
evol = (
round(((actuel - debut) / debut) * 100, 1)
if debut > 0 and len(snaps) > 1
else 0
)
result.append(
{
"horse_name": h,
"horse_number": data["horse_number"],
"odds_debut": debut,
"odds_actuel": actuel,
"evol_pct": evol,
"nb_snapshots": len(snaps),
"snapshots": snaps,
"tendance": "baisse"
if evol < -5
else "hausse"
if evol > 5
else "stable",
}
)
result.sort(key=lambda x: x["odds_actuel"])
conn.close()
return jsonify({"date": today, "horses": result})
@app.route("/api/weather")
def api_weather():
conn = get_db()
c = conn.execute("SELECT * FROM weather ORDER BY id DESC LIMIT 4")
weather = [dict(row) for row in c.fetchall()]
conn.close()
return jsonify(weather)
# === IDEAS API ===
def load_ideas():
if not os.path.exists(IDEAS_FILE):
os.makedirs(os.path.dirname(IDEAS_FILE), exist_ok=True)
default = {
"categories": [
{"id": "tech", "name": "Tech & IA", "color": "#7b2cbf"},
{"id": "saas", "name": "SaaS", "color": "#2ec4b6"},
{"id": "service", "name": "Service", "color": "#e94560"},
{"id": "produit", "name": "Produit", "color": "#ff9f1c"},
{"id": "invest", "name": "Investissement", "color": "#00d9ff"},
],
"ideas": [],
"next_id": 1,
}
with open(IDEAS_FILE, "w") as f:
json.dump(default, f, indent=2)
return default
with open(IDEAS_FILE, "r") as f:
return json.load(f)
def save_ideas(data):
os.makedirs(os.path.dirname(IDEAS_FILE), exist_ok=True)
with open(IDEAS_FILE, "w") as f:
json.dump(data, f, indent=2)
@app.route("/api/ideas")
def get_ideas():
return jsonify(load_ideas())
@app.route("/api/ideas", methods=["POST"])
def add_idea():
data = load_ideas()
new_idea = request.json
new_idea["id"] = data["next_id"]
new_idea["created"] = datetime.now().strftime("%Y-%m-%d")
data["ideas"].append(new_idea)
data["next_id"] += 1
save_ideas(data)
return jsonify({"success": True, "id": new_idea["id"]})
@app.route("/api/ideas/<int:idea_id>", methods=["GET"])
def get_idea(idea_id):
data = load_ideas()
for i in data["ideas"]:
if i["id"] == idea_id:
return jsonify(i)
return jsonify({"error": "Not found"}), 404
@app.route("/api/ideas/<int:idea_id>", methods=["PUT"])
def update_idea(idea_id):
data = load_ideas()
for i, idea in enumerate(data["ideas"]):
if idea["id"] == idea_id:
data["ideas"][i].update(request.json)
save_ideas(data)
return jsonify({"success": True})
return jsonify({"error": "Not found"}), 404
@app.route("/api/ideas/<int:idea_id>", methods=["DELETE"])
def delete_idea(idea_id):
data = load_ideas()
data["ideas"] = [i for i in data["ideas"] if i["id"] != idea_id]
save_ideas(data)
return jsonify({"success": True})
@app.route("/idees")
@app.route("/idees/")
def idees_page():
return send_from_directory("/home/h3r7/turf_saas", "idees_final.html")
@app.route("/H3R7Tech_logo.png")
@app.route("/turf/H3R7Tech_logo.png")
def serve_logo():
return send_from_directory("/home/h3r7/turf_saas", "H3R7Tech_logo.png")
@app.route("/turf/<path:filename>")
def serve_static_turf(filename):
if filename.endswith((".html", ".md", ".png", ".svg", ".jpg", ".css", ".js")):
return send_from_directory("/home/h3r7/turf_saas", filename)
return "Not found", 404
@app.route("/<path:filename>")
def serve_static(filename):
if filename.endswith((".html", ".md", ".png", ".svg", ".jpg")):
return send_from_directory("/home/h3r7/turf_saas", filename)
return "Not found", 404
def ensure_scoring_tables(conn):
conn.execute("""
CREATE TABLE IF NOT EXISTS scoring (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
race_name TEXT,
horse_name TEXT,
horse_number INTEGER,
score REAL,
score_cote REAL,
score_forme REAL,
score_victoire REAL,
score_place REAL,
score_rk REAL,
score_tendance REAL,
score_avis REAL,
cote REAL,
forme_recente REAL,
tx_victoire REAL,
tx_place REAL,
avis_entraineur TEXT,
musique TEXT,
rang_scoring INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS recommendations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
race_name TEXT,
type_pari TEXT,
cheval1 TEXT,
numero1 INTEGER,
cheval2 TEXT,
numero2 INTEGER,
cote REAL,
mise REAL,
gain_potentiel REAL,
confiance TEXT,
justification TEXT,
resultat TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
def build_scoring_fallback(conn, date_str):
try:
import sys
if "/home/h3r7/turf_saas" not in sys.path:
sys.path.insert(0, "/home/h3r7/turf_saas")
from scoring import build_recommendations, score_cheval
except Exception:
return [], {}, "R1C1"
def find_race(for_date):
return conn.execute(
"""
SELECT c.num_reunion, c.num_course, c.libelle, c.libelle_court, c.heure_depart_str
FROM pmu_courses c
LEFT JOIN pmu_reunions r
ON r.date_programme = c.date_programme AND r.num_reunion = c.num_reunion
WHERE c.date_programme = ? AND COALESCE(r.pays_code, 'FRA') = 'FRA'
ORDER BY
CASE WHEN UPPER(COALESCE(c.libelle, '')) LIKE '%QUINTE%' THEN 0 ELSE 1 END,
c.num_reunion,
c.num_course
LIMIT 1
""",
(for_date,),
).fetchone()
race = find_race(date_str)
if not race:
fallback_date = conn.execute(
"SELECT MAX(date_programme) FROM pmu_courses"
).fetchone()[0]
if not fallback_date:
fallback_date = conn.execute(
"SELECT MAX(date_programme) FROM pmu_partants"
).fetchone()[0]
if fallback_date:
date_str = fallback_date
race = find_race(date_str)
if not race:
return [], {}, "R1C1"
race_label = f"R{race['num_reunion']}C{race['num_course']}"
race_name = race["libelle"] or race["libelle_court"] or race_label
rows = conn.execute(
"""
SELECT p.num_pmu, p.nom, p.musique, p.nombre_courses, p.nombre_victoires,
p.nombre_places, p.cote_direct, p.cote_reference, p.driver_change,
p.entraineur, p.tx_victoire, p.tx_place, p.forme_recente,
p.oeilleres, c.distance, c.discipline, c.nb_declares_partants
FROM pmu_partants p
LEFT JOIN pmu_courses c
ON c.date_programme = p.date_programme
AND c.num_reunion = p.num_reunion
AND c.num_course = p.num_course
WHERE p.date_programme = ? AND p.num_reunion = ? AND p.num_course = ?
ORDER BY p.num_pmu
""",
(date_str, race["num_reunion"], race["num_course"]),
).fetchall()
participants = []
for row in rows:
r = dict(row)
participants.append(
{
"nom": r["nom"],
"numero": r["num_pmu"],
"musique": r.get("musique", ""),
"nombreCourses": r.get("nombre_courses", 0) or 0,
"nombreVictoires": r.get("nombre_victoires", 0) or 0,
"nombrePlaces": r.get("nombre_places", 0) or 0,
"reductionKilometrique": 0,
"avisEntraineur": "NEUTRE",
"driverChange": bool(r.get("driver_change", 0)),
"dernierRapportDirect": {"rapport": r.get("cote_direct", 0) or 0},
"dernierRapportReference": {
"rapport": r.get("cote_reference", 0)
or r.get("cote_direct", 0)
or 0
},
"partants": r.get("nb_declares_partants", 0) or 0,
"distance": r.get("distance", 0) or 0,
"discipline": r.get("discipline", "PLAT") or "PLAT",
}
)
if not participants:
return [], {}, race_label
scored_horses = []
for p in participants:
score, details = score_cheval(p, participants)
scored_horses.append(
{
"nom": p["nom"],
"numero": p["numero"],
"score": score,
"details": details,
}
)
scored_horses = sorted(scored_horses, key=lambda x: x["score"], reverse=True)
recos = build_recommendations(scored_horses)
scores = []
for rank, h in enumerate(scored_horses, start=1):
d = h["details"]
scores.append(
{
"race_label": race_label,
"horse_name": h["nom"],
"horse_number": h["numero"],
"score": h["score"],
"cote": d.get("cote", 0),
"forme_recente": d.get("forme_recente", 0),
"tx_victoire": d.get("tx_victoire", 0),
"avis_entraineur": d.get("avis_entraineur", "NEUTRE"),
"musique": d.get("musique", ""),
"rang_scoring": rank,
}
)
return scores, recos, race_name
@app.route("/api/scoring")
@app.route("/turf/api/scoring")
def api_scoring():
conn = get_db()
today = datetime.now().strftime("%Y-%m-%d")
race_filter = request.args.get("race", "").strip()
ensure_scoring_tables(conn)
def infer_race_type(libelle="", libelle_court="", nature=""):
text = f"{libelle or ''} {libelle_court or ''} {nature or ''}".upper()
if "QUINTE" in text:
return "Quinté+"
if "TROT" in text:
return "Trot"
if "PLAT" in text:
return "Plat"
return (nature or "").strip().title()
def format_race_label(row):
reunion = row["num_reunion"]
course = row["num_course"]
hippodrome = row["hippodrome_court"] or row["hippodrome_long"] or ""
name = row["libelle"] or row["libelle_court"] or ""
time = row["heure_depart_str"] or ""
race_type = infer_race_type(row["libelle"], row["libelle_court"], row["nature"])
label = f"R{reunion} {hippodrome} - {name} - {time}".strip(" -")
if race_type:
label = f"{label} : {race_type}"
return {
"race_label": f"R{reunion}C{course}",
"full_race_name": label,
"race_type": race_type,
"race_time": time,
"hippodrome": hippodrome,
"name": name,
}
race_info = None
try:
if race_filter:
race_info = conn.execute(
"""
SELECT c.num_reunion, c.num_course, c.libelle, c.libelle_court, c.heure_depart_str,
r.hippodrome_court, r.hippodrome_long, r.nature
FROM pmu_courses c
JOIN pmu_reunions r ON r.date_programme=c.date_programme AND r.num_reunion=c.num_reunion
WHERE c.date_programme=? AND (
c.libelle = ? OR c.libelle_court = ? OR c.libelle LIKE ? OR c.libelle_court LIKE ?
)
ORDER BY c.heure_depart_str ASC, c.num_reunion ASC, c.num_course ASC
LIMIT 1
""",
(
today,
race_filter,
race_filter,
f"%{race_filter}%",
f"%{race_filter}%",
),
).fetchone()
if not race_info:
# Essayer de trouver la course qui correspond au scoring du jour
scored_race = conn.execute(
"SELECT race_name FROM scoring WHERE date=? LIMIT 1", (today,)
).fetchone()
if scored_race and scored_race["race_name"]:
race_name_db = scored_race["race_name"]
race_info = conn.execute(
"""
SELECT c.num_reunion, c.num_course, c.libelle, c.libelle_court, c.heure_depart_str,
r.hippodrome_court, r.hippodrome_long, r.nature
FROM pmu_courses c
JOIN pmu_reunions r ON r.date_programme=c.date_programme AND r.num_reunion=c.num_reunion
WHERE c.date_programme=? AND (
c.libelle = ? OR c.libelle LIKE ? OR c.libelle_court LIKE ?
)
LIMIT 1
""",
(today, race_name_db, f"%{race_name_db}%", f"%{race_name_db}%"),
).fetchone()
if not race_info:
race_info = conn.execute(
"""
SELECT c.num_reunion, c.num_course, c.libelle, c.libelle_court, c.heure_depart_str,
r.hippodrome_court, r.hippodrome_long, r.nature
FROM pmu_courses c
JOIN pmu_reunions r ON r.date_programme=c.date_programme AND r.num_reunion=c.num_reunion
WHERE c.date_programme=? AND COALESCE(r.pays_code, 'FRA') = 'FRA'
ORDER BY c.heure_depart_str ASC, c.num_reunion ASC, c.num_course ASC
LIMIT 1
""",
(today,),
).fetchone()
except Exception:
race_info = None
if race_info:
race_meta = format_race_label(race_info)
race_label = race_meta["race_label"]
full_race_name = race_meta["full_race_name"]
else:
race_label = "R1C1"
full_race_name = "R1C1"
# Scores du jour
try:
c = conn.execute(
"""
SELECT horse_name, horse_number, score, COALESCE(cote,0) as cote, forme_recente,
tx_victoire, avis_entraineur, musique, rang_scoring
FROM scoring WHERE date=?
ORDER BY rang_scoring ASC
""",
(today,),
)
scores = [dict(r) for r in c.fetchall()]
# Add race label and full race name to each score
for s in scores:
s["race_label"] = race_label
s["full_race_name"] = full_race_name
except:
scores = []
if not scores:
scores, recos, fallback_race_name = build_scoring_fallback(conn, today)
if scores and fallback_race_name:
for s in scores:
s["race_label"] = fallback_race_name
s["full_race_name"] = (
full_race_name if full_race_name != "R1C1" else fallback_race_name
)
else:
# Recommandations du jour - Focus B4/B3 (ZE5)
recos = generate_ze5_recommendations(conn, today)
# Sauvegarder les recommandations ZE5 dans la DB
save_ze5_recommendations(conn, today, recos)
# Ajouter les recommandations depuis la table recommendations
try:
c = conn.execute(
"""
SELECT type_pari, cheval1, numero1, cheval2, numero2,
cote, mise, gain_potentiel, confiance, justification
FROM recommendations WHERE date=?
ORDER BY type_pari
""",
(today,),
)
for r in c.fetchall():
recos[r["type_pari"]] = dict(r)
except:
pass
conn.close()
return jsonify(
{
"date": today,
"scores": scores,
"recommendations": recos,
"full_race_name": full_race_name,
"race_label": race_label,
}
)
def generate_ze5_recommendations(conn, today):
"""
Génère des recommandations ZE5 avec focus B4/B3
Stratégie: Bases + Chances + 1 Outsider = bonne couverture pour B4/B3
"""
recos = {}
try:
# Récupérer nos prédictions
bases = [
r["horse_name"]
for r in conn.execute(
"""
SELECT DISTINCT horse_name FROM predictions
WHERE date=? AND source='canalturf_prono_bases'
ORDER BY prediction_rank LIMIT 2
""",
(today,),
).fetchall()
]
chances = [
r["horse_name"]
for r in conn.execute(
"""
SELECT DISTINCT horse_name FROM predictions
WHERE date=? AND source='canalturf_prono_chances'
ORDER BY prediction_rank LIMIT 3
""",
(today,),
).fetchall()
]
outsiders = [
r["horse_name"]
for r in conn.execute(
"""
SELECT DISTINCT horse_name FROM predictions
WHERE date=? AND source='canalturf_prono_outsiders'
ORDER BY prediction_rank LIMIT 2
""",
(today,),
).fetchall()
]
if not bases or not chances:
return recos
# Get horse numbers too
def get_horse_num(name):
row = conn.execute(
"SELECT horse_number FROM predictions WHERE date=? AND horse_name=? LIMIT 1",
(today, name),
).fetchone()
return str(row[0]) if row else ""
# Format: "NUMERO NOM"
def format_horse(name):
num = get_horse_num(name)
return f"{num} {name}" if num else name
# Stratégie B4/B3: 4-5 chevaux couvrant bases + chances
all_horses = bases[:2] + chances[:2]
if outsiders:
all_horses.append(outsiders[0])
if len(all_horses) >= 4:
combo = "-".join([format_horse(h) for h in all_horses[:5]])
recos["ze5_b4b3"] = {
"type_pari": "ZE5 B4/B3",
"cheval1": combo,
"cote": 15.0,
"mise": 3,
"gain_potentiel": 45,
"confiance": "haute",
"justification": "Bases + Chances + Outsider. Optimise pour B4/B3",
"strategy": "b4b3",
}
# ZE5 Conservateur: Top 2 bases + Top 2 chances
if len(bases) >= 1 and len(chances) >= 2:
combo_list = [
format_horse(bases[0]),
format_horse(chances[0]),
format_horse(chances[1]),
]
if len(bases) > 1:
combo_list.append(format_horse(bases[1]))
else:
combo_list.append(format_horse(chances[0]))
combo_conservative = "-".join(combo_list)
recos["ze5_conservateur"] = {
"type_pari": "ZE5 Conservateur",
"cheval1": combo_conservative,
"cote": 8.0,
"mise": 3,
"gain_potentiel": 24,
"confiance": "moyenne-haute",
"justification": "Favori + 2 Chances - Plus securise",
"strategy": "conservateur",
}
# ZE5 Audacieux: outsiders + 1 base
if len(outsiders) >= 2 and bases:
combo_audacieux_list = [
format_horse(bases[0]),
format_horse(outsiders[0]),
format_horse(outsiders[1]),
]
if chances:
combo_audacieux_list.append(format_horse(chances[0]))
combo_audacieux = "-".join(combo_audacieux_list)
recos["ze5_audacieux"] = {
"type_pari": "ZE5 Audacieux",
"cheval1": combo_audacieux,
"cote": 50.0,
"mise": 1,
"gain_potentiel": 50,
"confiance": "basse",
"justification": "Base + 2 Outsiders + Chance. Rapport élevé mais moins probable",
"strategy": "audacieux",
}
# ZE4 (équivalent Multi): Top 4 bases + chances
# Trouver les 4 premiers
if len(bases) >= 2 and len(chances) >= 2:
ze4_horses = [
format_horse(bases[0]),
format_horse(bases[1]),
format_horse(chances[0]),
format_horse(chances[1]),
]
combo_ze4 = "-".join(ze4_horses)
recos["ze4"] = {
"type_pari": "ZE4 (Multi 4)",
"cheval1": combo_ze4,
"cote": 20.0,
"mise": 3,
"gain_potentiel": 60,
"confiance": "haute",
"justification": "Top 2 Bases + Top 2 Chances. Trouver 4 premiers",
"strategy": "ze4",
}
except Exception as e:
print(f"Erreur génération recommandations: {e}")
return recos
def get_full_race_name(conn, date):
"""Construit le nom complet de la course: R1 MARSEILLE BORELY - 13h55 Grand National du Trot - Prix ..."""
# Try to get from pmu_reunions first
r = conn.execute(
"""
SELECT r.num_reunion, r.hippodrome_court, c.heure_depart_str, c.libelle
FROM pmu_courses c
JOIN pmu_reunions r ON r.date_programme=c.date_programme AND r.num_reunion=c.num_reunion
WHERE c.date_programme=? AND r.pays_code='FRA' AND c.num_course=1
ORDER BY r.num_reunion
LIMIT 1
""",
(date,),
).fetchone()
if r:
return f"R{r[0]} {r[1]} - {r[2]} {r[3]}"
# Fallback: get from predictions table
r2 = conn.execute(
"""
SELECT DISTINCT race_name, race_hippodrome, race_time
FROM predictions
WHERE date=? AND source='canalturf_partants'
ORDER BY race_time ASC
LIMIT 1
""",
(date,),
).fetchone()
if r2:
return f"{r2[1]} - {r2[2]} {r2[0]}"
return "Course du jour"
def save_ze5_recommendations(conn, today, recos):
"""Sauvegarde les recommandations ZE5 dans la base de données"""
try:
race_name = get_full_race_name(conn, today)
saved = 0
for key, r in recos.items():
if not (key.startswith("ze5") or key == "ze4"):
continue
conn.execute(
"""
INSERT OR REPLACE INTO recommendations
(date, race_name, type_pari, cheval1, numero1, cote, mise, gain_potentiel, confiance, justification, scoring_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
today,
race_name,
r["type_pari"],
r["cheval1"],
0,
r.get("cote", 0),
r.get("mise", 0),
r.get("gain_potentiel", 0),
r.get("confiance", ""),
r.get("justification", ""),
key,
),
)
saved += 1
conn.commit()
print(
f"{len([k for k in recos if k.startswith('ze5')])} recommandations ZE5 sauvegardées"
)
except Exception as e:
print(f"Erreur sauvegarde ZE5: {e}")
# === ML PREDICTIONS ===
import pickle
import pandas as pd
import numpy as np
from sklearn.preprocessing import LabelEncoder
ml_models = None
MODEL_PATH = "/home/h3r7/turf_saas/xgboost_models.pkl"
def load_models():
global ml_models
if ml_models is None and os.path.exists(MODEL_PATH):
try:
with open(MODEL_PATH, "rb") as f:
ml_models = pickle.load(f)
except:
ml_models = False
return ml_models
def table_exists(conn, table_name):
c = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table_name,)
)
return c.fetchone() is not None
def ensure_analytics_tables():
conn = sqlite3.connect(DB_PATH)
tables = [
(
"bet_results",
"""
CREATE TABLE IF NOT EXISTS bet_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT,
race_name TEXT,
type_pari TEXT,
horse_name TEXT,
horse_number INTEGER,
cote REAL,
mise REAL,
resultat TEXT,
gain REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""",
),
(
"daily_stats",
"""
CREATE TABLE IF NOT EXISTS daily_stats (
date TEXT PRIMARY KEY,
total_bets INTEGER,
bets_gagne INTEGER,
bets_perdu INTEGER,
mise_totale REAL,
gain_total REAL,
precision_pct REAL,
roi_pct REAL
)
""",
),
(
"stats_by_type",
"""
CREATE TABLE IF NOT EXISTS stats_by_type (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT,
type_pari TEXT,
total_bets INTEGER,
gagne INTEGER,
perdu INTEGER,
mise_totale REAL,
gain_total REAL,
precision_pct REAL,
roi_pct REAL
)
""",
),
(
"race_scores",
"""
CREATE TABLE IF NOT EXISTS race_scores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT,
race_name TEXT,
race_time TEXT,
hippodrome TEXT,
top5_cotes TEXT,
top5_cotes_hits INTEGER,
top5_bc TEXT,
top5_bc_hits INTEGER,
top5_bo TEXT,
top5_bo_hits INTEGER,
results_top5 TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""",
),
(
"paris",
"""
CREATE TABLE IF NOT EXISTS paris (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date_pari TEXT NOT NULL,
date_course TEXT NOT NULL,
race_name TEXT,
race_label TEXT,
hippodrome TEXT,
type_pari TEXT,
chevaux TEXT,
cheval1 TEXT,
numero1 INTEGER,
cheval2 TEXT,
numero2 INTEGER,
cheval3 TEXT,
numero3 INTEGER,
cote REAL,
mise REAL DEFAULT 1.0,
statut TEXT DEFAULT 'EN_ATTENTE',
gain REAL DEFAULT 0,
commentaire TEXT,
source_reco TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""",
),
]
for name, ddl in tables:
conn.execute(ddl)
conn.close()
ensure_analytics_tables()
def load_ml_horses(conn, today):
"""Load horses for ML predictions, with a fallback when historical_data is absent."""
course_info = {}
c = conn.execute(
"""
SELECT num_reunion, num_course, libelle, libelle_court, discipline, distance, heure_depart_str
FROM pmu_courses
WHERE date_programme = ?
ORDER BY num_reunion, num_course
""",
(today,),
)
for row in c.fetchall():
course_info[f"{row['num_reunion']}_{row['num_course']}"] = dict(row)
if table_exists(conn, "historical_data"):
c = conn.execute(
"""
SELECT DISTINCT p.horse_name, p.horse_number, p.odds,
h.age, h.sexe, h.nb_courses, h.nb_victoires, h.nb_places,
h.tx_victoire, h.tx_place, h.forme_recente, h.reduction_km,
h.gains_annee, h.cote_directe, h.distance, h.discipline,
h.avis_entraineur, h.oeilleres, h.deferre, h.nb_partants,
h.rang_cote, h.ratio_cote_field, h.musique
FROM predictions p
LEFT JOIN historical_data h ON h.horse_name = p.horse_name
WHERE p.date = ? AND p.source = 'canalturf_partants' AND p.odds > 0
""",
(today,),
)
horses = [dict(row) for row in c.fetchall()]
return today, horses, course_info
def fetch_fallback(date_str):
c = conn.execute(
"""
SELECT
p.date_programme AS date,
p.num_reunion,
p.num_course,
p.num_pmu AS horse_number,
p.nom AS horse_name,
p.age,
p.sexe,
p.musique,
p.nombre_courses AS nb_courses,
p.nombre_victoires AS nb_victoires,
p.nombre_places AS nb_places,
p.gains_annee_en_cours AS gains_annee,
COALESCE(p.cote_direct, 0) AS cote_directe,
COALESCE(c.distance, 0) AS distance,
COALESCE(c.discipline, 'PLAT') AS discipline,
COALESCE(c.nb_declares_partants, 0) AS nb_partants,
COALESCE(p.oeilleres, 'SANS_OEILLERES') AS oeilleres,
COALESCE(p.tx_victoire, 0) AS tx_victoire,
COALESCE(p.tx_place, 0) AS tx_place,
COALESCE(p.forme_recente, 0) AS forme_recente,
0 AS reduction_km,
'NEUTRE' AS avis_entraineur,
'NON' AS deferre,
0 AS rang_cote,
0 AS ratio_cote_field
FROM pmu_partants p
LEFT JOIN pmu_courses c
ON c.date_programme = p.date_programme
AND c.num_reunion = p.num_reunion
AND c.num_course = p.num_course
WHERE p.date_programme = ?
ORDER BY p.num_reunion, p.num_course, p.num_pmu
""",
(date_str,),
)
horses = [dict(row) for row in c.fetchall()]
return horses
horses = fetch_fallback(today)
if horses:
return today, horses, course_info
c = conn.execute("SELECT MAX(date_programme) FROM pmu_partants")
fallback_date = c.fetchone()[0]
if fallback_date:
return fallback_date, fetch_fallback(fallback_date), {}
return today, [], {}
def enrich_ml_horses(horses):
"""Fill missing ML fields and derive odds-based features."""
races = {}
for horse in horses:
race_key = (
horse.get("date") or horse.get("date_programme"),
horse.get("num_reunion"),
horse.get("num_course"),
)
races.setdefault(race_key, []).append(horse)
for group in races.values():
odds_values = []
for horse in group:
raw_odds = horse.get("odds", horse.get("cote_directe", 0))
try:
odds = float(raw_odds or 0)
except (TypeError, ValueError):
odds = 0.0
horse["odds"] = odds
horse["cote_directe"] = float(horse.get("cote_directe", odds) or odds or 0)
if odds > 0:
odds_values.append(odds)
avg_odds = sum(odds_values) / len(odds_values) if odds_values else 0
ranked = sorted(
group, key=lambda h: h.get("odds", h.get("cote_directe", 0)) or 999999
)
for idx, horse in enumerate(ranked, start=1):
horse.setdefault("horse_number", horse.get("num_pmu"))
horse.setdefault("horse_name", horse.get("nom"))
horse.setdefault("age", 0)
horse.setdefault("sexe", "U")
horse.setdefault("nb_courses", 0)
horse.setdefault("nb_victoires", 0)
horse.setdefault("nb_places", 0)
horse.setdefault("tx_victoire", 0)
horse.setdefault("tx_place", 0)
horse.setdefault("forme_recente", 0)
horse.setdefault("reduction_km", 0)
horse.setdefault("gains_annee", 0)
horse.setdefault("distance", 0)
horse.setdefault("discipline", "PLAT")
horse.setdefault("avis_entraineur", "NEUTRE")
horse.setdefault("oeilleres", "SANS")
horse.setdefault("deferre", "NON")
horse.setdefault("nb_partants", len(group))
horse.setdefault("musique", "")
horse.setdefault("rang_cote", idx)
if not horse.get("ratio_cote_field"):
horse["ratio_cote_field"] = (
round(horse.get("odds", 0) / avg_odds, 3) if avg_odds > 0 else 0
)
return horses
@app.route("/api/ml_predictions")
@app.route("/turf/api/ml_predictions")
def api_ml_predictions():
models = load_models()
if not models:
return jsonify({"error": "Models not loaded"})
conn = get_db()
today = datetime.now().strftime("%Y-%m-%d")
date_used, horses, course_info = load_ml_horses(conn, today)
horses = enrich_ml_horses(horses)
if not horses:
conn.close()
return jsonify(
{
"date": date_used,
"predictions": [],
"message": "No predictions available",
}
)
feature_cols = [
"age",
"sexe_enc",
"nb_courses",
"nb_victoires",
"nb_places",
"tx_victoire",
"tx_place",
"forme_recente",
"reduction_km",
"gains_annee",
"cote_directe",
"distance",
"nb_partants",
"discipline_enc",
"avis_enc",
"oeilleres_enc",
"deferre_enc",
"form_1",
"form_2",
"form_3",
"form_4",
"form_5",
"form_avg",
"win_rate_adj",
"place_rate_adj",
"implied_prob",
"victories_per_race",
"places_per_race",
"earnings_per_race",
"age_win_interact",
"distance_cat",
"is_favorite",
"rang_cote",
"ratio_cote_field",
]
all_sexes = set(h.get("sexe", "U") or "U" for h in horses)
all_avis = set(h.get("avis_entraineur", "NEUTRE") or "NEUTRE" for h in horses)
all_oeilleres = set(h.get("oeilleres", "SANS") or "SANS" for h in horses)
all_deferre = set(h.get("deferre", "NON") or "NON" for h in horses)
all_discipline = set(h.get("discipline", "PLAT") or "PLAT" for h in horses)
le_sexe = LabelEncoder()
le_sexe.fit(list(all_sexes) + ["U"])
le_avis = LabelEncoder()
le_avis.fit(list(all_avis) + ["NEUTRE"])
le_oeilleres = LabelEncoder()
le_oeilleres.fit(list(all_oeilleres) + ["SANS"])
le_deferre = LabelEncoder()
le_deferre.fit(list(all_deferre) + ["NON"])
le_discipline = LabelEncoder()
le_discipline.fit(list(all_discipline) + ["PLAT"])
predictions = []
import re
for horse in horses:
features = {}
for col in [
"age",
"nb_courses",
"nb_victoires",
"nb_places",
"tx_victoire",
"tx_place",
"forme_recente",
"reduction_km",
"gains_annee",
"cote_directe",
"distance",
"nb_partants",
"rang_cote",
"ratio_cote_field",
]:
features[col] = float(horse.get(col, 0) or 0)
features["sexe_enc"] = le_sexe.transform([horse.get("sexe", "U") or "U"])[0]
features["avis_enc"] = le_avis.transform(
[horse.get("avis_entraineur", "NEUTRE") or "NEUTRE"]
)[0]
features["oeilleres_enc"] = le_oeilleres.transform(
[horse.get("oeilleres", "SANS") or "SANS"]
)[0]
features["deferre_enc"] = le_deferre.transform(
[horse.get("deferre", "NON") or "NON"]
)[0]
features["discipline_enc"] = le_discipline.transform(
[horse.get("discipline", "PLAT") or "PLAT"]
)[0]
musique = horse.get("musique", "")
form_nums = re.findall(r"\d+", str(musique))[:5]
for i, fn in enumerate(form_nums):
features[f"form_{i + 1}"] = float(fn)
for i in range(len(form_nums) + 1, 6):
features[f"form_{i}"] = 0.0
features["form_avg"] = sum(features[f"form_{i}"] for i in range(1, 6)) / 5
features["implied_prob"] = (
1 / features["cote_directe"] if features["cote_directe"] > 0 else 0
)
features["win_rate_adj"] = features["tx_victoire"] * np.log1p(
features["nb_courses"]
)
features["place_rate_adj"] = features["tx_place"] * np.log1p(
features["nb_courses"]
)
features["victories_per_race"] = features["nb_victoires"] / max(
features["nb_courses"], 1
)
features["places_per_race"] = features["nb_places"] / max(
features["nb_courses"], 1
)
features["earnings_per_race"] = features["gains_annee"] / max(
features["nb_courses"], 1
)
features["age_win_interact"] = features["age"] * features["tx_victoire"]
features["distance_cat"] = (
2.0
if 1500 < features["distance"] <= 2000
else (3.0 if 2000 < features["distance"] <= 2500 else 1.0)
)
features["is_favorite"] = 1 if features["cote_directe"] < 5 else 0
try:
X = pd.DataFrame([features])[feature_cols]
X = X.fillna(0)
prob_top1 = float(models["model_top1"].predict_proba(X)[0][1])
prob_top3 = float(models["model_top3"].predict_proba(X)[0][1])
predictions.append(
{
"horse_name": horse["horse_name"],
"horse_number": horse["horse_number"],
"odds": float(horse["odds"]),
"prob_top1": round(prob_top1 * 100, 1),
"prob_top3": round(prob_top3 * 100, 1),
"ml_score": round((prob_top1 * 0.6 + prob_top3 * 0.4) * 100, 1),
"recommendation": "top1"
if prob_top1 > 0.15
else ("top3" if prob_top3 > 0.35 else "pass"),
"is_value_bet": 1
if (prob_top3 > 0.35 and float(horse.get("odds", 0)) > 10)
else 0,
"is_outlier": 1
if (
float(horse.get("odds", 0)) <= 5
and (prob_top1 < 0.1 and prob_top3 < 0.25)
)
else 0,
"num_reunion": horse.get("num_reunion"),
"num_course": horse.get("num_course"),
}
)
except Exception as e:
predictions.append(
{
"horse_name": horse["horse_name"],
"horse_number": horse["horse_number"],
"odds": float(horse["odds"]),
"error": str(e),
}
)
predictions.sort(key=lambda x: x.get("ml_score", 0), reverse=True)
for pred in predictions:
course_key = f"{pred.get('num_reunion', 1)}_{pred.get('num_course', 1)}"
if course_key in course_info:
cinfo = course_info[course_key]
pred["race_label"] = (
f"R{pred.get('num_reunion', 1)}C{pred.get('num_course', 1)}"
)
pred["race_name"] = cinfo.get("libelle", "")
pred["hippodrome"] = cinfo.get("libelle_court", "")
pred["discipline"] = cinfo.get("discipline", "")
pred["distance"] = cinfo.get("distance", 0)
pred["heure"] = cinfo.get("heure_depart_str", "")
conn.close()
return jsonify(
{
"date": date_used,
"model_version": "xgboost_v1",
"predictions": predictions,
"courses": course_info,
}
)
# === VITESSE ===
@app.route("/api/vitesse")
@app.route("/turf/api/vitesse")
def api_vitesse():
try:
conn = get_db()
today = datetime.now().strftime("%Y-%m-%d")
categories = ["bases", "chances", "outsiders"]
result = {"date": today, "predictions": {}}
for category in categories:
c = conn.execute(
f"""
SELECT horse_name, horse_number
FROM predictions
WHERE date=? AND source=? AND horse_name IS NOT NULL
GROUP BY horse_name
ORDER BY MIN(prediction_rank)
""",
(today, f"canalturf_prono_{category}"),
)
horses = []
for row in c.fetchall():
horse_name = row[0]
horse_number = row[1]
latest = conn.execute(
"""
SELECT odds, odds_prev, jockey, created_at
FROM predictions
WHERE date = ? AND horse_name = ? AND source = 'canalturf_partants'
ORDER BY created_at DESC LIMIT 1
""",
(today, horse_name),
).fetchone()
pmu = conn.execute(
"""
SELECT forme_recente, tx_victoire, tx_place, nombre_courses
FROM pmu_partants
WHERE nom LIKE ? || '%'
ORDER BY date_programme DESC
LIMIT 1
""",
(horse_name.split()[0],),
).fetchone()
if latest:
odds_change = ""
if latest[1] and latest[0]:
diff = latest[0] - latest[1]
if diff < 0:
odds_change = " 🔻"
elif diff > 0:
odds_change = " 🔺"
if pmu and pmu[0] is not None:
forme_str = f"{pmu[0]:.1f}" if pmu[0] else "N/A"
speed_info = {
"avg_time_ms": None,
"races": pmu[3] or 0,
"avg_time_formatted": f"F:{forme_str} | {pmu[1]:.0f}%V/{pmu[2]:.0f}%P",
"odds": latest[0],
"odds_prev": latest[1],
"jockey": latest[2] or "",
"forme": pmu[0],
"tx_victoire": pmu[1],
"tx_place": pmu[2],
"source": "predictions+pmu",
}
else:
trend = (
f" ({latest[1]:.1f}{latest[0]:.1f})" if latest[1] else ""
)
speed_info = {
"avg_time_ms": None,
"races": 0,
"avg_time_formatted": f"🚴 {latest[2][:12] if latest[2] else ''} | {latest[0]:.1f}{odds_change}",
"odds": latest[0],
"odds_prev": latest[1],
"jockey": latest[2] or "",
"source": "predictions",
}
else:
speed_info = {
"avg_time_ms": None,
"races": 0,
"avg_time_formatted": "",
"source": "none",
}
horses.append(
{
"horse_name": horse_name,
"horse_number": horse_number,
"speed_info": speed_info,
}
)
result["predictions"][category] = horses
conn.close()
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e), "status": "error"}), 500
# === BACKTEST ===
from datetime import timedelta
def calculate_backtest_data(start_date=None, end_date=None):
if not start_date:
start_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
if not end_date:
end_date = datetime.now().strftime("%Y-%m-%d")
conn = get_db()
query = """
SELECT
r.date,
r.race_name,
r.type_pari,
r.cheval1,
r.cote,
r.mise,
r.resultat
FROM recommendations r
WHERE r.resultat IS NOT NULL
AND r.resultat != ''
AND r.date BETWEEN ? AND ?
"""
try:
cursor = conn.execute(query, (start_date, end_date))
rows = cursor.fetchall()
except sqlite3.OperationalError as e:
conn.close()
return {
"summary": {
"total_bets": 0,
"message": "Données backtest indisponibles",
"error": str(e),
}
}
if not rows:
conn.close()
return {"summary": {"total_bets": 0, "message": "Aucune donnée"}}
stats = {
"total_bets": len(rows),
"gagne": 0,
"perdu": 0,
"mise_totale": 0,
"gain_total": 0,
}
details = []
by_type = {}
for date, race_name, type_pari, cheval1, cote, mise, resultat in rows:
mise = float(mise or 1)
cote = float(cote or 1)
stats["mise_totale"] += mise
if type_pari not in by_type:
by_type[type_pari] = {"count": 0, "gagne": 0, "mise": 0, "gain": 0}
by_type[type_pari]["count"] += 1
by_type[type_pari]["mise"] += mise
if resultat == "GAGNE":
stats["gagne"] += 1
stats["gain_total"] += mise * cote
by_type[type_pari]["gagne"] += 1
by_type[type_pari]["gain"] += mise * cote
else:
stats["perdu"] += 1
details.append(
{
"date": date,
"race_name": (race_name or "")[:30],
"type_pari": type_pari,
"cheval": cheval1,
"cote": cote,
"mise": mise,
"resultat": resultat,
"gain": mise * cote if resultat == "GAGNE" else 0,
}
)
roi = (
((stats["gain_total"] - stats["mise_totale"]) / stats["mise_totale"] * 100)
if stats["mise_totale"] > 0
else 0
)
precision = (
(stats["gagne"] / stats["total_bets"] * 100) if stats["total_bets"] > 0 else 0
)
# Par type
by_type_results = {}
for pari_type, data in by_type.items():
pari_roi = (
((data["gain"] - data["mise"]) / data["mise"] * 100)
if data["mise"] > 0
else 0
)
pari_precision = (
(data["gagne"] / data["count"] * 100) if data["count"] > 0 else 0
)
by_type_results[pari_type] = {
"count": data["count"],
"gagne": data["gagne"],
"mise": round(data["mise"], 2),
"gain": round(data["gain"], 2),
"roi": round(pari_roi, 1),
"precision": round(pari_precision, 1),
}
conn.close()
return {
"period": {"start": start_date, "end": end_date},
"summary": {
"total_bets": stats["total_bets"],
"gagne": stats["gagne"],
"perdu": stats["perdu"],
"precision": round(precision, 1),
"mise_totale": round(stats["mise_totale"], 2),
"gain_total": round(stats["gain_total"], 2),
"roi": round(roi, 1),
},
"by_type": by_type_results,
"details": details[-50:],
}
@app.route("/api/backtest")
@app.route("/turf/api/backtest")
def api_backtest():
"""Backtest - lit depuis la base de données"""
start = request.args.get("start")
end = request.args.get("end")
if not start:
start = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
if not end:
end = datetime.now().strftime("%Y-%m-%d")
conn = get_db()
# Récupérer les détails depuis bet_results
cursor = conn.execute(
"""
SELECT b.date, b.race_name, b.type_pari,
b.horse_name, b.horse_number,
COALESCE(b.cote, 0) as cote,
b.mise, b.resultat, b.gain
FROM bet_results b
WHERE b.date BETWEEN ? AND ?
ORDER BY b.date DESC, b.id DESC
LIMIT 50
""",
(start, end),
)
rows = cursor.fetchall()
# Add race_label to each row
details = []
import re
for r in rows:
d = dict(r)
race_label = "R1C1"
if d.get("race_name") and "R" in str(d["race_name"]):
match = re.search(r"R(\d+)C(\d+)", str(d["race_name"]))
if match:
race_label = f"R{match.group(1)}C{match.group(2)}"
d["race_label"] = race_label
details.append(d)
if not details:
conn.close()
return jsonify({"summary": {"total_bets": 0, "message": "Aucune donnée"}})
# Calculer résumé depuis la DB
cursor = conn.execute(
"""
SELECT
COUNT(*) as total,
SUM(CASE WHEN resultat = 'GAGNE' THEN 1 ELSE 0 END) as gagne,
SUM(mise) as mise,
SUM(gain) as gain
FROM bet_results
WHERE date BETWEEN ? AND ?
""",
(start, end),
)
row = cursor.fetchone()
total, gagne, mise, gain = row
roi = ((gain - mise) / mise * 100) if mise > 0 else 0
precision = (gagne / total * 100) if total > 0 else 0
# Par type
cursor = conn.execute(
"""
SELECT
type_pari,
COUNT(*) as total,
SUM(CASE WHEN resultat = 'GAGNE' THEN 1 ELSE 0 END) as gagne,
SUM(mise) as mise,
SUM(gain) as gain
FROM bet_results
WHERE date BETWEEN ? AND ?
GROUP BY type_pari
""",
(start, end),
)
by_type = {}
for row in cursor.fetchall():
type_pari, total, gagne, mise, gain = row
pari_roi = ((gain - mise) / mise * 100) if mise > 0 else 0
pari_precision = (gagne / total * 100) if total > 0 else 0
by_type[type_pari] = {
"count": total,
"gagne": gagne,
"mise": round(mise or 0, 2),
"gain": round(gain or 0, 2),
"roi": round(pari_roi, 1),
"precision": round(pari_precision, 1),
}
# Details - already prepared above with race_label
conn.close()
return jsonify(
{
"period": {"start": start, "end": end},
"summary": {
"total_bets": total,
"gagne": gagne,
"perdu": total - gagne,
"precision": round(precision, 1),
"mise_totale": round(mise or 0, 2),
"gain_total": round(gain or 0, 2),
"roi": round(roi, 1),
},
"by_type": by_type,
"details": details,
}
)
@app.route("/api/stats")
@app.route("/turf/api/stats")
def api_stats():
"""Statistiques quotidiennes - lit depuis la base"""
days = int(request.args.get("days", 30))
end_date = datetime.now().strftime("%Y-%m-%d")
start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
conn = get_db()
# Lire depuis daily_stats
if not table_exists(conn, "daily_stats"):
conn.close()
return jsonify({"daily": [], "period": {"start": start_date, "end": end_date}})
cursor = conn.execute(
"""
SELECT date, total_bets, bets_gagne, bets_perdu, mise_totale, gain_total, precision_pct, roi_pct
FROM daily_stats
WHERE date BETWEEN ? AND ?
ORDER BY date DESC
""",
(start_date, end_date),
)
rows = cursor.fetchall()
daily = []
for row in rows:
daily.append(
{
"date": row[0],
"total_bets": row[1],
"gagne": row[2],
"perdu": row[3],
"precision": row[6],
"mises": row[4],
"gains": row[5],
"roi": row[7],
}
)
conn.close()
return jsonify({"daily": daily, "period": {"start": start_date, "end": end_date}})
def generate_sql_from_question(question):
"""Generate SQL query from natural language question - keyword fallback first, then LLM"""
q = question.lower()
# Keyword-based queries - prioritize common questions
keyword_patterns = [
("taux", "favori"),
("taux", "victoire"),
("taux", "reussite"),
("jockey",),
("driver",),
("entraineur",),
("aujourd",),
("hier",),
("programme",),
("resultat",),
("resultats",),
("arrivee",),
("gagn", "victoire", "vainqueur"),
("cote", "evolu"),
("cote", "chang"),
("cote", "vari"),
("roi",),
("profit",),
("gain",),
("distance",),
("vincennes",),
("performances",),
("statistiques",),
("historique",),
]
# Check if question matches keyword patterns
is_keyword_query = any(
all(kw in q for kw in pattern) for pattern in keyword_patterns
)
# Try keyword first for common questions
if is_keyword_query:
sql = generate_sql_from_keywords(question, q)
if sql:
return sql
# Try LLM only for complex/niche questions
sql = generate_sql_with_llm(question)
if sql:
return sql
# Final fallback to keyword
return generate_sql_from_keywords(question, q)
def generate_sql_from_keywords(question, q):
"""Generate SQL from keywords - extracted from generate_sql_from_question"""
# Today's races
# Today's races (or most recent if no today)
if "aujourd" in q or "aujourd'hui" in q or "ce jour" in q or "programme" in q:
return """SELECT c.libelle as course, p.nom as cheval, p.cote_direct as cote, p.favoris, c.discipline, p.date_programme
FROM pmu_partants p
JOIN pmu_courses c ON p.date_programme = c.date_programme AND p.num_reunion = c.num_reunion AND p.num_course = c.num_course
WHERE p.date_programme >= date('now', '-1 day')
ORDER BY p.date_programme DESC, c.num_course, p.cote_direct
LIMIT 15"""
# Today's results (arrived horses)
if "resultat" in q or "résultat" in q or "arrivee" in q:
return """SELECT c.libelle as course, c.discipline, c.distance,
p.nom as cheval, p.ordre_arrivee as position, p.cote_direct as cote, p.driver
FROM pmu_partants p
JOIN pmu_courses c ON p.date_programme = c.date_programme AND p.num_reunion = c.num_reunion AND p.num_course = c.num_course
WHERE p.ordre_arrivee > 0 AND p.date_programme >= date('now', '-2 days')
ORDER BY p.date_programme DESC, c.num_course, p.ordre_arrivee
LIMIT 20"""
# Yesterday's results
if "hier" in q and ("resultat" in q or "résultat" in q):
return """SELECT c.libelle as course, p.nom as cheval, p.ordre_arrivee as position, p.cote_direct as cote, c.discipline
FROM pmu_partants p
JOIN pmu_courses c ON p.date_programme = c.date_programme AND p.num_reunion = c.num_reunion AND p.num_course = c.num_course
WHERE p.date_programme = date('now', '-1 day') AND p.ordre_arrivee > 0 AND p.ordre_arrivee <= 3
ORDER BY c.num_course, p.ordre_arrivee
LIMIT 15"""
# Winners today
if "gagn" in q or "victoire" in q or "vainqueur" in q:
return """SELECT c.libelle as course, p.nom as cheval, p.cote_direct as cote
FROM pmu_partants p
JOIN pmu_courses c ON p.date_programme = c.date_programme AND p.num_reunion = c.num_reunion AND p.num_course = c.num_course
WHERE p.date_programme >= date('now', '-3 days') AND p.ordre_arrivee = 1
ORDER BY p.date_programme DESC, c.num_course
LIMIT 10"""
# Favorites win rate
if "taux" in q and ("favori" in q or "favoris" in q):
return """SELECT
COUNT(*) as total_races,
SUM(CASE WHEN ordre_arrivee = 1 THEN 1 ELSE 0 END) as wins,
ROUND(CAST(SUM(CASE WHEN ordre_arrivee <= 3 THEN 1 ELSE 0 END) AS FLOAT) / NULLIF(SUM(CASE WHEN favoris = 1 THEN 1 ELSE 0 END), 0) * 100, 1) as podium_rate,
ROUND(CAST(SUM(CASE WHEN ordre_arrivee = 1 THEN 1 ELSE 0 END) AS FLOAT) / NULLIF(SUM(CASE WHEN favoris = 1 THEN 1 ELSE 0 END), 0) * 100, 1) as win_rate
FROM pmu_partants
WHERE favoris = 1 AND ordre_arrivee > 0 AND date_programme >= date('now', '-7 days')"""
# Odds changes
if "cote" in q and ("evolu" in q or "chang" in q or "vari" in q):
return """SELECT date_programme, nom as cheval, cote_direct, cote_reference, tendance_cote
FROM pmu_partants
WHERE date_programme >= date('now', '-2 days')
ORDER BY date_programme DESC, ABS(cote_direct - cote_reference) DESC
LIMIT 10"""
if "taux" in q and "favori" in q:
return """SELECT
COUNT(*) as total_races,
SUM(CASE WHEN ordre_arrivee = 1 THEN 1 ELSE 0 END) as wins,
ROUND(CAST(SUM(CASE WHEN ordre_arrivee <= 3 THEN 1 ELSE 0 END) AS FLOAT) / NULLIF(SUM(CASE WHEN favoris = 1 THEN 1 ELSE 0 END), 0) * 100, 1) as podium_rate,
ROUND(CAST(SUM(CASE WHEN ordre_arrivee = 1 THEN 1 ELSE 0 END) AS FLOAT) / NULLIF(SUM(CASE WHEN favoris = 1 THEN 1 ELSE 0 END), 0) * 100, 1) as win_rate
FROM pmu_partants
WHERE favoris = 1 AND ordre_arrivee > 0 AND date_programme >= date('now', '-7 days')"""
# ROI calculation
if "roi" in q or "profit" in q or "gain" in q:
return """SELECT
date_programme,
COUNT(*) as nb_partants,
SUM(CASE WHEN ordre_arrivee = 1 THEN (cote_direct - 1) ELSE -1 END) as profit_total,
ROUND(AVG(CASE WHEN ordre_arrivee = 1 THEN (cote_direct - 1) ELSE -1 END), 2) as profit_moyen,
SUM(CASE WHEN ordre_arrivee = 1 THEN 1 ELSE 0 END) as nb_victoires,
ROUND(CAST(SUM(CASE WHEN ordre_arrivee = 1 THEN 1 ELSE 0 END) AS FLOAT) / COUNT(*) * 100, 1) as tx_victoire
FROM pmu_partants
WHERE ordre_arrivee > 0 AND date_programme >= date('now', '-30 days')
GROUP BY date_programme
ORDER BY date_programme DESC
LIMIT 15"""
if "jockey" in q or "driver" in q or "driver" in q:
return """SELECT
driver as jockey,
COUNT(*) as total_races,
SUM(CASE WHEN ordre_arrivee = 1 THEN 1 ELSE 0 END) as victories,
ROUND(CAST(SUM(CASE WHEN ordre_arrivee <= 3 THEN 1 ELSE 0 END) AS FLOAT) / COUNT(*) * 100, 1) as podium_rate
FROM pmu_partants
WHERE driver IS NOT NULL AND driver != '' AND ordre_arrivee > 0
GROUP BY driver
ORDER BY victories DESC
LIMIT 10"""
if "entraineur" in q:
return """SELECT
entraineur,
COUNT(*) as total_races,
SUM(CASE WHEN ordre_arrivee = 1 THEN 1 ELSE 0 END) as victories,
ROUND(CAST(SUM(CASE WHEN ordre_arrivee <= 3 THEN 1 ELSE 0 END) AS FLOAT) / COUNT(*) * 100, 1) as podium_rate
FROM pmu_partants
WHERE entraineur IS NOT NULL AND entraineur != '' AND ordre_arrivee > 0
GROUP BY entraineur
ORDER BY victories DESC
LIMIT 10"""
if "distance" in q and "vincennes" in q:
return """SELECT
c.discipline,
COUNT(*) as races,
ROUND(AVG(c.distance), 0) as avg_distance
FROM pmu_courses c
WHERE c.libelle LIKE '%Vincennes%'
GROUP BY c.discipline
ORDER BY races DESC"""
# Horse name extraction - try various patterns
if any(
kw in q
for kw in ["performances", "statistiques", "historique", "cheval", "stats"]
):
import re
# Match sequences of uppercase words (horse names are all caps in DB)
# Looking for patterns like "TORTISAMBERT" or "TORTISAMBERT Z"
match = re.search(r"\b([A-Z]{4,}(?:\s+[A-Z]{4,})?)\b", question)
if match:
horse = match.group(1).strip()
return (
"""SELECT
nom as cheval,
COUNT(*) as total_races,
SUM(CASE WHEN ordre_arrivee = 1 THEN 1 ELSE 0 END) as victoires,
ROUND(AVG(cote_direct), 1) as cote_moyenne,
ROUND(CAST(SUM(CASE WHEN ordre_arrivee <= 3 THEN 1 ELSE 0 END) AS FLOAT) / COUNT(*) * 100, 1) as taux_place
FROM pmu_partants
WHERE nom LIKE ? AND ordre_arrivee > 0
GROUP BY nom""",
(f"%{horse}%",),
)
# Default: recent races with results
return """SELECT c.libelle as course, c.discipline, p.nom as cheval,
p.cote_direct as cote, p.favoris, p.ordre_arrivee as position
FROM pmu_partants p
JOIN pmu_courses c ON p.date_programme = c.date_programme
AND p.num_reunion = c.num_reunion AND p.num_course = c.num_course
WHERE p.date_programme >= date('now', '-3 days')
ORDER BY p.date_programme DESC, c.num_course, p.ordre_arrivee
LIMIT 15"""
@app.route("/turf/api/execute-sql", methods=["POST", "OPTIONS"])
@app.route("/api/execute-sql", methods=["POST", "OPTIONS"])
@app.route("/turf/api/ask", methods=["GET"])
@app.route("/api/ask", methods=["GET"])
def execute_sql():
"""Endpoint pour exécuter des requêtes SQL ou recevoir des questions via GET"""
if request.method == "OPTIONS":
return "", 200
# Pour GET, générer la requête SQL depuis la question
if request.method == "GET":
question = request.args.get("question", "")
result = generate_sql_from_question(question)
if not result:
return jsonify({"error": "No question provided"}), 400
if isinstance(result, tuple):
query, sql_params = result
else:
query, sql_params = result, ()
else:
data = request.get_json()
query = data.get("query", "") if data else ""
sql_params = ()
if not query:
return jsonify({"error": "No query provided"}), 400
# Limiter les requêtes SELECT uniquement pour la sécurité
query_upper = query.strip().upper()
if not query_upper.startswith("SELECT"):
return jsonify({"error": "Only SELECT queries allowed"}), 403
try:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(query, sql_params)
rows = cursor.fetchall()
results = [dict(row) for row in rows]
conn.close()
return jsonify({"results": results, "count": len(results)})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/call-workflow", methods=["POST"])
def call_workflow():
"""Endpoint pour exécuter un workflow n8n via MCP"""
import requests
data = request.get_json()
workflow_id = data.get("workflow_id", "sHVEK4hwyUmAww3F")
question = data.get("question", "")
if not question:
return jsonify({"error": "No question provided"}), 400
try:
# Call n8n MCP server
mcp_url = "https://kolifee.duckdns.org/mcp-server/http"
mcp_token = os.environ.get("MCP_TOKEN", "n8n_mcp_token_2025")
response = requests.post(
mcp_url,
json={
"json": {
"tool": "execute_workflow",
"arguments": {
"workflowId": workflow_id,
"data": {"question": question},
},
}
},
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {mcp_token}",
},
timeout=60,
)
if response.status_code == 200:
result = response.json()
return jsonify(
{"response": result.get("text", result.get("output", str(result)))}
)
else:
return jsonify(
{"error": f"MCP error: {response.status_code}"}
), response.status_code
except Exception as e:
return jsonify({"error": str(e)}), 500
# === NOUVEAUX ENDPOINTS LLM & ANALYTICS ===
try:
from prompts_llm import (
get_system_prompt,
get_sql_prompt,
get_keyword_sql,
get_suggestions_prompt,
)
from llm_cache import get_llm_cache, get_sql_cache
HAS_LLM_MODULES = True
except ImportError:
HAS_LLM_MODULES = False
try:
from analytics_reports import (
get_daily_report,
get_weekly_report,
get_monthly_report,
format_report_markdown,
)
HAS_ANALYTICS = True
except ImportError:
HAS_ANALYTICS = False
@app.route("/turf/api/report/daily", methods=["GET"])
@app.route("/api/report/daily", methods=["GET"])
def daily_report():
"""Rapport quotidien de performance"""
if not HAS_ANALYTICS:
return jsonify({"error": "analytics_reports module not available"}), 500
date = request.args.get("date")
report = get_daily_report(date)
return jsonify(report)
@app.route("/turf/api/report/weekly", methods=["GET"])
@app.route("/api/report/weekly", methods=["GET"])
def weekly_report():
"""Rapport hebdomadaire de performance"""
if not HAS_ANALYTICS:
return jsonify({"error": "analytics_reports module not available"}), 500
start_date = request.args.get("start")
end_date = request.args.get("end")
report = get_weekly_report(start_date, end_date)
return jsonify(report)
@app.route("/turf/api/report/monthly", methods=["GET"])
@app.route("/api/report/monthly", methods=["GET"])
def monthly_report():
"""Rapport mensuel de performance"""
if not HAS_ANALYTICS:
return jsonify({"error": "analytics_reports module not available"}), 500
year = request.args.get("year", type=int)
month = request.args.get("month", type=int)
report = get_monthly_report(year, month)
return jsonify(report)
@app.route("/turf/api/report/markdown/<report_type>", methods=["GET"])
def report_markdown(report_type):
"""Retourne le rapport en format Markdown"""
if not HAS_ANALYTICS:
return jsonify({"error": "analytics_reports module not available"}), 500
if report_type == "daily":
date = request.args.get("date")
report = get_daily_report(date)
markdown = format_report_markdown(report, "daily")
elif report_type == "weekly":
start = request.args.get("start")
end = request.args.get("end")
report = get_weekly_report(start, end)
markdown = format_report_markdown(report, "weekly")
elif report_type == "monthly":
year = request.args.get("year", type=int)
month = request.args.get("month", type=int)
report = get_monthly_report(year, month)
markdown = format_report_markdown(report, "monthly")
else:
return jsonify({"error": "Invalid report type"}), 400
return jsonify({"markdown": markdown, "type": report_type})
@app.route("/turf/api/suggestions", methods=["GET"])
@app.route("/api/suggestions", methods=["GET"])
def suggestions():
"""Retourne des suggestions de questions basées sur les données"""
if not HAS_LLM_MODULES:
return jsonify(
{
"suggestions": [
"Quel est mon taux de réussite cette semaine?",
"Liste les 5 meilleurs jockeys",
"Performances du cheval TORTISAMBERT",
"Évolution des cotes",
"Résultats d'hier",
]
}
)
conn = get_db()
c = conn.cursor()
suggestions = []
try:
c.execute(
"SELECT COUNT(*) as cnt FROM pmu_partants WHERE date_programme >= date('now', '-7 days')"
)
recent = c.fetchone()["cnt"]
if recent > 0:
suggestions = [
"Quel est mon taux de réussite cette semaine?",
"Liste les 5 meilleurs jockeys",
"Quel est le ROI du mois?",
"Résultats d'hier",
"Programme du jour",
]
else:
suggestions = [
"Derniers gagnants",
"Meilleurs entraîneurs",
"Performances à Vincennes",
"Évolution des cotes",
]
except:
suggestions = [
"Quel est le taux de réussite des favoris?",
"Liste les meilleurs jockeys",
"Résultats d'hier",
]
finally:
conn.close()
return jsonify({"suggestions": suggestions})
@app.route("/turf/api/ask-enhanced", methods=["GET", "POST"])
def ask_enhanced():
"""Version améliorée du endpoint /ask avec cache et fallback keywords"""
if not HAS_LLM_MODULES:
return jsonify({"error": "prompts_llm module not available"}), 500
if request.method == "GET":
question = request.args.get("question", "")
else:
data = request.get_json()
question = data.get("question", "") if data else ""
if not question:
return jsonify({"error": "No question provided"}), 400
sql_cache = get_sql_cache()
cached_sql = sql_cache.get_sql(question)
if cached_sql:
query = cached_sql
else:
query = get_keyword_sql(question)
if not query and LLM_API_KEY:
try:
import litellm
litellm.drop_params = True
prompt = get_sql_prompt(question)
response = litellm.completion(
model=f"openrouter/{LLM_MODEL}",
messages=[
{"role": "system", "content": get_system_prompt()},
{"role": "user", "content": prompt},
],
api_key=LLM_API_KEY,
max_tokens=300,
)
sql = response.choices[0].message.content.strip()
if sql.upper().startswith("SELECT"):
query = sql
sql_cache.set_sql(question, sql, success=True)
except Exception as e:
print(f"LLM error: {e}")
if not query:
return jsonify({"error": "Could not generate SQL for this question"}), 400
if not query.upper().startswith("SELECT"):
return jsonify({"error": "Invalid query generated"}), 400
try:
conn = get_db()
c = conn.cursor()
c.execute(query)
rows = c.fetchall()
results = [dict(row) for row in rows]
conn.close()
return jsonify(
{
"question": question,
"sql": query,
"results": results,
"count": len(results),
}
)
except Exception as e:
return jsonify({"error": str(e), "sql": query}), 500
@app.route("/turf/api/scores", methods=["POST"])
@require_auth
def save_race_scores():
data = request.json
if not data:
return jsonify({"error": "No data provided"}), 400
conn = get_db()
try:
conn.execute(
"""
INSERT OR REPLACE INTO race_scores
(date, race_name, race_time, hippodrome, top5_cotes, top5_cotes_hits,
top5_bc, top5_bc_hits, top5_bo, top5_bo_hits, results_top5)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
data.get("date"),
data.get("race_name"),
data.get("race_time"),
data.get("hippodrome"),
json.dumps(data.get("top5_cotes", [])),
data.get("top5_cotes_hits", 0),
json.dumps(data.get("top5_bc", [])),
data.get("top5_bc_hits", 0),
json.dumps(data.get("top5_bo", [])),
data.get("top5_bo_hits", 0),
json.dumps(data.get("results_top5", [])),
),
)
conn.commit()
return jsonify({"status": "ok", "message": "Scores saved"})
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@app.route("/turf/api/scores/history", methods=["GET"])
@require_auth
def get_scores_history():
limit = request.args.get("limit", 30)
conn = get_db()
try:
if not table_exists(conn, "race_scores"):
return jsonify({"results": [], "count": 0})
c = conn.execute(
"""
SELECT date, race_name, race_time, hippodrome,
top5_cotes, top5_cotes_hits,
top5_bc, top5_bc_hits,
top5_bo, top5_bo_hits,
results_top5
FROM race_scores
ORDER BY date DESC, race_time DESC
LIMIT ?
""",
(limit,),
)
rows = c.fetchall()
results = []
for r in rows:
d = dict(r)
d["top5_cotes"] = json.loads(d["top5_cotes"]) if d.get("top5_cotes") else []
d["top5_bc"] = json.loads(d["top5_bc"]) if d.get("top5_bc") else []
d["top5_bo"] = json.loads(d["top5_bo"]) if d.get("top5_bo") else []
d["results_top5"] = (
json.loads(d["results_top5"]) if d.get("results_top5") else []
)
results.append(d)
return jsonify({"results": results, "count": len(results)})
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
# ============================================================
# PARIS / ROI - Gestion des paris manuels
# ============================================================
@app.route("/api/parisroi", methods=["GET"])
@app.route("/turf/api/parisroi", methods=["GET"])
@require_auth
def api_parisroi():
"""Dashboard complet Paris/ROI"""
conn = get_db()
today = datetime.now().strftime("%Y-%m-%d")
try:
# Resume global depuis table paris
stats = conn.execute("""
SELECT COUNT(*) as total_bets,
SUM(CASE WHEN statut='GAGNE' THEN 1 ELSE 0 END) as gagne,
SUM(CASE WHEN statut='PERDU' THEN 1 ELSE 0 END) as perdu,
COALESCE(SUM(mise),0) as mise_totale,
COALESCE(SUM(gain),0) as gain_total
FROM paris
WHERE statut != 'EN_ATTENTE'
""").fetchone()
stats = (
dict(stats)
if stats
else {
"total_bets": 0,
"gagne": 0,
"perdu": 0,
"mise_totale": 0,
"gain_total": 0,
}
)
mise = stats["mise_totale"] or 0
gain = stats["gain_total"] or 0
roi = ((gain - mise) / mise * 100) if mise > 0 else 0
precision = (
(stats["gagne"] / stats["total_bets"] * 100)
if stats["total_bets"] > 0
else 0
)
stats["roi"] = round(roi, 1)
stats["precision"] = round(precision, 1)
# Paris du jour (EN_ATTENTE)
paris_jour = conn.execute("""
SELECT * FROM paris
WHERE statut = 'EN_ATTENTE'
ORDER BY created_at DESC
""").fetchall()
paris_jour = [dict(r) for r in paris_jour]
# Historique des 30 derniers paris
historique = conn.execute("""
SELECT * FROM paris
WHERE statut != 'EN_ATTENTE'
ORDER BY created_at DESC
LIMIT 30
""").fetchall()
historique = [dict(r) for r in historique]
# Stats par type
by_type = conn.execute("""
SELECT type_pari,
COUNT(*) as total,
SUM(CASE WHEN statut='GAGNE' THEN 1 ELSE 0 END) as gagne,
SUM(mise) as mise,
SUM(gain) as gain
FROM paris
WHERE statut != 'EN_ATTENTE'
GROUP BY type_pari
""").fetchall()
by_type = {
r["type_pari"]: {
"total": r["total"],
"gagne": r["gagne"],
"mise": round(r["mise"] or 0, 2),
"gain": round(r["gain"] or 0, 2),
"roi": round(
((r["gain"] or 0) - (r["mise"] or 0)) / (r["mise"] or 1) * 100, 1
),
}
for r in by_type
}
# ROI curve (30 derniers jours)
roi_curve = conn.execute("""
SELECT date, roi_pct, gain_total, mise_totale
FROM daily_stats
ORDER BY date DESC
LIMIT 30
""").fetchall()
roi_curve = [dict(r) for r in roi_curve]
return jsonify(
{
"summary": stats,
"paris_jour": paris_jour,
"historique": historique,
"by_type": by_type,
"roi_curve": roi_curve,
}
)
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@app.route("/api/paris", methods=["GET"])
@app.route("/turf/api/paris", methods=["GET"])
@require_auth
def api_get_paris():
"""Liste des paris avec filtres"""
conn = get_db()
date = request.args.get("date")
statut = request.args.get("statut")
try:
query = "SELECT * FROM paris WHERE 1=1"
params = []
if date:
query += " AND date_pari = ?"
params.append(date)
if statut:
query += " AND statut = ?"
params.append(statut)
query += " ORDER BY created_at DESC"
rows = conn.execute(query, params).fetchall()
return jsonify({"paris": [dict(r) for r in rows], "count": len(rows)})
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@app.route("/api/paris", methods=["POST"])
@app.route("/turf/api/paris", methods=["POST"])
@require_auth
def api_add_pari():
"""Ajouter un nouveau pari"""
data = request.json
if not data:
return jsonify({"error": "Donnees requises"}), 400
conn = get_db()
try:
today = datetime.now().strftime("%Y-%m-%d")
chevaux = data.get("chevaux", "")
c = conn.execute(
"""
INSERT INTO paris (date_pari, date_course, race_name, race_label, hippodrome,
type_pari, chevaux, cheval1, numero1, cheval2, numero2,
cheval3, numero3, cote, mise, statut, commentaire, source_reco)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'EN_ATTENTE', ?, ?)
""",
(
today,
data.get("date_course", today),
data.get("race_name", ""),
data.get("race_label", ""),
data.get("hippodrome", ""),
data.get("type_pari", "tierce"),
chevaux,
data.get("cheval1", ""),
data.get("numero1"),
data.get("cheval2", ""),
data.get("numero2"),
data.get("cheval3", ""),
data.get("numero3"),
data.get("cote", 0),
data.get("mise", 1.0),
data.get("commentaire", ""),
data.get("source_reco", "manuel"),
),
)
conn.commit()
return jsonify({"status": "ok", "id": c.lastrowid})
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@app.route("/api/paris/<int:pari_id>", methods=["PUT"])
@app.route("/turf/api/paris/<int:pari_id>", methods=["PUT"])
@require_auth
def api_update_pari(pari_id):
"""Mettre a jour un pari (resultat/gain)"""
data = request.json
if not data:
return jsonify({"error": "Donnees requises"}), 400
conn = get_db()
try:
statut = data.get("statut", "EN_ATTENTE")
gain = data.get("gain", 0)
commentaire = data.get("commentaire", "")
conn.execute(
"""
UPDATE paris SET statut = ?, gain = ?, commentaire = ?
WHERE id = ?
""",
(statut, gain, commentaire, pari_id),
)
conn.commit()
# Mettre a jour bet_results en cascade
pari = conn.execute("SELECT * FROM paris WHERE id = ?", (pari_id,)).fetchone()
if pari and statut != "EN_ATTENTE":
conn.execute(
"""
INSERT INTO bet_results (date, race_name, type_pari, horse_name, horse_number,
cote, mise, resultat, gain)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
pari["date_pari"],
pari["race_name"],
pari["type_pari"],
pari["cheval1"],
pari["numero1"],
pari["cote"],
pari["mise"],
statut,
gain,
),
)
conn.commit()
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@app.route("/api/paris/<int:pari_id>", methods=["DELETE"])
@app.route("/turf/api/paris/<int:pari_id>", methods=["DELETE"])
@require_auth
def api_delete_pari(pari_id):
"""Supprimer un pari"""
conn = get_db()
try:
conn.execute(
"DELETE FROM paris WHERE id = ? AND statut = 'EN_ATTENTE'", (pari_id,)
)
conn.commit()
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@app.route("/api/paris/auto-settle", methods=["POST"])
@app.route("/turf/api/paris/auto-settle", methods=["POST"])
@require_auth
def api_auto_settle():
"""Regler automatiquement les paris du jour via resultats PMU"""
conn = get_db()
today = datetime.now().strftime("%Y-%m-%d")
try:
paris = conn.execute(
"""
SELECT * FROM paris
WHERE date_course = ? AND statut = 'EN_ATTENTE'
""",
(today,),
).fetchall()
settled = 0
for pari in paris:
p = dict(pari)
chevaux_paris = [p.get("numero1"), p.get("numero2"), p.get("numero3")]
chevaux_paris = [c for c in chevaux_paris if c]
if not chevaux_paris:
continue
# Recuperer resultats
results = conn.execute(
"""
SELECT nom, ordre_arrivee FROM pmu_partants
WHERE date_programme = ? AND ordre_arrivee > 0
ORDER BY ordre_arrivee ASC
""",
(today,),
).fetchall()
top3 = [
r["ordre_arrivee"] for r in results if r["ordre_arrivee"] in [1, 2, 3]
]
if not top3:
continue
# Verifier si les numeros paris correspondent
# Simplifie: on verifie si tous les numeros sont dans le top3
is_gagne = all(n in top3 for n in chevaux_paris)
statut = "GAGNE" if is_gagne else "PERDU"
gain = 0
if is_gagne and p.get("cote"):
gain = p["mise"] * p["cote"]
conn.execute(
"""
UPDATE paris SET statut = ?, gain = ? WHERE id = ?
""",
(statut, gain, p["id"]),
)
settled += 1
conn.commit()
return jsonify({"status": "ok", "settled": settled})
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
# === EMAIL SENDING (Resend) ===
@app.route("/api/send-email", methods=["POST", "OPTIONS"])
@app.route("/turf/api/send-email", methods=["POST", "OPTIONS"])
def send_email():
"""Envoyer un email via Resend API"""
if request.method == "OPTIONS":
return "", 200
if not RESEND_API_KEY:
return jsonify({"error": "RESEND_API key not configured in environment"}), 500
data = request.get_json()
if not data:
return jsonify({"error": "JSON body required"}), 400
to = data.get("to")
subject = data.get("subject")
html = data.get("html")
text = data.get("text")
from_addr = data.get("from", "H3R7Tech <onboarding@resend.dev>")
if not to or not subject:
return jsonify({"error": "Missing required fields: to, subject"}), 400
if not html and not text:
return jsonify({"error": "Missing email body: provide html or text"}), 400
try:
payload = {
"from": from_addr,
"to": [to] if isinstance(to, str) else to,
"subject": subject,
}
if html:
payload["html"] = html
if text:
payload["text"] = text
resp = requests.post(
"https://api.resend.com/emails",
headers={
"Authorization": f"Bearer {RESEND_API_KEY}",
"Content-Type": "application/json",
},
json=payload,
timeout=15,
)
if resp.status_code in (200, 201):
result = resp.json()
return jsonify(
{
"success": True,
"id": result.get("id"),
"message": "Email sent successfully",
}
)
else:
return jsonify(
{"error": f"Resend API error: {resp.status_code}", "details": resp.text}
), resp.status_code
except Exception as e:
return jsonify({"error": str(e)}), 500
# === BRAVE SEARCH ===
@app.route("/api/brave-search", methods=["GET", "POST", "OPTIONS"])
@app.route("/turf/api/brave-search", methods=["GET", "POST", "OPTIONS"])
def brave_search():
"""Recherche web via Brave Search API"""
if request.method == "OPTIONS":
return "", 200
if not BRAVE_SEARCH_API_KEY:
return jsonify(
{"error": "BRAVE_SEARCH_API key not configured in environment"}
), 500
if request.method == "GET":
q = request.args.get("q", "")
count = int(request.args.get("count", 10))
offset = int(request.args.get("offset", 0))
search_type = request.args.get("type", "web")
else:
data = request.get_json() or {}
q = data.get("q", data.get("query", ""))
count = int(data.get("count", 10))
offset = int(data.get("offset", 0))
search_type = data.get("type", "web")
if not q:
return jsonify({"error": "Missing query parameter: q"}), 400
count = min(count, 20) # Limit to 20 results max
try:
# Choose endpoint based on type
if search_type == "news":
url = "https://api.search.brave.com/res/v1/news/search"
else:
url = "https://api.search.brave.com/res/v1/web/search"
resp = requests.get(
url,
headers={
"X-Subscription-Token": BRAVE_SEARCH_API_KEY,
"Accept": "application/json",
},
params={
"q": q,
"count": count,
"offset": offset,
"search_lang": "fr",
},
timeout=10,
)
if resp.status_code == 200:
result = resp.json()
# Normalize response
if search_type == "news":
items = result.get("results", [])
normalized = [
{
"title": r.get("title", ""),
"url": r.get("url", ""),
"description": r.get("description", ""),
"age": r.get("age", ""),
"source": r.get("source", {}).get("title", ""),
}
for r in items
]
else:
items = result.get("web", {}).get("results", [])
normalized = [
{
"title": r.get("title", ""),
"url": r.get("url", ""),
"description": r.get("description", ""),
"age": r.get("age", ""),
"language": r.get("language", ""),
}
for r in items
]
return jsonify(
{
"query": q,
"type": search_type,
"count": len(normalized),
"results": normalized,
}
)
else:
return jsonify(
{
"error": f"Brave Search API error: {resp.status_code}",
"details": resp.text,
}
), resp.status_code
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/turf/api/predictions_analysis", methods=["GET"])
def api_predictions_analysis():
"""Analyse des predictions vs resultats reels"""
from datetime import datetime, timedelta
days = int(request.args.get("days", 30))
end_date = datetime.now().strftime("%Y-%m-%d")
start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
conn = get_db()
cursor = conn.cursor()
stats = {
"canalturf": {
"total": 0,
"top1_pct": 0,
"top3_pct": 0,
"top5_pct": 0,
"ze2_pct": 0,
},
"scoring": {
"total": 0,
"top1_pct": 0,
"top3_pct": 0,
"top5_pct": 0,
"ze2_pct": 0,
},
}
for source in ["canalturf", "scoring"]:
pred_table = "predictions" if source == "canalturf" else "scoring"
pred_col = "predicted_1" if source == "canalturf" else "horse_number"
try:
cursor.execute(
f"""
SELECT c.libelle, c.ordre_arrivee, c.numero, p.{pred_col}
FROM pmu_courses c
LEFT JOIN {pred_table} p ON c.date_programme = p.date_programme
AND c.num_course = p.num_course
WHERE c.date_course BETWEEN ? AND ?
AND c.ordre_arrivee IS NOT NULL
ORDER BY c.date_course DESC
""",
(start_date, end_date),
)
races = {}
for row in cursor.fetchall():
race, ordre, numero, pred = row
if race not in races:
races[race] = {"actual": [], "predicted": []}
if ordre and ordre > 0:
races[race]["actual"].append(str(numero))
if pred:
races[race]["predicted"].append(str(pred))
top1_hit = top3_hit = 0
total = len(races)
for race, data in races.items():
actual = set(data["actual"][:3])
pred_top1 = data["predicted"][0] if data["predicted"] else None
actual_top1 = data["actual"][0] if data["actual"] else None
if pred_top1 and actual_top1 and pred_top1 == actual_top1:
top1_hit += 1
if len(set(data["predicted"][:3]) & actual) >= 1:
top3_hit += 1
if total > 0:
stats[source]["total"] = total
stats[source]["top1_pct"] = round(top1_hit / total * 100, 1)
stats[source]["top3_pct"] = round(top3_hit / total * 100, 1)
except Exception as e:
print(f"Erreur {source}: {e}")
conn.close()
return jsonify({"stats": stats, "period": {"start": start_date, "end": end_date}})
# ─────────────────────────────────────────────────────────────────────────────
# /api/v1/predictions — Ensemble model endpoint (Sprint 6-7 ML Upgrade)
# ─────────────────────────────────────────────────────────────────────────────
_predict_v2 = None
def _load_predict_v2():
"""Lazy import of predict_v2 module (ensemble model)."""
global _predict_v2
if _predict_v2 is None:
try:
import importlib.util, sys
spec = importlib.util.spec_from_file_location(
"predict_v2", "/home/h3r7/turf_saas/predict_v2.py"
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
_predict_v2 = mod
except Exception as e:
import logging
logging.error(f"[v1/predictions] predict_v2 import failed: {e}")
return _predict_v2
@app.route("/api/v1/predictions", methods=["GET"])
@app.route("/turf/api/v1/predictions", methods=["GET"])
def api_v1_predictions():
"""
Ensemble ML predictions using XGBoost + LightGBM + MLP (Optuna-tuned).
Query params:
- date: YYYY-MM-DD (default: today / latest available)
- reunion: int (default: all)
- course: int (default: all)
"""
import time as _time
t0 = _time.perf_counter()
mod = _load_predict_v2()
if mod is None:
# Graceful fallback: redirect to legacy ml_predictions
return jsonify(
{
"error": "Ensemble model not available yet",
"fallback": "/api/ml_predictions",
"message": "Model is still training. Use /api/ml_predictions for legacy XGBoost predictions.",
}
), 503
ensemble = mod.load_ensemble()
if ensemble is None:
return jsonify(
{
"error": "Ensemble model file not found",
"model_path": str(mod.ENSEMBLE_PATH),
"message": "Run train_ensemble.py to generate the model.",
"fallback": "/api/ml_predictions",
}
), 503
date_param = request.args.get("date", None)
reunion_param = request.args.get("reunion", None)
course_param = request.args.get("course", None)
conn = sqlite3.connect("/home/h3r7/turf_saas/turf.db")
conn.row_factory = sqlite3.Row
# Determine date to use
if date_param:
date_used = date_param
else:
row = conn.execute(
"SELECT MAX(date_programme) as d FROM pmu_partants"
).fetchone()
date_used = (
row["d"] if row and row["d"] else datetime.now().strftime("%Y-%m-%d")
)
# Build query
where_clauses = ["p.date_programme = ?"]
params = [date_used]
if reunion_param:
where_clauses.append("p.num_reunion = ?")
params.append(int(reunion_param))
if course_param:
where_clauses.append("p.num_course = ?")
params.append(int(course_param))
query = f"""
SELECT p.*, c.distance, c.discipline, c.specialite,
c.nb_declares_partants, c.montant_prix, c.penetrometre_intitule,
c.libelle as course_libelle, c.libelle_court as hippodrome,
c.heure_depart_str, c.parcours
FROM pmu_partants p
LEFT JOIN pmu_courses c ON p.date_programme = c.date_programme
AND p.num_reunion = c.num_reunion AND p.num_course = c.num_course
WHERE {" AND ".join(where_clauses)}
ORDER BY p.num_reunion, p.num_course, p.num_pmu
"""
rows = conn.execute(query, params).fetchall()
conn.close()
if not rows:
return jsonify(
{
"date": date_used,
"model_version": mod.get_model_version(),
"predictions": [],
"message": f"No partants found for date {date_used}",
}
)
# Convert to list of dicts
partants = [dict(r) for r in rows]
# Run ensemble prediction
preds = mod.predict_top3(partants, model=ensemble)
# Group by race
races = {}
for pred in preds:
key = f"R{pred.get('num_reunion', 0)}C{pred.get('num_course', 0)}"
if key not in races:
# Find race metadata from partants
for p in partants:
if p.get("num_reunion") == pred.get("num_reunion") and p.get(
"num_course"
) == pred.get("num_course"):
races[key] = {
"reunion": pred.get("num_reunion"),
"course": pred.get("num_course"),
"label": key,
"race_name": p.get("course_libelle", ""),
"hippodrome": p.get("hippodrome", ""),
"heure": p.get("heure_depart_str", ""),
"discipline": p.get("discipline", ""),
"distance": p.get("distance", 0),
"horses": [],
}
break
if key in races:
races[key]["horses"].append(pred)
latency_ms = (_time.perf_counter() - t0) * 1000
return jsonify(
{
"date": date_used,
"model_version": mod.get_model_version(),
"latency_ms": round(latency_ms, 1),
"total_horses": len(preds),
"races": list(races.values()),
}
)
@app.route("/api/v1/model/invalidate-cache", methods=["POST"])
@app.route("/turf/api/v1/model/invalidate-cache", methods=["POST"])
def api_v1_invalidate_cache():
"""Force reload of ensemble model on next prediction call."""
mod = _load_predict_v2()
if mod:
mod.invalidate_model_cache()
return jsonify({"status": "ok", "message": "Model cache invalidated"})
return jsonify({"status": "error", "message": "predict_v2 module not loaded"}), 500
@app.route("/api/v1/model/status", methods=["GET"])
@app.route("/turf/api/v1/model/status", methods=["GET"])
def api_v1_model_status():
"""Return ensemble model status and version."""
import os as _os
from pathlib import Path as _Path
ensemble_path = _Path("/home/h3r7/turf_saas/models/ensemble_top3.pkl")
benchmark_path = _Path("/home/h3r7/turf_saas/models/benchmark_report.json")
status = {
"ensemble_available": ensemble_path.exists(),
"ensemble_path": str(ensemble_path),
}
if ensemble_path.exists():
mtime = _os.path.getmtime(str(ensemble_path))
status["last_trained"] = datetime.fromtimestamp(mtime).isoformat()
if benchmark_path.exists():
try:
with open(benchmark_path) as f:
import json as _json
report = _json.load(f)
status["benchmark"] = {
"baseline_precision_at3": report.get("baseline", {}).get(
"precision_at3"
),
"ensemble_precision_at3": report.get("ensemble", {}).get(
"precision_at3"
),
"delta": report.get("delta_precision_at3"),
"deployed": report.get("deploy"),
"run_date": report.get("run_date"),
}
except Exception:
pass
mod = _load_predict_v2()
if mod and ensemble_path.exists():
status["model_version"] = mod.get_model_version()
return jsonify(status)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8790, debug=False)