Files
2026-06-25 14:00:20 +03:00

512 lines
22 KiB
Python

"""
AHAD QUANT — RL Agent Interface (Inférence Live) — V6 UNIFIED
Charge ahad_quant_unified.zip — UN seul fichier qui contient :
- Le modèle PPO (réseau de neurones, poids, optimiseur)
- L'ensemble supervisé (LightGBM + XGBoost + RF + TFT + TGRU + Meta)
- Le scaler z-score des features
Chargement :
agent = RLAgent(unified_path="ahad_quant_unified.zip")
signal, conf = agent.predict_signal(features, position_state)
Ou depuis fichiers séparés (compatibilité V5) :
agent = RLAgent(model_path="rl_agent", scaler_path="rl_scaler.pkl",
ensemble_path="model_ensemble.pkl")
Actions RL :
0 = HOLD/NEUTRAL
1 = LONG
2 = SHORT
3 = CLOSE
"""
import os
import io
import json
import pickle
import zipfile
import logging
import collections
import numpy as np
from typing import Tuple, Optional
log = logging.getLogger("RLAgent")
# Source unique pour le ML (voir ensemble_core.py). rl_agent.py sert à
# l'INFÉRENCE LIVE et ne doit PAS dépendre de rl_env.py, qui importe
# gymnasium en dur et charge tout l'environnement d'entraînement — corrige
# le point #9 du diagnostic (avant, importer rl_agent.py forçait
# l'installation de gymnasium même sur un déploiement live sans entraînement).
import ensemble_core as _ens_core
from features import NUM_FEATURES
_load_ensemble = _ens_core.load_ensemble
def _predict_ensemble(ensemble, raw_feat: np.ndarray, seq_buffer=None):
"""Wrapper local — délègue à ensemble_core (même logique exacte que
rl_env.py, ahad_quant.py et backtest.py : aucune divergence possible)."""
history = None
if seq_buffer is not None:
try:
history = np.asarray(seq_buffer, dtype=np.float32)
if history.ndim != 2 or len(history) == 0:
history = None
except Exception:
history = None
return _ens_core.predict_ensemble_single(ensemble, raw_feat, history=history)
SEQ_LEN = _ens_core.dl_seq_len()
# MAX_HOLD synchronisé sur config.py (même source unique de vérité que
# rl_env.py — corrige le point #5 du diagnostic côté inférence live, pour
# PositionState.to_array()).
try:
import config as _config
_DEFAULT_MAX_HOLD = int(getattr(_config, "MAX_HOLD_CANDLES", 6))
except Exception:
_DEFAULT_MAX_HOLD = 6
# ─── Constantes ──────────────────────────────────────────────────────────────
ACTION_TO_SIGNAL = {
0: "neutral",
1: "long",
2: "short",
3: "neutral",
}
N_ML_FEATURES = NUM_FEATURES
# ─── Position State ──────────────────────────────────────────────────────────
class PositionState:
__slots__ = ["in_position", "direction", "unrealized_pnl_pct",
"candles_held", "balance_ratio"]
def __init__(
self,
in_position: bool = False,
direction: int = 0,
unrealized_pnl_pct: float = 0.0,
candles_held: int = 0,
balance_ratio: float = 1.0,
):
self.in_position = in_position
self.direction = direction
self.unrealized_pnl_pct = unrealized_pnl_pct
self.candles_held = candles_held
self.balance_ratio = balance_ratio
def to_array(self, max_hold: int = _DEFAULT_MAX_HOLD) -> np.ndarray:
return np.array([
float(self.in_position),
float(self.direction),
float(np.clip(self.unrealized_pnl_pct / 0.05, -1.0, 1.0)),
float(np.clip(self.candles_held / max_hold, 0.0, 1.0)),
float(np.clip(self.balance_ratio, 0.0, 2.0)),
], dtype=np.float32)
# ─── RLAgent ─────────────────────────────────────────────────────────────────
class RLAgent:
"""
Interface d'inférence pour AHAD QUANT V6 Unified.
Deux modes de chargement :
Mode UNIFIÉ (recommandé) :
agent = RLAgent(unified_path="ahad_quant_unified.zip")
Mode SÉPARÉ (compatibilité V5) :
agent = RLAgent(model_path="rl_agent",
scaler_path="rl_scaler.pkl",
ensemble_path="model_ensemble.pkl")
"""
def __init__(
self,
# Mode unifié (repli portable)
unified_path: str = None,
# Mode séparé — FICHIERS VIVANTS, prioritaires (voir _load_best_available)
model_path: str = None,
scaler_path: str = None,
ensemble_path: str = None,
# Options
device: str = "cpu",
enabled: bool = True,
):
self.enabled = enabled
self._model = None
self._loaded = False
self._device = device
self._feat_mean: Optional[np.ndarray] = None
self._feat_std: Optional[np.ndarray] = None
self._ensemble = None
self._has_dl = False
self._metadata = {}
self._mtimes = {}
self._seq_buffer: collections.deque = collections.deque(maxlen=SEQ_LEN)
# Résout les chemins via config.py quand non fournis explicitement,
# pour que les "fichiers vivants" (#2 du diagnostic) soient toujours
# connus, même si l'appelant ne passe que unified_path (compat V6).
import config
self._model_path = model_path or getattr(config, "RL_MODEL_PATH", "rl_agent")
self._scaler_path = scaler_path or getattr(config, "RL_SCALER_PATH", "rl_scaler.pkl")
self._ensemble_path = ensemble_path or getattr(config, "ENSEMBLE_MODEL_PATH", "model_ensemble.pkl")
self._unified_path = unified_path or getattr(config, "UNIFIED_MODEL_PATH", "ahad_quant_unified.zip")
if not enabled:
log.info("[RL-AGENT] Désactivé")
return
self._load_best_available(device)
def _zip_path(self, path: str) -> str:
return path if path.endswith(".zip") else path + ".zip"
def _canonical_files_present(self) -> bool:
"""True si les fichiers vivants (rl_agent.zip + rl_scaler.pkl +
model_ensemble.pkl) sont TOUS présents. Ce sont eux qui reflètent un
éventuel ré-entraînement ML/RL — contrairement à ahad_quant_unified.zip,
qui n'est régénéré que par un export manuel (point #2 du diagnostic)."""
return (
os.path.exists(self._zip_path(self._model_path))
and os.path.exists(self._scaler_path)
and os.path.exists(self._ensemble_path)
)
def _load_best_available(self, device: str):
"""
Choisit la source à charger, par ordre de priorité :
1. Fichiers vivants (rl_agent.zip + rl_scaler.pkl +
model_ensemble.pkl) s'ils sont TOUS présents — toujours
prioritaires car ils reflètent le dernier ré-entraînement et
permettent le hot-reload (corrige #2 et #4 du diagnostic).
AVANT ce correctif, ahad_quant_unified.zip était préféré dès
qu'il existait, ce qui rendait tout ré-entraînement RL inopérant
en production tant que ce fichier restait sur le disque.
2. Sinon, ahad_quant_unified.zip — repli utile en déploiement
portable (autre machine, sans pipeline d'entraînement local).
"""
if self._canonical_files_present():
log.info("[RL-AGENT] Fichiers vivants détectés — chargement séparé (prioritaire)")
self._load_model(self._model_path, device)
self._load_scaler(self._scaler_path)
self._ensemble = _load_ensemble(self._ensemble_path)
self._has_dl = self._ensemble.get("has_dl", False) if self._ensemble else False
elif self._unified_path and os.path.exists(self._unified_path):
log.info(f"[RL-AGENT] Fichiers vivants absents — repli sur le bundle portable : {self._unified_path}")
self._load_unified(self._unified_path, device)
else:
log.warning("[RL-AGENT] Aucune source de modèle trouvée (ni fichiers vivants, ni bundle unifié)")
self.enabled = False
return
self._record_mtimes()
def _record_mtimes(self):
self._mtimes = {
"model": _ens_core.ensemble_mtime(self._zip_path(self._model_path)),
"scaler": _ens_core.ensemble_mtime(self._scaler_path),
"ensemble": _ens_core.ensemble_mtime(self._ensemble_path),
"unified": _ens_core.ensemble_mtime(self._unified_path) if self._unified_path else 0.0,
}
def reload_if_stale(self) -> bool:
"""
Compare les mtimes actuels des fichiers source à ceux du dernier
chargement (coût négligeable : quelques appels os.path.getmtime) et
recharge tout (PPO + scaler + ensemble) si l'un d'eux a changé.
Corrige le point #4 du diagnostic : avant, un ré-entraînement réussi
en arrière-plan (auto_retrain.py) restait totalement sans effet sur
le bot déjà en cours d'exécution, jusqu'à un redémarrage manuel du
process. Peut être appelé à chaque itération de la boucle principale
(voir unified_brain.py / ahad_quant.py::run()).
Retourne True si un rechargement a effectivement eu lieu.
"""
if not self.enabled:
return False
current = {
"model": _ens_core.ensemble_mtime(self._zip_path(self._model_path)),
"scaler": _ens_core.ensemble_mtime(self._scaler_path),
"ensemble": _ens_core.ensemble_mtime(self._ensemble_path),
"unified": _ens_core.ensemble_mtime(self._unified_path) if self._unified_path else 0.0,
}
if current == self._mtimes:
return False
log.info("[RL-AGENT] Changement détecté sur les fichiers modèle — rechargement à chaud")
self._seq_buffer.clear()
self._load_best_available(self._device)
return True
# ──────────────────────────────────────────────────────────────────────────
# Chargement UNIFIÉ
# ──────────────────────────────────────────────────────────────────────────
def _load_unified(self, path: str, device: str):
"""Charge tout depuis ahad_quant_unified.zip."""
log.info(f"[RL-AGENT] Chargement unifié : {path}")
try:
from stable_baselines3 import PPO
except ImportError:
log.error("[RL-AGENT] stable-baselines3 non installé.")
return
try:
with zipfile.ZipFile(path, "r") as uz:
files = uz.namelist()
# ── 1. Reconstruire le zip PPO en mémoire ────────────────────
# SB3 attend un zip avec les fichiers à la racine (pas dans ppo/)
ppo_buf = io.BytesIO()
with zipfile.ZipFile(ppo_buf, "w", zipfile.ZIP_DEFLATED) as ppo_zip:
for name in files:
if name.startswith("ppo/"):
inner_name = name[len("ppo/"):] # retirer le préfixe
if inner_name:
ppo_zip.writestr(inner_name, uz.read(name))
ppo_buf.seek(0)
# ── 2. Charger le PPO depuis le BytesIO ──────────────────────
self._model = PPO.load(ppo_buf, device=device)
self._loaded = True
log.info("[RL-AGENT] ✅ PPO chargé depuis mémoire")
# ── 3. Charger le scaler ─────────────────────────────────────
if "rl_scaler.pkl" in files:
scaler = pickle.loads(uz.read("rl_scaler.pkl"))
self._feat_mean = scaler["mean"].astype(np.float32)
self._feat_std = scaler["std"].astype(np.float32)
log.info("[RL-AGENT] ✅ Scaler chargé")
# ── 4. Charger l'ensemble ─────────────────────────────────────
if "ensemble.pkl" in files:
self._ensemble = pickle.loads(uz.read("ensemble.pkl"))
self._has_dl = self._ensemble.get("has_dl", False)
log.info(f"[RL-AGENT] ✅ Ensemble chargé — has_dl={self._has_dl}")
# ── 5. Metadata ───────────────────────────────────────────────
if "metadata.json" in files:
self._metadata = json.loads(uz.read("metadata.json"))
log.info(f"[RL-AGENT] Version : {self._metadata.get('version','?')}")
except Exception as e:
log.error(f"[RL-AGENT] Erreur chargement unifié : {e}")
self.enabled = False
# ──────────────────────────────────────────────────────────────────────────
# Chargement SÉPARÉ (fallback)
# ──────────────────────────────────────────────────────────────────────────
def _load_model(self, path: str, device: str):
try:
from stable_baselines3 import PPO
except ImportError:
log.error("[RL-AGENT] stable-baselines3 non installé.")
return
zip_path = path if path.endswith(".zip") else path + ".zip"
if not os.path.exists(zip_path):
log.warning(f"[RL-AGENT] Modèle introuvable : {zip_path}")
self.enabled = False
return
try:
self._model = PPO.load(path, device=device)
self._loaded = True
log.info(f"[RL-AGENT] ✅ PPO chargé : {zip_path}")
except Exception as e:
log.error(f"[RL-AGENT] Erreur : {e}")
self.enabled = False
def _load_scaler(self, path: str):
if not os.path.exists(path):
log.warning(f"[RL-AGENT] Scaler introuvable : {path}")
return
try:
with open(path, "rb") as f:
scaler = pickle.load(f)
self._feat_mean = scaler["mean"].astype(np.float32)
self._feat_std = scaler["std"].astype(np.float32)
log.info(f"[RL-AGENT] ✅ Scaler chargé : {path}")
except Exception as e:
log.error(f"[RL-AGENT] Erreur scaler : {e}")
# ──────────────────────────────────────────────────────────────────────────
# API Publique
# ──────────────────────────────────────────────────────────────────────────
def predict(
self,
features: np.ndarray,
position_state: PositionState = None,
deterministic: bool = True,
) -> int:
"""Retourne l'action RL (0=HOLD, 1=LONG, 2=SHORT, 3=CLOSE)."""
if not self.enabled or not self._loaded or self._model is None:
return 0
obs = self._build_obs(features, position_state)
try:
action, _ = self._model.predict(obs, deterministic=deterministic)
# numpy 2.x : int() ne fonctionne plus sur les arrays 1-D → .flat[0]
return int(action.flat[0]) if hasattr(action, "flat") else int(action)
except Exception as e:
log.error(f"[RL-AGENT] Erreur predict : {e}")
return 0
def predict_signal(
self,
features: np.ndarray,
position_state: PositionState = None,
return_action: bool = False,
):
"""Raccourci : features → (signal, confidence), ou (signal,
confidence, action) si return_action=True. L'action brute PPO
(0=HOLD,1=LONG,2=SHORT,3=CLOSE) est nécessaire pour le buffer
d'apprentissage continu — voir filter_signal()."""
action = self.predict(features, position_state)
signal = ACTION_TO_SIGNAL.get(action, "neutral")
# Confidence basée sur la certitude de l'ensemble intégré
_, ens_conf = _predict_ensemble(
self._ensemble,
features.astype(np.float32),
self._seq_buffer if self._has_dl else None,
)
# Confidence finale = moyenne ens_conf + base fixe
confidence = round(0.70 + ens_conf * 0.25, 4) # [0.70, 0.95]
if return_action:
return signal, confidence, action
return signal, confidence
def filter_signal(
self,
ml_signal: str,
ml_confidence: float,
features: np.ndarray,
position_state: "PositionState" = None,
):
"""
Filtre le signal ML via le PPO.
- RL confirme ML → boost confiance
- RL override fort → suit le RL
- RL neutral / erreur → fallback sur ML (ne bloque pas)
Retourne (signal, confidence, rl_action) — rl_action est l'action
PPO brute (None si une exception interne a empêché toute inférence
RL). Corrige le point #8 du diagnostic : avant, cette action réelle
n'était jamais propagée jusqu'au contexte d'ouverture de trade, et
online_learner.py devait la DEVINER à partir du signal final
(1 si LONG, 2 sinon), cassant la fidélité du replay RL.
"""
try:
rl_signal, rl_confidence, rl_action = self.predict_signal(
features, position_state, return_action=True
)
except Exception:
# Erreur interne RL → on laisse passer le signal ML
return ml_signal, ml_confidence, None
# Seuils lus depuis config.py (et non os.getenv directement) pour
# que la calibration adaptative (online_learner.calibrate_threshold,
# qui peut ajuster config.RL_OVERRIDE_THRESHOLD en mémoire) ait
# réellement un effet — corrige le point #7 du diagnostic.
import config as _cfg
# RL confirme la direction ML
if rl_signal == ml_signal:
boost = float(getattr(_cfg, "RL_CONFIDENCE_BOOST", 0.05))
return ml_signal, round(min(ml_confidence + boost, 0.99), 4), rl_action
# RL override avec haute confiance
override_thr = float(getattr(_cfg, "RL_OVERRIDE_THRESHOLD", 0.82))
if rl_signal != "neutral" and rl_confidence >= override_thr:
return rl_signal, rl_confidence, rl_action
# RL dit neutral ou pas assez confiant → on garde le signal ML
return ml_signal, ml_confidence, rl_action
def reset_buffer(self):
"""Vide le buffer de séquence (appeler entre paires / sessions)."""
self._seq_buffer.clear()
def info(self) -> dict:
"""Retourne les métadonnées du modèle chargé."""
return {
**self._metadata,
"ppo_loaded": self._loaded,
"ensemble": self._ensemble is not None,
"has_dl": self._has_dl,
"scaler": self._feat_mean is not None,
}
def is_ready(self) -> bool:
return self.enabled and self._loaded and self._model is not None
# ──────────────────────────────────────────────────────────────────────────
# Interne
# ──────────────────────────────────────────────────────────────────────────
def _build_obs(
self,
raw_features: np.ndarray,
position_state: Optional[PositionState],
) -> np.ndarray:
"""Construit le vecteur d'observation 69 dims (identique à rl_env)."""
feat = raw_features.astype(np.float32).copy()
if len(feat) > N_ML_FEATURES:
feat = feat[:N_ML_FEATURES]
elif len(feat) < N_ML_FEATURES:
feat = np.pad(feat, (0, N_ML_FEATURES - len(feat)))
if self._feat_mean is not None and self._feat_std is not None:
feat = (feat - self._feat_mean) / self._feat_std
feat = np.nan_to_num(feat, nan=0.0, posinf=0.0, neginf=0.0)
if position_state is None:
position_state = PositionState()
pos = position_state.to_array()
if self._ensemble is not None:
self._seq_buffer.append(raw_features.astype(np.float32))
ens_proba, ens_conf = _predict_ensemble(
self._ensemble,
raw_features.astype(np.float32),
self._seq_buffer if self._has_dl else None,
)
ens_feats = np.array([ens_proba, ens_conf], dtype=np.float32)
obs = np.concatenate([feat, pos, ens_feats])
else:
obs = np.concatenate([feat, pos])
return obs[np.newaxis, :].astype(np.float32)
# ─── Singleton global ─────────────────────────────────────────────────────────
_agent_instance: Optional[RLAgent] = None
def get_rl_agent() -> RLAgent:
global _agent_instance
if _agent_instance is None:
import config
unified = getattr(config, "UNIFIED_MODEL_PATH", "ahad_quant_unified.zip")
_agent_instance = RLAgent(
unified_path = unified,
enabled = getattr(config, "USE_RL_AGENT", False),
)
return _agent_instance
def reset_rl_agent():
global _agent_instance
_agent_instance = None
log.info("[RL-AGENT] Singleton réinitialisé")