Files
ahad-quant/unified_brain.py
2026-06-25 14:00:20 +03:00

366 lines
16 KiB
Python

"""
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é")