Compare commits

..

4 Commits

Author SHA1 Message Date
DevOps Engineer
8604dc78b1 feat(HRT-79): alertes Telegram configurables Premium/Pro
- telegram_alerts.py: service envoi alertes via Bot API (send_pre_race_alerts,
  build_race_alert, send_telegram_message) — gestion gracieuse TELEGRAM_BOT_TOKEN absent
- auth_db.py: migrate_telegram_columns() idempotente (ALTER TABLE + try/except OperationalError)
  colonnes: telegram_chat_id, alert_value_bets, alert_top1, alert_quinte_only
- api_v1/routes/user.py: blueprint user_bp GET/POST /api/v1/user/telegram-config
  protégé @jwt_required_middleware + @plan_required('premium','pro')
- api_v1/__init__.py: import + register user_bp
- turf_scheduler.py: run_telegram_alerts() + schedule_dynamic_telegram_alerts()
  planifiées 30min avant course (même pattern que schedule_dynamic_scoring)
  avec try/except Exception + fallback logger

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-29 16:42:15 +02:00
DevOps Engineer
30464fb40c Merge branch 'feature/HRT-84-dashboard-premium-pro' into master
Some checks failed
CD / Deploy → Staging (push) Has been cancelled
CD / Smoke Tests on Staging (push) Has been cancelled
CD / Deploy → Production (push) Has been cancelled
CD / Rollback Production (push) Has been cancelled
[HRT-84] Dashboard SaaS — UI Premium & Pro avec gating plan strict
- Sections Value Bets, Historique, Export CSV raccordées aux vrais endpoints
- Sections Telegram, API Token, Webhook avec mocks (TODO HRT-79, HRT-80)
- Gating plan strict: Free/Premium/Pro non contournable côté client
- Fix: maxDays Pro = 365j (corrige inversion 30j vs 90j)
- Multi-compte Pro: gating UI uniquement (endpoint non défini)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-29 15:49:56 +02:00
DevOps Engineer
31db3a8260 fix(HRT-84): maxDays historique Pro — 365j au lieu de 30j (inversion corrigée)
Pro = 365j (historique le plus long), Premium = 90j, Free = 7j
Corrigé suite au point d'attention CTO dans revue de code.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-29 15:49:25 +02:00
DevOps Engineer
278245cd7c feat(HRT-84): dashboard SaaS — UI Premium & Pro avec gating plan strict
- Ajout sections: Value Bets, Alertes Telegram, API Token, Webhook, Historique, Multi-compte
- Gating plan strict: Free < Premium < Pro (jamais de données réelles derrière plan inférieur)
- Value Bets: raccordé sur endpoint réel /api/v1/valuebets (premium+)
- Historique: raccordé sur endpoint réel /api/v1/history (HRT-81)
- Telegram / API Token / Webhook: mocks structurés avec contrats d'interface
  (TODO: replace mock — HRT-79 pour Telegram, HRT-80 pour API Token/Webhook)
- Multi-compte: gating UI Pro uniquement, endpoint non défini
- Navigation par section avec chargement lazy
- Design cohérent dark theme avec badges, lock icons et CTA upgrade par plan

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-29 15:43:02 +02:00
9 changed files with 1952 additions and 556 deletions

View File

@@ -3,6 +3,7 @@
API v1 Blueprint package — Turf SaaS
Sprint 3-4: HRT-29 — Refacto API /v1/
Sprint 5-6: HRT-31 — Billing Stripe
HRT-79: Alertes Telegram configurables (user blueprint)
Registers sub-blueprints:
/api/v1/health — public health-check
@@ -13,6 +14,7 @@ Registers sub-blueprints:
/api/v1/export/ — export CSV (pro)
/api/v1/metrics — métriques perf ML (premium+)
/api/v1/billing/ — Stripe checkout, portal, webhook, status
/api/v1/user/ — config utilisateur, alertes Telegram (premium+)
/api/v1/docs — Swagger UI (via flasgger, registered on app)
"""
@@ -26,6 +28,7 @@ from .routes.backtest import backtest_bp
from .routes.export import export_bp
from .routes.metrics import metrics_bp
from .routes.billing import billing_bp
from .routes.user import user_bp
# Master blueprint that aggregates all sub-routes under /api/v1
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
@@ -41,3 +44,4 @@ def register_api_v1(app):
app.register_blueprint(export_bp)
app.register_blueprint(metrics_bp)
app.register_blueprint(billing_bp)
app.register_blueprint(user_bp)

View File

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

216
api_v1/routes/user.py Normal file
View File

@@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""
User route for API v1 — Telegram alert configuration
HRT-79: Alertes Telegram configurables (Premium)
GET /api/v1/user/telegram-config — Lire la config Telegram de l'utilisateur connecté
POST /api/v1/user/telegram-config — Mettre à jour la config Telegram
Accès : Premium / Pro uniquement (@jwt_required_middleware + @plan_required)
"""
import sqlite3
from flask import Blueprint, jsonify, request
from api_v1.utils import internal_error, bad_request
from auth import jwt_required_middleware, plan_required
user_bp = Blueprint("v1_user", __name__, url_prefix="/api/v1/user")
# DB_PATH est résolu via la même variable d'env que auth_db.py
import os
_DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
def _get_db():
conn = sqlite3.connect(_DB_PATH)
conn.row_factory = sqlite3.Row
return conn
# ── GET /api/v1/user/telegram-config ──────────────────────────────────────────
@user_bp.route("/telegram-config", methods=["GET"])
@jwt_required_middleware
@plan_required("premium", "pro")
def get_telegram_config():
"""
Retourne la configuration Telegram de l'utilisateur connecté.
---
tags:
- Utilisateur
summary: Lire la config alertes Telegram (premium+)
security:
- Bearer: []
responses:
200:
description: Configuration Telegram courante
schema:
properties:
telegram_chat_id:
type: string
nullable: true
alert_value_bets:
type: boolean
alert_top1:
type: boolean
alert_quinte_only:
type: boolean
401:
description: Token invalide
403:
description: Plan insuffisant
"""
user_id = request.user_id # injecté par jwt_required_middleware
conn = _get_db()
try:
row = conn.execute(
"""
SELECT telegram_chat_id, alert_value_bets, alert_top1, alert_quinte_only
FROM users
WHERE id = ?
""",
(user_id,),
).fetchone()
if not row:
return jsonify({"error": "Utilisateur introuvable"}), 404
return jsonify(
{
"telegram_chat_id": row["telegram_chat_id"],
"alert_value_bets": bool(row["alert_value_bets"]),
"alert_top1": bool(row["alert_top1"]),
"alert_quinte_only": bool(row["alert_quinte_only"]),
}
), 200
except sqlite3.OperationalError as exc:
# Colonnes absentes : migration non appliquée
return jsonify(
{
"telegram_chat_id": None,
"alert_value_bets": True,
"alert_top1": True,
"alert_quinte_only": False,
"_warning": "Migration Telegram non appliquée",
}
), 200
except Exception as exc:
return internal_error(str(exc))
finally:
conn.close()
# ── POST /api/v1/user/telegram-config ─────────────────────────────────────────
@user_bp.route("/telegram-config", methods=["POST"])
@jwt_required_middleware
@plan_required("premium", "pro")
def update_telegram_config():
"""
Met à jour la configuration Telegram de l'utilisateur connecté.
---
tags:
- Utilisateur
summary: Configurer les alertes Telegram (premium+)
security:
- Bearer: []
parameters:
- in: body
name: body
required: true
schema:
properties:
telegram_chat_id:
type: string
description: Chat ID Telegram (ou null pour désactiver)
alert_value_bets:
type: boolean
default: true
alert_top1:
type: boolean
default: true
alert_quinte_only:
type: boolean
default: false
responses:
200:
description: Configuration mise à jour
400:
description: Paramètres invalides
401:
description: Token invalide
403:
description: Plan insuffisant
"""
user_id = request.user_id # injecté par jwt_required_middleware
data = request.get_json(silent=True)
if not data:
return bad_request("Corps JSON requis")
# Validation et extraction des champs
telegram_chat_id = data.get("telegram_chat_id")
if telegram_chat_id is not None and not isinstance(telegram_chat_id, str):
return bad_request("telegram_chat_id doit être une chaîne ou null")
if isinstance(telegram_chat_id, str):
telegram_chat_id = telegram_chat_id.strip() or None
alert_value_bets = data.get("alert_value_bets", True)
alert_top1 = data.get("alert_top1", True)
alert_quinte_only = data.get("alert_quinte_only", False)
if not isinstance(alert_value_bets, bool):
return bad_request("alert_value_bets doit être un booléen")
if not isinstance(alert_top1, bool):
return bad_request("alert_top1 doit être un booléen")
if not isinstance(alert_quinte_only, bool):
return bad_request("alert_quinte_only doit être un booléen")
conn = _get_db()
try:
conn.execute(
"""
UPDATE users
SET telegram_chat_id = ?,
alert_value_bets = ?,
alert_top1 = ?,
alert_quinte_only = ?
WHERE id = ?
""",
(
telegram_chat_id,
int(alert_value_bets),
int(alert_top1),
int(alert_quinte_only),
user_id,
),
)
conn.commit()
return jsonify(
{
"status": "ok",
"telegram_chat_id": telegram_chat_id,
"alert_value_bets": alert_value_bets,
"alert_top1": alert_top1,
"alert_quinte_only": alert_quinte_only,
}
), 200
except sqlite3.OperationalError as exc:
return jsonify(
{
"error": "Migration Telegram non appliquée — contacter le support",
"detail": str(exc),
}
), 500
except Exception as exc:
return internal_error(str(exc))
finally:
conn.close()

View File

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

View File

@@ -2,6 +2,7 @@
"""
Auth DB — users and subscriptions schema for turf_saas.db
Sprint 2-3: Auth JWT + Multi-tenant (HRT-28)
HRT-79: migration Telegram columns
"""
import sqlite3
@@ -63,6 +64,36 @@ def init_auth_tables():
conn.close()
print("[auth_db] Tables users, subscriptions, refresh_tokens created/verified.")
# Apply Telegram columns migration (idempotent)
migrate_telegram_columns()
def migrate_telegram_columns():
"""
Migration idempotente : ajoute les colonnes Telegram à la table users.
Utilise ALTER TABLE ... ADD COLUMN avec try/except OperationalError
pour être safe si les colonnes existent déjà (SQLite ne supporte pas IF NOT EXISTS).
HRT-79
"""
conn = get_db()
c = conn.cursor()
columns = [
("telegram_chat_id", "TEXT DEFAULT NULL"),
("alert_value_bets", "INTEGER DEFAULT 1"),
("alert_top1", "INTEGER DEFAULT 1"),
("alert_quinte_only", "INTEGER DEFAULT 0"),
]
for col, definition in columns:
try:
c.execute(f"ALTER TABLE users ADD COLUMN {col} {definition}")
print(f"[auth_db] Colonne '{col}' ajoutée.")
except sqlite3.OperationalError:
# Column already exists — safe to ignore
pass
conn.commit()
conn.close()
print("[auth_db] Migration Telegram columns OK.")
if __name__ == "__main__":
init_auth_tables()

File diff suppressed because it is too large Load Diff

View File

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

284
telegram_alerts.py Normal file
View File

@@ -0,0 +1,284 @@
#!/usr/bin/env python3
"""
Telegram Alerts — Service d'alertes pré-course pour les utilisateurs Premium/Pro
HRT-79: Alertes Telegram configurables (Premium)
Fonctionnement :
- 30 minutes avant chaque course détectée, envoie un message Telegram
aux utilisateurs Premium/Pro ayant configuré leur chat_id.
- Les préférences individuelles (value_bets, top1, quinte_only) sont respectées.
- Requiert la variable d'environnement TELEGRAM_BOT_TOKEN.
"""
import os
import logging
import sqlite3
from datetime import datetime
from typing import Optional
import requests
logger = logging.getLogger(__name__)
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_API_BASE = "https://api.telegram.org/bot{token}/sendMessage"
# ── Helpers ───────────────────────────────────────────────────────────────────
def _get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def send_telegram_message(chat_id: str, text: str) -> bool:
"""
Envoie un message Telegram à un chat_id donné.
Returns True si succès, False sinon.
Ne lève pas d'exception pour ne pas crasher le scheduler.
"""
if not BOT_TOKEN:
logger.warning("[TELEGRAM] TELEGRAM_BOT_TOKEN non configuré — envoi ignoré")
return False
url = TELEGRAM_API_BASE.format(token=BOT_TOKEN)
payload = {
"chat_id": chat_id,
"text": text,
"parse_mode": "Markdown",
"disable_web_page_preview": True,
}
try:
resp = requests.post(url, json=payload, timeout=10)
if resp.status_code == 200:
return True
logger.warning(
"[TELEGRAM] Echec envoi chat_id=%s status=%d body=%s",
chat_id,
resp.status_code,
resp.text[:200],
)
return False
except requests.RequestException as exc:
logger.error("[TELEGRAM] Exception HTTP chat_id=%s: %s", chat_id, exc)
return False
# ── Alert builder ─────────────────────────────────────────────────────────────
def build_race_alert(race_data: dict, predictions: list) -> str:
"""
Construit le message Markdown de l'alerte pré-course.
Args:
race_data: dict avec les clés 'hippo', 'num_course', 'heure', 'type_course'
predictions: liste de dicts {'num_cheval', 'nom_cheval', 'prob_top3', 'is_value_bet', 'ml_score'}
Returns: texte Markdown formaté
"""
hippo = race_data.get("hippo", "?")
num_course = race_data.get("num_course", "?")
heure = race_data.get("heure", "?")
type_course = race_data.get("type_course", "")
lines = [
f"🏇 *Alerte course — {hippo} R{num_course}*",
f"⏰ Départ prévu : *{heure}*",
]
if type_course:
lines.append(f"📋 Type : {type_course}")
lines.append("")
top3 = [p for p in predictions if p.get("prob_top3", 0) > 0][:3]
value_bets = [p for p in predictions if p.get("is_value_bet")]
if top3:
lines.append("📊 *Top-3 ML :*")
for i, p in enumerate(top3, 1):
nom = p.get("nom_cheval", f"#{p.get('num_cheval', '?')}")
prob = p.get("prob_top3", 0)
lines.append(f" {i}. {nom}{prob:.0%} prob top-3")
lines.append("")
if value_bets:
lines.append("💡 *Value bets :*")
for p in value_bets[:3]:
nom = p.get("nom_cheval", f"#{p.get('num_cheval', '?')}")
score = p.get("ml_score", 0)
lines.append(f"{nom} (score {score:.2f})")
lines.append("")
lines.append("_Alerte automatique Turf SaaS — 30min avant départ_")
return "\n".join(lines)
# ── Main send function ────────────────────────────────────────────────────────
def send_pre_race_alerts(minutes_before: int = 30) -> dict:
"""
Interroge la DB pour récupérer les courses du jour, puis envoie
des alertes Telegram aux utilisateurs Premium/Pro éligibles.
Args:
minutes_before: non utilisé directement (la planification est gérée
par le scheduler), présent pour documentation.
Returns: dict {'sent': int, 'skipped': int, 'errors': int}
"""
if not BOT_TOKEN:
logger.warning(
"[TELEGRAM] TELEGRAM_BOT_TOKEN absent — send_pre_race_alerts ignoré"
)
return {"sent": 0, "skipped": 0, "errors": 0}
stats = {"sent": 0, "skipped": 0, "errors": 0}
try:
conn = _get_db()
today = datetime.now().strftime("%Y-%m-%d")
# Récupère les courses du jour
try:
courses_rows = conn.execute(
"""
SELECT DISTINCT
hippo, num_course, heure_depart, type_course
FROM pmu_courses
WHERE date_programme = ?
AND heure_depart IS NOT NULL
ORDER BY heure_depart ASC
LIMIT 20
""",
(today,),
).fetchall()
except sqlite3.OperationalError as exc:
logger.warning("[TELEGRAM] Table pmu_courses introuvable: %s", exc)
conn.close()
return stats
if not courses_rows:
logger.info("[TELEGRAM] Aucune course aujourd'hui — pas d'alerte")
conn.close()
return stats
# Récupère les utilisateurs Premium/Pro avec chat_id configuré
try:
users = conn.execute(
"""
SELECT id, telegram_chat_id,
alert_value_bets, alert_top1, alert_quinte_only
FROM users
WHERE plan IN ('premium', 'pro')
AND is_active = 1
AND telegram_chat_id IS NOT NULL
AND telegram_chat_id != ''
""",
).fetchall()
except sqlite3.OperationalError as exc:
logger.warning(
"[TELEGRAM] Colonnes Telegram absentes (migration non appliquée?): %s",
exc,
)
conn.close()
return stats
if not users:
logger.info("[TELEGRAM] Aucun utilisateur avec chat_id configuré")
conn.close()
return stats
for course_row in courses_rows:
hippo = course_row["hippo"] or "?"
num_course = course_row["num_course"] or "?"
heure_ts = course_row["heure_depart"]
type_course = course_row["type_course"] or ""
try:
dt = datetime.fromtimestamp(heure_ts / 1000)
heure_str = dt.strftime("%H:%M")
except Exception:
heure_str = str(heure_ts)
race_data = {
"hippo": hippo,
"num_course": num_course,
"heure": heure_str,
"type_course": type_course,
}
# Récupère les prédictions ML pour cette course
predictions = []
try:
pred_rows = conn.execute(
"""
SELECT num_cheval, nom_cheval, prob_top3, is_value_bet, ml_score
FROM ml_predictions_cache
WHERE date = ?
AND hippo = ?
AND num_course = ?
ORDER BY prob_top3 DESC
LIMIT 10
""",
(today, hippo, num_course),
).fetchall()
predictions = [dict(r) for r in pred_rows]
except sqlite3.OperationalError:
pass # table absente, on envoie quand même avec données minimales
is_quinte = (
"quinté" in type_course.lower() or "quinte" in type_course.lower()
)
for user in users:
chat_id = user["telegram_chat_id"]
alert_quinte_only = bool(user["alert_quinte_only"])
alert_top1 = bool(user["alert_top1"])
alert_value_bets = bool(user["alert_value_bets"])
# Filtre quinte_only
if alert_quinte_only and not is_quinte:
stats["skipped"] += 1
continue
# Construit le message selon préférences
filtered_preds = []
if predictions:
for p in predictions:
include = False
if alert_top1 and p.get("prob_top3", 0) > 0:
include = True
if alert_value_bets and p.get("is_value_bet"):
include = True
if include:
filtered_preds.append(p)
text = build_race_alert(race_data, filtered_preds)
ok = send_telegram_message(chat_id, text)
if ok:
stats["sent"] += 1
else:
stats["errors"] += 1
conn.close()
except Exception as exc:
logger.error("[TELEGRAM] Erreur inattendue dans send_pre_race_alerts: %s", exc)
import traceback
traceback.print_exc()
stats["errors"] += 1
logger.info(
"[TELEGRAM] Alertes pré-course: %d envoyées, %d ignorées, %d erreurs",
stats["sent"],
stats["skipped"],
stats["errors"],
)
return stats

View File

@@ -193,6 +193,65 @@ def schedule_dynamic_scoring():
logger.info(" [SCHEDULER] Pas de course aujourd'hui, pas de scoring dynamique")
def run_telegram_alerts():
"""Envoie les alertes Telegram pré-course aux utilisateurs Premium/Pro"""
logger.info("📨 [SCHEDULER] Envoi alertes Telegram pré-course...")
try:
os.chdir("/home/h3r7/turf_saas")
import telegram_alerts
stats = telegram_alerts.send_pre_race_alerts(minutes_before=30)
logger.info(
"✅ [SCHEDULER] Alertes Telegram: %d envoyées, %d ignorées, %d erreurs",
stats.get("sent", 0),
stats.get("skipped", 0),
stats.get("errors", 0),
)
except Exception as e:
logger.error(f"❌ [SCHEDULER] Erreur alertes Telegram: {e}")
import traceback
traceback.print_exc()
def schedule_dynamic_telegram_alerts():
"""Planifie les alertes Telegram 30min avant la course (même pattern que schedule_dynamic_scoring)"""
race_time = get_todays_race_time()
if race_time:
try:
# Convertir timestamp ms en datetime
dt = datetime.fromtimestamp(race_time / 1000)
race_hour = dt.hour
race_min = dt.minute
logger.info(
f"📅 [SCHEDULER] Alertes Telegram — course à {race_hour:02d}:{race_min:02d}"
)
# Alertes 30min avant la course
pre_min = race_min - 30
pre_hour = race_hour
if pre_min < 0:
pre_min += 60
pre_hour -= 1
alert_time = f"{pre_hour:02d}:{pre_min:02d}"
schedule.every().day.at(alert_time).do(run_telegram_alerts).tag(
"telegram", "dynamic"
)
logger.info(
f"📅 [SCHEDULER] Alertes Telegram planifiées à {alert_time} (30min avant la course)"
)
except Exception as e:
logger.warning(f"⚠️ Impossible de planifier les alertes Telegram: {e}")
else:
logger.info(
" [SCHEDULER] Pas de course aujourd'hui, pas d'alertes Telegram dynamiques"
)
def schedule_dynamic_results():
"""Planifie le scraping des résultats à H+1 (1h après la course)"""
race_time = get_todays_race_time()
@@ -245,6 +304,9 @@ def main():
# Scoring dynamique (15min avant course)
schedule_dynamic_scoring()
# Alertes Telegram dynamiques (30min avant course)
schedule_dynamic_telegram_alerts()
# Résultats dynamiques (H+1)
schedule_dynamic_results()