""" AHAD QUANT Forex V4_RL — Web Command Center v4 Interface de contrôle complète — ML + RL Pipeline. Fonctionnalités : • Dashboard : balance, PnL, win rate, positions ouvertes • RL Monitor : progress, courbes d'entraînement, fine-tuning • Backtest : résultats ML seul et ML+RL • Trades : journal paper trading + courbe equity • MT5 Bridge : monitoring connexion, signaux, rapports • Config : éditeur .env • Terminal : logs temps réel • Features : feature importance chart Lancer: pip install fastapi uvicorn requests python web_ui.py Puis ouvrir: http://localhost:8080 """ import os, json, time, subprocess, sys, threading, queue, pickle from datetime import datetime from pathlib import Path from typing import Optional, Generator try: import requests as _ext_requests HAS_REQUESTS = True except ImportError: HAS_REQUESTS = False from fastapi import FastAPI, Request, HTTPException, Depends from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse, FileResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials import uvicorn import config import csv as _csv_mod # ═══════════════════════════════════════════════════════════════════════════════ # MT5 Bridge — lecteurs CSV directs # ═══════════════════════════════════════════════════════════════════════════════ def _mt5_path() -> Optional[Path]: raw = getattr(config, "MT5_FILES_PATH", os.getenv("MT5_FILES_PATH", "")) if not raw: return None p = Path(raw) return p if p.exists() else None def read_mt5_status() -> dict: p = _mt5_path() if not p: return {} try: f = p / "status.csv" if not f.exists(): return {} with open(f, encoding="utf-8", newline="") as fh: rows = list(_csv_mod.DictReader(fh)) if not rows: return {} r = rows[-1] return { "timestamp": r.get("timestamp", ""), "balance": float(r.get("balance", 0) or 0), "equity": float(r.get("equity", 0) or 0), "margin": float(r.get("margin", 0) or 0), "free_margin": float(r.get("free_margin", 0) or 0), "open_positions": int(r.get("open_positions", 0) or 0), "daily_pnl": float(r.get("daily_pnl", 0) or 0), } except Exception: return {} def _normalize_mt5_status(raw: str) -> tuple: raw = (raw or "").strip().upper() if raw in ("OPENED", "OPEN"): return "OPEN", raw, "" if raw in ("SL_HIT", "TP_HIT", "TIMEOUT", "MANUAL") or raw.startswith("CLOSED"): return "CLOSED", raw, raw if raw in ("SYMBOL_NOT_FOUND", "NO_PRICE", "INSUFFICIENT_MARGIN") or raw.startswith("FAILED"): return "REJECTED", raw, raw return raw, raw, "" def read_mt5_reports(limit: int = 200) -> list: p = _mt5_path() if not p: return [] try: f = p / "reports.csv" if not f.exists(): return [] with open(f, encoding="utf-8-sig", newline="") as fh: rows = list(_csv_mod.DictReader(fh)) normalized = [] for row in rows: sn, sr, cr = _normalize_mt5_status(row.get("status", "")) row["status"] = sn; row["status_raw"] = sr; row["close_reason"] = cr normalized.append(row) return list(reversed(normalized[-limit:])) except Exception: return [] def read_bridge_health() -> dict: p = _mt5_path() out = {"signals_rows": 0, "reports_rows": 0, "last_signal_id": None, "last_signal_ts": None, "last_report_id": None, "last_report_ts": None, "pending_signals": 0, "gap_seconds": None} if not p: return out try: sf = p / "signals.csv" if sf.exists(): with open(sf, encoding="utf-8-sig", newline="") as fh: rows = list(_csv_mod.DictReader(fh)) out["signals_rows"] = len(rows) if rows: out["last_signal_id"] = rows[-1].get("signal_id") out["last_signal_ts"] = rows[-1].get("timestamp") rf = p / "reports.csv" ack_ids = set() if rf.exists(): with open(rf, encoding="utf-8-sig", newline="") as fh: rrows = list(_csv_mod.DictReader(fh)) out["reports_rows"] = len(rrows) ack_ids = {r.get("signal_id") for r in rrows} if rrows: out["last_report_id"] = rrows[-1].get("signal_id") out["last_report_ts"] = rrows[-1].get("open_time") or rrows[-1].get("close_time") if sf.exists(): out["pending_signals"] = sum(1 for r in rows if r.get("signal_id") not in ack_ids) if out["last_signal_ts"] and out["last_report_ts"]: try: t1 = datetime.fromisoformat(out["last_signal_ts"].replace("Z", "+00:00")).replace(tzinfo=None) t2 = datetime.fromisoformat(out["last_report_ts"].replace("Z", "+00:00")).replace(tzinfo=None) out["gap_seconds"] = round((t2 - t1).total_seconds(), 1) except Exception: pass except Exception: pass return out def _mt5_connected(status: dict) -> bool: ts = status.get("timestamp", "") if not ts: return False try: dt = datetime.fromisoformat(ts.replace("Z", "+00:00")).replace(tzinfo=None) return (datetime.utcnow() - dt).total_seconds() < 90 except Exception: return False # ═══════════════════════════════════════════════════════════════════════════════ # App + Middleware # ═══════════════════════════════════════════════════════════════════════════════ app = FastAPI(title="AHAD QUANT V4_RL", docs_url=None, redoc_url=None) _ALLOWED_ORIGINS = os.getenv( "WEB_UI_CORS_ORIGINS", "http://localhost:8080,http://127.0.0.1:8080" ).split(",") app.add_middleware( CORSMiddleware, allow_origins=_ALLOWED_ORIGINS, allow_methods=["*"], allow_headers=["*"], ) _WEB_UI_TOKEN: str = os.getenv("WEB_UI_TOKEN", "") _http_bearer = HTTPBearer(auto_error=False) if not _WEB_UI_TOKEN: import warnings warnings.warn( "[SÉCURITÉ] WEB_UI_TOKEN non configuré — API accessible sans auth. " "Définir WEB_UI_TOKEN dans .env avant déploiement VPS.", stacklevel=1, ) def require_auth(credentials: HTTPAuthorizationCredentials = Depends(_http_bearer)): if not _WEB_UI_TOKEN: return if credentials is None or credentials.credentials != _WEB_UI_TOKEN: raise HTTPException(status_code=401, detail="Token invalide. Configurer WEB_UI_TOKEN dans .env") # ═══════════════════════════════════════════════════════════════════════════════ # Log infrastructure # ═══════════════════════════════════════════════════════════════════════════════ _log_queue: queue.Queue = queue.Queue(maxsize=2000) _log_history: list = [] _log_lock = threading.Lock() _reset_version: int = 0 # incrémenté à chaque reset global — le client peut détecter un nouveau reset def _emit(msg: str, proc: str = "UI"): ts = datetime.now().strftime("%H:%M:%S") entry = {"t": ts, "p": proc, "m": msg.rstrip()} with _log_lock: _log_history.append(entry) if len(_log_history) > 1000: _log_history.pop(0) try: _log_queue.put_nowait(entry) except queue.Full: pass # ═══════════════════════════════════════════════════════════════════════════════ # Process registry # ═══════════════════════════════════════════════════════════════════════════════ _procs: dict[str, Optional[subprocess.Popen]] = { "bot": None, "train": None, "download": None, "backtest": None, "finetune": None, "daily_local": None, # [NEW] cycle quotidien local (warm-start ML + RL réel) déclenché manuellement "setup": None, # [NEW] pipeline setup initial (download → train → rl_train → export) } _pending: set = set() _proc_lock = threading.Lock() BASE_DIR = Path(__file__).parent.resolve() def _is_running(name: str) -> bool: with _proc_lock: if name in _pending: return True p = _procs.get(name) return p is not None and p.poll() is None def _kill(name: str): with _proc_lock: p = _procs.get(name) if p and p.poll() is None: p.terminate() try: p.wait(timeout=5) except subprocess.TimeoutExpired: p.kill() _procs[name] = None _emit(f"⛔ Processus '{name}' arrêté", "UI") def _launch(name: str, script: str, label: str, extra_args: list = None): with _proc_lock: if name in _pending or (_procs.get(name) and _procs[name].poll() is None): _emit(f"[!] {name} déjà en cours — lancement ignoré", "UI") return _pending.add(name) _emit(f"▶ {label} démarré ({script})", name.upper()) try: cmd = [sys.executable, "-u", str(BASE_DIR / script)] + (extra_args or []) p = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, cwd=str(BASE_DIR), encoding="utf-8", errors="replace" ) with _proc_lock: _procs[name] = p _pending.discard(name) for line in iter(p.stdout.readline, ''): _emit(line.rstrip(), name.upper()) p.wait() _emit(f"[{'OK' if p.returncode == 0 else 'FAIL'}] {label} terminé (code {p.returncode})", name.upper()) except Exception as e: _emit(f"[ERREUR] Lancement {script}: {e}", name.upper()) finally: with _proc_lock: _procs[name] = None _pending.discard(name) # ═══════════════════════════════════════════════════════════════════════════════ # Config (.env) reader/writer # ═══════════════════════════════════════════════════════════════════════════════ ENV_PATH = BASE_DIR / ".env" def read_env() -> dict: result = {} if not ENV_PATH.exists(): return result for line in ENV_PATH.read_text(encoding="utf-8").splitlines(): s = line.strip() if s and not s.startswith('#') and '=' in s: k, _, v = s.partition('=') result[k.strip()] = v.strip() return result def write_env(updates: dict): lines = ENV_PATH.read_text(encoding="utf-8").splitlines() if ENV_PATH.exists() else [] written = set() new_lines = [] for line in lines: s = line.strip() if s and not s.startswith('#') and '=' in s: k = s.split('=')[0].strip() if k in updates: new_lines.append(f"{k}={updates[k]}") written.add(k) continue new_lines.append(line) for k, v in updates.items(): if k not in written: new_lines.append(f"{k}={v}") ENV_PATH.write_text('\n'.join(new_lines) + '\n', encoding="utf-8") SAFE_CONFIG_KEYS = { # Trading core "PAPER_MODE", "PAPER_INITIAL_BALANCE", "LEVERAGE", "MAX_POSITIONS", "RISK_PER_TRADE", "MAX_DAILY_LOSS_PCT", "STOP_LOSS_PCT", "TAKE_PROFIT_PCT", "MIN_CONFIDENCE", "MAIN_LOOP_SECONDS", "PAIRS", "CANDLE_INTERVAL", "MIN_LOT_SIZE", "MAX_LOT_SIZE", # Session filter "SESSION_FILTER_ENABLED", "SESSION_LONDON_START", "SESSION_LONDON_END", "SESSION_NY_START", "SESSION_NY_END", # Models "USE_ENSEMBLE", # RL "USE_RL_AGENT", "RL_MODE", "RL_CONFIDENCE_BOOST", "RL_OVERRIDE_THRESHOLD", "RL_FINETUNE_STEPS", # Apprentissage continu (quotidien, local) — ML + RL "DAILY_LOCAL_RETRAIN_ENABLED", "DAILY_LOCAL_RETRAIN_INTERVAL_HOURS", "CONTINUOUS_LEARNING_ENABLED", "WARMSTART_ENABLED", "WARMSTART_MIN_TRADES", "RL_REPLAY_MIN_TRADES", "RL_RETRAIN_INTERVAL_HOURS", "AUTO_RETRAIN_INTERVAL_HOURS", "AUTO_RETRAIN_MIN_ACCURACY", "EXPERIENCE_BUFFER_MAX_SIZE", "RL_AUTO_RETRAIN_ENABLED", "RL_REAL_REPLAY_ENABLED", # MT5 "MT5_BRIDGE_ENABLED", "MT5_FILES_PATH", "MT5_SIGNAL_TIMEOUT", "MT5_POLL_INTERVAL", "MT5_SYMBOL_SUFFIX", "MT5_SERVER", # Auth "WEB_UI_TOKEN", } CREDENTIAL_KEYS = {"MT5_LOGIN", "MT5_PASSWORD", "WEB_UI_TOKEN"} # ═══════════════════════════════════════════════════════════════════════════════ # State readers # ═══════════════════════════════════════════════════════════════════════════════ def read_paper_state() -> dict: try: with open(BASE_DIR / "paper_state.json") as f: return json.load(f) except Exception: return {"balance": 0, "positions": {}, "trades": [], "daily_pnl": 0, "total_pnl": 0, "peak_equity": 0} def read_backtest_results() -> dict: try: with open(BASE_DIR / "backtest_results.json") as f: return json.load(f) except Exception: return {} def read_model_info() -> dict: info = {} try: with open(BASE_DIR / "last_retrain.json") as f: info = json.load(f) except Exception: pass ensemble_path = BASE_DIR / getattr(config, "ENSEMBLE_MODEL_PATH", "model_ensemble.pkl") model_path = BASE_DIR / getattr(config, "MODEL_PATH", "model.pkl") if ensemble_path.exists(): info["model_file"] = ensemble_path.name info["model_size_mb"] = round(ensemble_path.stat().st_size / 1024 / 1024, 1) info["model_mtime"] = datetime.fromtimestamp(ensemble_path.stat().st_mtime).strftime("%Y-%m-%d %H:%M") info["has_ensemble"] = True elif model_path.exists(): info["model_file"] = model_path.name info["model_size_mb"] = round(model_path.stat().st_size / 1024 / 1024, 1) info["model_mtime"] = datetime.fromtimestamp(model_path.stat().st_mtime).strftime("%Y-%m-%d %H:%M") info["has_ensemble"] = False else: info["model_file"] = None return info def _detect_device() -> str: """Détecte CUDA réellement, sans planter si torch n'est pas installé.""" try: import torch return "CUDA (GPU)" if torch.cuda.is_available() else "CPU" except Exception: return "CPU (torch non installé)" def read_rl_status() -> dict: """Lit l'état complet de la couche RL.""" agent_path = BASE_DIR / "rl_agent.zip" scaler_path = BASE_DIR / "rl_scaler.pkl" ckpt_path = BASE_DIR / "rl_checkpoints" / "best_model.zip" curves_path = BASE_DIR / "rl_training_curves.png" out = { "agent_exists": agent_path.exists(), "scaler_exists": scaler_path.exists(), "checkpoint_exists": ckpt_path.exists(), "curves_exists": curves_path.exists(), "agent_size_mb": round(agent_path.stat().st_size / 1024 / 1024, 2) if agent_path.exists() else 0, "agent_mtime": datetime.fromtimestamp(agent_path.stat().st_mtime).strftime("%Y-%m-%d %H:%M") if agent_path.exists() else None, "progress": {}, # ── [FIX] Avant : "Device CUDA (GPU)" et "Fine-tune Steps 200 000" # étaient écrits en dur dans le HTML, peu importe la machine réelle # ou la config courante. Désormais lus dynamiquement. ── "device": _detect_device(), "finetune_steps": getattr(config, "RL_FINETUNE_STEPS", 200_000), } try: with open(BASE_DIR / "rl_progress.json") as f: out["progress"] = json.load(f) except Exception: pass # ── Dernier résultat de fine-tune (reward avant/après, accepté ou non, # ET la source des données — "real_trades_only" vs "simulated") — # persisté par rl_train.py::fine_tune()/fine_tune_real_only() dans # last_rl_retrain.json. AVANT : ce résultat n'existait que dans le # stdout du process, perdu une fois celui-ci terminé ; et même une fois # lu ici, "data_source"/"n_real_trades" n'étaient jamais affichés côté UI. ── out["last_finetune"] = None try: with open(BASE_DIR / "last_rl_retrain.json") as f: out["last_finetune"] = json.load(f) except Exception: pass # ── [FIX] Statut du cycle quotidien LOCAL (warm-start ML + RL réel) — # remplace le bloc "Planning Fine-tuning" statique ("Semaine 3/4", # "hebdomadaire") qui ne correspondait plus au cycle quotidien actuel. ── out["daily_local"] = { "last_run": None, "interval_hours": getattr(config, "DAILY_LOCAL_RETRAIN_INTERVAL_HOURS", 24), } try: with open(BASE_DIR / "last_daily_local.json") as f: out["daily_local"]["last_run"] = json.load(f).get("datetime") except Exception: pass return out def read_data_status() -> dict: d = BASE_DIR / "data" files = list(d.glob("*.json")) if d.exists() else [] return { # Champs utilisés par la page Setup Initial (loadSetupStatus JS) "ensemble_model": (BASE_DIR / "model_ensemble.pkl").exists(), "rl_model": (BASE_DIR / "rl_agent.zip").exists(), "unified_model": (BASE_DIR / "ahad_quant_unified.zip").exists(), "data_files_count": len(files), # Champs originaux conservés (utilisés ailleurs) "count": len(files), "files": sorted(f.stem.replace("_1h", "") for f in files), "total_mb": round(sum(f.stat().st_size for f in files) / 1024 / 1024, 1) if files else 0, } def read_bot_state() -> dict: try: with open(BASE_DIR / "bot_state.json") as f: return json.load(f) except Exception: return {} def read_feature_importance() -> dict: try: with open(BASE_DIR / "feature_importance.json") as f: return json.load(f) except Exception: return {} def _calc_win_rate(trades: list) -> float: if not trades: return 0.0 return round(sum(1 for t in trades if float(t.get("pnl", 0)) > 0) / len(trades) * 100, 1) def _equity_curve(trades: list) -> list: bal, curve = 100.0, [] for t in trades: bal += float(t.get("pnl", 0)) curve.append({"t": t.get("closed_at", ""), "v": round(bal, 2)}) return curve[-300:] def fetch_prices(symbols: list) -> dict: """Prix Forex via Frankfurter (ECB rates, gratuit, ~55 paires).""" if not HAS_REQUESTS or not symbols: return {} out = {s: 0.0 for s in symbols} try: r = _ext_requests.get("https://api.frankfurter.app/latest", params={"from": "EUR"}, timeout=5) if r.ok: rates = r.json().get("rates", {}) rates["EUR"] = 1.0 for sym in symbols: if len(sym) != 6: continue bc, qc = sym[:3], sym[3:] b, q = rates.get(bc), rates.get(qc) if b and q: out[sym] = round(q / b, 6) except Exception: pass return out # ═══════════════════════════════════════════════════════════════════════════════ # API # ═══════════════════════════════════════════════════════════════════════════════ @app.get("/api/status", dependencies=[Depends(require_auth)]) def api_status(): model = read_model_info() data = read_data_status() rl = read_rl_status() # ── Badge de synchronisation ML/RL — compare les horodatages des # DERNIÈRES mises à jour effectives (pas juste la dernière tentative) : # - ML : last_retrain.json["datetime"] (écrit uniquement si accepté) # - RL : last_rl_retrain.json["datetime"], MAIS uniquement si accepted=True # AVANT : aucune comparaison n'existait — impossible de savoir si l'un # avait dérivé de l'autre. ── sync = {"status": "unknown", "ml_updated_at": None, "rl_updated_at": None, "drift_days": None} try: ml_dt_str = model.get("datetime") rl_finetune = rl.get("last_finetune") or {} rl_dt_str = rl_finetune.get("datetime") if rl_finetune.get("accepted") else None sync["ml_updated_at"] = ml_dt_str sync["rl_updated_at"] = rl_dt_str if ml_dt_str and rl_dt_str: ml_dt = datetime.fromisoformat(ml_dt_str) rl_dt = datetime.fromisoformat(rl_dt_str) drift_days = abs((ml_dt - rl_dt).total_seconds()) / 86400 sync["drift_days"] = round(drift_days, 1) sync["status"] = "synced" if drift_days <= 1 else "drifted" elif ml_dt_str or rl_dt_str: sync["status"] = "partial" # un seul des deux a déjà été (re)validé except Exception: pass paper_mode = getattr(config, "PAPER_MODE", True) if paper_mode: paper = read_paper_state() pos = paper.get("positions", {}) prices = fetch_prices(list(pos.keys())) for coin, p in pos.items(): price = prices.get(coin) if price and p.get("entry"): mult = 1 if p.get("side") == "long" else -1 p["upnl"] = round(mult * (price - p["entry"]) / p["entry"] * p.get("qty", 0) * p["entry"] * getattr(config, "LEVERAGE", 30), 3) p["current_price"] = round(price, 6) balance = round(paper.get("balance", 0), 2) daily_pnl = round(paper.get("daily_pnl", 0), 2) total_pnl = round(paper.get("total_pnl", 0), 2) peak_eq = round(paper.get("peak_equity", 0), 2) open_pos = pos total_tr = len(paper.get("trades", [])) win_rate = _calc_win_rate(paper.get("trades", [])) else: mt5 = read_mt5_status() reports = read_mt5_reports(limit=500) balance = round(mt5.get("balance", 0), 2) daily_pnl = round(mt5.get("daily_pnl", 0), 2) total_pnl = round(sum(float(r.get("pnl", 0)) for r in reports), 2) peak_eq = round(mt5.get("equity", balance), 2) open_pos = {} total_tr = len(reports) win_rate = round( sum(1 for r in reports if float(r.get("profit", 0)) > 0) / len(reports) * 100 if reports else 0, 1) pairs = getattr(config, "PAIRS", []) return { "paper_mode": paper_mode, "balance": balance, "daily_pnl": daily_pnl, "total_pnl": total_pnl, "peak_equity": peak_eq, "open_positions": open_pos, "total_trades": total_tr, "win_rate": win_rate, "model": model, "sync": sync, "data": data, "rl": rl, "leverage": getattr(config, "LEVERAGE", 30), "max_positions": getattr(config, "MAX_POSITIONS", 5), "risk_per_trade": getattr(config, "RISK_PER_TRADE", 0.02), "min_confidence": getattr(config, "MIN_CONFIDENCE", 0.72), "pairs_count": len(pairs), "use_ensemble": getattr(config, "USE_ENSEMBLE", True), "use_rl_agent": getattr(config, "USE_RL_AGENT", False), "rl_mode": getattr(config, "RL_MODE", "filter"), "rl_override_threshold": getattr(config, "RL_OVERRIDE_THRESHOLD", 0.82), "rl_confidence_boost": getattr(config, "RL_CONFIDENCE_BOOST", 0.05), "session_filter": getattr(config, "SESSION_FILTER_ENABLED", False), "mt5_bridge": getattr(config, "MT5_BRIDGE_ENABLED", False), "candle_interval": getattr(config, "CANDLE_INTERVAL", "1h"), "bot_running": _is_running("bot"), "training": _is_running("train"), "downloading": _is_running("download"), "backtesting": _is_running("backtest"), "finetuning": _is_running("train"), # UNIFIÉ : même slot que "training" (un seul système ML+RL) "daily_local_running": _is_running("daily_local"), # [NEW] "setup_running": _is_running("setup"), # [NEW] pipeline setup initial "paused": (BASE_DIR / ".paused").exists(), "stopped": (BASE_DIR / ".stopped").exists(), } @app.get("/api/trades", dependencies=[Depends(require_auth)]) def api_trades(limit: int = 200): paper_mode = getattr(config, "PAPER_MODE", True) if paper_mode: state = read_paper_state() trades = list(reversed(state.get("trades", [])))[:limit] return {"trades": trades, "equity_curve": _equity_curve(state.get("trades", []))} else: reports = read_mt5_reports(limit=limit) return {"trades": reports, "equity_curve": _equity_curve(reports)} @app.get("/api/backtest/results", dependencies=[Depends(require_auth)]) def api_backtest_results(): return read_backtest_results() @app.get("/api/data/status", dependencies=[Depends(require_auth)]) def api_data_status(): return read_data_status() @app.get("/api/rl/status", dependencies=[Depends(require_auth)]) def api_rl_status(): return read_rl_status() @app.get("/api/rl/progress", dependencies=[Depends(require_auth)]) def api_rl_progress(): try: with open(BASE_DIR / "rl_progress.json") as f: return json.load(f) except Exception: return {} @app.get("/api/rl/curves", dependencies=[Depends(require_auth)]) def api_rl_curves(): path = BASE_DIR / "rl_training_curves.png" if not path.exists(): raise HTTPException(status_code=404, detail="Courbes non disponibles — lancer l'entraînement RL d'abord") return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache"}) def _start_unified_training() -> dict: """ Lance train_unified.py (ML complet + RL) en arrière-plan. [FIX] Fonction extraite — AVANT, /api/train et /api/rl/finetune dupliquaient exactement la même logique en copier-coller (risque de divergence future). De plus, le bouton frontend ("🧠 Entraîner ML+RL") n'appelait QUE /api/rl/finetune : /api/train n'était appelé par AUCUN élément de l'UI — un endpoint mort, accessible seulement en curl direct. Les deux routes appellent désormais ce même helper. """ if _is_running("train"): return {"ok": False, "message": "Entraînement déjà en cours"} steps = str(getattr(config, "RL_FINETUNE_STEPS", 200_000)) threading.Thread( target=_launch, args=("train", "train_unified.py", "Entraînement Unifié (ML+RL)"), kwargs={"extra_args": ["--rl-steps", steps]}, daemon=True ).start() return {"ok": True, "message": "Entraînement Unifié ML+RL démarré ▶ (un seul système, ne remplace l'ancien que s'il est meilleur)"} @app.post("/api/rl/finetune", dependencies=[Depends(require_auth)]) def api_rl_finetune(): return _start_unified_training() @app.get("/api/bot-state", dependencies=[Depends(require_auth)]) def api_bot_state(): return read_bot_state() @app.get("/api/feature-importance", dependencies=[Depends(require_auth)]) def api_feature_importance(): return read_feature_importance() # ── Bot controls ────────────────────────────────────────────────────────────── @app.post("/api/bot/start", dependencies=[Depends(require_auth)]) def api_bot_start(): if _is_running("bot"): return {"ok": False, "message": "Bot déjà en cours"} (BASE_DIR / ".stopped").unlink(missing_ok=True) (BASE_DIR / ".paused").unlink(missing_ok=True) threading.Thread(target=_launch, args=("bot", "ahad_quant.py", "Bot principal"), daemon=True).start() return {"ok": True, "message": "Bot démarré ✅"} @app.post("/api/bot/stop", dependencies=[Depends(require_auth)]) def api_bot_stop(): (BASE_DIR / ".stopped").touch() _kill("bot") return {"ok": True, "message": "Bot arrêté"} @app.post("/api/bot/pause", dependencies=[Depends(require_auth)]) def api_bot_pause(): (BASE_DIR / ".paused").touch() _emit("⏸ Bot mis en pause", "UI") return {"ok": True, "message": "Bot en pause ⏸"} @app.post("/api/bot/resume", dependencies=[Depends(require_auth)]) def api_bot_resume(): (BASE_DIR / ".paused").unlink(missing_ok=True) _emit("▶ Bot repris", "UI") return {"ok": True, "message": "Bot repris ▶"} @app.post("/api/bot/emergency", dependencies=[Depends(require_auth)]) def api_bot_emergency(): (BASE_DIR / ".stopped").touch() _kill("bot") _emit("⚠️ ARRÊT D'URGENCE — processus tué + flag .stopped posé", "UI") return {"ok": True, "message": "Arrêt d'urgence déclenché ⚠️"} @app.post("/api/bot/reset", dependencies=[Depends(require_auth)]) def api_bot_reset(): (BASE_DIR / ".stopped").unlink(missing_ok=True) (BASE_DIR / ".paused").unlink(missing_ok=True) _emit("↺ Flags réinitialisés", "UI") return {"ok": True} # ── Process controls ────────────────────────────────────────────────────────── @app.post("/api/download", dependencies=[Depends(require_auth)]) def api_download(): if _is_running("download"): return {"ok": False, "message": "Téléchargement déjà en cours"} threading.Thread(target=_launch, args=("download", "download_data.py", "Téléchargement données"), daemon=True).start() return {"ok": True, "message": "Téléchargement démarré ⬇"} @app.post("/api/train", dependencies=[Depends(require_auth)]) def api_train(): # Alias historique — conservé pour compatibilité avec d'éventuels # scripts externes, même si le bouton UI utilise /api/rl/finetune. # Délègue désormais au même helper (voir _start_unified_training). return _start_unified_training() @app.post("/api/retrain/daily-local", dependencies=[Depends(require_auth)]) def api_retrain_daily_local(): """ [NEW] Déclenche manuellement le cycle quotidien LOCAL (warm-start LightGBM + RL fine-tune 100% réel) — exactement le même cycle que le thread de fond AutoRetrainer exécute tout seul toutes les DAILY_LOCAL_RETRAIN_INTERVAL_HOURS, mais à la demande, sans attendre l'intervalle. Avant ce fix, ce mécanisme léger n'était accessible que via le bot en tournant en continu — aucun moyen de le forcer depuis l'UI. """ if _is_running("train"): return {"ok": False, "message": "Un entraînement complet (ML+RL) est déjà en cours — attends qu'il termine"} if _is_running("daily_local"): return {"ok": False, "message": "Cycle quotidien local déjà en cours"} threading.Thread( target=_launch, args=("daily_local", "daily_local_retrain.py", "Cycle quotidien local (ML+RL léger)"), daemon=True ).start() return {"ok": True, "message": "Cycle quotidien local démarré ⚡ (warm-start ML + RL réel)"} @app.post("/api/setup/run", dependencies=[Depends(require_auth)]) def api_setup_run(): """ [NEW] Pipeline setup initial complet : 1. download_data.py — téléchargement données historiques (réseau) 2. train.py — entraînement ML complet (LGB + XGBoost + RF) 3. rl_train.py — entraînement PPO RL from scratch (1 000 000 steps) 4. export_unified.py — bundle ahad_quant_unified.zip Chaque étape tourne dans le même slot "setup" — le terminal affiche la progression en temps réel. À n'exécuter qu'une seule fois. """ if _is_running("setup"): return {"ok": False, "message": "Setup initial déjà en cours"} if _is_running("train"): return {"ok": False, "message": "Un entraînement est déjà en cours — attends qu'il termine"} if _is_running("bot"): return {"ok": False, "message": "Arrête le bot avant de lancer le setup initial"} def _run_setup(): steps = [ ("download_data.py", "Téléchargement données historiques"), ("train.py", "Entraînement ML (LGB + XGBoost + RF)"), ("rl_train.py", "Entraînement RL PPO (1 000 000 steps)"), ("export_unified.py","Export bundle ahad_quant_unified.zip"), ] _emit("═" * 60, "SETUP") _emit(" AHAD QUANT — Setup Initial (4 étapes)", "SETUP") _emit("═" * 60, "SETUP") for i, (script, label) in enumerate(steps, 1): _emit(f"\n[{i}/4] {label}...", "SETUP") try: cmd = [sys.executable, "-u", str(BASE_DIR / script)] p = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, cwd=str(BASE_DIR), encoding="utf-8", errors="replace" ) with _proc_lock: _procs["setup"] = p for line in iter(p.stdout.readline, ''): _emit(line.rstrip(), "SETUP") p.wait() if p.returncode != 0: _emit(f"❌ Étape {i} échouée (code {p.returncode}) — setup interrompu", "SETUP") return _emit(f"✅ Étape {i}/4 terminée : {label}", "SETUP") except Exception as e: _emit(f"❌ Erreur étape {i} : {e}", "SETUP") return _emit("\n" + "═" * 60, "SETUP") _emit(" ✅ Setup initial terminé — AHAD QUANT prêt à trader !", "SETUP") _emit("═" * 60, "SETUP") with _proc_lock: _procs["setup"] = None with _proc_lock: _pending.add("setup") threading.Thread(target=_run_setup, daemon=True).start() with _proc_lock: _pending.discard("setup") return {"ok": True, "message": "Setup initial démarré ▶ — 4 étapes en cours (voir Terminal)"} @app.post("/api/setup/stop", dependencies=[Depends(require_auth)]) def api_setup_stop(): """Interrompt le setup initial en cours.""" if not _is_running("setup"): return {"ok": False, "message": "Aucun setup en cours"} _kill("setup") return {"ok": True, "message": "Setup interrompu"} @app.post("/api/backtest/run", dependencies=[Depends(require_auth)]) def api_backtest_run(): if _is_running("backtest"): return {"ok": False, "message": "Backtest déjà en cours"} threading.Thread(target=_launch, args=("backtest", "backtest.py", "Backtest"), daemon=True).start() return {"ok": True, "message": "Backtest démarré — voir Terminal"} @app.post("/api/stop/{proc}", dependencies=[Depends(require_auth)]) def api_stop_proc(proc: str): if proc not in _procs: return {"ok": False, "message": f"Processus inconnu: {proc}"} _kill(proc) return {"ok": True, "message": f"{proc} arrêté"} # ── Logs stream ─────────────────────────────────────────────────────────────── @app.get("/api/logs/stream", dependencies=[Depends(require_auth)]) def api_logs_stream(): def gen() -> Generator: with _log_lock: hist = list(_log_history) for entry in hist: yield f"data: {json.dumps(entry)}\n\n" while True: try: entry = _log_queue.get(timeout=30) yield f"data: {json.dumps(entry)}\n\n" except queue.Empty: yield f"data: {json.dumps({'ping': True})}\n\n" return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) # ── MT5 Bridge ──────────────────────────────────────────────────────────────── @app.get("/api/mt5/status", dependencies=[Depends(require_auth)]) def api_mt5_status(): status = read_mt5_status() connected = _mt5_connected(status) return { "connected": connected, "path_configured": bool(getattr(config, "MT5_FILES_PATH", "")), "mt5_path": getattr(config, "MT5_FILES_PATH", ""), "bridge": read_bridge_health(), **status, } @app.get("/api/mt5/reports", dependencies=[Depends(require_auth)]) def api_mt5_reports(limit: int = 200): reports = read_mt5_reports(limit) open_trades = [r for r in reports if r.get("status") == "OPEN"] closed_trades = [r for r in reports if r.get("status") == "CLOSED"] errors = [r for r in reports if r.get("status") in ("ERROR", "REJECTED")] total_profit = sum(float(r.get("profit") or 0) for r in closed_trades) wins = sum(1 for r in closed_trades if float(r.get("profit") or 0) > 0) return { "reports": reports, "open_count": len(open_trades), "closed_count": len(closed_trades), "error_count": len(errors), "total_profit": round(total_profit, 2), "win_count": wins, "loss_count": len(closed_trades) - wins, "win_rate": round(wins / len(closed_trades) * 100, 1) if closed_trades else 0.0, } @app.get("/api/mt5/stream", dependencies=[Depends(require_auth)]) def api_mt5_stream(): def gen() -> Generator: last_hash = None while True: try: status = read_mt5_status() reports = read_mt5_reports(200) connected = _mt5_connected(status) cur_hash = f"{status.get('timestamp','')}{len(reports)}" if cur_hash != last_hash: last_hash = cur_hash closed = [r for r in reports if r.get("status") == "CLOSED"] wins = sum(1 for r in closed if float(r.get("profit") or 0) > 0) payload = { "type": "mt5_update", "connected": connected, "status": status, "open_count": len([r for r in reports if r.get("status") == "OPEN"]), "closed_count": len(closed), "total_profit": round(sum(float(r.get("profit") or 0) for r in closed), 2), "win_rate": round(wins / len(closed) * 100, 1) if closed else 0.0, "latest_reports": reports[:10], } yield f"data: {json.dumps(payload)}\n\n" else: yield f"data: {json.dumps({'ping': True})}\n\n" except GeneratorExit: break except Exception: yield f"data: {json.dumps({'ping': True})}\n\n" time.sleep(5) return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) # ── Config / Paper / Export ─────────────────────────────────────────────────── @app.get("/api/config", dependencies=[Depends(require_auth)]) def api_get_config(): env = read_env() secret_patterns = ["secret","key","password","token","passphrase","private"] result = {k: v for k, v in env.items() if not any(p in k.lower() for p in secret_patterns)} _defaults = { "PAIRS": ",".join(getattr(config, "PAIRS", [])), "LEVERAGE": str(getattr(config, "LEVERAGE", 30)), "PAPER_MODE": str(getattr(config, "PAPER_MODE", True)).lower(), "PAPER_INITIAL_BALANCE": str(getattr(config, "PAPER_INITIAL_BALANCE", 100)), "MIN_CONFIDENCE": str(getattr(config, "MIN_CONFIDENCE", 0.72)), "MAX_POSITIONS": str(getattr(config, "MAX_POSITIONS", 5)), "RISK_PER_TRADE": str(getattr(config, "RISK_PER_TRADE", 0.02)), "MAX_DAILY_LOSS_PCT": str(getattr(config, "MAX_DAILY_LOSS_PCT", 0.05)), "STOP_LOSS_PCT": str(getattr(config, "STOP_LOSS_PCT", 0.01)), "TAKE_PROFIT_PCT": str(getattr(config, "TAKE_PROFIT_PCT", 0.02)), "MAIN_LOOP_SECONDS": str(getattr(config, "MAIN_LOOP_SECONDS", 60)), "CANDLE_INTERVAL": getattr(config, "CANDLE_INTERVAL", "1h"), "MIN_LOT_SIZE": str(getattr(config, "MIN_LOT_SIZE", 0.01)), "MAX_LOT_SIZE": str(getattr(config, "MAX_LOT_SIZE", 1.0)), "USE_ENSEMBLE": str(getattr(config, "USE_ENSEMBLE", True)).lower(), "USE_RL_AGENT": str(getattr(config, "USE_RL_AGENT", True)).lower(), "RL_MODE": getattr(config, "RL_MODE", "filter"), "RL_OVERRIDE_THRESHOLD": str(getattr(config, "RL_OVERRIDE_THRESHOLD", 0.82)), "RL_CONFIDENCE_BOOST": str(getattr(config, "RL_CONFIDENCE_BOOST", 0.05)), "RL_FINETUNE_STEPS": str(getattr(config, "RL_FINETUNE_STEPS", 200000)), "DAILY_LOCAL_RETRAIN_ENABLED": str(getattr(config, "DAILY_LOCAL_RETRAIN_ENABLED", True)).lower(), "DAILY_LOCAL_RETRAIN_INTERVAL_HOURS": str(getattr(config, "DAILY_LOCAL_RETRAIN_INTERVAL_HOURS", 24)), "CONTINUOUS_LEARNING_ENABLED": str(getattr(config, "CONTINUOUS_LEARNING_ENABLED", True)).lower(), "WARMSTART_ENABLED": str(getattr(config, "WARMSTART_ENABLED", True)).lower(), "WARMSTART_MIN_TRADES": str(getattr(config, "WARMSTART_MIN_TRADES", 5)), "RL_REPLAY_MIN_TRADES": str(getattr(config, "RL_REPLAY_MIN_TRADES", 10)), "RL_RETRAIN_INTERVAL_HOURS": str(getattr(config, "RL_RETRAIN_INTERVAL_HOURS", 24)), "AUTO_RETRAIN_INTERVAL_HOURS": str(getattr(config, "AUTO_RETRAIN_INTERVAL_HOURS", 24)), "AUTO_RETRAIN_MIN_ACCURACY": str(getattr(config, "AUTO_RETRAIN_MIN_ACCURACY", 0.70)), "EXPERIENCE_BUFFER_MAX_SIZE": str(getattr(config, "EXPERIENCE_BUFFER_MAX_SIZE", 10000)), "RL_AUTO_RETRAIN_ENABLED": str(getattr(config, "RL_AUTO_RETRAIN_ENABLED", True)).lower(), "RL_REAL_REPLAY_ENABLED": str(getattr(config, "RL_REAL_REPLAY_ENABLED", True)).lower(), "MT5_BRIDGE_ENABLED": str(getattr(config, "MT5_BRIDGE_ENABLED", False)).lower(), "MT5_FILES_PATH": getattr(config, "MT5_FILES_PATH", ""), "MT5_SYMBOL_SUFFIX": getattr(config, "MT5_SYMBOL_SUFFIX", ""), "MT5_SERVER": getattr(config, "MT5_SERVER", ""), "MT5_SIGNAL_TIMEOUT": str(getattr(config, "MT5_SIGNAL_TIMEOUT", 30)), "MT5_POLL_INTERVAL": str(getattr(config, "MT5_POLL_INTERVAL", 5)), "SESSION_FILTER_ENABLED": str(getattr(config, "SESSION_FILTER_ENABLED", False)).lower(), } for k, v in _defaults.items(): result.setdefault(k, v) return result @app.post("/api/config", dependencies=[Depends(require_auth)]) async def api_save_config(request: Request): body = await request.json() filtered = {k: str(v) for k, v in body.items() if k in SAFE_CONFIG_KEYS} write_env(filtered) _emit(f"✅ Config sauvegardée : {list(filtered.keys())}", "UI") return {"ok": True, "saved": list(filtered.keys()), "restart_needed": _is_running("bot")} @app.post("/api/paper/reset", dependencies=[Depends(require_auth)]) def api_paper_reset(): init_bal = getattr(config, "PAPER_INITIAL_BALANCE", 100) fresh = { "balance": init_bal, "positions": {}, "trades": [], "daily_pnl": 0, "total_pnl": 0, "peak_equity": init_bal, "daily_losses": 0, "circuit_breaker_until": 0, "reset_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), } with open(BASE_DIR / "paper_state.json", "w") as f: json.dump(fresh, f, indent=2) _emit(f"↺ Paper trading réinitialisé — balance: ${init_bal:,.0f}", "UI") return {"ok": True, "balance": init_bal} # ── RESET GLOBAL ────────────────────────────────────────────────────────────── @app.post("/api/reset/all", dependencies=[Depends(require_auth)]) def api_reset_all(): """Réinitialise TOUT : logs mémoire, paper state, backtest, rl_progress, last_retrain, bot_state. Laisse les modèles et checkpoints intacts.""" global _log_history errors = [] # 1. Vider l'historique de logs en mémoire with _log_lock: _log_history.clear() # 2. Paper state → balance initiale init_bal = getattr(config, "PAPER_INITIAL_BALANCE", 100) fresh_paper = { "balance": init_bal, "positions": {}, "trades": [], "daily_pnl": 0, "total_pnl": 0, "peak_equity": init_bal, "daily_losses": 0, "circuit_breaker_until": 0, "reset_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), } try: with open(BASE_DIR / "paper_state.json", "w") as f: json.dump(fresh_paper, f, indent=2) except Exception as e: errors.append(f"paper_state: {e}") # 3. Backtest results → vide try: (BASE_DIR / "backtest_results.json").write_text("{}") except Exception as e: errors.append(f"backtest_results: {e}") # 4. RL progress → vide try: (BASE_DIR / "rl_progress.json").write_text("{}") except Exception as e: errors.append(f"rl_progress: {e}") # 5. Last retrain → vide try: lr = BASE_DIR / "last_retrain.json" if lr.exists(): lr.write_text("{}") except Exception as e: errors.append(f"last_retrain: {e}") # 6. Bot state → vide try: (BASE_DIR / "bot_state.json").write_text("{}") except Exception as e: errors.append(f"bot_state: {e}") # 7. Flags .stopped / .paused (BASE_DIR / ".stopped").unlink(missing_ok=True) (BASE_DIR / ".paused").unlink(missing_ok=True) if errors: _emit(f"⚠️ Réinitialisation partielle — erreurs : {', '.join(errors)}", "UI") return {"ok": False, "errors": errors} global _reset_version _reset_version += 1 # Émettre un signal spécial que les clients SSE peuvent détecter special = {"t": time.strftime("%H:%M:%S"), "p": "UI", "m": "🗑 Réinitialisation complète effectuée (logs, trades, backtest, RL, bot state)", "reset": _reset_version} try: _log_queue.put_nowait(special) except queue.Full: pass return {"ok": True, "balance": init_bal, "reset_version": _reset_version} @app.get("/api/export/{filename}", dependencies=[Depends(require_auth)]) def api_export_json(filename: str): ALLOWED = {"paper_state.json", "backtest_results.json", "last_retrain.json", "rl_progress.json"} if filename not in ALLOWED: raise HTTPException(status_code=403, detail="Fichier non autorisé") path = BASE_DIR / filename if not path.exists(): raise HTTPException(status_code=404, detail="Fichier introuvable") return FileResponse(path, media_type="application/json", filename=filename) # ═══════════════════════════════════════════════════════════════════════════════ # HTML # ═══════════════════════════════════════════════════════════════════════════════ HTML = r""" AHAD QUANT V4_RL — Command Center
PAPER
OFFLINE
--:--:--
Dashboard Live
Balance
Paper Mode
PnL Journalier
vs. ouverture
PnL Total
Peak: —
Win Rate
0 trades
Modèles ML
RL Agent (PPO)
Synchro ML ↔ RL
Données yfinance
Levier
30x
Fixe
Confiance Min
72%
ML threshold
Seuil RL Override
82%
RL mode: filter
Paires actives
25
Forex 1H
Positions Ouvertes
Aucune position ouverte
RL Monitor PPO
rl_agent.zip
rl_scaler.pkl
Normalisation obs
best_model.zip
Checkpoint RL
Fine-tuning RL
Configuration PPO
AlgorithmePPO (Stable-Baselines3)
Steps entraîné
Device
N_ENVS4 (parallèle)
TRAIN_PAIRS8 paires majeures
EVAL_PAIRSGBPJPY, NZDUSD
Modefilter
Override Threshold82%
Confidence Boost+5%
Steps Full Retrain (RL)
Résultats Backtest ML+RL
Pas encore de backtest — onglet Backtest
Courbes d'Entraînement RL
Courbes non générées — lancer l'entraînement RL complet
Cycle quotidien local ML+RL réel
Dernier cycle local
Warm-start ML + RL réel
Intervalle
Automatique si bot lancé
Dernier fine-tune RL
Réel vs simulé
Backtest OOS
⬇ JSON
Système testé
Lancer un backtest pour voir l'état du système
Dernier Backtest
Pas de résultats — lancer un backtest
Courbe Equity
Paper Trades
⬇ Export
Trades Total
0
Win Rate
0%
PnL Total
0
Trades Gagnants
0
Courbe Equity (base 100)
Journal des Trades
PaireDir.EntréeSortiePnL Confiance MLRL FiltreOuvertFermé
🚀 Setup Initial
Pipeline — 4 étapes séquentielles
1
Téléchargement données
download_data.py — 25 paires × 1000 jours (internet requis)
EN ATTENTE
2
Entraînement ML
train.py — LightGBM + XGBoost + RandomForest ensemble
EN ATTENTE
3
Entraînement RL
rl_train.py — PPO 1 000 000 steps from scratch (~2–4h GPU)
EN ATTENTE
4
Export bundle
export_unified.py — génère ahad_quant_unified.zip
EN ATTENTE
État des fichiers
model_ensemble.pkl
rl_agent.zip
ahad_quant_unified.zip
Fichiers données /data
MT5 Bridge CSV
Statut Connexion
Balance MT5
Equity: —
Positions Ouvertes
0
PnL jour: —
Bridge Health
Chemin configuré
Signaux envoyés0
Rapports reçus0
Signaux en attente0
Dernier signal ID
Dernier rapport ID
Gap sig→rep
MT5 Path
Rapports MT5
Signal IDPaireDir.Entrée ProfitStatutRaisonTemps
Aucun rapport MT5 — bridge non connecté
Feature Importance LightGBM
Feature importance non disponible — lancer un Full Retrain (bouton "🧠 Full Retrain (ML+RL)") d'abord
Terminal Live
Configuration .env
💱 Trading
🧠 ML + RL
🔄 Apprentissage continu (quotidien, local)
🔌 MT5 Bridge
🕐 Session Filter
""" # ═══════════════════════════════════════════════════════════════════════════════ @app.get("/", response_class=HTMLResponse) def root(): return HTML if __name__ == "__main__": _emit("🚀 AHAD QUANT V4_RL — Web Command Center v4 démarré", "UI") print("\n" + "=" * 58) print(" AHAD QUANT Forex V4_RL — Command Center") print("=" * 58) print(" → http://localhost:8080") print(" ML + RL (PPO) · 25 paires · 1H · Paper Mode") print(" Ctrl+C pour arrêter") print("=" * 58 + "\n") uvicorn.run(app, host="0.0.0.0", port=8080, log_level="warning")