Initial commit - AHAD QUANT v1
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
"""
|
||||
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é")
|
||||
Reference in New Issue
Block a user