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

804 lines
33 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
AHAD QUANT — Auto-Retraining Scheduler
Re-downloads data and retrains the ensemble model on a schedule.
Activated via .env:
AUTO_RETRAIN_ENABLED=true
AUTO_RETRAIN_INTERVAL_HOURS=24
Logic: the new model replaces the old one ONLY if it is strictly
more accurate than the currently active model. The active model's
accuracy is always saved in last_retrain.json and used as the
comparison baseline — not a fixed threshold.
"""
import os, time, json, pickle, shutil, logging, threading, subprocess, sys
from datetime import datetime, timezone
import config
log = logging.getLogger("AutoRetrain")
# [FIX] Lock global : empêche daily-local et full-retrain de s'exécuter
# simultanément et d'écrire model_ensemble.pkl en même temps.
# Toute fonction qui modifie un fichier modèle DOIT acquérir ce lock.
_retrain_lock = threading.Lock()
TIMESTAMP_FILE = "last_retrain.json"
# [FIX 21/06/2026] Constante manquante — provoquait un NameError dans
# _load_daily_local_state()/_save_daily_local_state() dès le premier appel
# (should_run_daily_local_cycle() l'appelle sans condition au tout premier
# tick de AutoRetrainer._loop()), tuant silencieusement le thread de fond
# entier : ni le cycle quotidien local (warm-start ML + RL real-only), ni
# le full-retrain réseau ne s'exécutaient jamais en pratique.
DAILY_LOCAL_TIMESTAMP_FILE = "last_daily_local.json"
def _safe_export_unified():
"""
Régénère ahad_quant_unified.zip (best-effort, non-bloquant) après un
ré-entraînement réussi. N'est PLUS critique pour la correction en live
depuis le correctif du point #2 du diagnostic (rl_agent.py priorise déjà
les fichiers vivants rl_agent.zip/rl_scaler.pkl/model_ensemble.pkl, et
unified_brain.py les recharge à chaud) — reste utile pour des
déploiements portables sur une autre machine.
export_unified.export_unified() appelle sys.exit(1) en interne si les
fichiers source sont absents : on attrape donc BaseException (et pas
seulement Exception) pour ne jamais faire planter le cycle de retrain
à cause de cet export secondaire.
"""
try:
import export_unified
export_unified.export_unified()
log.info("[EXPORT] ahad_quant_unified.zip régénéré")
except BaseException as e:
log.warning(f"[EXPORT] Échec régénération ahad_quant_unified.zip (non-bloquant) : {e}")
# ── Persistence helpers ───────────────────────────────────────────────────────
def _load_state() -> dict:
"""
Load the saved state of the currently active model.
Returns dict with keys: timestamp, accuracy, datetime.
Returns defaults (accuracy=0) if no state file exists yet.
"""
if not os.path.exists(TIMESTAMP_FILE):
return {"timestamp": 0.0, "accuracy": 0.0, "datetime": "never"}
try:
with open(TIMESTAMP_FILE) as f:
return json.load(f)
except Exception:
return {"timestamp": 0.0, "accuracy": 0.0, "datetime": "never"}
def _save_state(accuracy: float):
"""Save the accuracy of the newly active model."""
with open(TIMESTAMP_FILE, "w") as f:
json.dump({
"timestamp": time.time(),
"accuracy": round(accuracy, 6),
"datetime": datetime.now(timezone.utc).isoformat(),
}, f, indent=2)
def get_active_accuracy() -> float:
"""Return the accuracy of the currently active model (0 if unknown)."""
return _load_state().get("accuracy", 0.0)
def should_retrain() -> bool:
"""True if enough time has passed since last retrain."""
if not config.AUTO_RETRAIN_ENABLED:
return False
elapsed = time.time() - _load_state().get("timestamp", 0.0)
return elapsed >= config.AUTO_RETRAIN_INTERVAL_HOURS * 3600
# ── Parse accuracy from train.py output ──────────────────────────────────────
def _parse_accuracy(output: str) -> float:
"""Extract ensemble (or LightGBM) accuracy from train.py stdout."""
accuracy = 0.0
for line in output.splitlines():
# Ensemble line has priority
if "Ensemble test accuracy:" in line:
try:
val = line.split(":")[-1].strip().split()[0]
accuracy = float(val.replace("%", "")) / (100 if "%" in val else 1)
return accuracy
except Exception:
pass
# Fallback: single model line
if "Test accuracy:" in line:
try:
val = line.split(":")[-1].strip().split()[0]
accuracy = float(val.replace("%", "")) / (100 if "%" in val else 1)
except Exception:
pass
return accuracy
# ── Main retrain cycle ────────────────────────────────────────────────────────
def run_retrain(notify_fn=None) -> dict:
"""
Execute a full retrain cycle.
[FIX] Protégé par _retrain_lock — ne peut pas tourner en même temps
qu'un cycle daily-local.
Decision logic:
new_accuracy > active_accuracy → replace model ✅
new_accuracy ≤ active_accuracy → keep old model ❌
Returns dict: {success, new_accuracy, old_accuracy, improved, message}
"""
if not _retrain_lock.acquire(blocking=False):
log.warning("[FULL-RETRAIN] Cycle daily-local en cours — full-retrain reporté")
return {"success": False, "message": "retrain_lock — réessayer dans quelques minutes"}
try:
return _run_retrain_inner(notify_fn)
finally:
_retrain_lock.release()
def _run_retrain_inner(notify_fn=None) -> dict:
"""Corps du full retrain (appelé uniquement si lock acquis)."""
result = {
"success": False,
"new_accuracy": 0.0,
"old_accuracy": 0.0,
"improved": False,
"message": "",
}
# Accuracy of the model currently in production
old_accuracy = get_active_accuracy()
result["old_accuracy"] = old_accuracy
old_label = f"{old_accuracy:.2%}" if old_accuracy > 0 else "unknown (first run)"
log.info(f"Auto-retrain starting — active model accuracy: {old_label}")
if notify_fn:
notify_fn(
f"🔄 *Auto-retrain started*\n"
f"Active model accuracy: `{old_label}`\n"
f"Downloading fresh data..."
)
# ── Step 1: Download data ────────────────────────────────────────────────
# Timeout généreux : 27 coins × 1000 jours peut prendre 3060 min selon
# la connexion et les rate-limits de l'exchange (14400s = 4h max).
try:
proc = subprocess.run(
[sys.executable, "download_data.py"],
capture_output=True, text=True, timeout=14400
)
if proc.returncode != 0:
raise RuntimeError(proc.stderr[-500:] or "download failed")
log.info("Data download complete")
except subprocess.TimeoutExpired:
msg = "❌ Auto-retrain: download timed out after 4h — vérifie la connexion ou réduis la liste de coins"
log.error(msg)
if notify_fn: notify_fn(msg)
result["message"] = msg
return result
except Exception as e:
msg = f"❌ Auto-retrain: data download failed — {e}"
log.error(msg)
if notify_fn: notify_fn(msg)
result["message"] = msg
return result
# ── Step 2: Train new model ──────────────────────────────────────────────
try:
proc = subprocess.run(
[sys.executable, "train.py"],
capture_output=True, text=True, timeout=7200
)
output = proc.stdout + proc.stderr
new_accuracy = _parse_accuracy(output)
result["new_accuracy"] = new_accuracy
if proc.returncode != 0:
raise RuntimeError(output[-500:])
log.info(f"Training complete — new accuracy: {new_accuracy:.2%}")
except Exception as e:
msg = f"❌ Auto-retrain: training failed — {e}"
log.error(msg)
if notify_fn: notify_fn(msg)
result["message"] = msg
return result
# ── Step 3: Compare strictly against active model ────────────────────────
improved = new_accuracy > old_accuracy
result["improved"] = improved
if improved:
# New model is better → save it as active
_save_state(new_accuracy)
result["success"] = True
gain = new_accuracy - old_accuracy
msg = (
f"✅ *Model updated*\n"
f"Old accuracy: `{old_label}`\n"
f"New accuracy: `{new_accuracy:.2%}` (+{gain:.2%})\n"
f"New model is now active."
)
log.info(msg.replace("*","").replace("`",""))
if notify_fn: notify_fn(msg)
result["message"] = msg
else:
# New model is not better → keep old model (train.py already wrote
# the new .pkl files; we restore the backup if it exists, otherwise
# we simply do nothing since train.py didn't overwrite if accuracy
# was logged as lower — but to be safe we log a warning)
gap = old_accuracy - new_accuracy
msg = (
f"⚠️ *Model NOT updated*\n"
f"Old accuracy: `{old_label}`\n"
f"New accuracy: `{new_accuracy:.2%}` (-{gap:.2%})\n"
f"Active model unchanged — old model was better."
)
log.warning(msg.replace("*","").replace("`",""))
if notify_fn: notify_fn(msg)
result["message"] = msg
# Restore backup if it was created before training
for path in [config.MODEL_PATH, config.ENSEMBLE_MODEL_PATH]:
backup = path + ".backup"
if os.path.exists(backup):
shutil.copy2(backup, path)
log.info(f"Restored backup: {path}")
# ── RL — TOUJOURS exécuté après le ML, que le nouveau ML ait été accepté
# ou non (le RL doit de toute façon rester synchronisé avec l'ensemble
# actif, ancien ou nouveau). C'est ce qui garantit que ce chemin
# "full retrain réseau" ne redevient JAMAIS ML-seul : ML et RL forment
# un seul cycle, même ici. Le fine-tune RL a son propre accept/reject
# (voir rl_train.py::fine_tune()), donc aucun risque de régression. ──
log.info("[FULL-RETRAIN] Fine-tuning RL sur l'ensemble ML actif...")
try:
rl_updated = run_rl_retrain_with_replay(notify_fn)
except Exception as e:
log.warning(f"[FULL-RETRAIN] Fine-tuning RL échoué (non-bloquant) : {e}")
rl_updated = False
result["rl_updated"] = rl_updated
# ── Export unifié — UNE SEULE fois, après ML ET RL, pour ne jamais
# bundler un ML neuf avec un RL périmé (ou l'inverse). ──
_safe_export_unified()
return result
# ── Backup helper (called before training to preserve old model) ───────────────
def _backup_models():
"""Create .backup copies of model files before overwriting."""
for path in [config.MODEL_PATH, config.ENSEMBLE_MODEL_PATH]:
if os.path.exists(path):
shutil.copy2(path, path + ".backup")
log.debug(f"Backed up: {path}")
# ── Background scheduler ──────────────────────────────────────────────────────
class AutoRetrainer:
"""
Background thread that periodically retrains the model.
Integrates with the running AHAD QUANT bot.
"""
def __init__(self, notify_fn=None):
self.notify_fn = notify_fn
self._thread = None
self._stop_event = threading.Event()
def start(self):
if not config.AUTO_RETRAIN_ENABLED and not getattr(config, "DAILY_LOCAL_RETRAIN_ENABLED", True):
log.info("Auto-retrain entièrement désactivé (AUTO_RETRAIN_ENABLED=false et DAILY_LOCAL_RETRAIN_ENABLED=false)")
return
self._thread = threading.Thread(
target=self._loop, daemon=True, name="AutoRetrain"
)
self._thread.start()
active = get_active_accuracy()
label = f"{active:.2%}" if active > 0 else "unknown (first run)"
daily_interval = getattr(config, "DAILY_LOCAL_RETRAIN_INTERVAL_HOURS", 24)
log.info(
f"Auto-retrain scheduler started "
f"(cycle quotidien local ML+RL toutes les {daily_interval}h"
+ (f" | ré-entraînement complet réseau toutes les {config.AUTO_RETRAIN_INTERVAL_HOURS}h"
if config.AUTO_RETRAIN_ENABLED else " | ré-entraînement complet réseau désactivé")
+ f" | active model: {label})"
)
def stop(self):
self._stop_event.set()
def _loop(self):
while not self._stop_event.is_set():
# ── Cycle quotidien LOCAL (ML + RL unifiés) — c'est le chemin
# automatique principal demandé : 100% local, journalier, et
# n'accepte un nouveau modèle (ML ou RL) que s'il est meilleur.
if should_run_daily_local_cycle():
log.info("[DAILY-LOCAL] Intervalle atteint — démarrage du cycle quotidien local")
run_daily_unified_retrain(self.notify_fn)
# ── Ré-entraînement complet (lourd, réseau) — optionnel, désactivé
# par défaut (AUTO_RETRAIN_ENABLED=false) car il télécharge de
# nouvelles données ; reste disponible si explicitement activé.
if config.AUTO_RETRAIN_ENABLED and should_retrain():
log.info("[FULL-RETRAIN] Intervalle atteint — backup et ré-entraînement complet (réseau)")
_backup_models()
run_retrain(self.notify_fn)
# Check every 30 minutes
self._stop_event.wait(timeout=1800)
# ── CLI ───────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO,
format="%(asctime)s [%(name)s] %(message)s")
def notify(msg):
print(f"\n[NOTIFY] {msg}\n")
state = _load_state()
print("=" * 55)
print(" AHAD QUANT — Auto-Retrain")
print("=" * 55)
print(f" Active model accuracy : "
f"{state['accuracy']:.2%}" if state['accuracy'] else " Active model: unknown (first run)")
print(f" Last retrain : {state.get('datetime','never')}")
print(f" Interval : every {config.AUTO_RETRAIN_INTERVAL_HOURS}h")
print(f" Rule : new model must beat active model")
print("=" * 55 + "\n")
print("Backing up current models...")
_backup_models()
print("Running retrain now...\n")
result = run_retrain(notify)
print("\n" + "=" * 55)
print(f" Old accuracy : {result['old_accuracy']:.2%}")
print(f" New accuracy : {result['new_accuracy']:.2%}")
print(f" Improved : {'✅ YES — model updated' if result['improved'] else '❌ NO — old model kept'}")
print("=" * 55)
# ─── RL Agent Auto-Retrain ────────────────────────────────────────────────────
RL_TIMESTAMP_FILE = "last_rl_retrain.json"
def _load_rl_state() -> dict:
if not os.path.exists(RL_TIMESTAMP_FILE):
return {"timestamp": 0.0, "datetime": "never"}
try:
with open(RL_TIMESTAMP_FILE) as f:
return json.load(f)
except Exception:
return {"timestamp": 0.0, "datetime": "never"}
def _save_rl_state():
with open(RL_TIMESTAMP_FILE, "w") as f:
json.dump({
"timestamp": time.time(),
"datetime": datetime.now(timezone.utc).isoformat(),
}, f, indent=2)
def should_retrain_rl() -> bool:
"""True si le fine-tuning RL hebdomadaire est dû."""
if not getattr(config, "RL_AUTO_RETRAIN_ENABLED", False):
return False
interval = getattr(config, "RL_RETRAIN_INTERVAL_HOURS", 168) * 3600
elapsed = time.time() - _load_rl_state().get("timestamp", 0.0)
return elapsed >= interval
def run_rl_finetune() -> dict:
"""
Lance le fine-tuning RL en sous-processus (rl_train.py --finetune).
Depuis le correctif rl_train.py::fine_tune(), le sous-processus évalue
l'ancienne et la nouvelle politique sur les mêmes épisodes (seed fixe)
et n'écrase rl_agent.zip QUE si la nouvelle est meilleure — exactement
la même philosophie "si meilleur on remplace, sinon on conserve" déjà
appliquée côté ML. Un fine-tune REJETÉ (ancienne politique conservée)
est un résultat NORMAL, pas un échec — distinct d'un crash du
sous-processus (returncode != 0).
Retourne {"success": bool, "accepted": bool, "old_mean_reward": float|None,
"new_mean_reward": float|None}.
"""
model_path = getattr(config, "RL_MODEL_PATH", "rl_agent")
steps = getattr(config, "RL_FINETUNE_STEPS", 200_000)
result = {"success": False, "accepted": False, "old_mean_reward": None, "new_mean_reward": None}
if not os.path.exists(f"{model_path}.zip"):
log.warning("[RL-RETRAIN] Modèle rl_agent.zip introuvable — fine-tune ignoré")
return result
log.info(f"[RL-RETRAIN] Démarrage fine-tune RL (+{steps:,} steps)...")
try:
proc = subprocess.run(
[sys.executable, "rl_train.py",
"--finetune",
"--model", model_path,
"--steps", str(steps)],
capture_output=True,
text=True,
timeout=7200, # 2h max
)
if proc.returncode != 0:
log.error(f"[RL-RETRAIN] Échec fine-tune RL:\n{proc.stderr[-500:]}")
return result
result["success"] = True
# Parse le marqueur "RL_FINETUNE_RESULT accepted=... old=... new=..."
# émis par rl_train.py::fine_tune() — seule façon pour ce process
# parent de connaître le résultat accept/reject du sous-processus.
accepted = False
for line in proc.stdout.splitlines():
if line.startswith("RL_FINETUNE_RESULT"):
try:
parts = dict(p.split("=") for p in line.split()[1:])
accepted = parts.get("accepted", "False") == "True"
result["old_mean_reward"] = float(parts.get("old", "nan"))
result["new_mean_reward"] = float(parts.get("new", "nan"))
except Exception:
pass
break
result["accepted"] = accepted
if accepted:
log.info(
f"[RL-RETRAIN] ✅ Fine-tune accepté "
f"(reward {result['old_mean_reward']:.4f}{result['new_mean_reward']:.4f})"
)
_save_rl_state()
# Recharger le singleton rl_agent en mémoire — le fichier a changé
try:
from rl_agent import reset_rl_agent
reset_rl_agent()
log.info("[RL-RETRAIN] Singleton RL rechargé")
except ImportError:
pass
_safe_export_unified()
else:
log.info(
f"[RL-RETRAIN] ↔️ Fine-tune rejeté — politique actuelle conservée "
f"(reward {result['old_mean_reward']:.4f} vs {result['new_mean_reward']:.4f} proposé)"
)
return result
except subprocess.TimeoutExpired:
log.error("[RL-RETRAIN] Timeout fine-tune RL (>2h)")
return result
except Exception as e:
log.error(f"[RL-RETRAIN] Exception : {e}")
return result
# ─── Apprentissage Continu V7 — Cycle quotidien LGB + Replay RL ──────────────
def run_daily_lightgbm_warmstart(notify_fn=None) -> bool:
"""
Cycle quotidien léger (local CPU, ~2-5 min).
Warm-start LightGBM sur les trades LOSS + TIMEOUT.
[FIX] 3 améliorations :
1. Fenêtre progressive : si pas assez de trades en 24h, on élargit à 48h,
72h, puis tout le buffer — les phases de bonne performance ne bloquent
plus le cycle.
2. Tous les types de trades sont inclus (WIN surpondéré 1x, LOSS 2x,
TIMEOUT 1.5x via error_weight déjà dans le buffer) — plus de dépendance
à un minimum de pertes.
3. Modèle candidat accumulatif : si le warmstart est rejeté, les arbres
appris sont sauvegardés dans model.pkl.candidate pour être utilisés comme
point de départ du prochain cycle (au lieu de repartir de la production).
L'apprentissage des mauvais trades s'accumule même quand le modèle actuel
reste en place.
"""
if not getattr(config, "CONTINUOUS_LEARNING_ENABLED", False):
return False
if not getattr(config, "WARMSTART_ENABLED", False):
return False
try:
from experience_buffer import get_experience_buffer
from online_learner import OnlineLearner
buf = get_experience_buffer(auto_load=True)
learner = OnlineLearner(buf)
min_trades = getattr(config, "WARMSTART_MIN_TRADES", 5)
# ── Fenêtre progressive : 24h → 48h → 72h → tout le buffer ──────────
trades = []
window_hours = 24
for window_hours in [24, 48, 72, 9999]:
if window_hours == 9999:
trades = buf.get_recent(hours=87600) # ~10 ans = tout le buffer
else:
trades = buf.get_recent(hours=window_hours)
if len(trades) >= min_trades:
break
n = len(trades)
if n < min_trades:
log.info(f"[CL-DAILY] Buffer total seulement {n} trades (min={min_trades}) — skip warm-start")
return False
window_label = f"{window_hours}h" if window_hours != 9999 else "tout le buffer"
log.info(f"[CL-DAILY] Warm-start LGB sur {n} trades ({window_label})...")
updated = learner.daily_update(loss_trades=trades)
msg = (
f"{'✅' if updated else '⚠️'} *CL Warm-start quotidien*\n"
f"Trades utilisés : {n} ({window_label})\n"
f"Modèle {'mis à jour' if updated else 'inchangé (candidate sauvegardé)'}"
)
log.info(msg.replace("*","").replace("`",""))
if notify_fn:
notify_fn(msg)
if updated:
_safe_export_unified()
return updated
except Exception as e:
log.error(f"[CL-DAILY] Erreur warm-start : {e}")
return False
def run_rl_retrain_with_replay(notify_fn=None) -> bool:
"""
Cycle RL quotidien — 100% basé sur les vrais trades du bot, ZERO
simulation dans l'entrainement.
[CORRECTIF 21/06/2026] AVANT : cette fonction lancait TOUJOURS
run_rl_finetune() en etape 1 -- un fine-tune PPO classique sur
l'environnement SIMULE (AhadQuantForexEnv, donnees de marche
historiques) -- puis tentait d'injecter les vrais trades en etape 2.
Cette injection ne servait a rien : rl_train.py::fine_tune() appelait
model.learn(), qui reinitialise le rollout_buffer
(rollout_buffer.reset() en tete de collect_rollouts()) AVANT tout
entrainement. Le RL n'apprenait donc QUE de la simulation, jamais des
vrais trades -- meme symptome que le bug RL/backtest deja corrige.
MAINTENANT : si le buffer contient >= RL_REPLAY_MIN_TRADES (config,
defaut 50) trades reels, on lance rl_train.py --finetune --replay
<fichier>, qui appelle fine_tune_real_only() : un model.train() PPO
direct sur un rollout_buffer construit a 100% a partir des vrais
trades (voir rl_train.py). Aucun appel a run_rl_finetune() / a
AhadQuantForexEnv dans ce cycle.
Si le nombre de trades reels est insuffisant : le cycle RL est
SKIP cette fois-ci -- PAS de repli sur la simulation. Le RL ne doit
progresser que sur ce que le bot a reellement fait ; tant qu'il n'y a
pas assez de volume reel, on attend plutot que d'entrainer sur du
synthetique.
Retourne True si le fine-tune reel a ete accepte (modele mis a jour).
"""
if not getattr(config, "CONTINUOUS_LEARNING_ENABLED", False):
return False
if not getattr(config, "RL_REAL_REPLAY_ENABLED", False):
return False
try:
from experience_buffer import get_experience_buffer
from online_learner import OnlineLearner
buf = get_experience_buffer(auto_load=True)
learner = OnlineLearner(buf)
min_trades = getattr(config, "RL_REPLAY_MIN_TRADES", 50)
if buf.total_trades < min_trades:
log.info(f"[CL-RL] Seulement {buf.total_trades} trades reels dans le buffer "
f"(min={min_trades}) -- RL update SKIP ce cycle (aucun repli simule)")
return False
episodes = learner.prepare_rl_episodes(min_trades=min_trades)
if not episodes:
log.info("[CL-RL] Aucun episode reel valide prepare -- RL update SKIP")
return False
# Sauvegarder les episodes pour injection dans rl_train.py
import json, os
replay_file = "rl_replay_episodes.json"
with open(replay_file, "w") as f:
json.dump(episodes, f)
log.info(f"[CL-RL] {len(episodes)} episodes reels sauvegardes -> {replay_file} "
f"(entrainement RL 100% reel -- pas de AhadQuantForexEnv)")
# Declencher fine_tune_real_only() via rl_train.py --replay (accept/
# reject integre, voir rl_train.py::fine_tune_real_only()). Pas de
# --steps : ce mode ne fait qu'un seul model.train() sur le buffer
# reel, donc largement plus rapide qu'un fine-tune simule.
import subprocess, sys
proc = subprocess.run(
[sys.executable, "rl_train.py",
"--finetune",
"--replay", replay_file,
"--real-min-trades", str(min_trades)],
capture_output=True, text=True, timeout=1800, # 30 min max -- pas de simulation, donc rapide
)
# Nettoyer le fichier temporaire dans tous les cas
if os.path.exists(replay_file):
os.remove(replay_file)
if proc.returncode != 0:
log.warning(f"[CL-RL] Echec fine-tune reel : {proc.stderr[-300:]}")
return False
accepted = False
for line in proc.stdout.splitlines():
if line.startswith("RL_FINETUNE_RESULT"):
accepted = "accepted=True" in line
break
if accepted:
log.info(f"[CL-RL] OK RL mis a jour -- {len(episodes)} vrais trades, aucune simulation")
try:
from rl_agent import reset_rl_agent
reset_rl_agent()
except ImportError:
pass
if notify_fn:
notify_fn(
f"OK *RL mis a jour (100% reel)*\n"
f"{len(episodes)} vrais trades integres au PPO -- aucune simulation"
)
return True
else:
log.info("[CL-RL] Fine-tune reel rejete -- politique conservee")
return False
except Exception as e:
log.error(f"[CL-RL] Erreur cycle RL reel : {e}")
return False
def _load_daily_local_state() -> dict:
if not os.path.exists(DAILY_LOCAL_TIMESTAMP_FILE):
return {"timestamp": 0.0, "datetime": "never"}
try:
with open(DAILY_LOCAL_TIMESTAMP_FILE) as f:
return json.load(f)
except Exception:
return {"timestamp": 0.0, "datetime": "never"}
def _save_daily_local_state():
with open(DAILY_LOCAL_TIMESTAMP_FILE, "w") as f:
json.dump({
"timestamp": time.time(),
"datetime": datetime.now(timezone.utc).isoformat(),
}, f, indent=2)
def should_run_daily_local_cycle() -> bool:
"""True si le cycle unifié quotidien et local (ML + RL) est dû."""
if not getattr(config, "DAILY_LOCAL_RETRAIN_ENABLED", True):
return False
interval = getattr(config, "DAILY_LOCAL_RETRAIN_INTERVAL_HOURS", 24) * 3600
elapsed = time.time() - _load_daily_local_state().get("timestamp", 0.0)
return elapsed >= interval
def run_daily_unified_retrain(notify_fn=None) -> dict:
"""
Cycle d'entraînement UNIFIÉ, quotidien et 100% LOCAL pour ML et RL.
[FIX] Protégé par _retrain_lock — ne peut pas tourner en même temps
qu'un full-retrain ou qu'un autre cycle local.
"""
if not _retrain_lock.acquire(blocking=False):
log.warning("[DAILY-LOCAL] Un autre cycle d'entraînement est déjà en cours — skip")
return {"skipped": True, "reason": "retrain_lock"}
try:
return _run_daily_unified_retrain_inner(notify_fn)
finally:
_retrain_lock.release()
def _run_daily_unified_retrain_inner(notify_fn=None) -> dict:
"""
Cycle d'entraînement UNIFIÉ, quotidien et 100% LOCAL pour ML et RL —
traités comme un seul et même modèle qui se met à jour ensemble.
Aucune des deux étapes ne télécharge quoi que ce soit sur le réseau :
1. ML — warm-start LightGBM EMBARQUÉ DANS L'ENSEMBLE
(online_learner.py::daily_update, sur les trades réels du
buffer local des dernières 24h). N'écrase model_ensemble.pkl
que si l'accuracy de l'ENSEMBLE COMPLET s'améliore (sinon
l'ancien est conservé) — point #3 du diagnostic.
2. RL — [21/06/2026] désormais 100% basé sur les vrais trades du
bot, ZÉRO simulation (voir run_rl_retrain_with_replay et
rl_train.py::fine_tune_real_only). N'écrase rl_agent.zip que
si la nouvelle politique obtient un reward moyen au moins
équivalent à l'ancienne (yardstick simulé, comparaison
uniquement). Si moins de RL_REPLAY_MIN_TRADES trades réels
sont disponibles, cette étape est SKIP — pas de repli sur la
simulation.
Si l'une des deux étapes a effectivement remplacé un modèle, le bundle
portable ahad_quant_unified.zip est régénéré (best-effort) pour rester
cohérent avec les fichiers vivants.
Retourne {"ml_updated": bool, "rl_updated": bool, "ran": bool}.
"""
result = {"ml_updated": False, "rl_updated": False, "ran": False}
if not getattr(config, "DAILY_LOCAL_RETRAIN_ENABLED", True):
log.debug("[DAILY-LOCAL] Désactivé (DAILY_LOCAL_RETRAIN_ENABLED=false)")
return result
result["ran"] = True
log.info("[DAILY-LOCAL] === Démarrage du cycle quotidien unifié ML+RL (local) ===")
# ── 1. ML — warm-start local de l'ensemble (accept/reject intégré) ─────
try:
ml_updated = run_daily_lightgbm_warmstart(notify_fn=notify_fn)
result["ml_updated"] = bool(ml_updated)
except Exception as e:
log.error(f"[DAILY-LOCAL] Erreur étape ML : {e}")
# ── 2. RL — fine-tune local + replay réel (accept/reject intégré) ──────
if getattr(config, "RL_AUTO_RETRAIN_ENABLED", True):
try:
rl_updated = run_rl_retrain_with_replay(notify_fn=notify_fn)
result["rl_updated"] = bool(rl_updated)
except Exception as e:
log.error(f"[DAILY-LOCAL] Erreur étape RL : {e}")
else:
log.debug("[DAILY-LOCAL] RL désactivé (RL_AUTO_RETRAIN_ENABLED=false)")
# ── 3. Bilan ─────────────────────────────────────────────────────────
_save_daily_local_state()
if result["ml_updated"] or result["rl_updated"]:
parts = []
if result["ml_updated"]: parts.append("ML mis à jour")
if result["rl_updated"]: parts.append("RL mis à jour")
msg = f"🔄 *Cycle quotidien local terminé* — {', '.join(parts)}"
log.info(msg.replace("*", ""))
if notify_fn:
notify_fn(msg)
else:
log.info("[DAILY-LOCAL] === Cycle terminé — aucun modèle n'a été amélioré, anciens modèles conservés ===")
return result
"""
Vérification horaire du monitor de performance.
Retourne le statut (OK/WARNING/DANGER/EMERGENCY).
"""
if not getattr(config, "CONTINUOUS_LEARNING_ENABLED", False):
return "OK"
if not getattr(config, "MONITOR_ENABLED", False):
return "OK"
try:
from experience_buffer import get_experience_buffer
from performance_monitor import PerformanceMonitor
buf = get_experience_buffer(auto_load=False)
monitor = PerformanceMonitor(buf)
return monitor.check()
except Exception as e:
log.debug(f"[CL-MONITOR] Erreur check horaire : {e}")
return "OK"