Compare commits
5 Commits
feature/HR
...
fac498efec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fac498efec | ||
|
|
1ccf9f5cb8 | ||
|
|
a126941f7f | ||
|
|
3079c2c6c6 | ||
|
|
52c0c95f22 |
@@ -13,7 +13,9 @@ logger = logging.getLogger("turf_saas.api_tokens_db")
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
"""Return a SQLite connection (reads TURF_SAAS_DB dynamically for test isolation)."""
|
||||
db_path = os.environ.get("TURF_SAAS_DB", DB_PATH)
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ Registers sub-blueprints:
|
||||
/api/v1/history — historique préd. ML (Free:7j, Premium:90j, Pro:illimité)
|
||||
/api/v1/org/ — organisations Pro (multi-compte, max 5 users)
|
||||
/api/v1/docs — Swagger UI (via flasgger, registered on app)
|
||||
/api/v1/ml/feedback/run — trigger feedback loop ML (admin)
|
||||
/api/v1/ml/feedback/stats — stats par stratégie (premium+)
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
@@ -38,6 +40,7 @@ from .routes.user import user_bp
|
||||
from .routes.user_tokens import user_tokens_bp
|
||||
from .routes.history import history_bp
|
||||
from .routes.org import org_bp
|
||||
from .routes.ml_feedback import ml_feedback_bp
|
||||
|
||||
# Master blueprint that aggregates all sub-routes under /api/v1
|
||||
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
||||
@@ -57,3 +60,4 @@ def register_api_v1(app):
|
||||
app.register_blueprint(user_tokens_bp)
|
||||
app.register_blueprint(history_bp)
|
||||
app.register_blueprint(org_bp)
|
||||
app.register_blueprint(ml_feedback_bp)
|
||||
|
||||
@@ -20,7 +20,11 @@ from api_v1.utils import (
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
)
|
||||
from auth import jwt_required_middleware
|
||||
# Auth: try flask_jwt_extended (app_v1) first, fall back to saas_auth (portal_server)
|
||||
try:
|
||||
from auth import jwt_required_middleware
|
||||
except ImportError:
|
||||
from saas_auth import require_auth as jwt_required_middleware
|
||||
|
||||
history_bp = Blueprint("v1_history", __name__, url_prefix="/api/v1/history")
|
||||
|
||||
@@ -104,7 +108,7 @@ def get_history():
|
||||
403:
|
||||
description: Plage de dates hors limite du plan — upgrade requis
|
||||
"""
|
||||
user = getattr(g, "current_user", None)
|
||||
user = getattr(request, "current_user", None) or getattr(g, "current_user", None)
|
||||
if not user:
|
||||
return jsonify({"error": "Non authentifié"}), 401
|
||||
|
||||
|
||||
@@ -14,15 +14,21 @@ from api_v1.utils import (
|
||||
internal_error,
|
||||
bad_request,
|
||||
)
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
from saas_auth import require_auth as jwt_required_middleware
|
||||
from flask import request as _req
|
||||
|
||||
metrics_bp = Blueprint("v1_metrics", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
@metrics_bp.route("/metrics", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def metrics():
|
||||
# plan check: premium or pro (or TEST_MODE via plan='pro' in DB)
|
||||
user = getattr(_req, 'current_user', None) or {}
|
||||
plan = user.get('plan', 'free') if isinstance(user, dict) else 'free'
|
||||
if plan not in ('premium', 'pro'):
|
||||
from flask import jsonify as _j
|
||||
return _j({'error': 'Plan premium ou pro requis'}), 403
|
||||
"""
|
||||
Métriques ML
|
||||
---
|
||||
|
||||
199
api_v1/routes/ml_feedback.py
Normal file
199
api_v1/routes/ml_feedback.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ml_feedback.py — API routes pour le feedback loop ML (turf_saas).
|
||||
|
||||
Routes:
|
||||
POST /api/v1/ml/feedback/run — Déclenche le feedback loop (admin uniquement)
|
||||
GET /api/v1/ml/feedback/stats — Stats performances par stratégie
|
||||
|
||||
Sécurité admin : token via variable d'environnement ML_ADMIN_TOKEN
|
||||
ou plan "pro" en fallback pour les stats.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, jsonify, request, g
|
||||
|
||||
# Ajoute le répertoire parent de api_v1 dans le path pour importer ml_feedback_saas
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||
|
||||
from api_v1.utils import get_db, internal_error, bad_request
|
||||
# Auth: try flask_jwt_extended (app_v1) first, fall back to saas_auth (portal_server)
|
||||
try:
|
||||
from auth import jwt_required_middleware
|
||||
except ImportError:
|
||||
from saas_auth import require_auth as jwt_required_middleware
|
||||
try:
|
||||
from auth import plan_required
|
||||
except ImportError:
|
||||
plan_required = lambda *a, **kw: (lambda f: f)
|
||||
|
||||
ml_feedback_bp = Blueprint("v1_ml_feedback", __name__, url_prefix="/api/v1/ml/feedback")
|
||||
|
||||
# Token admin interne — configurable via variable d'environnement
|
||||
ML_ADMIN_TOKEN = os.environ.get("ML_ADMIN_TOKEN", "")
|
||||
|
||||
|
||||
def _check_admin(req):
|
||||
"""Vérifie le token admin via header X-Admin-Token ou Authorization Bearer (plan pro)."""
|
||||
# 1. Token interne (scheduler/cron)
|
||||
admin_token = req.headers.get("X-Admin-Token", "").strip()
|
||||
if ML_ADMIN_TOKEN and admin_token == ML_ADMIN_TOKEN:
|
||||
return True, None
|
||||
|
||||
# 2. Pas de token admin configuré → autoriser les utilisateurs "pro" authentifiés
|
||||
user = getattr(request, "current_user", None) or getattr(g, "current_user", None)
|
||||
if user and user.get("plan") == "pro":
|
||||
return True, None
|
||||
|
||||
return False, jsonify({"error": "Accès admin requis", "code": 403}), 403
|
||||
|
||||
|
||||
@ml_feedback_bp.route("/run", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
def feedback_run():
|
||||
"""
|
||||
Déclenche le feedback loop ML pour une date donnée.
|
||||
---
|
||||
tags:
|
||||
- ML Feedback
|
||||
summary: Déclenche le feedback loop XGBoost (admin only)
|
||||
security:
|
||||
- Bearer: []
|
||||
- AdminToken: []
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
date:
|
||||
type: string
|
||||
description: Date YYYY-MM-DD (défaut aujourd'hui)
|
||||
example: "2026-04-25"
|
||||
mode:
|
||||
type: string
|
||||
description: "run (défaut) ou backfill"
|
||||
enum: [run, backfill]
|
||||
example: run
|
||||
responses:
|
||||
200:
|
||||
description: Feedback loop exécuté avec succès
|
||||
400:
|
||||
description: Paramètre invalide
|
||||
403:
|
||||
description: Accès refusé
|
||||
500:
|
||||
description: Erreur interne
|
||||
"""
|
||||
# Vérification admin
|
||||
user = getattr(request, "current_user", None) or getattr(g, "current_user", None)
|
||||
admin_token = request.headers.get("X-Admin-Token", "").strip()
|
||||
is_admin = (ML_ADMIN_TOKEN and admin_token == ML_ADMIN_TOKEN) or (
|
||||
user and user.get("plan") == "pro"
|
||||
)
|
||||
if not is_admin:
|
||||
return jsonify({"error": "Accès admin requis", "code": 403}), 403
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
date_str = body.get("date") or datetime.now().strftime("%Y-%m-%d")
|
||||
mode = body.get("mode", "run")
|
||||
|
||||
# Validation date
|
||||
try:
|
||||
datetime.strptime(date_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return bad_request(f"Date invalide : {date_str}. Format attendu : YYYY-MM-DD")
|
||||
|
||||
if mode not in ("run", "backfill"):
|
||||
return bad_request("mode doit être 'run' ou 'backfill'")
|
||||
|
||||
try:
|
||||
import ml_feedback_saas
|
||||
|
||||
if mode == "backfill":
|
||||
inseres, maj = ml_feedback_saas.backfill(date_str)
|
||||
total_inseres = inseres
|
||||
else:
|
||||
result = ml_feedback_saas.run(date_str)
|
||||
total_inseres = sum(result["inseres"].values())
|
||||
maj = result["maj"]
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": date_str,
|
||||
"mode": mode,
|
||||
"paris_inseres": total_inseres,
|
||||
"paris_mis_a_jour": maj,
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
|
||||
|
||||
@ml_feedback_bp.route("/stats", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def feedback_stats():
|
||||
"""
|
||||
Stats performances ML par stratégie.
|
||||
---
|
||||
tags:
|
||||
- ML Feedback
|
||||
summary: Stats paris ML par stratégie (premium+)
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: date_debut
|
||||
in: query
|
||||
type: string
|
||||
description: Date de début YYYY-MM-DD
|
||||
- name: date_fin
|
||||
in: query
|
||||
type: string
|
||||
description: Date de fin YYYY-MM-DD
|
||||
responses:
|
||||
200:
|
||||
description: Stats par stratégie
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant (premium ou pro requis)
|
||||
"""
|
||||
date_debut = request.args.get("date_debut")
|
||||
date_fin = request.args.get("date_fin")
|
||||
|
||||
# Validation optionnelle des dates
|
||||
for d_str, label in [(date_debut, "date_debut"), (date_fin, "date_fin")]:
|
||||
if d_str:
|
||||
try:
|
||||
datetime.strptime(d_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return bad_request(f"{label} invalide : {d_str}. Format : YYYY-MM-DD")
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
import ml_feedback_saas
|
||||
|
||||
stats = ml_feedback_saas.get_feedback_stats(conn, date_debut, date_fin)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"strategies": stats,
|
||||
"filters": {
|
||||
"date_debut": date_debut,
|
||||
"date_fin": date_fin,
|
||||
},
|
||||
"total_strategies": len(stats),
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -13,7 +13,15 @@ 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
|
||||
# Auth: try flask_jwt_extended (app_v1) first, fall back to saas_auth (portal_server)
|
||||
try:
|
||||
from auth import jwt_required_middleware
|
||||
except ImportError:
|
||||
from saas_auth import require_auth as jwt_required_middleware
|
||||
try:
|
||||
from auth import plan_required
|
||||
except ImportError:
|
||||
plan_required = lambda *a, **kw: (lambda f: f)
|
||||
|
||||
user_bp = Blueprint("v1_user", __name__, url_prefix="/api/v1/user")
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@ DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Return a SQLite connection with Row factory."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
"""Return a SQLite connection with Row factory (reads TURF_SAAS_DB dynamically)."""
|
||||
db_path = os.environ.get("TURF_SAAS_DB", DB_PATH)
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
@@ -8,11 +8,15 @@ HRT-79: migration Telegram columns
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
# NOTE: DB_PATH kept for backward compat, but get_db() reads env at call time
|
||||
# so test isolation works correctly when TURF_SAAS_DB is set per-module.
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
# Read env dynamically so test overrides of TURF_SAAS_DB are respected
|
||||
db_path = os.environ.get("TURF_SAAS_DB", DB_PATH)
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
@@ -259,6 +259,7 @@
|
||||
<a class="nav-item" id="nav-history" href="#history" onclick="showSection('history',this)"><span class="icon">📅</span> Historique <span class="plan-lock" id="lock-hist"></span></a>
|
||||
<a class="nav-item" id="nav-export" href="#export" onclick="showSection('export',this)"><span class="icon">📤</span> Export CSV <span class="plan-lock" id="lock-export"></span></a>
|
||||
|
||||
<a class="nav-item" id="nav-metrics" href="#metrics" onclick="showSection('metrics',this)"><span class="icon">📈</span> Métriques</a>
|
||||
<div class="nav-section">Paramètres</div>
|
||||
<a class="nav-item" id="nav-telegram" href="#telegram" onclick="showSection('telegram',this)"><span class="icon">📱</span> Alertes Telegram <span class="plan-lock" id="lock-tg"></span></a>
|
||||
<a class="nav-item" id="nav-api-token" href="#api-token" onclick="showSection('api-token',this)"><span class="icon">⚡</span> API Token <span class="plan-lock" id="lock-api"></span></a>
|
||||
@@ -753,11 +754,59 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════ METRICS -->
|
||||
<div id="section-metrics" class="dashboard-section" style="display:none">
|
||||
<div class="section-header">
|
||||
<h2>📈 Métriques de performance</h2>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<select id="metrics-days" style="background:var(--dark3);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 10px;font-size:.85rem" onchange="loadMetrics()">
|
||||
<option value="7">7 jours</option>
|
||||
<option value="30" selected>30 jours</option>
|
||||
<option value="90">90 jours</option>
|
||||
<option value="365">365 jours</option>
|
||||
</select>
|
||||
<button class="btn btn-sm" onclick="loadMetrics()" style="padding:4px 14px;font-size:.85rem">🔄 Rafraîchir</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI cards -->
|
||||
<div class="stats-grid" id="metrics-kpis" style="margin-bottom:20px">
|
||||
<div class="stat-card"><div class="stat-label">Total paris</div><div class="stat-value" id="m-total-bets">—</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Précision</div><div class="stat-value" id="m-precision" style="color:var(--green)">—</div></div>
|
||||
<div class="stat-card"><div class="stat-label">ROI</div><div class="stat-value" id="m-roi">—</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Mise totale</div><div class="stat-value" id="m-mise">—</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Gain total</div><div class="stat-value" id="m-gain">—</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Prédictions ML</div><div class="stat-value" id="m-ml-preds">—</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Value Bets ML</div><div class="stat-value" id="m-value-bets">—</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Prob. Top-3 moy.</div><div class="stat-value" id="m-prob-top3">—</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Charts row -->
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px">
|
||||
<div class="form-card" style="padding:16px">
|
||||
<h3 style="font-size:.9rem;margin-bottom:12px">📊 ROI & Précision quotidiens</h3>
|
||||
<canvas id="chart-roi-daily" height="200"></canvas>
|
||||
</div>
|
||||
<div class="form-card" style="padding:16px">
|
||||
<h3 style="font-size:.9rem;margin-bottom:12px">💰 Cumul gains vs mises</h3>
|
||||
<canvas id="chart-cumul" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily stats table -->
|
||||
<div class="form-card">
|
||||
<h3 style="font-size:.9rem;margin-bottom:12px">📋 Détail quotidien</h3>
|
||||
<div id="metrics-table-wrap" style="overflow-x:auto">
|
||||
<div class="loader-row"><div class="spinner"></div> Chargement…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- .content -->
|
||||
</div><!-- .main -->
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
const API = '/api/v1';
|
||||
let currentUser = null;
|
||||
@@ -793,7 +842,11 @@ function logout() {
|
||||
location.href = '/login';
|
||||
}
|
||||
|
||||
// ⚠️ TEST_MODE — mettre false pour réactiver les restrictions de plan
|
||||
const TEST_MODE = true;
|
||||
|
||||
function planLevel(plan) {
|
||||
if (TEST_MODE) return 2; // pro level pour tous
|
||||
return { free: 0, premium: 1, pro: 2 }[plan] || 0;
|
||||
}
|
||||
|
||||
@@ -830,6 +883,7 @@ const SECTION_TITLES = {
|
||||
'api-token': 'API Token',
|
||||
'webhook': 'Webhook',
|
||||
'multi-account': 'Multi-compte',
|
||||
'metrics': 'Métriques de performance',
|
||||
};
|
||||
|
||||
function showSection(name, navEl) {
|
||||
@@ -856,6 +910,7 @@ function onSectionShow(name) {
|
||||
if (name === 'api-token' && planAllows(currentPlan, 'premium')) loadApiToken();
|
||||
if (name === 'webhook' && planAllows(currentPlan, 'pro')) loadWebhook();
|
||||
if (name === 'multi-account' && planAllows(currentPlan, 'pro')) loadMultiAccount();
|
||||
if (name === 'metrics') loadMetrics();
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────
|
||||
@@ -1525,6 +1580,7 @@ function initNavFromHash() {
|
||||
'api-token': 'nav-api-token',
|
||||
'webhook': 'nav-webhook',
|
||||
'multi-account': 'nav-multi-account',
|
||||
'metrics': 'nav-metrics',
|
||||
};
|
||||
if (hash && sectionMap[hash]) {
|
||||
setTimeout(() => {
|
||||
@@ -1545,6 +1601,140 @@ window.showSection = function(name, navEl) {
|
||||
return _origShowSection(name, navEl);
|
||||
};
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────
|
||||
// Métriques
|
||||
// ────────────────────────────────────────────────────────
|
||||
let chartRoiDaily = null;
|
||||
let chartCumul = null;
|
||||
|
||||
async function loadMetrics() {
|
||||
const days = document.getElementById('metrics-days')?.value || 30;
|
||||
const data = await fetchJson(`${API}/metrics?days=${days}`);
|
||||
if (!data) return;
|
||||
|
||||
// KPIs
|
||||
const bm = data.bet_metrics || {};
|
||||
const ml = data.ml_metrics || {};
|
||||
setText('m-total-bets', bm.available ? bm.total_bets : '—');
|
||||
setText('m-precision', bm.available ? bm.precision_pct + ' %' : '—');
|
||||
const roi = bm.available ? bm.roi_pct : null;
|
||||
const roiEl = document.getElementById('m-roi');
|
||||
if (roiEl) {
|
||||
roiEl.textContent = roi !== null ? roi + ' %' : '—';
|
||||
roiEl.style.color = roi > 0 ? 'var(--green)' : roi < 0 ? '#f44' : 'var(--text)';
|
||||
}
|
||||
setText('m-mise', bm.available ? bm.mise_totale + ' €' : '—');
|
||||
setText('m-gain', bm.available ? bm.gain_total + ' €' : '—');
|
||||
setText('m-ml-preds', ml.available ? ml.total_predictions : '—');
|
||||
setText('m-value-bets', ml.available ? ml.value_bets : '—');
|
||||
setText('m-prob-top3', ml.available ? (ml.avg_prob_top3 * 100).toFixed(1) + ' %' : '—');
|
||||
|
||||
// Daily charts
|
||||
const daily = data.daily || [];
|
||||
const labels = daily.map(r => r.date ? r.date.slice(5) : '').reverse();
|
||||
const roiArr = daily.map(r => r.roi_pct || 0).reverse();
|
||||
const precArr = daily.map(r => r.precision_pct || 0).reverse();
|
||||
const gainArr = daily.map(r => r.gain_total || 0).reverse();
|
||||
const miseArr = daily.map(r => r.mise_totale || 0).reverse();
|
||||
|
||||
// Cumul gains
|
||||
const cumulGain = gainArr.reduce((acc, v, i) => { acc.push((acc[i-1]||0) + v); return acc; }, []);
|
||||
const cumulMise = miseArr.reduce((acc, v, i) => { acc.push((acc[i-1]||0) + v); return acc; }, []);
|
||||
|
||||
renderChartRoi(labels, roiArr, precArr);
|
||||
renderChartCumul(labels, cumulGain, cumulMise);
|
||||
|
||||
// Table
|
||||
renderMetricsTable(daily);
|
||||
}
|
||||
|
||||
function setText(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = val;
|
||||
}
|
||||
|
||||
function renderChartRoi(labels, roiArr, precArr) {
|
||||
const ctx = document.getElementById('chart-roi-daily');
|
||||
if (!ctx) return;
|
||||
if (chartRoiDaily) chartRoiDaily.destroy();
|
||||
chartRoiDaily = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'ROI %', data: roiArr, backgroundColor: roiArr.map(v => v >= 0 ? 'rgba(0,200,83,.6)' : 'rgba(244,67,54,.6)'), yAxisID: 'y' },
|
||||
{ label: 'Précision %', data: precArr, type: 'line', borderColor: '#ffd600', backgroundColor: 'transparent', tension: 0.3, yAxisID: 'y2', pointRadius: 2 }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: true,
|
||||
plugins: { legend: { labels: { color: '#ccc', font: { size: 11 } } } },
|
||||
scales: {
|
||||
x: { ticks: { color: '#888', maxTicksLimit: 10 }, grid: { color: 'rgba(255,255,255,.05)' } },
|
||||
y: { ticks: { color: '#888' }, grid: { color: 'rgba(255,255,255,.05)' } },
|
||||
y2: { position: 'right', ticks: { color: '#ffd600' }, grid: { display: false } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderChartCumul(labels, cumulGain, cumulMise) {
|
||||
const ctx = document.getElementById('chart-cumul');
|
||||
if (!ctx) return;
|
||||
if (chartCumul) chartCumul.destroy();
|
||||
chartCumul = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'Gain cumulé (€)', data: cumulGain, borderColor: '#00c853', backgroundColor: 'rgba(0,200,83,.1)', fill: true, tension: 0.3, pointRadius: 2 },
|
||||
{ label: 'Mise cumulée (€)', data: cumulMise, borderColor: '#aaa', backgroundColor: 'transparent', borderDash: [4,4], tension: 0.3, pointRadius: 0 }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: true,
|
||||
plugins: { legend: { labels: { color: '#ccc', font: { size: 11 } } } },
|
||||
scales: {
|
||||
x: { ticks: { color: '#888', maxTicksLimit: 10 }, grid: { color: 'rgba(255,255,255,.05)' } },
|
||||
y: { ticks: { color: '#888' }, grid: { color: 'rgba(255,255,255,.05)' } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderMetricsTable(daily) {
|
||||
const wrap = document.getElementById('metrics-table-wrap');
|
||||
if (!wrap) return;
|
||||
if (!daily.length) {
|
||||
wrap.innerHTML = '<p style="color:var(--muted);padding:12px">Aucune donnée disponible pour cette période.</p>';
|
||||
return;
|
||||
}
|
||||
const rows = daily.map(r => `
|
||||
<tr>
|
||||
<td>${r.date || '—'}</td>
|
||||
<td>${r.total_bets ?? '—'}</td>
|
||||
<td>${r.bets_gagne ?? '—'}</td>
|
||||
<td style="color:${(r.precision_pct||0)>50?'var(--green)':'var(--text)'}">${r.precision_pct != null ? r.precision_pct.toFixed(1)+' %' : '—'}</td>
|
||||
<td style="color:${(r.roi_pct||0)>0?'var(--green)':'#f44'}">${r.roi_pct != null ? (r.roi_pct>0?'+':'')+r.roi_pct.toFixed(2)+' %' : '—'}</td>
|
||||
<td>${r.mise_totale != null ? r.mise_totale.toFixed(2)+' €' : '—'}</td>
|
||||
<td style="color:${(r.gain_total||0)>0?'var(--green)':'#f44'}">${r.gain_total != null ? (r.gain_total>0?'+':'')+r.gain_total.toFixed(2)+' €' : '—'}</td>
|
||||
</tr>`).join('');
|
||||
wrap.innerHTML = `
|
||||
<table style="width:100%;border-collapse:collapse;font-size:.85rem">
|
||||
<thead><tr style="color:var(--muted);border-bottom:1px solid var(--border)">
|
||||
<th style="padding:6px 8px;text-align:left">Date</th>
|
||||
<th style="padding:6px 8px;text-align:left">Paris</th>
|
||||
<th style="padding:6px 8px;text-align:left">Gagnés</th>
|
||||
<th style="padding:6px 8px;text-align:left">Précision</th>
|
||||
<th style="padding:6px 8px;text-align:left">ROI</th>
|
||||
<th style="padding:6px 8px;text-align:left">Mise</th>
|
||||
<th style="padding:6px 8px;text-align:left">Gain</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
loadDashboard().then(initNavFromHash);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -30,7 +30,9 @@ from leadhunter_crm import (
|
||||
insert_leads,
|
||||
get_leads,
|
||||
get_lead_by_id,
|
||||
update_lead,
|
||||
update_lead_status,
|
||||
delete_lead,
|
||||
get_stats,
|
||||
export_csv,
|
||||
VALID_STATUSES,
|
||||
@@ -285,6 +287,59 @@ def api_update_status(lead_id: int):
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/leads/<int:lead_id>", methods=["GET"])
|
||||
def api_get_lead(lead_id: int):
|
||||
"""
|
||||
Retourne le detail d'un lead par son ID.
|
||||
|
||||
Returns:
|
||||
JSON avec les informations completes du lead, ou 404.
|
||||
"""
|
||||
lead = get_lead_by_id(lead_id)
|
||||
if not lead:
|
||||
return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404
|
||||
return jsonify(lead)
|
||||
|
||||
|
||||
@app.route("/api/leads/<int:lead_id>", methods=["PUT"])
|
||||
def api_put_lead(lead_id: int):
|
||||
"""
|
||||
Met a jour completement un lead.
|
||||
|
||||
Body JSON : dict avec les champs a mettre a jour.
|
||||
"""
|
||||
body = request.get_json(silent=True)
|
||||
if not body:
|
||||
return jsonify({"error": "Body JSON requis"}), 400
|
||||
|
||||
lead = get_lead_by_id(lead_id)
|
||||
if not lead:
|
||||
return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404
|
||||
|
||||
success = update_lead(lead_id, body)
|
||||
if not success:
|
||||
return jsonify({"error": "Mise a jour echouee"}), 500
|
||||
|
||||
updated_lead = get_lead_by_id(lead_id)
|
||||
return jsonify({"success": True, "lead": updated_lead})
|
||||
|
||||
|
||||
@app.route("/api/leads/<int:lead_id>", methods=["DELETE"])
|
||||
def api_delete_lead(lead_id: int):
|
||||
"""
|
||||
Supprime un lead physiquement.
|
||||
"""
|
||||
lead = get_lead_by_id(lead_id)
|
||||
if not lead:
|
||||
return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404
|
||||
|
||||
success = delete_lead(lead_id)
|
||||
if not success:
|
||||
return jsonify({"error": "Suppression echouee"}), 500
|
||||
|
||||
return jsonify({"success": True, "lead_id": lead_id, "deleted": True})
|
||||
|
||||
|
||||
@app.route("/health", methods=["GET"])
|
||||
def health():
|
||||
"""Healthcheck pour systemd / monitoring."""
|
||||
|
||||
@@ -52,8 +52,24 @@ if not logger.handlers:
|
||||
# ─── Chemin DB ───────────────────────────────────────────────────────────────
|
||||
DB_PATH = "/home/h3r7/leadhunter.db"
|
||||
|
||||
# Statuts valides pour un lead
|
||||
VALID_STATUSES = {"new", "contacted", "closed", "rejected"}
|
||||
# Statuts valides pour un lead (7 etapes Kanban)
|
||||
VALID_STATUSES = {
|
||||
"nouveau", # NOUVEAU
|
||||
"contacte", # CONTACTÉ
|
||||
"interesse", # INTÉRESSÉ
|
||||
"demo_planifiee", # DÉMO PLANIFIÉE
|
||||
"proposition_envoyee", # PROPOSITION ENVOYÉE
|
||||
"negotiation", # NÉGOCIATION
|
||||
"signe_ou_refuse", # SIGNÉ / REFUSÉ
|
||||
}
|
||||
|
||||
# Mapping des anciens statuts vers les nouveaux (pour migration)
|
||||
LEGACY_STATUS_MAP = {
|
||||
"new": "nouveau",
|
||||
"contacted": "contacte",
|
||||
"closed": "signe_ou_refuse",
|
||||
"rejected": "signe_ou_refuse",
|
||||
}
|
||||
|
||||
|
||||
# ─── Initialisation ──────────────────────────────────────────────────────────
|
||||
@@ -212,6 +228,77 @@ def get_lead_by_id(lead_id: int, db_path: str = DB_PATH) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def update_lead(lead_id: int, data: dict, db_path: str = DB_PATH) -> bool:
|
||||
"""
|
||||
Met à jour un lead avec les champs fournis.
|
||||
|
||||
Args:
|
||||
lead_id: id du lead.
|
||||
data: dict avec les champs a mettre a jour (name, address, phone, etc.)
|
||||
|
||||
Returns:
|
||||
True si mise a jour reussie, False sinon.
|
||||
"""
|
||||
allowed_fields = {
|
||||
"name",
|
||||
"address",
|
||||
"phone",
|
||||
"rating",
|
||||
"reviews_count",
|
||||
"website",
|
||||
"score",
|
||||
"rgpd_ok",
|
||||
"status",
|
||||
}
|
||||
fields_to_update = {k: v for k, v in data.items() if k in allowed_fields}
|
||||
|
||||
if not fields_to_update:
|
||||
logger.warning(
|
||||
f"update_lead : aucun champ valide fourni pour lead_id={lead_id}"
|
||||
)
|
||||
return False
|
||||
|
||||
if (
|
||||
"status" in fields_to_update
|
||||
and fields_to_update["status"] not in VALID_STATUSES
|
||||
):
|
||||
logger.warning(f"update_lead : statut invalide '{fields_to_update['status']}'")
|
||||
return False
|
||||
|
||||
try:
|
||||
with _get_conn(db_path) as conn:
|
||||
set_clause = ", ".join([f"{k} = ?" for k in fields_to_update])
|
||||
values = list(fields_to_update.values()) + [lead_id]
|
||||
conn.execute(f"UPDATE leads SET {set_clause} WHERE id = ?", values)
|
||||
logger.info(
|
||||
f"Lead id={lead_id} mis a jour : {list(fields_to_update.keys())}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"update_lead error : {e}")
|
||||
return False
|
||||
|
||||
|
||||
def delete_lead(lead_id: int, db_path: str = DB_PATH) -> bool:
|
||||
"""
|
||||
Supprime un lead physiquement.
|
||||
|
||||
Args:
|
||||
lead_id: id du lead a supprimer.
|
||||
|
||||
Returns:
|
||||
True si suppression reussie, False sinon.
|
||||
"""
|
||||
try:
|
||||
with _get_conn(db_path) as conn:
|
||||
conn.execute("DELETE FROM leads WHERE id = ?", (lead_id,))
|
||||
logger.info(f"Lead id={lead_id} supprime")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"delete_lead error : {e}")
|
||||
return False
|
||||
|
||||
|
||||
def update_lead_status(lead_id: int, status: str, db_path: str = DB_PATH) -> bool:
|
||||
"""
|
||||
Met à jour le statut d'un lead.
|
||||
|
||||
600
ml_feedback_saas.py
Normal file
600
ml_feedback_saas.py
Normal file
@@ -0,0 +1,600 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ml_feedback_saas.py — Feedback loop ML pour turf_saas.
|
||||
Enregistre les paris virtuels XGBoost depuis ml_predictions_cache
|
||||
et met à jour les résultats/dividendes depuis pmu_partants + pmu_rapports.
|
||||
|
||||
DB cible : /home/h3r7/turf_saas/turf_saas.db
|
||||
|
||||
Stratégies :
|
||||
A) xgboost_sg : simple_gagnant — top1 ML par course, ml_score >= 70, mise 1€
|
||||
B) xgboost_value : simple_gagnant — is_value_bet = 1, mise 1€
|
||||
C) xgboost_sp : simple_place — top1 ML par course, ml_score >= 50, mise 1€
|
||||
D) xgboost_2sur4 : deux_sur_quatre — top4 ML par course, 6 combos x 1€ = mise 6€
|
||||
|
||||
Usage :
|
||||
python3 ml_feedback_saas.py # Traite aujourd'hui
|
||||
python3 ml_feedback_saas.py --backfill 2026-04-25
|
||||
python3 ml_feedback_saas.py --date 2026-04-25
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
|
||||
|
||||
os.makedirs("/home/h3r7/turf_saas/logs", exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [ml_feedback_saas] %(levelname)s %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler("/home/h3r7/turf_saas/logs/ml_feedback_saas.log"),
|
||||
logging.StreamHandler(),
|
||||
],
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# UTILITAIRES
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def pari_existe(cursor, date, num_reunion, num_course, numero1, type_pari, source_reco):
|
||||
"""Vérifie si un pari identique existe déjà (idempotence)."""
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id FROM paris
|
||||
WHERE date_course = ? AND source_reco = ?
|
||||
AND type_pari = ? AND numero1 = ?
|
||||
AND race_label = ?
|
||||
""",
|
||||
(date, source_reco, type_pari, numero1, f"R{num_reunion}C{num_course}"),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
|
||||
def pari_2sur4_existe(cursor, date, num_reunion, num_course, source_reco):
|
||||
"""Vérifie si un pari 2sur4 existe déjà pour cette course."""
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id FROM paris
|
||||
WHERE date_course = ? AND source_reco = ?
|
||||
AND race_label = ?
|
||||
""",
|
||||
(date, source_reco, f"R{num_reunion}C{num_course}"),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
|
||||
def get_top_ml_par_course(cursor, date, n=4, min_score=0):
|
||||
"""Retourne les n meilleurs chevaux ML par course pour une date."""
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT num_reunion, num_course, horse_name, horse_number,
|
||||
ml_score, odds, recommendation, is_value_bet,
|
||||
race_label, race_name, hippodrome, heure,
|
||||
discipline, distance
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ?
|
||||
AND ml_score >= ?
|
||||
ORDER BY num_reunion, num_course, ml_score DESC
|
||||
""",
|
||||
(date, min_score),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
courses = {}
|
||||
for r in rows:
|
||||
key = (r["num_reunion"], r["num_course"])
|
||||
if key not in courses:
|
||||
courses[key] = []
|
||||
if len(courses[key]) < n:
|
||||
courses[key].append(dict(r))
|
||||
return courses
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# STRATÉGIE A — Simple Gagnant top1 ML (score >= 70)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def save_ml_paris_sg(conn, date):
|
||||
"""Insère 1 pari simple_gagnant par course : top1 ML avec ml_score >= 70."""
|
||||
cursor = conn.cursor()
|
||||
courses = get_top_ml_par_course(cursor, date, n=1, min_score=70)
|
||||
inseres = 0
|
||||
|
||||
for (num_reunion, num_course), chevaux in courses.items():
|
||||
cheval = chevaux[0]
|
||||
if pari_existe(
|
||||
cursor,
|
||||
date,
|
||||
num_reunion,
|
||||
num_course,
|
||||
cheval["horse_number"],
|
||||
"simple_gagnant",
|
||||
"xgboost_sg",
|
||||
):
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO paris
|
||||
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||
statut, gain, source_reco, model_source)
|
||||
VALUES (?, ?, ?, ?, ?, 'simple_gagnant', ?, ?, ?, ?, 1.0,
|
||||
'EN_ATTENTE', 0.0, 'xgboost_sg', 'xgboost_v1')
|
||||
""",
|
||||
(
|
||||
date,
|
||||
date,
|
||||
cheval.get("race_name") or "",
|
||||
f"R{num_reunion}C{num_course}",
|
||||
cheval.get("hippodrome") or "",
|
||||
cheval["horse_name"],
|
||||
cheval["horse_name"],
|
||||
cheval["horse_number"],
|
||||
cheval["odds"],
|
||||
),
|
||||
)
|
||||
inseres += 1
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[SG] {date} → {inseres} paris simple_gagnant insérés (score>=70)")
|
||||
return inseres
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# STRATÉGIE B — Value Bet (is_value_bet = 1)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def save_ml_paris_value(conn, date):
|
||||
"""Insère 1 pari simple_gagnant pour chaque cheval is_value_bet=1."""
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT num_reunion, num_course, horse_name, horse_number,
|
||||
ml_score, odds, race_label, race_name, hippodrome
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ? AND is_value_bet = 1
|
||||
ORDER BY num_reunion, num_course, ml_score DESC
|
||||
""",
|
||||
(date,),
|
||||
)
|
||||
rows = [dict(r) for r in cursor.fetchall()]
|
||||
inseres = 0
|
||||
|
||||
for r in rows:
|
||||
if pari_existe(
|
||||
cursor,
|
||||
date,
|
||||
r["num_reunion"],
|
||||
r["num_course"],
|
||||
r["horse_number"],
|
||||
"simple_gagnant",
|
||||
"xgboost_value",
|
||||
):
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO paris
|
||||
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||
statut, gain, source_reco, model_source)
|
||||
VALUES (?, ?, ?, ?, ?, 'simple_gagnant', ?, ?, ?, ?, 1.0,
|
||||
'EN_ATTENTE', 0.0, 'xgboost_value', 'xgboost_v1')
|
||||
""",
|
||||
(
|
||||
date,
|
||||
date,
|
||||
r.get("race_name") or "",
|
||||
r.get("race_label") or f"R{r['num_reunion']}C{r['num_course']}",
|
||||
r.get("hippodrome") or "",
|
||||
r["horse_name"],
|
||||
r["horse_name"],
|
||||
r["horse_number"],
|
||||
r["odds"],
|
||||
),
|
||||
)
|
||||
inseres += 1
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[VALUE] {date} → {inseres} paris value_bet insérés")
|
||||
return inseres
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# STRATÉGIE C — Simple Placé top1 ML (score >= 50)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def save_ml_paris_sp(conn, date):
|
||||
"""Insère 1 pari simple_place par course : top1 ML avec ml_score >= 50."""
|
||||
cursor = conn.cursor()
|
||||
courses = get_top_ml_par_course(cursor, date, n=1, min_score=50)
|
||||
inseres = 0
|
||||
|
||||
for (num_reunion, num_course), chevaux in courses.items():
|
||||
cheval = chevaux[0]
|
||||
if pari_existe(
|
||||
cursor,
|
||||
date,
|
||||
num_reunion,
|
||||
num_course,
|
||||
cheval["horse_number"],
|
||||
"simple_place",
|
||||
"xgboost_sp",
|
||||
):
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO paris
|
||||
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||
statut, gain, source_reco, model_source)
|
||||
VALUES (?, ?, ?, ?, ?, 'simple_place', ?, ?, ?, ?, 1.0,
|
||||
'EN_ATTENTE', 0.0, 'xgboost_sp', 'xgboost_v1')
|
||||
""",
|
||||
(
|
||||
date,
|
||||
date,
|
||||
cheval.get("race_name") or "",
|
||||
f"R{num_reunion}C{num_course}",
|
||||
cheval.get("hippodrome") or "",
|
||||
cheval["horse_name"],
|
||||
cheval["horse_name"],
|
||||
cheval["horse_number"],
|
||||
cheval["odds"],
|
||||
),
|
||||
)
|
||||
inseres += 1
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[SP] {date} → {inseres} paris simple_place insérés (score>=50)")
|
||||
return inseres
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# STRATÉGIE D — 2sur4 top4 ML (6 combinaisons x 1€)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def save_ml_paris_2sur4(conn, date):
|
||||
"""Insère 1 pari deux_sur_quatre par course : top4 ML, mise 6€."""
|
||||
cursor = conn.cursor()
|
||||
courses = get_top_ml_par_course(cursor, date, n=4, min_score=0)
|
||||
inseres = 0
|
||||
|
||||
for (num_reunion, num_course), chevaux in courses.items():
|
||||
if len(chevaux) < 4:
|
||||
continue
|
||||
if pari_2sur4_existe(cursor, date, num_reunion, num_course, "xgboost_2sur4"):
|
||||
continue
|
||||
|
||||
top4 = chevaux[:4]
|
||||
nums = [str(c["horse_number"]) for c in top4]
|
||||
noms = [c["horse_name"] for c in top4]
|
||||
chevaux_str = "/".join(noms)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO paris
|
||||
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||
statut, gain, source_reco, model_source, commentaire)
|
||||
VALUES (?, ?, ?, ?, ?, 'deux_sur_quatre', ?, ?, ?, 0.0, 6.0,
|
||||
'EN_ATTENTE', 0.0, 'xgboost_2sur4', 'xgboost_v1', ?)
|
||||
""",
|
||||
(
|
||||
date,
|
||||
date,
|
||||
top4[0].get("race_name") or "",
|
||||
f"R{num_reunion}C{num_course}",
|
||||
top4[0].get("hippodrome") or "",
|
||||
chevaux_str,
|
||||
top4[0]["horse_name"],
|
||||
top4[0]["horse_number"],
|
||||
f"top4 ML: {'/'.join(nums)}",
|
||||
),
|
||||
)
|
||||
inseres += 1
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[2S4] {date} → {inseres} paris deux_sur_quatre insérés")
|
||||
return inseres
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# UPDATE RÉSULTATS + DIVIDENDES
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def update_ml_paris_results(conn, date):
|
||||
"""
|
||||
Met à jour statut + gain (dividende PMU réel) pour tous les paris ML EN_ATTENTE.
|
||||
Sources: pmu_partants (ordre_arrivee) + pmu_rapports (dividende_euro).
|
||||
"""
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, race_label, type_pari, numero1, chevaux, mise, source_reco, commentaire
|
||||
FROM paris
|
||||
WHERE date_course = ? AND statut = 'EN_ATTENTE'
|
||||
AND source_reco LIKE 'xgboost%'
|
||||
""",
|
||||
(date,),
|
||||
)
|
||||
paris = [dict(r) for r in cursor.fetchall()]
|
||||
|
||||
if not paris:
|
||||
log.info(f"[UPDATE] {date} → aucun pari ML EN_ATTENTE")
|
||||
return 0
|
||||
|
||||
maj = 0
|
||||
for pari in paris:
|
||||
pari_id = pari["id"]
|
||||
race_label = pari["race_label"] or ""
|
||||
type_pari = pari["type_pari"]
|
||||
numero1 = pari["numero1"]
|
||||
mise = pari["mise"]
|
||||
|
||||
# Extraire num_reunion / num_course depuis le race_label "R{r}C{c}"
|
||||
try:
|
||||
parts = race_label.replace("R", "").split("C")
|
||||
num_reunion = int(parts[0])
|
||||
num_course = int(parts[1])
|
||||
except Exception:
|
||||
log.warning(f"[UPDATE] race_label invalide : {race_label}")
|
||||
continue
|
||||
|
||||
if type_pari == "simple_gagnant":
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT ordre_arrivee FROM pmu_partants
|
||||
WHERE date_programme = ? AND num_reunion = ?
|
||||
AND num_course = ? AND num_pmu = ?
|
||||
""",
|
||||
(date, num_reunion, num_course, numero1),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row or row["ordre_arrivee"] is None or row["ordre_arrivee"] == 0:
|
||||
continue
|
||||
|
||||
gagne = row["ordre_arrivee"] == 1
|
||||
gain = 0.0
|
||||
if gagne:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT dividende_euro FROM pmu_rapports
|
||||
WHERE date_programme = ? AND num_reunion = ?
|
||||
AND num_course = ? AND type_pari = 'SIMPLE_GAGNANT'
|
||||
AND CAST(combinaison AS INTEGER) = ?
|
||||
AND libelle NOT LIKE '%NP%'
|
||||
""",
|
||||
(date, num_reunion, num_course, numero1),
|
||||
)
|
||||
div = cursor.fetchone()
|
||||
gain = div["dividende_euro"] if div and div["dividende_euro"] else 0.0
|
||||
|
||||
cursor.execute(
|
||||
"UPDATE paris SET statut=?, gain=? WHERE id=?",
|
||||
("GAGNE" if gagne else "PERDU", gain, pari_id),
|
||||
)
|
||||
maj += 1
|
||||
|
||||
elif type_pari == "simple_place":
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT ordre_arrivee FROM pmu_partants
|
||||
WHERE date_programme = ? AND num_reunion = ?
|
||||
AND num_course = ? AND num_pmu = ?
|
||||
""",
|
||||
(date, num_reunion, num_course, numero1),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row or not row["ordre_arrivee"]:
|
||||
continue
|
||||
|
||||
gagne = 1 <= row["ordre_arrivee"] <= 3
|
||||
gain = 0.0
|
||||
if gagne:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT dividende_euro FROM pmu_rapports
|
||||
WHERE date_programme = ? AND num_reunion = ?
|
||||
AND num_course = ? AND type_pari = 'SIMPLE_PLACE'
|
||||
AND CAST(combinaison AS INTEGER) = ?
|
||||
AND libelle NOT LIKE '%NP%'
|
||||
""",
|
||||
(date, num_reunion, num_course, numero1),
|
||||
)
|
||||
div = cursor.fetchone()
|
||||
gain = div["dividende_euro"] if div and div["dividende_euro"] else 0.0
|
||||
|
||||
cursor.execute(
|
||||
"UPDATE paris SET statut=?, gain=? WHERE id=?",
|
||||
("GAGNE" if gagne else "PERDU", gain, pari_id),
|
||||
)
|
||||
maj += 1
|
||||
|
||||
elif type_pari == "deux_sur_quatre":
|
||||
# Récupère les 4 numéros depuis commentaire "top4 ML: n1/n2/n3/n4"
|
||||
try:
|
||||
nums_str = (
|
||||
pari["commentaire"].split(": ")[1]
|
||||
if pari.get("commentaire")
|
||||
else ""
|
||||
)
|
||||
nums_top4 = [int(n) for n in nums_str.split("/") if n.strip().isdigit()]
|
||||
except Exception:
|
||||
nums_top4 = []
|
||||
|
||||
if len(nums_top4) < 4:
|
||||
# Fallback : reconstituer top4 depuis ml_predictions_cache
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT horse_number FROM ml_predictions_cache
|
||||
WHERE date = ? AND num_reunion = ? AND num_course = ?
|
||||
ORDER BY ml_score DESC LIMIT 4
|
||||
""",
|
||||
(date, num_reunion, num_course),
|
||||
)
|
||||
nums_top4 = [r["horse_number"] for r in cursor.fetchall()]
|
||||
|
||||
if len(nums_top4) < 2:
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT combinaison, dividende_euro FROM pmu_rapports
|
||||
WHERE date_programme = ? AND num_reunion = ?
|
||||
AND num_course = ? AND type_pari = 'DEUX_SUR_QUATRE'
|
||||
AND libelle NOT LIKE '%NP%'
|
||||
""",
|
||||
(date, num_reunion, num_course),
|
||||
)
|
||||
rapports = [dict(r) for r in cursor.fetchall()]
|
||||
gain_total = 0.0
|
||||
|
||||
for rap in rapports:
|
||||
try:
|
||||
n1, n2 = [int(x) for x in rap["combinaison"].split("-")]
|
||||
except Exception:
|
||||
continue
|
||||
if n1 in nums_top4 and n2 in nums_top4:
|
||||
gain_total += rap["dividende_euro"]
|
||||
|
||||
gagne = gain_total > 0
|
||||
cursor.execute(
|
||||
"UPDATE paris SET statut=?, gain=? WHERE id=?",
|
||||
("GAGNE" if gagne else "PERDU", round(gain_total, 2), pari_id),
|
||||
)
|
||||
maj += 1
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[UPDATE] {date} → {maj}/{len(paris)} paris ML mis à jour")
|
||||
return maj
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# STATS PAR STRATÉGIE
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_feedback_stats(conn, date_debut=None, date_fin=None):
|
||||
"""Stats performances ML par stratégie (source_reco)."""
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT source_reco,
|
||||
COUNT(*) as n_paris,
|
||||
SUM(CASE WHEN statut='GAGNE' THEN 1 ELSE 0 END) as n_gagne,
|
||||
SUM(CASE WHEN statut='PERDU' THEN 1 ELSE 0 END) as n_perdu,
|
||||
SUM(CASE WHEN statut='EN_ATTENTE' THEN 1 ELSE 0 END) as n_attente,
|
||||
ROUND(100.0 * SUM(CASE WHEN statut='GAGNE' THEN 1 ELSE 0 END)
|
||||
/ NULLIF(SUM(CASE WHEN statut IN ('GAGNE','PERDU') THEN 1 ELSE 0 END), 0), 1) as win_rate_pct,
|
||||
ROUND(SUM(gain), 2) as gain_total,
|
||||
ROUND(SUM(mise), 2) as mise_totale,
|
||||
ROUND(SUM(gain) - SUM(mise), 2) as roi_net
|
||||
FROM paris
|
||||
WHERE source_reco LIKE 'xgboost%'
|
||||
AND (:debut IS NULL OR date_course >= :debut)
|
||||
AND (:fin IS NULL OR date_course <= :fin)
|
||||
GROUP BY source_reco
|
||||
ORDER BY source_reco
|
||||
""",
|
||||
{"debut": date_debut, "fin": date_fin},
|
||||
)
|
||||
return [dict(r) for r in cursor.fetchall()]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# PIPELINE COMPLET
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def run(date):
|
||||
"""Enregistre les paris ML du jour + met à jour les résultats de J-1."""
|
||||
conn = get_db()
|
||||
log.info(f"=== ml_feedback_saas.run({date}) ===")
|
||||
|
||||
# 1. Enregistre les paris ML pour la date (depuis le cache du jour)
|
||||
sg = save_ml_paris_sg(conn, date)
|
||||
vb = save_ml_paris_value(conn, date)
|
||||
sp = save_ml_paris_sp(conn, date)
|
||||
s4 = save_ml_paris_2sur4(conn, date)
|
||||
log.info(f"[SAVE] {date} → total insérés : SG={sg} VALUE={vb} SP={sp} 2S4={s4}")
|
||||
|
||||
# 2. Met à jour les résultats de J-1 (résultats PMU disponibles)
|
||||
yesterday = (datetime.strptime(date, "%Y-%m-%d") - timedelta(days=1)).strftime(
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
maj = update_ml_paris_results(conn, yesterday)
|
||||
log.info(f"[UPDATE] {yesterday} → {maj} paris mis à jour")
|
||||
|
||||
conn.close()
|
||||
return {"inseres": {"sg": sg, "value": vb, "sp": sp, "2sur4": s4}, "maj": maj}
|
||||
|
||||
|
||||
def backfill(date):
|
||||
"""Backfill : insère ET met à jour les résultats pour une date passée."""
|
||||
conn = get_db()
|
||||
log.info(f"=== ml_feedback_saas.backfill({date}) ===")
|
||||
|
||||
sg = save_ml_paris_sg(conn, date)
|
||||
vb = save_ml_paris_value(conn, date)
|
||||
sp = save_ml_paris_sp(conn, date)
|
||||
s4 = save_ml_paris_2sur4(conn, date)
|
||||
log.info(f"[SAVE] {date} → SG={sg} VALUE={vb} SP={sp} 2S4={s4}")
|
||||
|
||||
maj = update_ml_paris_results(conn, date)
|
||||
log.info(f"[UPDATE] {date} → {maj} paris mis à jour")
|
||||
|
||||
conn.close()
|
||||
return sg + vb + sp + s4, maj
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# MAIN
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--backfill" in sys.argv:
|
||||
idx = sys.argv.index("--backfill")
|
||||
date = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else None
|
||||
if not date:
|
||||
print("Usage: python3 ml_feedback_saas.py --backfill YYYY-MM-DD")
|
||||
sys.exit(1)
|
||||
inseres, maj = backfill(date)
|
||||
print(f"Backfill {date} : {inseres} paris insérés, {maj} mis à jour")
|
||||
|
||||
elif "--date" in sys.argv:
|
||||
idx = sys.argv.index("--date")
|
||||
date = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else None
|
||||
if not date:
|
||||
print("Usage: python3 ml_feedback_saas.py --date YYYY-MM-DD")
|
||||
sys.exit(1)
|
||||
result = run(date)
|
||||
total = sum(result["inseres"].values())
|
||||
print(f"Run {date} : {total} paris insérés, {result['maj']} mis à jour")
|
||||
|
||||
else:
|
||||
result = run(datetime.now().strftime("%Y-%m-%d"))
|
||||
total = sum(result["inseres"].values())
|
||||
print(f"Run today : {total} paris insérés, {result['maj']} mis à jour")
|
||||
@@ -19,9 +19,13 @@ SAAS_DIR = "/home/h3r7/turf_saas"
|
||||
try:
|
||||
from saas_auth import auth_bp
|
||||
from saas_api_v1 import api_v1_bp
|
||||
from api_v1.routes.ml_feedback import ml_feedback_bp
|
||||
from api_v1.routes.metrics import metrics_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(api_v1_bp)
|
||||
app.register_blueprint(ml_feedback_bp)
|
||||
app.register_blueprint(metrics_bp)
|
||||
print("[portal] SaaS auth & API v1 blueprints registered ✅")
|
||||
except Exception as e:
|
||||
print(f"[portal] Warning: could not register SaaS blueprints: {e}")
|
||||
@@ -352,6 +356,29 @@ def template_complet():
|
||||
return send_from_directory("/home/h3r7/turf_saas", "template_complet.html")
|
||||
|
||||
|
||||
@app.route("/leadhunter/clients/le-big-ben/")
|
||||
@app.route("/leadhunter/clients/le-big-ben")
|
||||
def big_ben():
|
||||
return send_from_directory(
|
||||
"/home/h3r7/turf_saas/templates/leadhunter/clients/le-big-ben", "index.html"
|
||||
)
|
||||
|
||||
|
||||
@app.route("/leadhunter/clients/le-big-ben/sitemap.xml")
|
||||
def big_ben_sitemap():
|
||||
return send_from_directory(
|
||||
"/home/h3r7/turf_saas/templates/leadhunter/clients/le-big-ben",
|
||||
"sitemap.xml",
|
||||
mimetype="application/xml",
|
||||
)
|
||||
|
||||
|
||||
@app.route("/formation/ai102")
|
||||
@app.route("/formation/ai102/")
|
||||
def certif_ai102():
|
||||
return send_from_directory("/home/h3r7/turf_saas/pitch", "certif-ai102.html")
|
||||
|
||||
|
||||
@app.route("/boite_a_idees_dashboard")
|
||||
def boite_a_idees_dashboard():
|
||||
return send_from_directory("/home/h3r7/turf_saas", "boite_a_idees_dashboard.html")
|
||||
|
||||
@@ -31,3 +31,6 @@ python-dotenv==1.1.0
|
||||
|
||||
# Utilities
|
||||
python-dateutil==2.9.0
|
||||
|
||||
# Hyperparameter optimization (ML ensemble tuning — HRT-136)
|
||||
optuna>=4.0.0
|
||||
|
||||
@@ -298,3 +298,35 @@ try:
|
||||
print("[saas_api_v1] Org blueprint (multi-compte Pro) registered ✅")
|
||||
except Exception as _org_err:
|
||||
print(f"[saas_api_v1] Warning: org blueprint not loaded: {_org_err}")
|
||||
|
||||
|
||||
# ─── User Blueprint — HRT-79 (Telegram) + HRT-80 (API Token + Webhook) ───────
|
||||
# Registers /api/v1/user/* routes (Premium+ for telegram, Pro for api-token/webhook)
|
||||
try:
|
||||
from api_v1.routes.user import user_bp
|
||||
from api_v1.routes.user_tokens import user_tokens_bp
|
||||
|
||||
@api_v1_bp.record_once
|
||||
def _register_user_bp(state):
|
||||
app = state.app
|
||||
app.register_blueprint(user_bp)
|
||||
app.register_blueprint(user_tokens_bp)
|
||||
|
||||
print('[saas_api_v1] User blueprint (Telegram config + API token + Webhook) registered ✅')
|
||||
except Exception as _user_err:
|
||||
print(f'[saas_api_v1] Warning: user blueprints not loaded: {_user_err}')
|
||||
|
||||
|
||||
# ─── History Blueprint — HRT-81 ───────────────────────────────────────────────
|
||||
# Registers /api/v1/history route (Free:7j, Premium:90j, Pro:illimité)
|
||||
try:
|
||||
from api_v1.routes.history import history_bp
|
||||
|
||||
@api_v1_bp.record_once
|
||||
def _register_history_bp(state):
|
||||
app = state.app
|
||||
app.register_blueprint(history_bp)
|
||||
|
||||
print('[saas_api_v1] History blueprint (plan-limited history) registered ✅')
|
||||
except Exception as _history_err:
|
||||
print(f'[saas_api_v1] Warning: history blueprint not loaded: {_history_err}')
|
||||
|
||||
@@ -52,6 +52,9 @@ def auth_header(token: str) -> dict:
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
# Enforce this module s temp DB
|
||||
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||
os.environ["JWT_SECRET_KEY"] = "test-history-secret-key"
|
||||
application = create_app()
|
||||
application.config["TESTING"] = True
|
||||
application.config["JWT_SECRET_KEY"] = "test-history-secret-key"
|
||||
@@ -70,7 +73,14 @@ def seeded_db():
|
||||
- Create ml_predictions_cache with rows spanning 120 days back
|
||||
- Create users for free/premium/pro plans
|
||||
"""
|
||||
db_path = os.environ["TURF_SAAS_DB"]
|
||||
# Reset TURF_SAAS_DB to this module-s temp DB at runtime
|
||||
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||
db_path = _tmp_db.name
|
||||
|
||||
# Ensure auth tables (users, refresh_tokens, subscriptions) exist in the test DB
|
||||
# init_auth_tables() is idempotent — safe to call even if tables already exist
|
||||
init_auth_tables()
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
|
||||
# Create ml_predictions_cache table if absent
|
||||
@@ -124,7 +134,9 @@ def auth_tokens(client, seeded_db):
|
||||
assert r.status_code in (201, 409), f"register failed for {plan}: {r.data}"
|
||||
|
||||
# Set plan via direct DB
|
||||
db_path = os.environ["TURF_SAAS_DB"]
|
||||
# Reset TURF_SAAS_DB to this module-s temp DB at runtime
|
||||
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||
db_path = _tmp_db.name
|
||||
conn = sqlite3.connect(db_path)
|
||||
for plan, email in plans.items():
|
||||
conn.execute("UPDATE users SET plan = ? WHERE email = ?", (plan, email))
|
||||
|
||||
@@ -36,6 +36,7 @@ os.environ["JWT_SECRET_KEY"] = "test-secret-hrt80"
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from app_v1 import create_app # noqa: E402
|
||||
from api_tokens_db import migrate_api_tokens_tables # noqa: E402
|
||||
|
||||
TEST_CONFIG = {
|
||||
"TESTING": True,
|
||||
@@ -45,6 +46,10 @@ TEST_CONFIG = {
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
# Enforce this module s temp DB at fixture runtime
|
||||
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||
os.environ["JWT_SECRET_KEY"] = "test-secret-hrt80"
|
||||
migrate_api_tokens_tables() # ensure tables exist in THIS module s temp DB
|
||||
application = create_app()
|
||||
application.config.update(TEST_CONFIG)
|
||||
yield application
|
||||
|
||||
Reference in New Issue
Block a user