366 lines
16 KiB
Python
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é")
|