634 lines
27 KiB
Python
634 lines
27 KiB
Python
"""
|
|
AHAD QUANT — Online Learner (Apprentissage Continu V7)
|
|
=========================================================
|
|
Apprentissage continu à partir des expériences stockées dans le buffer.
|
|
|
|
3 mécanismes distincts :
|
|
|
|
1. LightGBM WARM-START (quotidien, ~2-5 min, local CPU)
|
|
- Récupère les trades LOSS + TIMEOUT des dernières 24h
|
|
- Pondère chaque sample par error_weight
|
|
- Ré-entraîne LightGBM avec init_model=model_actuel (50 arbres supplémentaires)
|
|
- Remplace model.pkl SEULEMENT si accuracy >= accuracy_actuelle - 0.5%
|
|
|
|
2. RL EXPERIENCE REPLAY (hebdomadaire, ~2-4h)
|
|
- Convertit les trades réels du buffer en épisodes RL (obs, action, reward, next_obs)
|
|
- Injecte ces épisodes dans le fine-tune PPO SB3
|
|
|
|
3. CALIBRATION SEUIL (immédiat, après chaque trade fermé)
|
|
- Sliding window sur les 50 derniers trades
|
|
- Si win_rate < 50% → MIN_CONFIDENCE += 0.01
|
|
- Si win_rate > 65% → MIN_CONFIDENCE -= 0.005
|
|
- Borné entre [0.62, 0.88]
|
|
- Sauvegardé dans adaptive_state.json
|
|
|
|
Usage :
|
|
from experience_buffer import get_experience_buffer
|
|
from online_learner import OnlineLearner
|
|
|
|
buf = get_experience_buffer()
|
|
learner = OnlineLearner(buf)
|
|
learner.daily_update()
|
|
new_conf = learner.calibrate_threshold(recent_n=50)
|
|
episodes = learner.prepare_rl_episodes()
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import pickle
|
|
import shutil
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
import numpy as np
|
|
|
|
import config
|
|
from experience_buffer import ExperienceBuffer
|
|
import ensemble_core as _ens_core
|
|
from features import NUM_FEATURES
|
|
|
|
log = logging.getLogger("OnlineLearner")
|
|
|
|
# ── Constantes ────────────────────────────────────────────────────────────────
|
|
WARMSTART_ROUNDS = 50 # arbres supplémentaires par cycle quotidien
|
|
WARMSTART_MIN_TRADES = 20 # minimum de trades échoués pour déclencher
|
|
CALIBRATION_WINDOW = 50 # trades pour sliding win rate
|
|
CONFIDENCE_MIN = 0.62 # plancher MIN_CONFIDENCE
|
|
CONFIDENCE_MAX = 0.88 # plafond MIN_CONFIDENCE
|
|
CONFIDENCE_STEP_UP = 0.01 # hausse si win_rate < 50%
|
|
CONFIDENCE_STEP_DOWN = 0.005 # baisse si win_rate > 65%
|
|
ACCURACY_TOLERANCE = 0.005 # -0.5% toléré pour accepter le nouveau modèle
|
|
ADAPTIVE_STATE_FILE = "adaptive_state.json"
|
|
|
|
# Calibration jointe ML/RL (corrige le point #7 du diagnostic) : avant,
|
|
# config.RL_OVERRIDE_THRESHOLD (utilisé par rl_agent.py::filter_signal pour
|
|
# décider si le RL peut s'imposer face au ML) n'était JAMAIS ajusté par
|
|
# calibrate_threshold(), qui ne touchait que MIN_CONFIDENCE — les deux
|
|
# seuils dérivaient l'un de l'autre silencieusement avec le temps. On les
|
|
# fait maintenant bouger ensemble, à écart ("marge") constant.
|
|
RL_OVERRIDE_MIN = 0.65
|
|
RL_OVERRIDE_MAX = 0.95
|
|
|
|
|
|
class OnlineLearner:
|
|
"""
|
|
Apprentissage incrémental à partir des expériences réelles de trading.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
buffer: ExperienceBuffer,
|
|
model_path: Optional[str] = None,
|
|
ensemble_path: Optional[str] = None,
|
|
adaptive_state_file: str = ADAPTIVE_STATE_FILE,
|
|
):
|
|
self.buffer = buffer
|
|
self.model_path = model_path or getattr(config, "MODEL_PATH", "model.pkl")
|
|
self.ensemble_path = ensemble_path or getattr(config, "ENSEMBLE_MODEL_PATH", "model_ensemble.pkl")
|
|
self.state_file = adaptive_state_file
|
|
self._state = self._load_adaptive_state()
|
|
|
|
# ── Adaptive state I/O ───────────────────────────────────────────────────
|
|
|
|
def _load_adaptive_state(self) -> Dict:
|
|
default_margin = round(
|
|
getattr(config, "RL_OVERRIDE_THRESHOLD", 0.82)
|
|
- getattr(config, "MIN_CONFIDENCE", 0.72), 4
|
|
)
|
|
defaults = {
|
|
"min_confidence": getattr(config, "MIN_CONFIDENCE", 0.72),
|
|
"rl_override_threshold": getattr(config, "RL_OVERRIDE_THRESHOLD", 0.82),
|
|
"rl_override_margin": default_margin,
|
|
"last_calibration": None,
|
|
"win_rate_50": 0.0,
|
|
"avg_pnl_50": 0.0,
|
|
"total_trades": 0,
|
|
"emergency_retrain_at": None,
|
|
"confidence_history": [],
|
|
}
|
|
if not os.path.exists(self.state_file):
|
|
return defaults
|
|
try:
|
|
with open(self.state_file, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
# Fusionner avec les defaults (robustesse)
|
|
return {**defaults, **data}
|
|
except Exception as e:
|
|
log.warning(f"Erreur chargement adaptive_state : {e} — reset")
|
|
return defaults
|
|
|
|
def _save_adaptive_state(self) -> None:
|
|
try:
|
|
self._state["last_calibration"] = datetime.now(timezone.utc).isoformat()
|
|
self._state["total_trades"] = self.buffer.total_trades
|
|
tmp = self.state_file + ".tmp"
|
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
json.dump(self._state, f, indent=2, ensure_ascii=False)
|
|
os.replace(tmp, self.state_file)
|
|
except Exception as e:
|
|
log.error(f"Erreur sauvegarde adaptive_state : {e}")
|
|
|
|
@property
|
|
def current_confidence(self) -> float:
|
|
return self._state.get("min_confidence", getattr(config, "MIN_CONFIDENCE", 0.72))
|
|
|
|
# ── Mécanisme 3 : Calibration du seuil de confiance ─────────────────────
|
|
|
|
def calibrate_threshold(self, recent_n: int = CALIBRATION_WINDOW) -> float:
|
|
"""
|
|
Ajuste MIN_CONFIDENCE dynamiquement selon le win_rate récent.
|
|
- win_rate < 50% → hausse le seuil (devient plus sélectif)
|
|
- win_rate > 65% → baisse le seuil (devient plus actif)
|
|
Retourne la nouvelle valeur de MIN_CONFIDENCE.
|
|
"""
|
|
if not getattr(config, "CONTINUOUS_LEARNING_ENABLED", True):
|
|
return self.current_confidence
|
|
|
|
recent = self.buffer.get_last_n(recent_n)
|
|
if len(recent) < 5:
|
|
return self.current_confidence # pas assez de données
|
|
|
|
wins = sum(1 for e in recent if e.get("outcome") == "WIN")
|
|
win_rate = wins / len(recent)
|
|
|
|
current_conf = self.current_confidence
|
|
|
|
if win_rate < 0.50:
|
|
new_conf = min(current_conf + CONFIDENCE_STEP_UP, CONFIDENCE_MAX)
|
|
reason = f"win_rate={win_rate:.1%} < 50% → hausse seuil"
|
|
elif win_rate > 0.65:
|
|
new_conf = max(current_conf - CONFIDENCE_STEP_DOWN, CONFIDENCE_MIN)
|
|
reason = f"win_rate={win_rate:.1%} > 65% → baisse seuil"
|
|
else:
|
|
new_conf = current_conf
|
|
reason = f"win_rate={win_rate:.1%} stable — pas de changement"
|
|
|
|
changed = abs(new_conf - current_conf) > 1e-5
|
|
|
|
self._state["min_confidence"] = round(new_conf, 4)
|
|
self._state["win_rate_50"] = round(win_rate, 4)
|
|
|
|
# Historique des 10 dernières valeurs
|
|
history = self._state.get("confidence_history", [])
|
|
history.append(round(new_conf, 4))
|
|
self._state["confidence_history"] = history[-10:]
|
|
|
|
# Synchronise config en mémoire (pour la session courante)
|
|
config.MIN_CONFIDENCE = new_conf
|
|
|
|
# ── Calibration jointe RL (corrige le point #7) ──────────────────
|
|
# RL_OVERRIDE_THRESHOLD suit MIN_CONFIDENCE à marge constante (l'écart
|
|
# initial entre les deux, persisté), au lieu de rester figé pendant
|
|
# que MIN_CONFIDENCE dérive. rl_agent.py::filter_signal() lit déjà
|
|
# config.RL_OVERRIDE_THRESHOLD (et non plus une variable d'env figée),
|
|
# donc cet ajustement a un effet réel dès le prochain signal.
|
|
margin = self._state.get(
|
|
"rl_override_margin",
|
|
round(getattr(config, "RL_OVERRIDE_THRESHOLD", 0.82) - current_conf, 4),
|
|
)
|
|
new_rl_override = float(np.clip(new_conf + margin, RL_OVERRIDE_MIN, RL_OVERRIDE_MAX))
|
|
config.RL_OVERRIDE_THRESHOLD = round(new_rl_override, 4)
|
|
self._state["rl_override_threshold"] = round(new_rl_override, 4)
|
|
|
|
self._save_adaptive_state()
|
|
|
|
if changed:
|
|
log.info(
|
|
f"[CALIBRATION] {reason} | conf {current_conf:.3f} → {new_conf:.3f} "
|
|
f"| RL_OVERRIDE_THRESHOLD → {new_rl_override:.3f}"
|
|
)
|
|
|
|
return new_conf
|
|
|
|
# ── Mécanisme 1 : LightGBM Warm-Start quotidien ──────────────────────────
|
|
|
|
def daily_update(
|
|
self,
|
|
loss_trades: Optional[List[Dict]] = None,
|
|
hours: float = 24.0,
|
|
) -> bool:
|
|
"""
|
|
Warm-start LightGBM sur les trades LOSS + TIMEOUT des dernières <hours>h.
|
|
|
|
Si USE_ENSEMBLE=true (configuration par défaut) ET que
|
|
model_ensemble.pkl existe, le warm-start cible le sous-modèle
|
|
`lgbm` EMBARQUÉ DANS L'ENSEMBLE, et l'acceptation est évaluée au
|
|
niveau de l'ensemble complet (ensemble_core.predict_ensemble_batch)
|
|
plutôt que sur le seul LightGBM isolé. AVANT ce correctif, cette
|
|
méthode ne mettait à jour QUE model.pkl — un fichier jamais relu en
|
|
production dès que USE_ENSEMBLE=true (la valeur par défaut), ce qui
|
|
rendait ce mécanisme un no-op silencieux pour la quasi-totalité des
|
|
déploiements (point #3 du diagnostic).
|
|
|
|
Repli sur model.pkl (LightGBM autonome) si USE_ENSEMBLE=false ou si
|
|
model_ensemble.pkl est absent — comportement identique à avant pour
|
|
ce cas.
|
|
|
|
Retourne True si le modèle a été mis à jour.
|
|
"""
|
|
if not getattr(config, "WARMSTART_ENABLED", True):
|
|
log.debug("[WARMSTART] Désactivé (WARMSTART_ENABLED=false)")
|
|
return False
|
|
|
|
use_ensemble = bool(getattr(config, "USE_ENSEMBLE", True)) and os.path.exists(self.ensemble_path)
|
|
target_path = self.ensemble_path if use_ensemble else self.model_path
|
|
|
|
if not os.path.exists(target_path):
|
|
log.warning(f"[WARMSTART] Modèle introuvable ({target_path}) — skip")
|
|
return False
|
|
|
|
# ── Charger : préférer le candidat accumulé si disponible ───────────
|
|
# [FIX] Si le warmstart précédent a été rejeté (accuracy insuffisante),
|
|
# les arbres qu'il a appris sont sauvegardés dans <target>.candidate.
|
|
# On repart de ce candidat plutôt que de la production pour que les
|
|
# mauvais trades s'accumulent cycle après cycle, même quand le modèle
|
|
# actif reste inchangé.
|
|
candidate_path = target_path + ".candidate"
|
|
start_model_path = candidate_path if os.path.exists(candidate_path) else target_path
|
|
start_label = "candidate" if os.path.exists(candidate_path) else "production"
|
|
|
|
try:
|
|
with open(start_model_path, "rb") as f:
|
|
loaded = pickle.load(f)
|
|
except Exception as e:
|
|
log.error(f"[WARMSTART] Erreur chargement modèle ({start_model_path}) : {e}")
|
|
return False
|
|
|
|
log.info(f"[WARMSTART] Point de départ : {start_label} ({start_model_path})")
|
|
|
|
if use_ensemble:
|
|
ensemble_dict = loaded
|
|
current_model = ensemble_dict.get("lgbm")
|
|
if current_model is None:
|
|
log.warning("[WARMSTART] Pas de sous-modèle LightGBM dans l'ensemble — skip")
|
|
return False
|
|
else:
|
|
ensemble_dict = None
|
|
current_model = loaded["model"] if isinstance(loaded, dict) else loaded
|
|
|
|
# Récupérer les trades échoués
|
|
if loss_trades is None:
|
|
loss_trades = self.buffer.get_recent(hours=hours, outcome_filter=["LOSS", "TIMEOUT"])
|
|
|
|
min_trades = getattr(config, "WARMSTART_MIN_TRADES", WARMSTART_MIN_TRADES)
|
|
if len(loss_trades) < min_trades:
|
|
log.info(f"[WARMSTART] Seulement {len(loss_trades)} trades échoués "
|
|
f"(min={min_trades}) — skip")
|
|
return False
|
|
|
|
try:
|
|
import lightgbm as lgb
|
|
except ImportError:
|
|
log.error("[WARMSTART] LightGBM non installé — skip")
|
|
return False
|
|
|
|
log.info(
|
|
f"[WARMSTART] Démarrage warm-start ({'ensemble' if use_ensemble else 'LightGBM seul'}) "
|
|
f"sur {len(loss_trades)} trades échoués..."
|
|
)
|
|
|
|
# Construire le dataset
|
|
X, y, weights = _build_dataset_from_trades(loss_trades)
|
|
if X is None or len(X) < 10:
|
|
log.warning("[WARMSTART] Dataset insuffisant — skip")
|
|
return False
|
|
|
|
# Préparer le dataset LightGBM avec pondération par error_weight
|
|
train_data = lgb.Dataset(X, label=y, weight=weights)
|
|
|
|
# Params hérités du modèle courant si disponibles
|
|
params = {
|
|
"objective": "binary",
|
|
"metric": "binary_logloss",
|
|
"learning_rate": 0.03,
|
|
"num_leaves": 63,
|
|
"feature_fraction": 0.8,
|
|
"bagging_fraction": 0.8,
|
|
"bagging_freq": 5,
|
|
"min_child_samples": 10,
|
|
"verbose": -1,
|
|
}
|
|
|
|
try:
|
|
new_model = lgb.train(
|
|
params,
|
|
train_data,
|
|
num_boost_round = WARMSTART_ROUNDS,
|
|
init_model = current_model, # ← warm-start cumulatif
|
|
valid_sets = [train_data], # requis par early_stopping (LightGBM >= 4.x)
|
|
callbacks = [lgb.early_stopping(10, verbose=False),
|
|
lgb.log_evaluation(period=-1)],
|
|
)
|
|
except Exception as e:
|
|
log.error(f"[WARMSTART] Erreur entraînement : {e}")
|
|
return False
|
|
|
|
# ── Évaluation de l'acceptation ───────────────────────────────────
|
|
if use_ensemble and ensemble_dict.get("meta") is not None and ensemble_dict.get("scaler") is not None:
|
|
# Juger l'effet RÉEL sur la prédiction finale de l'ensemble (et
|
|
# non sur le seul sous-modèle LightGBM isolé) : on construit un
|
|
# ensemble candidat avec le lgbm mis à jour, et on compare ses
|
|
# probabilités à celles de l'ensemble actuel sur le même jeu de
|
|
# trades. Délègue à ensemble_core — la même fonction utilisée
|
|
# partout ailleurs, aucune divergence possible.
|
|
current_probas = _ens_core.predict_ensemble_batch(ensemble_dict, X)[:, 0]
|
|
current_acc = float(((current_probas > 0.5) == y).mean())
|
|
|
|
candidate_ensemble = dict(ensemble_dict)
|
|
candidate_ensemble["lgbm"] = new_model
|
|
new_probas = _ens_core.predict_ensemble_batch(candidate_ensemble, X)[:, 0]
|
|
new_acc = float(((new_probas > 0.5) == y).mean())
|
|
else:
|
|
current_preds = current_model.predict(X) > 0.5
|
|
current_acc = float((current_preds == y).mean())
|
|
new_preds = new_model.predict(X) > 0.5
|
|
new_acc = float((new_preds == y).mean())
|
|
|
|
# Accepter si accuracy >= actuelle - tolérance
|
|
# IMPORTANT : on compare toujours par rapport à la PRODUCTION (target_path),
|
|
# pas par rapport au candidat — c'est la production qui est le champion.
|
|
prod_acc = current_acc # current_acc vient du modèle chargé (candidat ou prod)
|
|
if os.path.exists(candidate_path):
|
|
# Re-évaluer l'accuracy de la PRODUCTION pour comparaison juste
|
|
try:
|
|
with open(target_path, "rb") as _pf:
|
|
prod_loaded = pickle.load(_pf)
|
|
if use_ensemble:
|
|
prod_ens = prod_loaded
|
|
prod_lgbm = prod_ens.get("lgbm")
|
|
if prod_lgbm is not None:
|
|
prod_probas = _ens_core.predict_ensemble_batch(prod_ens, X)[:, 0]
|
|
prod_acc = float(((prod_probas > 0.5) == y).mean())
|
|
else:
|
|
prod_m = prod_loaded["model"] if isinstance(prod_loaded, dict) else prod_loaded
|
|
prod_acc = float(((prod_m.predict(X) > 0.5) == y).mean())
|
|
except Exception:
|
|
pass # si lecture prod échoue, on garde current_acc comme référence
|
|
|
|
min_acceptable = prod_acc - ACCURACY_TOLERANCE
|
|
if new_acc >= min_acceptable:
|
|
# ✅ Nouveau modèle meilleur → remplace la production
|
|
backup = target_path + ".warmstart_backup"
|
|
shutil.copy2(target_path, backup)
|
|
if use_ensemble:
|
|
ensemble_dict["lgbm"] = new_model
|
|
with open(target_path, "wb") as _f:
|
|
pickle.dump(ensemble_dict, _f)
|
|
else:
|
|
with open(target_path, "wb") as _f:
|
|
pickle.dump(new_model, _f)
|
|
# Candidat obsolète — nettoyer
|
|
if os.path.exists(candidate_path):
|
|
os.remove(candidate_path)
|
|
log.info("[WARMSTART] Candidat accumulé promu en production — fichier .candidate supprimé")
|
|
log.info(
|
|
f"[WARMSTART] ✅ Modèle mis à jour ({target_path}) : "
|
|
f"acc {prod_acc:.3f} → {new_acc:.3f} ({new_acc - prod_acc:+.3f})"
|
|
)
|
|
return True
|
|
else:
|
|
# ❌ Pas encore assez bon pour remplacer la production
|
|
# [FIX] On sauvegarde quand même les arbres appris en tant que
|
|
# candidat. Le prochain cycle repartira de ce candidat et continuera
|
|
# d'absorber les mauvais trades — l'apprentissage ne s'arrête jamais.
|
|
try:
|
|
if use_ensemble:
|
|
candidate_dict = dict(ensemble_dict)
|
|
candidate_dict["lgbm"] = new_model
|
|
with open(candidate_path, "wb") as _cf:
|
|
pickle.dump(candidate_dict, _cf)
|
|
else:
|
|
with open(candidate_path, "wb") as _cf:
|
|
pickle.dump(new_model, _cf)
|
|
log.info(
|
|
f"[WARMSTART] ⏳ Candidat sauvegardé ({candidate_path}) : "
|
|
f"acc {new_acc:.3f} vs prod {prod_acc:.3f} — "
|
|
f"le prochain cycle repartira de ce candidat"
|
|
)
|
|
except Exception as _ce:
|
|
log.warning(f"[WARMSTART] Impossible de sauvegarder le candidat : {_ce}")
|
|
log.warning(
|
|
f"[WARMSTART] ❌ Nouveau modèle rejeté (prod conservée) : "
|
|
f"acc {new_acc:.3f} < {min_acceptable:.3f}"
|
|
)
|
|
return False
|
|
|
|
# ── Mécanisme 2 : Préparation des épisodes RL pour injection PPO ─────────
|
|
|
|
def prepare_rl_episodes(
|
|
self,
|
|
buffer: Optional[ExperienceBuffer] = None,
|
|
min_trades: int = 50,
|
|
) -> List[Dict]:
|
|
"""
|
|
Convertit les trades réels du buffer en épisodes RL.
|
|
Format : {"obs": [...67 dims], "action": int, "reward": float, "done": bool}
|
|
|
|
Ces épisodes seront injectés dans le replay buffer SB3 lors du fine-tune PPO.
|
|
Retourne une liste d'épisodes RL.
|
|
"""
|
|
buf = buffer or self.buffer
|
|
|
|
if not getattr(config, "RL_REAL_REPLAY_ENABLED", True):
|
|
log.debug("[RL-REPLAY] Désactivé (RL_REAL_REPLAY_ENABLED=false)")
|
|
return []
|
|
|
|
if buf.total_trades < min_trades:
|
|
log.info(f"[RL-REPLAY] Seulement {buf.total_trades} trades (min={min_trades}) — skip")
|
|
return []
|
|
|
|
# Cibler en priorité les LOSS et TIMEOUT
|
|
trades = buf.get_loss_trades(min_weight=1.5)
|
|
if len(trades) < 10:
|
|
trades = buf.get_all()
|
|
|
|
episodes = []
|
|
for trade in trades:
|
|
try:
|
|
episode = _trade_to_rl_episode(trade)
|
|
if episode:
|
|
episodes.append(episode)
|
|
except Exception as e:
|
|
log.debug(f"[RL-REPLAY] Skipping trade {trade.get('id','?')} : {e}")
|
|
|
|
log.info(f"[RL-REPLAY] {len(episodes)} épisodes RL préparés depuis {len(trades)} trades")
|
|
return episodes
|
|
|
|
# ── Rapport ──────────────────────────────────────────────────────────────
|
|
|
|
def get_status(self) -> Dict:
|
|
"""Retourne l'état actuel du learner."""
|
|
return {
|
|
"min_confidence": self.current_confidence,
|
|
"total_trades": self.buffer.total_trades,
|
|
"last_calibration": self._state.get("last_calibration"),
|
|
"win_rate_50": self._state.get("win_rate_50", 0.0),
|
|
"buffer_stats": self.buffer.stats(window=50),
|
|
}
|
|
|
|
|
|
# ── Helpers privés ────────────────────────────────────────────────────────────
|
|
|
|
def _build_dataset_from_trades(
|
|
trades: List[Dict],
|
|
) -> Tuple[Optional[np.ndarray], Optional[np.ndarray], Optional[np.ndarray]]:
|
|
"""
|
|
Construit (X, y, weights) depuis une liste d'expériences.
|
|
X = features (62-dim), y = label (WIN=1, LOSS/TIMEOUT=0), weights = error_weight.
|
|
"""
|
|
X_list, y_list, w_list = [], [], []
|
|
|
|
for trade in trades:
|
|
features = trade.get("features")
|
|
if not features or len(features) != NUM_FEATURES:
|
|
continue
|
|
|
|
outcome = trade.get("outcome", "WIN")
|
|
label = 1.0 if outcome == "WIN" else 0.0
|
|
weight = float(trade.get("error_weight", 1.0))
|
|
|
|
X_list.append(features)
|
|
y_list.append(label)
|
|
w_list.append(weight)
|
|
|
|
if len(X_list) < 5:
|
|
return None, None, None
|
|
|
|
return (
|
|
np.array(X_list, dtype=np.float32),
|
|
np.array(y_list, dtype=np.float32),
|
|
np.array(w_list, dtype=np.float32),
|
|
)
|
|
|
|
|
|
def _trade_to_rl_episode(trade: Dict) -> Optional[Dict]:
|
|
"""
|
|
Convertit un trade réel en épisode RL au format SB3.
|
|
obs = [features(62), in_position, direction, unrealized_pnl, candles_held,
|
|
balance_ratio, ens_proba_long, ens_confidence] → 69 dims.
|
|
|
|
[BUG CORRIGÉ 21/06/2026] Cette fonction ne produisait avant que 67 dims
|
|
(sans les 2 dims "ensemble"), alors que le modèle PPO réellement
|
|
entraîné et déployé attend 69 dims dans TOUS les cas : voir
|
|
rl_env.py::_get_obs() (obs = feat + pos_feats + ens_feats quand
|
|
self._ensemble est actif, ce qui est la config par défaut) et
|
|
rl_agent.py::predict() qui construit l'obs de production de façon
|
|
identique. Avec seulement 67 dims, _build_real_trade_buffer()
|
|
(rl_train.py) aurait filtré TOUS les trades réels pour incompatibilité
|
|
de dimensions — silencieusement, comme les bugs précédents.
|
|
|
|
Les 2 dims manquantes [ens_proba_long, ens_confidence] sont
|
|
reconstruites EXACTEMENT (pas une approximation) à partir des champs
|
|
déjà stockés dans chaque trade — confidence et signal — en inversant
|
|
la formule utilisée partout ailleurs (ensemble_core.py::predict_ensemble_batch) :
|
|
confidence = |proba_long - 0.5| * 2
|
|
donc :
|
|
proba_long = 0.5 + confidence/2 si signal == LONG (proba_long >= 0.5)
|
|
proba_long = 0.5 - confidence/2 si signal == SHORT (proba_long < 0.5)
|
|
"""
|
|
features = trade.get("features")
|
|
if not features or len(features) != NUM_FEATURES:
|
|
return None
|
|
|
|
signal = trade.get("signal", "LONG").upper()
|
|
pnl = float(trade.get("pnl", 0.0))
|
|
hold = int(trade.get("hold_candles", 1))
|
|
outcome = trade.get("outcome", "WIN")
|
|
|
|
# Reconstituer l'observation 69-dim
|
|
in_position = 1.0
|
|
direction = 1.0 if signal == "LONG" else -1.0
|
|
unrealized_pnl = float(np.clip(pnl, -1.0, 1.0))
|
|
candles_norm = float(np.clip(hold / 24.0, 0.0, 1.0))
|
|
balance_ratio = 1.0 + float(np.clip(pnl * 10, -0.5, 0.5))
|
|
|
|
# Dims ensemble [proba_long, confidence] — reconstruction exacte (voir
|
|
# docstring ci-dessus). confidence par défaut à 0.5 si absent (donne
|
|
# proba_long=0.75/0.25 selon le sens — meilleur repli que de planter).
|
|
confidence = float(trade.get("confidence", 0.5))
|
|
ens_confidence = float(np.clip(confidence, 0.0, 1.0))
|
|
if signal == "LONG":
|
|
ens_proba_long = 0.5 + ens_confidence / 2.0
|
|
else:
|
|
ens_proba_long = 0.5 - ens_confidence / 2.0
|
|
|
|
obs = (
|
|
list(features)
|
|
+ [in_position, direction, unrealized_pnl, candles_norm, balance_ratio]
|
|
+ [ens_proba_long, ens_confidence]
|
|
)
|
|
|
|
# Action RL originale (si disponible), sinon déduite
|
|
rl_action = trade.get("rl_action")
|
|
if rl_action is None:
|
|
rl_action = 1 if signal == "LONG" else 2 # LONG=1, SHORT=2
|
|
|
|
# Récompense : basée sur le résultat réel, clippée comme dans l'entraînement
|
|
if outcome == "WIN":
|
|
reward = float(np.clip(pnl * 10, 0.1, 1.0))
|
|
elif outcome == "LOSS":
|
|
reward = float(np.clip(pnl * 10, -1.0, -0.1))
|
|
else: # TIMEOUT
|
|
reward = float(np.clip(pnl * 5, -0.5, 0.5))
|
|
|
|
return {
|
|
"obs": obs,
|
|
"action": int(rl_action),
|
|
"reward": float(np.clip(reward, -1.0, 1.0)),
|
|
"done": True, # chaque trade = fin d'épisode
|
|
"trade_id": trade.get("id", ""),
|
|
}
|
|
|
|
|
|
# ── CLI de test ───────────────────────────────────────────────────────────────
|
|
|
|
if __name__ == "__main__":
|
|
import logging
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s")
|
|
|
|
from experience_buffer import ExperienceBuffer
|
|
|
|
# Créer un buffer de test
|
|
buf = ExperienceBuffer(max_size=200, buffer_file="test_learner_buffer.json")
|
|
|
|
for i in range(60):
|
|
outcome = ["WIN", "LOSS", "TIMEOUT"][i % 3]
|
|
buf.add({
|
|
"pair": "EURUSD",
|
|
"signal": "LONG",
|
|
"confidence": 0.75,
|
|
"ml_probas": [0.72, 0.73, 0.71, 0.74, 0.72],
|
|
"rl_action": 1,
|
|
"rl_agreed": True,
|
|
"features": list(np.random.randn(NUM_FEATURES).astype(float)),
|
|
"regime": "NORMAL",
|
|
"entry_price": 1.0850,
|
|
"exit_price": 1.0880 if outcome == "WIN" else 1.0820,
|
|
"pnl": 0.03 if outcome == "WIN" else -0.02,
|
|
"outcome": outcome,
|
|
"hold_candles": 4,
|
|
})
|
|
|
|
learner = OnlineLearner(buf, adaptive_state_file="test_adaptive_state.json")
|
|
|
|
# Test calibration
|
|
new_conf = learner.calibrate_threshold(recent_n=50)
|
|
print(f"\nNouvelle confidence : {new_conf:.4f}")
|
|
|
|
# Test préparation épisodes RL
|
|
episodes = learner.prepare_rl_episodes(min_trades=10)
|
|
print(f"Épisodes RL préparés : {len(episodes)}")
|
|
|
|
# Statut
|
|
print(f"\nStatut learner : {json.dumps(learner.get_status(), indent=2)}")
|
|
|
|
# Nettoyage
|
|
import os
|
|
for f in ["test_learner_buffer.json", "test_adaptive_state.json"]:
|
|
if os.path.exists(f):
|
|
os.remove(f)
|
|
|
|
print("\n✅ OnlineLearner — test OK")
|