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

620 lines
26 KiB
Python

"""
AHAD QUANT — PPO Training Script (V6 — UNIFIED + RESUME)
Entraîne l'agent RL avec Stable-Baselines3 PPO.
NOUVEAUTÉS V6 + FIXES :
[FIX BUG-RL-01] sys.stdout.reconfigure() wrappé dans try/except
[FIX BUG-RL-02] Bloc argparse déplacé dans if __name__ == "__main__"
[NEW] rl_progress.json — suivi de progression toutes les 10k steps
[NEW] --resume flag — reprise automatique depuis dernier checkpoint
[NEW] Drive backup automatique toutes les 100k steps
[NEW] CHECKPOINT_FREQ réduit à 50k steps (plus de granularité)
[NEW] ProgressCallback avec sauvegarde Drive intégrée
ORDRE D'ENTRAÎNEMENT OBLIGATOIRE :
1. python download_data.py
2. python train.py → model_ensemble.pkl
3. python rl_train.py → rl_agent.zip
Reprise après interruption :
python rl_train.py --resume → reprend depuis dernier checkpoint
Usage (local ou cloud) :
!pip install stable-baselines3[extra] gymnasium shimmy
!python rl_train.py
!python rl_train.py --resume # si interruption
Résultats :
rl_agent.zip → modèle PPO final unifié
rl_scaler.pkl → scaler z-score des features
rl_checkpoints/ → checkpoints toutes les 50k steps + best_model.zip
rl_train_log/ → logs TensorBoard
rl_progress.json → progression (permet la reprise)
"""
import os, sys, pickle, json, time, shutil, glob
import numpy as np
# ─── FIX BUG-RL-01 : sys.stdout.reconfigure() incompatible avec certains terminaux ──────────────
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except AttributeError:
pass # certains terminaux ne supportent pas reconfigure()
# ─── Vérification dépendances ─────────────────────────────────────────────────
try:
from stable_baselines3 import PPO
from stable_baselines3.common.env_util import make_vec_env
from stable_baselines3.common.callbacks import (
EvalCallback, CheckpointCallback, BaseCallback
)
from stable_baselines3.common.monitor import Monitor
from stable_baselines3.common.vec_env import DummyVecEnv
except ImportError:
print("❌ stable-baselines3 non installé.")
print(" pip install stable-baselines3[extra] gymnasium shimmy")
sys.exit(1)
import config
from rl_env import AhadQuantForexEnv
# ─── Vérification prérequis : ensemble doit être entraîné d'abord ─────────────
ENS_PATH = getattr(config, "ENSEMBLE_MODEL_PATH", "model_ensemble.pkl")
if not os.path.exists(ENS_PATH):
print("=" * 65)
print(" ❌ ERREUR : model_ensemble.pkl introuvable !")
print(f" Chemin attendu : {ENS_PATH}")
print(" Entraîner d'abord l'ensemble : python train.py")
print("=" * 65)
sys.exit(1)
print(f" ✅ Ensemble trouvé : {ENS_PATH}")
# ─── Fichier de progression (reprise après interruption) ─────────────────────
PROGRESS_FILE = "rl_progress.json"
def save_progress(steps_done, total_steps, last_checkpoint=None, start_time=None):
"""Sauvegarde la progression pour permettre la reprise automatique."""
elapsed = time.time() - start_time if start_time else 0
eta_sec = 0
if steps_done > 0 and elapsed > 0:
rate = steps_done / elapsed
eta_sec = (total_steps - steps_done) / rate if rate > 0 else 0
data = {
"steps_done": steps_done,
"total_steps": total_steps,
"pct": round(steps_done / total_steps * 100, 2),
"last_checkpoint": last_checkpoint,
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
"elapsed_min": round(elapsed / 60, 1),
"eta_min": round(eta_sec / 60, 1),
"status": "running",
}
with open(PROGRESS_FILE, "w") as f:
json.dump(data, f, indent=2)
def load_progress():
"""Charge le fichier de progression s'il existe."""
if os.path.exists(PROGRESS_FILE):
try:
with open(PROGRESS_FILE) as f:
return json.load(f)
except Exception:
return None
return None
def mark_completed(total_steps):
"""Marque l'entraînement comme terminé dans rl_progress.json."""
data = load_progress() or {}
data["steps_done"] = total_steps
data["total_steps"] = total_steps
data["pct"] = 100.0
data["status"] = "completed"
data["timestamp"] = time.strftime("%Y-%m-%d %H:%M:%S")
with open(PROGRESS_FILE, "w") as f:
json.dump(data, f, indent=2)
# ─── Hyperparamètres PPO ──────────────────────────────────────────────────────
PPO_CONFIG = dict(
policy = "MlpPolicy",
learning_rate = 3e-4,
n_steps = 2048,
batch_size = 256,
n_epochs = 10,
gamma = 0.99,
gae_lambda = 0.95,
clip_range = 0.2,
ent_coef = 0.01,
vf_coef = 0.5,
max_grad_norm = 0.5,
verbose = 1,
tensorboard_log = "rl_train_log",
policy_kwargs = dict(
net_arch = [dict(pi=[256, 256, 128], vf=[256, 256, 128])],
),
)
# ─── Config entraînement ─────────────────────────────────────────────────────
DEFAULT_TOTAL_TIMESTEPS = 1_000_000
N_ENVS = 4
EVAL_FREQ = 20_000
CHECKPOINT_FREQ = 50_000 # [AMÉLIO] Réduit de 100k → 50k steps
SAVE_PATH = "rl_agent"
LOG_DIR = "rl_train_log"
CHECKPOINT_DIR = "rl_checkpoints"
DRIVE_BACKUP_DIR = "/content/drive/MyDrive/ahad_quant_checkpoints"
DRIVE_BACKUP_EVERY = 100_000 # Backup Drive toutes les 100k steps
TRAIN_PAIRS = [
"EURUSD", "GBPUSD", "USDJPY", "USDCHF",
"AUDUSD", "USDCAD", "EURGBP", "EURJPY",
]
EVAL_PAIRS = ["GBPJPY", "NZDUSD"]
# ─── Callback métriques episodes ─────────────────────────────────────────────
class EpisodeMetricsCallback(BaseCallback):
def __init__(self, verbose=0):
super().__init__(verbose)
def _on_step(self) -> bool:
for info in self.locals.get("infos", []):
if "episode_metrics" in info:
m = info["episode_metrics"]
self.logger.record("ep/win_rate", m.get("ep/win_rate", 0))
self.logger.record("ep/total_trades", m.get("ep/total_trades", 0))
self.logger.record("ep/sharpe", m.get("ep/sharpe", 0))
return True
# ─── Callback de progression et backup Drive ─────────────────────────────────
class ProgressCallback(BaseCallback):
"""
[NEW] Sauvegarde rl_progress.json toutes les SAVE_EVERY steps.
Permet la reprise automatique après une interruption imprévue.
Effectue un backup Drive toutes les DRIVE_BACKUP_EVERY steps.
"""
def __init__(self, total_steps=1_000_000, save_every=10_000,
drive_backup_every=100_000, verbose=0):
super().__init__(verbose)
self.total_steps = total_steps
self.save_every = save_every
self.drive_backup_every = drive_backup_every
self._last_save = 0
self._last_drive_backup = 0
self._start_time = None
def on_training_start(self, locals_, globals_):
self._start_time = time.time()
def _on_step(self) -> bool:
ts = self.num_timesteps
# Sauvegarde progression JSON
if ts - self._last_save >= self.save_every:
# Trouver le dernier checkpoint généré
ckpts = sorted(glob.glob(f"{CHECKPOINT_DIR}/ppo_ahad_quant_unified_*.zip"))
last_ckpt = ckpts[-1] if ckpts else None
save_progress(ts, self.total_steps, last_ckpt, self._start_time)
self._last_save = ts
# Backup Drive automatique
if ts - self._last_drive_backup >= self.drive_backup_every:
self._backup_to_drive(ts)
self._last_drive_backup = ts
return True
def _backup_to_drive(self, steps):
"""Copie les fichiers critiques vers Google Drive."""
drive_root = "/content/drive/MyDrive"
if not os.path.exists(drive_root):
return # Drive pas monté — non bloquant
try:
os.makedirs(DRIVE_BACKUP_DIR, exist_ok=True)
files_to_backup = [
(PROGRESS_FILE, "rl_progress.json"),
("rl_scaler.pkl", "rl_scaler.pkl"),
(f"{CHECKPOINT_DIR}/best_model.zip", "best_model.zip"),
]
# Dernier checkpoint numéroté
ckpts = sorted(glob.glob(f"{CHECKPOINT_DIR}/ppo_ahad_quant_unified_*.zip"))
if ckpts:
files_to_backup.append((ckpts[-1], "latest_checkpoint.zip"))
copied = 0
for src, dst_name in files_to_backup:
if os.path.exists(src):
shutil.copy2(src, os.path.join(DRIVE_BACKUP_DIR, dst_name))
copied += 1
pct = steps / self.total_steps * 100
print(f"\n 💾 [Drive backup @ {steps:,} steps — {pct:.0f}%] {copied} fichiers → {DRIVE_BACKUP_DIR}")
except Exception as e:
print(f"\n ⚠️ Drive backup échoué (non bloquant) : {e}")
# ─── Factory d'environnements ─────────────────────────────────────────────────
def _make_env(pairs: list, seed: int = 0, ensemble_path: str = None):
def _init():
env = AhadQuantForexEnv(
data_dir = config.DATA_DIR,
pairs = pairs,
sl_pct = config.STOP_LOSS_PCT,
tp_pct = config.TAKE_PROFIT_PCT,
leverage = float(config.LEVERAGE),
risk_per_trade = config.RISK_PER_TRADE,
ensemble_path = ensemble_path,
seed = seed,
)
return Monitor(env)
return _init
# ─── Sauvegarde du scaler ─────────────────────────────────────────────────────
def save_scaler(env: AhadQuantForexEnv, path: str = "rl_scaler.pkl"):
scaler = {
"mean": env._feat_mean,
"std": env._feat_std,
"pairs": env._pairs,
}
with open(path, "wb") as f:
pickle.dump(scaler, f)
print(f"[SCALER] Sauvegardé → {path}")
return scaler
# ─── Fonction de reprise — trouver le bon checkpoint ─────────────────────────
def find_resume_checkpoint():
"""
[NEW] Identifie le meilleur checkpoint depuis lequel reprendre.
Priorité : last_checkpoint dans progress > best_model.zip > dernier numéroté
"""
prog = load_progress()
if prog:
last_ckpt = prog.get("last_checkpoint")
if last_ckpt and os.path.exists(last_ckpt):
return last_ckpt.replace(".zip", ""), prog
best = f"{CHECKPOINT_DIR}/best_model.zip"
if os.path.exists(best):
return best.replace(".zip", ""), prog
numbered = sorted(glob.glob(f"{CHECKPOINT_DIR}/ppo_ahad_quant_unified_*.zip"))
if numbered:
return numbered[-1].replace(".zip", ""), prog
# Chercher aussi dans le backup Drive
drive_ckpt = os.path.join(DRIVE_BACKUP_DIR, "latest_checkpoint.zip")
drive_best = os.path.join(DRIVE_BACKUP_DIR, "best_model.zip")
for drive_src in [drive_ckpt, drive_best]:
if os.path.exists(drive_src):
local_dst = os.path.join(CHECKPOINT_DIR, os.path.basename(drive_src))
os.makedirs(CHECKPOINT_DIR, exist_ok=True)
shutil.copy2(drive_src, local_dst)
print(f" ✅ Checkpoint récupéré depuis Drive : {os.path.basename(drive_src)}")
return local_dst.replace(".zip", ""), prog
return None, prog
# ─── Entraînement principal ───────────────────────────────────────────────────
def train(total_steps: int = DEFAULT_TOTAL_TIMESTEPS, resume: bool = False):
"""
Entraîne ou reprend l'agent PPO.
Args:
total_steps: Nombre total de steps à entraîner.
resume: Si True, tente de reprendre depuis le dernier checkpoint.
"""
print("=" * 65)
print(" AHAD QUANT — RL Training Unifié (PPO V6)")
print(" Ensemble supervisé intégré dans l'observation")
if resume:
print(" MODE : REPRISE depuis checkpoint")
else:
print(" MODE : Nouvel entraînement")
print("=" * 65)
os.makedirs(LOG_DIR, exist_ok=True)
os.makedirs(CHECKPOINT_DIR, exist_ok=True)
# ── Filtrer paires disponibles ─────────────────────────────────────────────
available = [
p for p in TRAIN_PAIRS
if os.path.exists(os.path.join(config.DATA_DIR, f"{p}_1h.json"))
]
if not available:
print(f"\n❌ Aucune donnée dans {config.DATA_DIR}. Exécuter download_data.py d'abord.")
sys.exit(1)
print(f"\n Paires entraînement : {available}")
print(f" Ensemble : {ENS_PATH}")
# ── Résolution checkpoint de reprise ──────────────────────────────────────
resume_from = None
steps_already = 0
remaining_steps = total_steps
if resume:
resume_from, prog = find_resume_checkpoint()
if prog:
steps_already = prog.get("steps_done", 0)
remaining_steps = max(0, total_steps - steps_already)
if steps_already >= total_steps:
print(f"\n✅ Entraînement déjà complété ({steps_already:,} steps). Rien à faire.")
return
if resume_from:
print(f"\n 🔄 Reprise depuis : {resume_from}.zip")
print(f" ✅ Steps déjà effectués : {steps_already:,} / {total_steps:,} ({steps_already/total_steps*100:.1f}%)")
print(f" ⏳ Steps restants : {remaining_steps:,}")
else:
print(" ⚠️ Aucun checkpoint trouvé — entraînement depuis zéro")
resume = False
print(f"\n Steps à entraîner : {remaining_steps:,}")
print(f" Checkpoints toutes les {CHECKPOINT_FREQ:,} steps")
print(f" Backup Drive toutes les {DRIVE_BACKUP_EVERY:,} steps\n")
# ── Envs parallèles ────────────────────────────────────────────────────────
env_fns = [_make_env(available, seed=i, ensemble_path=ENS_PATH) for i in range(N_ENVS)]
vec_env = DummyVecEnv(env_fns)
# ── Env d'évaluation ──────────────────────────────────────────────────────
eval_pairs = [p for p in EVAL_PAIRS
if os.path.exists(os.path.join(config.DATA_DIR, f"{p}_1h.json"))]
if not eval_pairs:
eval_pairs = available[:2]
print(f" Paires évaluation : {eval_pairs}")
eval_env = Monitor(AhadQuantForexEnv(
data_dir = config.DATA_DIR,
pairs = eval_pairs,
ensemble_path = ENS_PATH,
seed = 999,
))
# ── Scaler (toujours recalculé pour cohérence) ────────────────────────────
tmp_env = AhadQuantForexEnv(
data_dir = config.DATA_DIR,
pairs = available,
ensemble_path = ENS_PATH,
seed = 0,
)
tmp_env.reset()
save_scaler(tmp_env, "rl_scaler.pkl")
obs_dim = tmp_env.obs_dim
del tmp_env
print(f"\n OBS_DIM : {obs_dim} dims")
# ── Créer ou charger le modèle PPO ────────────────────────────────────────
if resume and resume_from and os.path.exists(f"{resume_from}.zip"):
try:
model = PPO.load(
resume_from,
env = vec_env,
verbose = PPO_CONFIG["verbose"],
tensorboard_log= PPO_CONFIG["tensorboard_log"],
)
print(f"\n ✅ Modèle PPO chargé depuis checkpoint")
except Exception as e:
print(f"\n ⚠️ Échec chargement checkpoint : {e}")
print(" → Redémarrage depuis zéro")
model = PPO(env=vec_env, **PPO_CONFIG)
resume = False
steps_already = 0
remaining_steps = total_steps
else:
model = PPO(env=vec_env, **PPO_CONFIG)
print(f"\n 🆕 Nouveau modèle PPO créé")
# ── Callbacks ─────────────────────────────────────────────────────────────
eval_cb = EvalCallback(
eval_env,
best_model_save_path = CHECKPOINT_DIR,
log_path = LOG_DIR,
eval_freq = EVAL_FREQ // N_ENVS,
n_eval_episodes = 10,
deterministic = True,
verbose = 1,
)
checkpoint_cb = CheckpointCallback(
save_freq = CHECKPOINT_FREQ // N_ENVS,
save_path = CHECKPOINT_DIR,
name_prefix = "ppo_ahad_quant_unified",
verbose = 1,
)
metrics_cb = EpisodeMetricsCallback()
progress_cb = ProgressCallback(
total_steps = total_steps,
save_every = 10_000,
drive_backup_every = DRIVE_BACKUP_EVERY,
)
# ── Lancement ─────────────────────────────────────────────────────────────
print(f"\n Démarrage entraînement...\n")
start = time.time()
try:
model.learn(
total_timesteps = remaining_steps,
callback = [eval_cb, checkpoint_cb, metrics_cb, progress_cb],
reset_num_timesteps = not resume, # Ne pas reset si reprise
progress_bar = True,
)
except KeyboardInterrupt:
print("\n\n⚠️ Entraînement interrompu manuellement (Ctrl+C).")
print(f" Progression sauvegardée dans {PROGRESS_FILE}")
print(" Relancer avec : python rl_train.py --resume")
vec_env.close()
eval_env.close()
return
except Exception as e:
print(f"\n\n❌ Erreur pendant l'entraînement : {e}")
print(f" Progression sauvegardée dans {PROGRESS_FILE}")
print(" Relancer avec : python rl_train.py --resume")
vec_env.close()
eval_env.close()
raise
# ── Sauvegarde finale ─────────────────────────────────────────────────────
elapsed = time.time() - start
print(f"\n ✅ Entraînement terminé en {elapsed/60:.1f} min")
model.save(SAVE_PATH)
print(f" 💾 Modèle sauvegardé → {SAVE_PATH}.zip")
mark_completed(total_steps)
print(f" 📝 Progression marquée 'completed' → {PROGRESS_FILE}")
vec_env.close()
eval_env.close()
# ── Résumé ────────────────────────────────────────────────────────────────
print("\n" + "=" * 65)
print(" FICHIERS PRODUITS")
print("=" * 65)
for fname, desc in [
(f"{SAVE_PATH}.zip", "PPO unifié (cerveau final)"),
("rl_scaler.pkl", "Normalisation features"),
(f"{CHECKPOINT_DIR}/best_model.zip", "Meilleur checkpoint"),
(PROGRESS_FILE, "Fichier de progression"),
(ENS_PATH, "Ensemble ML (feature PPO)"),
]:
if os.path.exists(fname):
size = os.path.getsize(fname) / 1024 / 1024
print(f" ✅ {fname:<45} {size:.1f} MB")
else:
print(f" ⚠️ {fname} — non trouvé")
print()
# ── Export unifié ─────────────────────────────────────────────────────────
print("[Export] Modèle unifié...")
try:
from export_unified import export_unified
unified_path = getattr(config, "UNIFIED_MODEL_PATH", "ahad_quant_unified.zip")
export_unified(
ppo_path = SAVE_PATH,
ensemble_path = ENS_PATH,
scaler_path = "rl_scaler.pkl",
output_path = unified_path,
)
except Exception as e:
print(f" ⚠️ Export unifié échoué (non bloquant) : {e}")
# ─── Fine-tuning classique ────────────────────────────────────────────────────
def fine_tune(model_path: str = "rl_agent", extra_steps: int = 200_000):
"""Fine-tune le modèle existant (appelé par auto_retrain.py ou CLI)."""
print(f"[RL FINE-TUNE] Chargement {model_path}.zip...")
available = [
p for p in TRAIN_PAIRS
if os.path.exists(os.path.join(config.DATA_DIR, f"{p}_1h.json"))
]
env_fns = [_make_env(available, seed=i, ensemble_path=ENS_PATH) for i in range(N_ENVS)]
vec_env = DummyVecEnv(env_fns)
model = PPO.load(model_path, env=vec_env)
model.learn(
total_timesteps = extra_steps,
reset_num_timesteps = False,
progress_bar = True,
)
model.save(model_path)
print(f"[RL FINE-TUNE] ✅ Sauvegardé → {model_path}.zip (+{extra_steps:,} steps)")
vec_env.close()
# ─── Fine-tuning avec replay ─────────────────────────────────────────────────
def fine_tune_with_replay(
model_path: str = "rl_agent",
extra_steps: int = 200_000,
replay_file: str = None,
) -> None:
"""Fine-tune PPO + injection d'épisodes réels depuis un fichier JSON."""
print(f"[RL FINE-TUNE+REPLAY] Chargement {model_path}.zip...")
available = [
p for p in TRAIN_PAIRS
if os.path.exists(os.path.join(config.DATA_DIR, f"{p}_1h.json"))
]
env_fns = [_make_env(available, seed=i, ensemble_path=ENS_PATH) for i in range(N_ENVS)]
vec_env = DummyVecEnv(env_fns)
model = PPO.load(model_path, env=vec_env)
if replay_file and os.path.exists(replay_file):
try:
with open(replay_file, "r") as f:
episodes = json.load(f)
print(f"[RL FINE-TUNE+REPLAY] {len(episodes)} épisodes réels chargés")
injected = 0
for ep in episodes:
try:
obs = np.array(ep["obs"], dtype=np.float32)
action = int(ep["action"])
reward = float(np.clip(ep["reward"], -1.0, 1.0))
done = bool(ep.get("done", False))
if hasattr(model, "rollout_buffer") and model.rollout_buffer is not None:
buf = model.rollout_buffer
if not buf.full:
buf.add(
obs.reshape(1, -1),
np.array([[action]]),
np.array([reward]),
np.array([done]),
np.zeros((1,), dtype=np.float32),
np.zeros((1,), dtype=np.float32),
)
injected += 1
except Exception:
continue
print(f"[RL FINE-TUNE+REPLAY] ✅ {injected}/{len(episodes)} épisodes injectés")
except Exception as rep_err:
print(f"[RL FINE-TUNE+REPLAY] ⚠️ Erreur injection (non bloquant) : {rep_err}")
elif replay_file:
print(f"[RL FINE-TUNE+REPLAY] ⚠️ Fichier replay introuvable : {replay_file}")
model.learn(
total_timesteps = extra_steps,
reset_num_timesteps = False,
progress_bar = True,
)
model.save(model_path)
print(f"[RL FINE-TUNE+REPLAY] ✅ Sauvegardé → {model_path}.zip (+{extra_steps:,} steps)")
vec_env.close()
# ─── FIX BUG-RL-02 : argparse déplacé dans if __name__ == "__main__" ──────────
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="AHAD QUANT PPO Training")
parser.add_argument("--finetune", action="store_true",
help="Fine-tuner le modèle existant")
parser.add_argument("--resume", action="store_true",
help="[NEW] Reprendre depuis le dernier checkpoint")
parser.add_argument("--steps", type=int, default=DEFAULT_TOTAL_TIMESTEPS,
help=f"Nombre de steps (défaut: {DEFAULT_TOTAL_TIMESTEPS:,})")
parser.add_argument("--model", type=str, default="rl_agent",
help="Chemin du modèle pour fine-tune (sans .zip)")
parser.add_argument("--replay", type=str, default=None,
help="Fichier JSON d'épisodes réels pour fine-tune+replay")
args = parser.parse_args()
if args.finetune and args.replay:
fine_tune_with_replay(args.model, args.steps, args.replay)
elif args.finetune:
fine_tune(args.model, args.steps)
elif args.resume:
train(total_steps=args.steps, resume=True)
else:
train(total_steps=args.steps, resume=False)