""" AHAD QUANT — Unified Brain (orchestrateur central ML + RL) ================================================================ UN seul point d'entrée pour transformer (bougies, contexte) en décision de trading complète. Combine : - L'ensemble ML (LightGBM + XGBoost + RF [+ TFT + TGRU]), via ensemble_core.py — la source unique de vérité pour le ML (voir ce module pour le détail du bug #1 qu'il corrige). - La détection de régime de marché (HMM), relocalisée ici depuis ahad_quant.py pour qu'elle soit accessible à tout appelant (live, backtest) sans dépendre de l'état global du bot. - Le filtre RL (PPO), via rl_agent.py — appelé tel quel, SANS changer sa sémantique de boost/override existante, pour ne pas modifier silencieusement une calibration déjà en place. Avant ce module, ces trois briques étaient combinées à la main, en ligne, dans ahad_quant.py::_scan_entries() — avec un vrai bug : les variables ml_probas/rl_action/rl_agreed/current_regime calculées pendant le scan n'étaient JAMAIS transmises jusqu'au moment d'ouvrir le trade pour les branches MT5 et live réelles (NameError garanti à la première ouverture de position hors PAPER_MODE — point #8 du diagnostic). En centralisant la décision dans un objet Decision unique, ce problème disparaît structurellement : tout le contexte voyage ensemble, du signal à l'ouverture du trade. Hot-reload : maybe_reload() vérifie (avec un léger throttle) si le fichier modèle ML a changé sur disque, et délègue à rl_agent.reload_if_stale() pour le PPO — corrige le point #4 du diagnostic (avant, un ré-entraînement réussi en arrière-plan restait sans le moindre effet sur le bot déjà en cours d'exécution, jusqu'à un redémarrage manuel du process). """ import os import time import pickle import logging import threading from dataclasses import dataclass, field from typing import Optional import numpy as np import config import ensemble_core as _ens_core from features import build_features, NUM_FEATURES log = logging.getLogger("UnifiedBrain") try: from regime_detector import GaussianHMM HAS_REGIME = True except ImportError: HAS_REGIME = False _RL_READY = False try: from rl_agent import get_rl_agent, PositionState _RL_READY = True except ImportError: PositionState = None # type: ignore[assignment,misc] _REGIME_LABELS = {0: "CALM", 1: "NORMAL", 2: "VOLATILE"} # ─── Décision ────────────────────────────────────────────────────────────── @dataclass class Decision: """ Décision complète d'une itération de scan, pour UNE paire. `signal`/`confidence` sont le résultat FINAL (après filtre RL le cas échéant) — c'est ce que le bot doit exécuter. `ml_*` conserve toujours le signal ML brut, avant RL, pour la traçabilité et le buffer d'apprentissage continu. """ signal: str # "long" | "short" | "neutral" — À EXÉCUTER confidence: float ml_signal: str # signal ML brut, avant filtre RL ml_confidence: float ml_proba: float # proba LONG brute du méta-modèle, [0,1] rl_action: Optional[int] = None # 0=HOLD,1=LONG,2=SHORT,3=CLOSE rl_signal: Optional[str] = None rl_confidence: Optional[float] = None rl_agreed: Optional[bool] = None rl_used: bool = False regime: int = 1 regime_label: str = "NORMAL" threshold_used: float = 0.0 features: Optional[np.ndarray] = None # dernière ligne de features (pour le buffer CL) model_version: str = "?" # ─── Orchestrateur ─────────────────────────────────────────────────────────── class UnifiedBrain: """ Une instance suffit par process (voir get_brain() / singleton en bas de fichier). Pas de dépendance à l'état du bot (AHAD QUANT._BOT_STATE etc.) — ahad_quant.py reste responsable de mettre à jour SON état global après avoir appelé decide(), ce module reste un pur moteur de décision. """ def __init__(self): self._ensemble = None self._ensemble_path = None self._ensemble_mtime = 0.0 self._is_full_ensemble = False # False si LightGBM seul (fallback) self._hmm_model = None self._hmm_lock = threading.Lock() self._reload_lock = threading.Lock() self._last_reload_check = 0.0 self._reload_throttle_s = float(getattr(config, "MODEL_RELOAD_CHECK_INTERVAL_S", 5.0)) self._load_ml() # ── Chargement / hot-reload ML ──────────────────────────────────────── def _expected_ml_path(self) -> str: """Le chemin que load_model() choisirait EN CE MOMENT, selon la même règle de repli que l'ancien ahad_quant.py::load_model() : l'ensemble d'abord si USE_ENSEMBLE et qu'il existe, sinon le LightGBM seul.""" ensemble_path = config.ENSEMBLE_MODEL_PATH if config.USE_ENSEMBLE and os.path.exists(ensemble_path): return ensemble_path return config.MODEL_PATH def _load_ml(self): """ Reproduit EXACTEMENT la logique de repli de l'ancien ahad_quant.py::load_model() : ENSEMBLE_MODEL_PATH d'abord (si USE_ENSEMBLE), sinon MODEL_PATH (LightGBM seul) — deux chemins DIFFÉRENTS, pas une simple substitution d'un seul chemin selon USE_ENSEMBLE. """ ensemble_path = config.ENSEMBLE_MODEL_PATH lgbm_path = config.MODEL_PATH if config.USE_ENSEMBLE and os.path.exists(ensemble_path): self._ensemble = _ens_core.load_ensemble(ensemble_path) self._is_full_ensemble = self._ensemble is not None self._ensemble_path = ensemble_path elif os.path.exists(lgbm_path): # Repli LightGBM seul — enveloppé dans le même format dict # qu'un ensemble pour qu'ensemble_core.predict_ensemble_single() # le traite de façon identique (un seul sous-modèle disponible # = stacking d'un seul élément = pas de moyenne à faire). try: with open(lgbm_path, "rb") as f: raw = pickle.load(f) model = raw["model"] if isinstance(raw, dict) else raw self._ensemble = { "lgbm": model, "xgb": None, "rf": None, "meta": None, "scaler": None, "has_dl": False, "version": "lgbm-only", } self._is_full_ensemble = False log.info(f"[BRAIN] Modèle LightGBM seul chargé ({lgbm_path}) — pas d'ensemble complet") except Exception as e: log.error(f"[BRAIN] Erreur chargement modèle ({lgbm_path}) : {e}") self._ensemble = None self._ensemble_path = lgbm_path else: self._ensemble = None self._ensemble_path = ensemble_path if config.USE_ENSEMBLE else lgbm_path log.warning(f"[BRAIN] Aucun modèle ML trouvé (ni {ensemble_path}, ni {lgbm_path})") self._ensemble_mtime = _ens_core.ensemble_mtime(self._ensemble_path) def is_ready(self) -> bool: """True si un modèle ML (ensemble complet ou LightGBM seul) est chargé. Remplace l'ancien `self.model_data is None` de ahad_quant.py.""" return self._ensemble is not None def maybe_reload(self, force: bool = False) -> bool: """ Vérifie si le modèle ML a changé sur disque (mtime) et délègue le hot-reload du PPO à rl_agent.reload_if_stale(). Throttle léger (5s par défaut, configurable via MODEL_RELOAD_CHECK_INTERVAL_S) pour ne pas faire un appel os.path.getmtime() à chaque candle. Corrige le point #4 du diagnostic : avant ce module, un ré-entraînement réussi (auto_retrain.py, en arrière-plan) restait totalement sans effet sur le bot déjà lancé, jusqu'à un redémarrage manuel du process — aussi bien pour le ML que pour le RL. Retourne True si quelque chose (ML et/ou RL) a effectivement été rechargé. """ now = time.time() if not force and (now - self._last_reload_check) < self._reload_throttle_s: return False self._last_reload_check = now reloaded = False with self._reload_lock: expected_path = self._expected_ml_path() current_mtime = _ens_core.ensemble_mtime(expected_path) if expected_path != self._ensemble_path or current_mtime != self._ensemble_mtime: log.info(f"[BRAIN] Modèle ML modifié sur disque ({expected_path}) — rechargement à chaud") self._load_ml() reloaded = True if _RL_READY and config.USE_RL_AGENT: try: rl = get_rl_agent() if rl.reload_if_stale(): reloaded = True except Exception as e: log.warning(f"[BRAIN] Erreur hot-reload RL : {e}") return reloaded # ── Détection de régime (relocalisée depuis ahad_quant.py) ───────────── def _detect_regime(self, close: np.ndarray, volume: np.ndarray) -> int: """Retourne 0=CALM, 1=NORMAL, 2=VOLATILE. Singleton GaussianHMM ré-ajusté sur la fenêtre récente à chaque appel (comportement identique à l'ancien ahad_quant.py::_detect_regime).""" if not HAS_REGIME or len(close) < 50: return 1 returns = np.diff(np.log(close + 1e-8)) vol = np.abs(returns) vol_ma = np.convolve(volume / (volume.mean() + 1e-8), np.ones(5) / 5, mode="same") obs = np.column_stack([returns[-48:], vol[-48:], vol_ma[-48:]]) with self._hmm_lock: if self._hmm_model is None: self._hmm_model = GaussianHMM(n_states=3) try: self._hmm_model.fit(obs) states = self._hmm_model.predict(obs) return int(states[-1]) except Exception: return 1 # ── Décision ──────────────────────────────────────────────────────────── def decide( self, candles: list, btc_candles: list | None = None, funding: float = 0.0, position_state: "PositionState" = None, pair: str | None = None, ) -> Decision: """ Point d'entrée UNIQUE : bougies (+ contexte) → décision complète. Préserve EXACTEMENT la sémantique de combinaison ML/RL existante : le RL ne filtre QUE les signaux ML non-neutres, via rl_agent.RLAgent.filter_signal() (boost si accord, override si RL très confiant et en désaccord, fallback sinon) — cette logique de calibration reste dans rl_agent.py, simplement appelée d'ici, pour ne PAS changer silencieusement un comportement déjà calibré. """ open_ = np.array([c["o"] for c in candles]) high = np.array([c["h"] for c in candles]) low = np.array([c["l"] for c in candles]) close = np.array([c["c"] for c in candles]) volume = np.array([c["v"] for c in candles]) btc_close = None if btc_candles: btc_close = np.array([c["c"] for c in btc_candles]) min_len = min(len(close), len(btc_close)) open_, high, low, close, volume = ( arr[-min_len:] for arr in (open_, high, low, close, volume) ) btc_close = btc_close[-min_len:] candles_for_features = [ {"o": o, "h": h, "l": l, "c": c, "v": v} for o, h, l, c, v in zip(open_, high, low, close, volume) ] X_all = build_features(candles_for_features, btc_closes=btc_close, funding_map=None) X_all = np.nan_to_num(X_all, nan=0.0) last_feat = X_all[-1] # ── ML : ensemble_core, SOURCE UNIQUE (corrige le bug #1 pour cette # 3ᵉ implémentation — l'ancienne ahad_quant.py::predict_signal() # pouvait lever une ValueError non-attrapée si has_dl=True avec un # historique trop court ; ensemble_core gère ce cas en interne, # jamais d'exception qui remonte) ── if self._ensemble is not None: proba, _ = _ens_core.predict_ensemble_single(self._ensemble, last_feat, history=X_all) else: proba = 0.5 # ── Régime ──────────────────────────────────────────────────────── regime = 1 if config.USE_REGIME_FILTER and HAS_REGIME and len(close) >= 50: try: regime = self._detect_regime(close, volume) except Exception: regime = 1 regime_label = _REGIME_LABELS.get(regime, "NORMAL") threshold = config.MIN_CONFIDENCE if regime == 2: # VOLATILE → barre plus haute threshold = min(config.MIN_CONFIDENCE + 0.05, 0.75) if proba > threshold: ml_signal, ml_confidence = "long", float(proba) elif proba < (1 - threshold): ml_signal, ml_confidence = "short", float(1 - proba) else: ml_signal, ml_confidence = "neutral", float(max(proba, 1 - proba)) decision = Decision( signal=ml_signal, confidence=ml_confidence, ml_signal=ml_signal, ml_confidence=ml_confidence, ml_proba=float(proba), regime=regime, regime_label=regime_label, threshold_used=threshold, features=last_feat, model_version=(self._ensemble.get("version", "?") if self._ensemble else "?"), ) # ── Filtre RL — uniquement sur signal ML non-neutre, comme avant ── if ml_signal != "neutral" and _RL_READY and config.USE_RL_AGENT: try: rl = get_rl_agent() if rl.is_ready(): rl_signal, rl_confidence, rl_action = rl.filter_signal( ml_signal=ml_signal, ml_confidence=ml_confidence, features=last_feat, position_state=position_state, ) decision.rl_used = True decision.rl_signal = rl_signal decision.rl_confidence = rl_confidence decision.rl_action = rl_action decision.rl_agreed = (rl_signal == ml_signal) decision.signal = rl_signal decision.confidence = rl_confidence else: log.info(f"[BRAIN] RL non prêt — signal ML non filtré ({pair or '?'})") except Exception as e: log.warning(f"[BRAIN] Erreur RL ({pair or '?'}) : {e} — fallback signal ML non filtré") return decision # ─── Singleton global (même pattern que rl_agent.get_rl_agent) ────────────── _brain_instance: Optional[UnifiedBrain] = None _brain_lock = threading.Lock() def get_brain() -> UnifiedBrain: global _brain_instance if _brain_instance is None: with _brain_lock: if _brain_instance is None: _brain_instance = UnifiedBrain() return _brain_instance def reset_brain(): global _brain_instance _brain_instance = None log.info("[BRAIN] Singleton réinitialisé")