Initial commit: XAUUSD AI Trading Bot with proper gitignore

This commit is contained in:
YouDgn 2026-05-11 19:43:11 +02:00
commit 627ec0e766
16 changed files with 5892 additions and 0 deletions

97
.gitignore vendored Normal file
View File

@ -0,0 +1,97 @@
# ============================================================
# Python & Virtual Environments
# ============================================================
venv/
env/
ENV/
.venv/
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# ============================================================
# IDE & Editor
# ============================================================
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
*.sublime-project
*.sublime-workspace
# ============================================================
# Environment & Config (sensitive)
# ============================================================
.env
.env.local
config.local.py
# ============================================================
# Logs & Temporary Files
# ============================================================
logs/
*.log
*.log.*
tmp/
temp/
# ============================================================
# Models & Checkpoints (TOO LARGE)
# ============================================================
models/checkpoints/
models/*.pt
models/*.pth
*.pt
*.pth
# ============================================================
# Data Files (optional - customize as needed)
# ============================================================
data/*.csv
*.csv
# ============================================================
# Database & Cache
# ============================================================
*.db
*.sqlite
*.sqlite3
.cache/
__pycache__/
# ============================================================
# OS Files
# ============================================================
.DS_Store
Thumbs.db
*.lnk
# ============================================================
# Project Specific
# ============================================================
*.bak
*.tmp
.project
.pydevproject

87
config.py Normal file
View File

@ -0,0 +1,87 @@
# ============================================================
# config.py — Configuration Centrale du Bot XAUUSD
# ============================================================
import os
from dataclasses import dataclass, field
from typing import List
# ── Compte MT5 ──────────────────────────────────────────────
MT5_LOGIN = 0 # Renseigné via l'écran de login du dashboard
MT5_PASSWORD = "" # Ne jamais mettre ici
MT5_SERVER = "" # Ne jamais mettre ici
MANUAL_LOT_SIZE = 0.05
USE_MANUAL_LOT = True
SYMBOL = "XAUUSD"
MAGIC_NUMBER = 20240101 # Identifiant unique pour les ordres du bot
# ── Paramètres de l'Instrument ───────────────────────────────
POINT_VALUE = 0.1 # Valeur d'un point pour XAUUSD (à vérifier dans MT5)
LOT_MIN = 0.01
LOT_MAX = 10.0
LOT_STEP = 0.01
DEVIATION = 20 # Slippage max en points
# ── Gestion du Risque ────────────────────────────────────────
# NOTE : Ces paramètres sont GÉRÉS AUTOMATIQUEMENT par l'IA (trading_env.py)
# Ne pas modifier — l'env RL les calcule dynamiquement
STOP_LOSS_ATR_MULT = 8.0 # Extrême : SL très loin
TAKE_PROFIT_ATR_MULT = 24.0 # Extrême : RR 1:3 (TP = 3× SL)
RISK_PER_TRADE_PCT = 0.01 # 1% de risque par trade
# Protection compte (live_bot.py) — seul paramètre restant
DAILY_MAX_LOSS = 0.05 # Arrêt si -5% sur la session
DAILY_PROFIT_TARGET = 0.05 # Info seulement (géré par l'IA)
# ── Timeframe & Données ──────────────────────────────────────
TIMEFRAME = "M15" # Timeframe principal (M15 = 15 minutes)
LOOKBACK_BARS = 100 # 50→100 : ATR plus stable et représentatif
TRAINING_YEARS = 2 # 5→2 ans : données MT5 réelles suffisantes
TICK_INTERVAL_SEC = 1 # Intervalle de polling des ticks en live
# ── Réseau de Neurones ───────────────────────────────────────
HIDDEN_SIZE = 128 # 256→128 : 2x plus rapide, qualité quasi identique
NUM_LAYERS = 2 # 3→2 : moins de calcul
DROPOUT = 0.1
# ── Hyperparamètres PPO ──────────────────────────────────────
PPO_LR = 3e-4 # Revenir à 3e-4 (converge mieux)
PPO_GAMMA = 0.99 # Discount factor
PPO_GAE_LAMBDA = 0.95 # GAE lambda
PPO_CLIP_EPS = 0.2 # Clipping epsilon
PPO_VALUE_COEF = 0.5 # Coefficient de la value loss
PPO_ENTROPY_COEF = 0.15 # 0.10→0.15 : Plus d'exploration pour découvrir meilleures stratégies
PPO_UPDATE_EPOCHS = 4 # bon équilibre vitesse/qualité
PPO_BATCH_SIZE = 512 # bon pour CPU
PPO_ROLLOUT_STEPS = 8192 # 4096→8192 : encore moins d'overhead à 1000+ steps/s
# ── Entraînement ─────────────────────────────────────────────
TOTAL_TRAIN_STEPS = 1_000_000
SAVE_EVERY_STEPS = 25_000 # 50k→25k : évaluations 2x plus fréquentes
MODEL_PATH = "models/ppo_xauusd.pt"
CHECKPOINT_DIR = "models/checkpoints/"
# ── News & Sentiment ─────────────────────────────────────────
NEWS_UPDATE_INTERVAL = 300 # Rafraîchissement des news toutes les 5 min
SENTIMENT_WEIGHT = 0.15 # Poids du sentiment dans la décision IA
RSS_FEEDS = [
"https://feeds.reuters.com/reuters/businessNews",
"https://www.forexfactory.com/ff_calendar_thisweek.xml",
"https://www.investing.com/rss/news_25.rss", # Gold news
"https://www.kitco.com/rss/news.xml",
]
GOLD_KEYWORDS = [
"gold", "xauusd", "bullion", "inflation", "fed", "federal reserve",
"interest rate", "dollar", "usd", "treasury", "safe haven",
"geopolitical", "war", "crisis", "recession", "cpi", "pce"
]
# ── Logging & Dashboard ──────────────────────────────────────
LOG_FILE = "logs/bot_decisions.log"
LOG_LEVEL = "INFO"
DASHBOARD_REFRESH = 2 # Secondes entre chaque refresh du dashboard
# ── Device Calcul (AMD DirectML) ────────────────────────────
USE_DIRECTML = True # False = CPU fallback

359
csv_data_loader.py Normal file
View File

@ -0,0 +1,359 @@
# ============================================================
# csv_data_loader.py — Chargeur de Données Historiques CSV
# ============================================================
# Ce module charge le fichier xauusd_gold_history_10years.csv
# et l'intègre comme source de données alternative (ou enrichissement)
# pour l'entraînement du modèle IA.
#
# Utilisations :
# 1) Entraînement sans MT5 (offline)
# 2) Enrichir les données MT5 avec les annotations d'événements
# 3) Ajouter une feature "contexte historique" à l'agent RL
# ============================================================
import os
import pandas as pd
import numpy as np
import logging
from typing import Optional, Tuple, Dict
from datetime import datetime
logger = logging.getLogger(__name__)
# Chemin par défaut du CSV (à la racine du projet)
DEFAULT_CSV_PATH = os.path.join(os.path.dirname(__file__), "data", "xauusd_gold_history_10years.csv")
# Mapping des types d'événements vers un score numérique (pour l'IA)
EVENT_TYPE_SCORES: Dict[str, float] = {
"GUERRE": 0.9, # Très bullish pour l'or
"CRISE": 0.75,
"GEOPOLITIQUE": 0.65,
"INFLATION": 0.55,
"FED": 0.0, # Neutre (dépend du contexte BULLISH/BEARISH)
"MACRO": 0.0,
"RECORD": 0.5,
"BANQUES CENTRALES":0.6,
"POLITIQUE": 0.2,
"COMMERCE": 0.1,
"FOREX": -0.3, # Souvent bearish (dollar fort)
"": 0.0,
}
IMPACT_MULTIPLIER: Dict[str, float] = {
"BULLISH": 1.0,
"BEARISH": -1.0,
"NEUTRAL": 0.0,
"": 0.0,
}
class GoldCSVLoader:
"""
Charge et traite les données historiques XAUUSD depuis le CSV.
Fonctionnalités :
- Chargement et validation des données
- Calcul de features techniques sur les données mensuelles
- Encodage numérique des événements pour l'IA
- Fusion avec des données MT5 haute fréquence
- Rapport statistique des 10 ans de données
"""
def __init__(self, csv_path: str = DEFAULT_CSV_PATH):
self.csv_path = csv_path
self.df_raw: Optional[pd.DataFrame] = None
self.df: Optional[pd.DataFrame] = None
# ── Chargement ─────────────────────────────────────────────
def load(self) -> pd.DataFrame:
"""Charge le CSV et retourne un DataFrame traité."""
if not os.path.exists(self.csv_path):
raise FileNotFoundError(
f"CSV introuvable : {self.csv_path}\n"
f"Place le fichier 'xauusd_gold_history_10years.csv' dans le dossier 'data/'"
)
logger.info(f"📂 Chargement du CSV : {self.csv_path}")
df = pd.read_csv(self.csv_path, parse_dates=["date"])
df.set_index("date", inplace=True)
df.sort_index(inplace=True)
# Renommer pour compatibilité avec le reste du bot
df.rename(columns={
"open": "Open",
"high": "High",
"low": "Low",
"close": "Close",
"volume": "Volume",
}, inplace=True)
self.df_raw = df.copy()
# Traitement
df = self._encode_events(df)
df = self._add_technical_features(df)
df = self._add_regime_labels(df)
self.df = df
logger.info(
f"{len(df)} enregistrements chargés | "
f"{df.index[0].strftime('%Y-%m')}{df.index[-1].strftime('%Y-%m')}"
)
return df
# ── Encodage des Événements ────────────────────────────────
def _encode_events(self, df: pd.DataFrame) -> pd.DataFrame:
"""Encode les colonnes texte des événements en valeurs numériques."""
df = df.copy()
# Score du type d'événement
df["event_type_score"] = df["event_type"].fillna("").map(
lambda x: EVENT_TYPE_SCORES.get(x.strip(), 0.0)
)
# Multiplicateur d'impact
df["impact_multiplier"] = df["impact"].fillna("").map(
lambda x: IMPACT_MULTIPLIER.get(x.strip(), 0.0)
)
# Score événement final = type × impact
df["event_score"] = df["event_type_score"] * df["impact_multiplier"].replace(0, 1)
# Clamp entre -1 et 1
df["event_score"] = df["event_score"].clip(-1.0, 1.0)
# Flag : y a-t-il un événement majeur ?
df["has_major_event"] = (df["event"].fillna("") != "").astype(int)
# One-hot encoding simplifié des types
major_types = ["GUERRE", "CRISE", "FED", "INFLATION", "GEOPOLITIQUE", "RECORD"]
for etype in major_types:
df[f"event_{etype.lower()}"] = (
df["event_type"].fillna("").str.upper() == etype
).astype(int)
return df
# ── Features Techniques (sur données mensuelles) ───────────
def _add_technical_features(self, df: pd.DataFrame) -> pd.DataFrame:
"""Ajoute des indicateurs techniques adaptés aux données mensuelles."""
df = df.copy()
# Retours
df["return_1m"] = df["Close"].pct_change(1)
df["return_3m"] = df["Close"].pct_change(3)
df["return_6m"] = df["Close"].pct_change(6)
df["return_12m"] = df["Close"].pct_change(12)
# Moyennes mobiles
df["sma_6m"] = df["Close"].rolling(6).mean()
df["sma_12m"] = df["Close"].rolling(12).mean()
df["sma_24m"] = df["Close"].rolling(24).mean()
# Volatilité
df["volatility_3m"] = df["return_1m"].rolling(3).std()
df["volatility_12m"] = df["return_1m"].rolling(12).std()
# RSI mensuel (14 périodes = ~14 mois)
delta = df["Close"].diff()
gain = (delta.where(delta > 0, 0)).rolling(14).mean()
loss = (-delta.where(delta < 0, 0)).rolling(14).mean()
rs = gain / (loss + 1e-9)
df["rsi_14m"] = 100 - (100 / (1 + rs))
# ATR mensuel
df["hl_range"] = df["High"] - df["Low"]
df["atr_6m"] = df["hl_range"].rolling(6).mean()
# Position vs SMA
df["price_vs_sma12"] = (df["Close"] - df["sma_12m"]) / (df["sma_12m"] + 1e-9)
df["price_vs_sma24"] = (df["Close"] - df["sma_24m"]) / (df["sma_24m"] + 1e-9)
# Drawdown depuis ATH
df["ath_rolling"] = df["High"].cummax()
df["drawdown_from_ath"] = (df["ath_rolling"] - df["Close"]) / df["ath_rolling"]
# Volume relatif
df["volume_ratio"] = df["Volume"] / (df["Volume"].rolling(12).mean() + 1e-9)
return df
# ── Régimes de Marché ──────────────────────────────────────
def _add_regime_labels(self, df: pd.DataFrame) -> pd.DataFrame:
"""
Étiquette chaque période avec un régime de marché :
0 = Bear, 1 = Sideways, 2 = Bull
Utile pour l'analyse et l'entraînement conditionnel.
"""
df = df.copy()
sma6 = df["sma_6m"]
sma12 = df["sma_12m"]
ret6 = df["return_6m"]
conditions = [
(sma6 > sma12) & (ret6 > 0.05), # Bull trend
(sma6 < sma12) & (ret6 < -0.05), # Bear trend
]
choices = [2, 0]
df["market_regime"] = np.select(conditions, choices, default=1)
regime_labels = {0: "BEAR", 1: "SIDEWAYS", 2: "BULL"}
df["regime_label"] = df["market_regime"].map(regime_labels)
return df
# ── Accès aux Données ──────────────────────────────────────
def get_event_context(self, target_date: datetime) -> Dict:
"""
Retourne le contexte événementiel pour une date donnée.
Utilisé par le live bot pour enrichir l'observation IA.
"""
if self.df is None:
return {"event_score": 0.0, "has_major_event": 0, "regime": 1}
# Trouver la donnée mensuelle la plus proche
idx = self.df.index.searchsorted(target_date, side="right") - 1
if idx < 0:
return {"event_score": 0.0, "has_major_event": 0, "regime": 1}
row = self.df.iloc[idx]
return {
"event_score": float(row.get("event_score", 0.0)),
"has_major_event": int(row.get("has_major_event", 0)),
"regime": int(row.get("market_regime", 1)),
"volatility_3m": float(row.get("volatility_3m", 0.0)),
"return_12m": float(row.get("return_12m", 0.0)),
"event_text": str(row.get("event", "")),
}
def get_feature_columns(self) -> list:
"""Retourne les colonnes numériques utilisables comme features IA."""
return [
"return_1m", "return_3m", "return_6m", "return_12m",
"volatility_3m", "volatility_12m",
"rsi_14m", "atr_6m",
"price_vs_sma12", "price_vs_sma24",
"drawdown_from_ath", "volume_ratio",
"event_score", "has_major_event",
"event_crise", "event_guerre", "event_fed",
"event_inflation", "event_geopolitique",
]
def get_ohlcv(self) -> pd.DataFrame:
"""Retourne seulement les colonnes OHLCV propres."""
if self.df is None:
raise RuntimeError("Appelle d'abord .load()")
return self.df[["Open", "High", "Low", "Close", "Volume"]].copy()
# ── Rapport Statistique ────────────────────────────────────
def print_report(self):
"""Affiche un rapport complet des 10 années de données."""
if self.df is None:
print("⚠️ Charge d'abord les données avec .load()")
return
df = self.df
print("\n" + "=" * 65)
print(" 📊 RAPPORT DONNÉES HISTORIQUES XAUUSD (10 ANS)")
print("=" * 65)
# Statistiques générales
print(f"\n📅 Période : {df.index[0]:%Y-%m}{df.index[-1]:%Y-%m}")
print(f"📈 Enregistrements: {len(df)} mois")
print(f"💰 Prix min : ${df['Low'].min():.2f} ({df['Low'].idxmin().strftime('%Y-%m')})")
print(f"💰 Prix max : ${df['High'].max():.2f} ({df['High'].idxmax().strftime('%Y-%m')})")
print(f"📊 Perf totale : {(df['Close'].iloc[-1]/df['Close'].iloc[0]-1)*100:+.1f}%")
# Régimes de marché
regime_counts = df["regime_label"].value_counts()
print(f"\n🏷️ Régimes de marché :")
for regime, count in regime_counts.items():
pct = count / len(df) * 100
print(f" {regime:<10} : {count:3d} mois ({pct:.1f}%)")
# Événements majeurs
events = df[df["has_major_event"] == 1][["Close", "event", "event_type", "impact", "return_1m"]]
print(f"\n📰 Événements majeurs ({len(events)}) :")
print("-" * 65)
for date, row in events.iterrows():
ret_str = f"{row['return_1m']*100:+.1f}%" if pd.notna(row['return_1m']) else "N/A"
print(
f" {date.strftime('%Y-%m')} | ${row['Close']:6.0f} | {ret_str:>7} | "
f"[{row['event_type']:<15}] {row['event'][:40]}"
)
# Meilleures et pires périodes
df_clean = df.dropna(subset=["return_1m"])
top3 = df_clean.nlargest(3, "return_1m")[["Close", "return_1m", "event"]]
worst3 = df_clean.nsmallest(3, "return_1m")[["Close", "return_1m", "event"]]
print(f"\n🟢 Top 3 meilleures performances mensuelles :")
for date, row in top3.iterrows():
print(f" {date.strftime('%Y-%m')} | {row['return_1m']*100:+.1f}% | {str(row['event'])[:45]}")
print(f"\n🔴 Top 3 pires performances mensuelles :")
for date, row in worst3.iterrows():
print(f" {date.strftime('%Y-%m')} | {row['return_1m']*100:+.1f}% | {str(row['event'])[:45]}")
print("\n" + "=" * 65)
# ── Fusion avec données MT5 haute fréquence ────────────────
@staticmethod
def enrich_mt5_data(df_mt5: pd.DataFrame, df_csv: pd.DataFrame) -> pd.DataFrame:
"""
Fusionne les données MT5 (haute fréquence) avec le contexte
mensuel du CSV (événements, régime de marché).
Les features mensuelles sont propagées vers l'avant (forward fill)
sur les barres intra-day.
"""
monthly_features = [
"event_score", "has_major_event", "market_regime",
"volatility_12m", "return_12m", "drawdown_from_ath",
]
# Garder seulement les colonnes existantes
monthly_features = [c for c in monthly_features if c in df_csv.columns]
if not monthly_features:
logger.warning("Aucune feature mensuelle disponible pour l'enrichissement.")
return df_mt5
# Ré-indexer les données mensuelles sur le calendrier MT5
df_monthly = df_csv[monthly_features].copy()
df_monthly = df_monthly.reindex(
df_mt5.index.union(df_monthly.index)
).ffill().reindex(df_mt5.index)
# Fusionner
result = df_mt5.copy()
for col in monthly_features:
result[f"macro_{col}"] = df_monthly[col].values
logger.info(
f"✅ Enrichissement MT5 : +{len(monthly_features)} features macro mensuelles"
)
return result
# ── Point d'entrée standalone ──────────────────────────────────
if __name__ == "__main__":
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
loader = GoldCSVLoader(
os.path.join(os.path.dirname(__file__), "..", "data", "xauusd_gold_history_10years.csv")
)
df = loader.load()
loader.print_report()
print("\n📋 Aperçu des features IA :")
feat_cols = [c for c in loader.get_feature_columns() if c in df.columns]
print(df[feat_cols].tail(6).to_string())

207
dashboard.py Normal file
View File

@ -0,0 +1,207 @@
# ============================================================
# dashboard.py — Dashboard Console (Rich)
# ============================================================
import logging
import threading
import time
from datetime import datetime
from typing import List, Optional, Dict
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.layout import Layout
from rich.text import Text
from rich.live import Live
from rich import box
import config
logger = logging.getLogger(__name__)
console = Console()
class TradingDashboard:
"""
Dashboard console temps-réel avec Rich.
Affiche : prix, sentiment, compte, positions, logs récents.
"""
def __init__(self):
self._state: Dict = {
"price": 0.0,
"bid": 0.0,
"ask": 0.0,
"spread": 0.0,
"sentiment": 0.0,
"sent_label": "⚪ NEUTRE",
"balance": 0.0,
"equity": 0.0,
"daily_pnl": 0.0,
"daily_pnl_pct": 0.0,
"drawdown": 0.0,
"open_trades": 0,
"positions": [],
"ai_action": "HOLD",
"ai_probs": [0.25, 0.25, 0.25, 0.25],
"last_news": [],
"status": "🟢 EN COURS",
"total_steps": 0,
"last_update": datetime.now(),
}
self._log_buffer: List[str] = []
self._lock = threading.Lock()
self._running = False
def update(self, **kwargs):
"""Met à jour l'état du dashboard (thread-safe)."""
with self._lock:
self._state.update(kwargs)
self._state["last_update"] = datetime.now()
def add_log(self, message: str):
"""Ajoute une ligne au buffer de logs."""
with self._lock:
timestamp = datetime.now().strftime("%H:%M:%S")
self._log_buffer.append(f"[dim]{timestamp}[/dim] {message}")
if len(self._log_buffer) > 20:
self._log_buffer.pop(0)
def _build_layout(self) -> Layout:
"""Construit le layout Rich du dashboard."""
with self._lock:
s = dict(self._state)
logs = list(self._log_buffer)
layout = Layout()
layout.split_column(
Layout(name="header", size=3),
Layout(name="main", ratio=1),
Layout(name="footer", size=3),
)
layout["main"].split_row(
Layout(name="left", ratio=2),
Layout(name="right", ratio=1),
)
layout["left"].split_column(
Layout(name="market", size=9),
Layout(name="positions"),
)
# ── Header ─────────────────────────────────────────────
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
header_text = Text(
f" 🤖 XAUUSD AI TRADING BOT | {now} | {s['status']}",
style="bold white on dark_blue",
justify="center"
)
layout["header"].update(Panel(header_text, box=box.HEAVY))
# ── Marché & Compte ────────────────────────────────────
market_table = Table(box=box.SIMPLE_HEAVY, expand=True, show_header=False)
market_table.add_column("Clé", style="bold cyan", width=20)
market_table.add_column("Valeur", style="bold white")
market_table.add_column("Clé2", style="bold cyan", width=20)
market_table.add_column("Valeur2",style="bold white")
pnl_color = "green" if s["daily_pnl"] >= 0 else "red"
pnl_str = f"[{pnl_color}]{s['daily_pnl']:+.2f} ({s['daily_pnl_pct']:+.2f}%)[/{pnl_color}]"
dd_color = "green" if s["drawdown"] < 1.0 else ("yellow" if s["drawdown"] < 2.0 else "red")
ai_action_colors = {"HOLD": "white", "BUY": "green", "SELL": "red", "CLOSE": "yellow"}
ai_color = ai_action_colors.get(s["ai_action"], "white")
market_table.add_row("💰 XAUUSD", f"{s['price']:.2f}",
"📊 Balance", f"{s['balance']:.2f} USD")
market_table.add_row("📈 Bid", f"{s['bid']:.2f}",
"💳 Equity", f"{s['equity']:.2f} USD")
market_table.add_row("📉 Ask", f"{s['ask']:.2f}",
"📅 PnL jour", pnl_str)
market_table.add_row("↔️ Spread", f"{s['spread']:.1f} pts",
"📉 Drawdown", f"[{dd_color}]{s['drawdown']:.2f}%[/{dd_color}]")
market_table.add_row("📰 Sentiment", s["sent_label"],
"🤖 Action IA", f"[bold {ai_color}]{s['ai_action']}[/bold {ai_color}]")
layout["market"].update(Panel(market_table, title="[bold]MARCHÉ & COMPTE[/bold]", box=box.ROUNDED))
# ── Positions Ouvertes ─────────────────────────────────
pos_table = Table(box=box.SIMPLE, expand=True)
pos_table.add_column("Ticket", style="dim", width=10)
pos_table.add_column("Type", style="bold", width=6)
pos_table.add_column("Lots", justify="right", width=8)
pos_table.add_column("Ouvert @", justify="right", width=10)
pos_table.add_column("SL", justify="right", width=10)
pos_table.add_column("TP", justify="right", width=10)
pos_table.add_column("P&L", justify="right", width=10)
positions = s.get("positions", [])
if positions:
for p in positions:
pnl_clr = "green" if p["profit"] >= 0 else "red"
type_clr = "green" if p["type"] == "BUY" else "red"
pos_table.add_row(
str(p["ticket"]),
f"[{type_clr}]{p['type']}[/{type_clr}]",
str(p["volume"]),
f"{p['open_price']:.2f}",
f"{p['sl']:.2f}",
f"{p['tp']:.2f}",
f"[{pnl_clr}]{p['profit']:+.2f}[/{pnl_clr}]",
)
else:
pos_table.add_row("", "", "", "", "", "", "")
layout["positions"].update(
Panel(pos_table, title=f"[bold]POSITIONS OUVERTES ({len(positions)})[/bold]", box=box.ROUNDED)
)
# ── Probabilités IA & News ─────────────────────────────
right_text = Text()
probs = s.get("ai_probs", [0.25] * 4)
action_names = ["HOLD", "BUY", "SELL", "CLOSE"]
action_emojis = ["", "🟢", "🔴", "🔵"]
right_text.append("── PROBABILITÉS IA ──\n", style="bold cyan")
for i, (name, prob, emoji) in enumerate(zip(action_names, probs, action_emojis)):
bar_len = int(prob * 20)
bar = "" * bar_len + "" * (20 - bar_len)
clr = "green" if name == "BUY" else ("red" if name == "SELL" else "white")
right_text.append(f" {emoji} {name:<6} ", style=f"bold {clr}")
right_text.append(f"{bar} {prob*100:5.1f}%\n", style=clr)
right_text.append("\n── DERNIÈRES NEWS ──\n", style="bold cyan")
for news in s.get("last_news", [])[:3]:
sent = news.get("sentiment_score", 0)
clr = "green" if sent > 0.1 else ("red" if sent < -0.1 else "white")
title = news.get("title", "")[:45]
right_text.append(f" [{clr}]{sent:+.2f}[/{clr}] {title}\n")
layout["right"].update(Panel(right_text, title="[bold]IA & NEWS[/bold]", box=box.ROUNDED))
# ── Footer — Logs ──────────────────────────────────────
log_text = Text()
for line in logs[-3:]:
log_text.append(line + "\n")
layout["footer"].update(Panel(log_text, title="[bold]DERNIÈRES DÉCISIONS[/bold]", box=box.ROUNDED))
return layout
def run(self, refresh_rate: float = config.DASHBOARD_REFRESH):
"""Lance le dashboard en mode Live (bloque le thread)."""
self._running = True
try:
with Live(self._build_layout(), refresh_per_second=1/refresh_rate, screen=True) as live:
while self._running:
live.update(self._build_layout())
time.sleep(refresh_rate)
except KeyboardInterrupt:
pass
finally:
self._running = False
def start_background(self):
"""Lance le dashboard dans un thread daemon."""
t = threading.Thread(target=self.run, daemon=True)
t.start()
def stop(self):
self._running = False

87
diagnose_model.py Normal file
View File

@ -0,0 +1,87 @@
#!/usr/bin/env python
# ============================================================
# diagnose_model.py — Diagnostic du modèle PPO
# ============================================================
import torch
import numpy as np
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import config
from ppo_agent import PPOAgent, get_device
def diagnose_model():
"""Teste le modèle et affiche les probabilités d'action."""
print("\n" + "="*60)
print("DIAGNOSTIC MODÈLE PPO XAUUSD")
print("="*60 + "\n")
# Charger le modèle
print("[1] Chargement du modèle...")
try:
agent = PPOAgent(obs_size=80, n_actions=4, training_mode=False)
agent.load(config.MODEL_PATH)
print(f"✅ Modèle chargé : {config.MODEL_PATH}\n")
except Exception as e:
print(f"❌ Erreur chargement modèle : {e}")
return
# Test 1: Observations aléatoires
print("[2] Test sur 100 observations aléatoires...")
action_counts = {0: 0, 1: 0, 2: 0, 3: 0}
action_names = {0: "HOLD", 1: "BUY", 2: "SELL", 3: "CLOSE"}
prob_sums = {0: 0.0, 1: 0.0, 2: 0.0, 3: 0.0}
for i in range(100):
obs = np.random.randn(80).astype(np.float32)
with torch.no_grad():
action, _, _ = agent.predict(obs, deterministic=False)
probs = agent.get_action_probabilities(obs)
action_counts[action] += 1
for j in range(4):
prob_sums[j] += probs[j]
print("\nDistribution des actions (100 samples):")
for action_id in range(4):
count = action_counts[action_id]
avg_prob = prob_sums[action_id] / 100
print(f" {action_names[action_id]:<6} : {count:3d}x ({count:3.0f}%) | Prob moyenne: {avg_prob:.3f}")
# Test 2: Observation déterministe
print("\n[3] Test déterministe (même observation, 10x)...")
obs = np.zeros(80, dtype=np.float32)
obs[0] = 1.0 # Feature particulière
print(f"Observation : {obs[:5]}... (80 dims)")
probs_list = []
for i in range(10):
with torch.no_grad():
action, _, _ = agent.predict(obs, deterministic=False)
probs = agent.get_action_probabilities(obs)
probs_list.append(probs)
print(f" Iter {i+1}: Action={action_names[action]:<6} | Probs: H={probs[0]:.3f} B={probs[1]:.3f} S={probs[2]:.3f} C={probs[3]:.3f}")
# Verdict
print("\n" + "="*60)
print("VERDICT :")
print("="*60)
avg_close_prob = prob_sums[3] / 100
if avg_close_prob > 0.35:
print(f"⚠️ PROBLÈME : Modèle output trop de CLOSE ({avg_close_prob:.1%})")
print(" → Le modèle a probablement convergé vers une stratégie de fermeture rapide")
print(" → Réentraînement recommandé avec récompense modifiée")
else:
print(f"✅ Modèle OK : Distribution équilibrée (CLOSE={avg_close_prob:.1%})")
print()
if __name__ == "__main__":
diagnose_model()

170
evaluate_model.py Normal file
View File

@ -0,0 +1,170 @@
#!/usr/bin/env python3
# ============================================================
# evaluate_model.py — Génère un rapport sur le modèle entraîné
# ============================================================
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import logging
import config
from mt5_connector import MT5Connector
from trading_env import XAUUSDTradingEnv
from ppo_agent import PPOAgent
from macro_features import MacroFeaturesModule
import numpy as np
import torch
import time
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-8s | %(message)s"
)
logger = logging.getLogger(__name__)
def evaluate_agent(agent, env, n_episodes=5):
"""Évalue l'agent sur n épisodes."""
rewards = []
win_rates = []
pnls = []
for ep in range(n_episodes):
obs, info = env.reset()
ep_reward = 0
ep_pnl = 0
ep_trades = 0
ep_wins = 0
done = False
step = 0
while not done and step < 1000:
with torch.no_grad():
action, _, _ = agent.predict(obs, deterministic=True)
result = env.step(action)
# Gymnasium retourne 5 valeurs, ancienne Gym en retourne 4
if len(result) == 5:
obs, reward, terminated, truncated, info = result
done = terminated or truncated
else:
obs, reward, done, info = result
ep_reward += reward
ep_pnl += info.get("pnl", 0)
if info.get("trade_executed"):
ep_trades += 1
if info.get("pnl", 0) > 0:
ep_wins += 1
step += 1
wr = (ep_wins / max(ep_trades, 1)) * 100
rewards.append(ep_reward)
pnls.append(ep_pnl)
win_rates.append(wr)
logger.info(f"Episode {ep+1}/{n_episodes}: Reward={ep_reward:.2f}, PnL={ep_pnl:.2f}$, WR={wr:.1f}%")
return {
"mean_reward": np.mean(rewards),
"std_reward": np.std(rewards),
"mean_pnl": np.mean(pnls),
"mean_winrate": np.mean(win_rates) / 100,
}
def main():
print("\n" + "=" * 70)
print("RAPPORT D'ÉVALUATION DU MODÈLE PPO XAUUSD")
print("=" * 70 + "\n")
# 1. Connexion MT5 et données
logger.info("Connexion à MetaTrader5...")
mt5 = MT5Connector()
if not mt5.connect():
logger.error("Impossible de se connecter à MT5")
return
logger.info("Téléchargement des données historiques...")
df_bars = mt5.get_historical_data(years=config.TRAINING_YEARS)
if df_bars is None or len(df_bars) < config.LOOKBACK_BARS:
logger.error("Données insuffisantes")
return
# 2. Split train/val
n_train = int(len(df_bars) * 0.85)
df_train = df_bars.iloc[:n_train]
df_val = df_bars.iloc[n_train:]
logger.info(f"📊 Train: {len(df_train)} barres | Val: {len(df_val)} barres")
# 3. Environnements
logger.info("Création des environnements...")
macro_mod = MacroFeaturesModule()
macro_mod.start()
env_train = XAUUSDTradingEnv(df_train, lookback=config.LOOKBACK_BARS, sentiment_score=0.0)
env_val = XAUUSDTradingEnv(df_val, lookback=config.LOOKBACK_BARS, sentiment_score=0.0)
# 4. Agent
logger.info("Chargement du modèle...")
obs_size = env_train.observation_space.shape[0]
agent = PPOAgent(obs_size=obs_size, n_actions=4)
try:
agent.load(config.MODEL_PATH)
logger.info(f"✅ Modèle chargé : {config.MODEL_PATH}")
except Exception as e:
logger.error(f"Erreur chargement : {e}")
return
# 5. Évaluation
print("\n--- ÉVALUATION IN-SAMPLE (Train) ---")
eval_train = evaluate_agent(agent, env_train, n_episodes=3)
print("\n--- ÉVALUATION OUT-OF-SAMPLE (Val, données non vues) ---")
eval_val = evaluate_agent(agent, env_val, n_episodes=5)
# 6. Profit Factor
pf_ratio = "N/A"
try:
wins = eval_val["mean_pnl"] * eval_val["mean_winrate"]
loss = abs(eval_val["mean_pnl"]) * (1 - eval_val["mean_winrate"])
pf = wins / (loss + 1e-8)
pf_ratio = f"{pf:.2f}"
except:
pass
overfitting_gap = eval_train["mean_reward"] - eval_val["mean_reward"]
# 7. Rapport
print("\n" + "=" * 70)
print(" RÉSUMÉ FINAL")
print("=" * 70)
print(f" Modèle : {config.MODEL_PATH}")
print(f" Taille observation : {obs_size:,}")
print(f"")
print(f" -- IN-SAMPLE (train) --")
print(f" Reward moyen : {eval_train['mean_reward']:+.3f}{eval_train['std_reward']:.3f})")
print(f" Win Rate : {eval_train['mean_winrate']*100:.1f}%")
print(f"")
print(f" -- OUT-OF-SAMPLE (val, données non vues) --")
print(f" Reward moyen : {eval_val['mean_reward']:+.3f}")
print(f" PnL moyen / épisode : {eval_val['mean_pnl']:+.2f}$")
print(f" Win Rate : {eval_val['mean_winrate']*100:.1f}%")
print(f" Profit Factor : {pf_ratio}")
print(f"")
print(f" Overfitting gap : {overfitting_gap:.2f} (< 5 = bon)")
print("=" * 70)
if overfitting_gap > 10:
print(" ⚠️ ATTENTION : Overfitting détecté (gap train/val > 10)")
elif eval_val["mean_winrate"] > 0.45:
print(" ✅ Modèle PRÊT pour le live trading !")
else:
print(" ⚠️ Continuer l'entraînement ou ajuster la reward")
print("\n Prochaine étape: python live_bot.py\n")
print("=" * 70 + "\n")
macro_mod.stop()
mt5.disconnect()
if __name__ == "__main__":
main()

864
live_bot.py Normal file
View File

@ -0,0 +1,864 @@
# ============================================================
# live_bot.py — Bot de Trading XAUUSD en Production
# ============================================================
# Lance avec : python live_bot.py
# Arrête avec : Ctrl+C (ferme proprement les positions)
# ============================================================
import sys
import os
import time
import signal
import logging
import threading
import numpy as np
from datetime import datetime, date
from typing import Optional, List
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import config
from mt5_connector import MT5Connector
from ppo_agent import PPOAgent
from risk_manager import RiskManager, FeatureEngineer
from news_sentiment import NewsSentimentModule
from macro_features import MacroFeaturesModule
from dashboard import TradingDashboard
from web_dashboard import WebDashboardServer
# ── Logging ────────────────────────────────────────────────────
os.makedirs("logs", exist_ok=True)
import io as _io
logging.basicConfig(
level=logging.DEBUG, # DEBUG pour voir toutes les décisions IA
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
handlers=[
logging.FileHandler(config.LOG_FILE, encoding="utf-8"),
logging.StreamHandler(
stream=_io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
),
]
)
logger = logging.getLogger("LIVE_BOT")
class DecisionLogger:
"""Enregistre chaque décision de l'IA dans un fichier texte structuré."""
def __init__(self, path: str = config.LOG_FILE):
self.path = path
os.makedirs(os.path.dirname(path), exist_ok=True)
def log_decision(
self,
action_name: str,
price: float,
probs: List[float],
sentiment: float,
account_stats: dict,
reason: str = "",
lot_size: float = 0.0,
sl: float = 0.0,
tp: float = 0.0,
):
"""Enregistre une décision de trading avec contexte complet."""
entry = (
f"\n{'='*70}\n"
f"DÉCISION IA — {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
f"{'='*70}\n"
f" Action : {action_name}\n"
f" Prix XAUUSD : {price:.2f}\n"
f" Sentiment : {sentiment:+.4f}\n"
f" Probabilités : "
f"HOLD={probs[0]:.3f} BUY={probs[1]:.3f} "
f"SELL={probs[2]:.3f} CLOSE={probs[3]:.3f}\n"
)
if action_name in ("BUY", "SELL"):
entry += (
f" Lot Size : {lot_size:.2f}\n"
f" Stop Loss : {sl:.2f}\n"
f" Take Profit : {tp:.2f}\n"
f" Risque €/$ : {account_stats.get('equity', 0) * config.RISK_PER_TRADE_PCT:.2f}\n"
)
entry += (
f" Balance : {account_stats.get('balance', 0):.2f}\n"
f" Equity : {account_stats.get('equity', 0):.2f}\n"
f" PnL Open : {account_stats.get('profit', 0):.2f}\n"
)
if reason:
entry += f" Raison : {reason}\n"
with open(self.path, "a", encoding="utf-8") as f:
f.write(entry)
class XAUUSDBot:
"""
Bot de trading XAUUSD autonome.
Orchestre l'ensemble des modules : MT5, PPO, News, RiskManager, Dashboard.
"""
def __init__(self):
self.mt5 = MT5Connector()
self.news_mod = NewsSentimentModule()
self.macro_mod = MacroFeaturesModule()
self.dashboard = TradingDashboard()
self.web_dash = WebDashboardServer(port=8765, bot_ref=self)
self.dec_log = DecisionLogger()
self.fe = FeatureEngineer()
self.agent: Optional[PPOAgent] = None
self.risk_mgr: Optional[RiskManager] = None
self.running = False
self._obs_size: int = 0
self._run_event = threading.Event()
self._last_trade_time = 0.0 # Timestamp du dernier ordre envoyé
self._trade_cooldown = 15.0 # Minimum 15 secondes entre deux ordres
self._position_open_time = None # Timestamp quand la position a été ouverte
self._min_hold_bars = 5 # Minimum 5 bars (75 min en M15) avant de fermer
# Register signal handlers pour arrêt propre
signal.signal(signal.SIGINT, self._handle_shutdown)
signal.signal(signal.SIGTERM, self._handle_shutdown)
# ── Démarrage ──────────────────────────────────────────────
def start(self):
"""Initialise et lance le bot. Peut être rappelé après un stop."""
try:
if self._run_event.is_set():
logger.warning("Bot deja en cours")
return
logger.info(">>> start() appelé")
self.web_dash.broadcast_sync({"log_message": "Initialisation en cours..."})
self._start_inner()
except Exception as e:
logger.exception(f"CRASH dans start() : {e}")
self.web_dash.broadcast_sync({
"log_message": f"ERREUR : {str(e)[:120]}",
"status": "STOPPED"
})
self._run_event.clear()
self.running = False
def _start_inner(self):
"""Corps réel du démarrage."""
print("\n" + "=" * 70)
print(" XAUUSD AI TRADING BOT — DEMARRAGE")
print("=" * 70 + "\n")
# 1. Connexion MT5
logger.info("Connexion à MetaTrader5...")
if not self.mt5.connect():
msg = "ERREUR: Impossible de se connecter a MT5. Lance MT5 et active Algo Trading."
logger.error(msg)
self.web_dash.broadcast_sync({"log_message": msg, "status": "STOPPED"})
self._run_event.clear()
self.running = False
return
# 2. Récupérer les données live initiales
logger.info("Chargement des données récentes...")
df = self.mt5.get_latest_bars(n_bars=config.LOOKBACK_BARS + 250)
if df is None or len(df) < config.LOOKBACK_BARS:
msg = "Donnees MT5 insuffisantes — verifie la connexion."
logger.error(msg)
self.web_dash.broadcast_sync({"log_message": msg, "status": "STOPPED"})
self._run_event.clear()
self.running = False
return
# 3. Calculer la taille d'observation (doit correspondre exactement au training)
df_feat = self.fe.compute_features(df)
feat_cols = [c for c in self.fe.get_feature_columns() if c in df_feat.columns]
n_raw_features = len(feat_cols) + 4 # + position/sentiment/pnl/dd
macro_size = 18 # DXY + US10Y + Sessions (macro_features.py)
self._obs_size = config.LOOKBACK_BARS * n_raw_features + macro_size
logger.info(f"Taille observation live : {self._obs_size} "
f"(tech={config.LOOKBACK_BARS * n_raw_features} + macro={macro_size})")
# 4. Charger l'agent PPO
logger.info("Chargement du modèle IA...")
self.agent = PPOAgent(obs_size=self._obs_size, n_actions=4)
if not self.agent.load(config.MODEL_PATH):
print("⚠️ ATTENTION: Aucun modèle trouvé. Lance d'abord : python train.py")
print(" Le bot va tout de même démarrer en mode HOLD.")
logger.warning("Modèle non trouvé — mode HOLD uniquement.")
# 5. Gestionnaire de risque (init apres connexion MT5)
self.risk_mgr = RiskManager(self.mt5)
# Forcer re-lecture de la balance maintenant que MT5 est connecte
self.risk_mgr._init_day()
logger.info(f"Balance depart : {self.risk_mgr.start_balance:.2f}")
# 6. Demarrer les modules asynchrones (réinit si déjà stoppés)
logger.info("Demarrage du module news...")
try:
self.news_mod.force_update()
self.news_mod.start()
except Exception as e:
logger.warning(f"News module: {e}")
from news_sentiment import NewsSentimentModule
self.news_mod = NewsSentimentModule()
self.news_mod.force_update()
self.news_mod.start()
logger.info("Demarrage du module macro...")
try:
self.macro_mod.start()
except Exception as e:
logger.warning(f"Macro module: {e}")
from macro_features import MacroFeaturesModule
self.macro_mod = MacroFeaturesModule()
self.macro_mod.start()
logger.info("Tous les modules initialises. Trading demarre.")
self.dashboard.add_log("Bot demarre")
self.web_dash.broadcast_sync({"log_message": "Bot demarre", "status": "EN COURS"})
self.running = True
self._run_event.set()
self._main_loop()
# ── Boucle Principale ──────────────────────────────────────
def _dashboard_update_loop(self):
"""Thread dédié : met à jour le dashboard web toutes les 500ms."""
while self._run_event.is_set():
try:
tick = self.mt5.get_tick()
account_stats = self.mt5.get_account_stats()
positions = self.mt5.get_open_positions()
if tick and account_stats:
mid_price = (tick["bid"] + tick["ask"]) / 2
session_stats = self.risk_mgr.get_session_stats() if self.risk_mgr else {}
macro_data = self.macro_mod.get_features()
sentiment, _ = self.news_mod.get_current_sentiment()
action_probs = getattr(self, "_last_probs", [0.25]*4)
action_name = getattr(self, "_last_action", "HOLD")
# Sérialiser positions (datetime → str)
positions_safe = []
for p in positions:
ps = dict(p)
if "open_time" in ps:
ps["open_time"] = str(ps["open_time"])
positions_safe.append(ps)
self.web_dash.broadcast_sync({
"price": round(float(mid_price), 2),
"bid": round(float(tick["bid"]), 2),
"ask": round(float(tick["ask"]), 2),
"spread": round(float(tick.get("spread", 0)), 1),
"sentiment": round(float(sentiment), 3),
"balance": round(float(account_stats.get("balance", 0)), 2),
"equity": round(float(account_stats.get("equity", 0)), 2),
"daily_pnl": round(float(session_stats.get("pnl_abs", 0)), 2),
"daily_pnl_pct": round(float(session_stats.get("pnl_pct", 0)), 3),
"drawdown": round(float(session_stats.get("drawdown_pct", 0)), 3),
"open_trades": len(positions),
"positions": positions_safe,
"ai_action": str(action_name),
"ai_probs": [round(float(x), 3) for x in action_probs],
"status": "EN COURS",
"macro": {
"session_asia": int(macro_data.get("session_asia", 0)),
"session_london": int(macro_data.get("session_london", 0)),
"session_newyork": int(macro_data.get("session_newyork", 0)),
"dxy_price": round(float(macro_data.get("dxy_price", 0)), 2),
"us10y_rate": round(float(macro_data.get("us10y_rate", 0)), 2),
}
})
except Exception as e:
logger.debug(f"dashboard_update_loop error: {e}")
# Mise à jour toutes les 500ms
for _ in range(5):
if not self._run_event.is_set(): break
time.sleep(0.1)
def _main_loop(self):
"""Boucle de trading principale."""
last_bar_time = None
symbol_info = self.mt5.get_symbol_info()
if symbol_info is None:
logger.error(f"Symbole {config.SYMBOL} introuvable dans MT5.")
return
# Lancer le thread de mise à jour dashboard (500ms)
self._last_probs = [0.25, 0.25, 0.25, 0.25]
self._last_action = "HOLD"
dash_thread = threading.Thread(
target=self._dashboard_update_loop,
daemon=True,
name="DashboardUpdateThread"
)
dash_thread.start()
logger.info("Thread dashboard update demarre (500ms)")
while self._run_event.is_set():
try:
# ── A. Récupérer données marché ────────────────
# Vérif immédiate du flag d'arrêt
if not self._run_event.is_set():
break
tick = self.mt5.get_tick()
if tick is None:
logger.warning("Tick MT5 None — en attente...")
self.web_dash.broadcast_sync({"log_message": "Tick MT5 indisponible — reconnexion..."})
for _ in range(int(config.TICK_INTERVAL_SEC * 10)):
if not self._run_event.is_set(): break
time.sleep(0.1)
# Tenter reconnexion MT5
try:
self.mt5.connect()
except Exception: pass
continue
df_bars = self.mt5.get_latest_bars(n_bars=config.LOOKBACK_BARS + 250)
if df_bars is None or len(df_bars) < config.LOOKBACK_BARS:
for _ in range(int(config.TICK_INTERVAL_SEC * 10)):
if not self._run_event.is_set(): break
time.sleep(0.1)
continue
current_bar_time = df_bars.index[-1]
mid_price = (tick["bid"] + tick["ask"]) / 2
# ── Check arrêt immédiat ───────────────────────
if not self._run_event.is_set():
break
# ── B. Sentiment News ──────────────────────────
sentiment_score, recent_news = self.news_mod.get_current_sentiment()
# ── C. Compte ──────────────────────────────────
account_stats = self.mt5.get_account_stats()
positions = self.mt5.get_open_positions()
session_stats = self.risk_mgr.get_session_stats()
# ── D. Protection Compte (hard-coded, pas configurable) ──
if not self._run_event.is_set(): break
if account_stats:
equity = account_stats.get("equity", 0)
balance = account_stats.get("balance", 0)
# Arrêt si perte > 5% sur la session (protection absolue)
if balance > 0 and equity > 0:
session_loss = (equity - balance) / balance
if session_loss <= -0.05:
logger.critical(f"PROTECTION COMPTE : perte session {session_loss*100:.1f}% > 5%")
self._run_event.clear()
self.running = False
self.mt5.close_all_positions()
self.web_dash.broadcast_sync({
"log_message": f"STOP — perte session {session_loss*100:.1f}%",
"status": "STOPPED"
})
break
# ── E. Objectif Journalier ─────────────────────
if self.risk_mgr.check_daily_profit_target():
self.dashboard.add_log("Objectif journalier atteint — pause")
self._wait_for_new_day()
self.risk_mgr._init_day()
continue
# ── F. Construire l'observation pour l'IA ──────
if not self._run_event.is_set(): break # re-check avant inférence
df_feat = self.fe.compute_features(df_bars)
obs = self._build_live_observation(df_feat, sentiment_score, positions)
# ── G. Inférence IA ────────────────────────────
action, log_prob, value = self.agent.predict(obs, deterministic=False)
action_probs = self.agent.get_action_probabilities(obs)
action_name = PPOAgent.ACTION_NAMES.get(action, "HOLD")
# Partager avec le thread dashboard
self._last_probs = action_probs.tolist()
self._last_action = action_name
# ── H. Exécution ──────────────────────────────
if not self._run_event.is_set(): break # NE PAS trader si arrêt demandé
is_new_bar = (current_bar_time != last_bar_time)
if is_new_bar:
last_bar_time = current_bar_time
self._execute_action(
action_name,
mid_price,
df_feat,
symbol_info,
account_stats,
positions,
action_probs,
sentiment_score
)
# Log debug pour voir ce que l'IA décide
logger.debug(
f"IA: {action_name} | "
f"H={action_probs[0]:.2f} B={action_probs[1]:.2f} "
f"S={action_probs[2]:.2f} C={action_probs[3]:.2f} | "
f"Prix={mid_price:.2f}"
)
# ── I. Mise à jour du Dashboard ────────────────
self.risk_mgr.update_daily_high()
sent_label = self.news_mod.get_summary_string()
news_for_dash = [
{"title": n.title, "sentiment_score": n.sentiment_score}
for n in recent_news
]
macro_str = self.macro_mod.get_dashboard_string()
macro_data = self.macro_mod.get_features()
self.dashboard.update(
price = mid_price,
bid = tick["bid"],
ask = tick["ask"],
spread = tick["spread"],
sentiment = sentiment_score,
sent_label = sent_label,
macro_info = macro_str,
balance = account_stats.get("balance", 0),
equity = account_stats.get("equity", 0),
daily_pnl = session_stats["pnl_abs"],
daily_pnl_pct = session_stats["pnl_pct"],
drawdown = session_stats["drawdown_pct"],
open_trades = len(positions),
positions = positions,
ai_action = action_name,
ai_probs = action_probs.tolist(),
last_news = news_for_dash,
status = "🟢 EN COURS",
total_steps = self.agent.total_steps,
)
# ── Broadcast vers le Dashboard Web ──────────
# Dashboard mis à jour par _dashboard_update_loop (thread 500ms)
# Sleep interruptible : vérifie running toutes les 100ms
for _ in range(int(config.TICK_INTERVAL_SEC * 10)):
if not self._run_event.is_set(): break
time.sleep(0.1)
except Exception as e:
logger.exception(f"Erreur boucle : {e}")
self.dashboard.add_log(f"Erreur: {str(e)[:60]}")
for _ in range(50): # 5s max
if not self._run_event.is_set(): break
time.sleep(0.1)
# ── Exécution des Ordres ───────────────────────────────────
def _execute_action(
self,
action_name: str,
price: float,
df_feat,
symbol_info: dict,
account_stats: dict,
positions: list,
probs: np.ndarray,
sentiment: float,
):
"""Traduit la décision IA en ordre MT5 réel."""
has_position = len(positions) > 0
reason = ""
lot_size = 0.0
sl_price = 0.0
tp_price = 0.0
logger.info(
f"EXECUTE: action={action_name} | "
f"H={probs[0]:.2f} B={probs[1]:.2f} S={probs[2]:.2f} C={probs[3]:.2f} | "
f"has_pos={has_position} | prix={price:.2f}"
)
pos_type = positions[0]["type"] if has_position else None
# ── Logique de gestion des positions ──────────────────
# Si position ouverte + signal opposé → fermer (reverse)
if has_position and action_name == "SELL" and pos_type == "BUY":
# Blocage des reversals trop rapides (minimum 5 bars M15 = 75 min)
min_hold_seconds = self._min_hold_bars * 15 * 60
if self._position_open_time is not None:
hold_time = time.time() - self._position_open_time
if hold_time < min_hold_seconds:
bars_held = hold_time / (15 * 60)
logger.info(f"SELL reverse ignore : position tenue {bars_held:.1f} bars < {self._min_hold_bars} min")
return
logger.info("Signal SELL avec BUY ouvert → fermeture forcee")
for pos in positions:
self.mt5.close_position(pos["ticket"])
self._position_open_time = None # Reset timer
return
if has_position and action_name == "BUY" and pos_type == "SELL":
# Blocage des reversals trop rapides (minimum 5 bars M15 = 75 min)
min_hold_seconds = self._min_hold_bars * 15 * 60
if self._position_open_time is not None:
hold_time = time.time() - self._position_open_time
if hold_time < min_hold_seconds:
bars_held = hold_time / (15 * 60)
logger.info(f"BUY reverse ignore : position tenue {bars_held:.1f} bars < {self._min_hold_bars} min")
return
logger.info("Signal BUY avec SELL ouvert → fermeture forcee")
for pos in positions:
self.mt5.close_position(pos["ticket"])
self._position_open_time = None # Reset timer
return
# Si position ouverte + même signal → ignorer
if has_position and action_name == "BUY" and pos_type == "BUY":
logger.debug("BUY ignore : position BUY deja ouverte")
return
if has_position and action_name == "SELL" and pos_type == "SELL":
logger.debug("SELL ignore : position SELL deja ouverte")
return
# HOLD = aucune action
if action_name == "HOLD":
return
# ── Seuils de confiance ────────────────────────────────
min_prob_trade = 0.26
min_prob_close = 0.25
if action_name == "BUY" and probs[1] < min_prob_trade:
logger.info(f"BUY ignore : prob={probs[1]:.3f} < {min_prob_trade}")
return
if action_name == "SELL" and probs[2] < min_prob_trade:
logger.info(f"SELL ignore : prob={probs[2]:.3f} < {min_prob_trade}")
return
if action_name == "CLOSE" and probs[3] < min_prob_close:
logger.info(f"CLOSE ignore : prob={probs[3]:.3f} < {min_prob_close}")
return
# ── Cooldown anti-surtrading ───────────────────────────
now = time.time()
if action_name in ("BUY", "SELL"):
since_last = now - self._last_trade_time
if since_last < self._trade_cooldown:
logger.info(f"{action_name} ignore : cooldown {since_last:.0f}s < {self._trade_cooldown:.0f}s")
return
# Re-fetch positions directement depuis MT5 (pas le cache)
live_positions = self.mt5.get_open_positions()
if len(live_positions) > 0:
logger.info(f"{action_name} ignore : {len(live_positions)} position(s) ouverte(s)")
return
# ── Filtre de tendance EMA ─────────────────────────
# DÉSACTIVÉ TEMPORAIREMENT : trop restrictif, bloquait tous les trades
# À réactiver après avec un filtre moins strict
# try:
# close = df_feat["Close"].values
# # Filtre tendance multi-timeframe
# # H1 : EMA20 sur les 20 dernières heures (80 bougies M15)
# # M15 : EMA50 sur les 50 dernières bougies M15
# close_vals = df_feat["Close"].values
#
# # Tendance H1 (court terme robuste)
# h1_period = 80 # 80 bougies M15 = 20h
# if len(close_vals) >= h1_period:
# ema_h1_fast = float(df_feat["Close"].ewm(span=20).mean().iloc[-1])
# ema_h1_slow = float(df_feat["Close"].ewm(span=80).mean().iloc[-1])
# trend_up = ema_h1_fast > ema_h1_slow and price > ema_h1_fast
# trend_down = ema_h1_fast < ema_h1_slow and price < ema_h1_fast
# else:
# trend_up = trend_down = True # Pas assez de données → pas de filtre
#
# if action_name == "BUY" and not trend_up:
# logger.info(f"BUY BLOQUE | tendance H1 baissiere EMA20={ema_h1_fast:.1f} < EMA80={ema_h1_slow:.1f}")
# return
# if action_name == "SELL" and not trend_down:
# logger.info(f"SELL BLOQUE | tendance H1 haussiere EMA20={ema_h1_fast:.1f} > EMA80={ema_h1_slow:.1f}")
# return
#
# logger.info(f"Filtre H1 OK : {'HAUSSIER' if trend_up else 'BAISSIER'} | EMA20={ema_h1_fast:.1f} EMA80={ema_h1_slow:.1f}")
# except Exception as e:
# logger.warning(f"Filtre tendance erreur : {e} — trade autorise quand meme")
# ── BUY ───────────────────────────────────────────────
if action_name == "BUY" and not has_position:
atr = self.fe.get_atr(df_feat)
sl, tp = self.risk_mgr.calculate_sl_tp("BUY", price, atr, symbol_info["point"])
sl_pips = self.risk_mgr.sl_to_pips(price, sl, symbol_info["point"])
# Lot sizing : 1% du compte réel MT5, pas du capital config
equity = account_stats.get("equity", 10000)
lot_size = self.risk_mgr.calculate_lot_size(sl_pips, symbol_info, equity)
reason = (
f"IA BUY | Prob={probs[1]:.3f} | "
f"Equity={equity:.0f}$ | ATR={atr:.2f}"
)
result = self.mt5.place_order("BUY", lot_size, sl, tp, comment="AI_PPO_BUY")
if result:
self._last_trade_time = time.time() # Cooldown démarre ici
self._position_open_time = time.time() # Track position open time
sl_price = result["sl"]
tp_price = result["tp"]
self.dashboard.add_log(
f"BUY {lot_size:.2f} lots @ {price:.2f} | SL={sl:.2f} TP={tp:.2f}"
)
# ── SELL ──────────────────────────────────────────────
elif action_name == "SELL" and not has_position:
atr = self.fe.get_atr(df_feat)
sl, tp = self.risk_mgr.calculate_sl_tp("SELL", price, atr, symbol_info["point"])
sl_pips = self.risk_mgr.sl_to_pips(price, sl, symbol_info["point"])
lot_size = self.risk_mgr.calculate_lot_size(
sl_pips, symbol_info, account_stats.get("equity")
)
reason = (
f"IA décide SELL | Prob={probs[2]:.3f} | "
f"Sentiment={sentiment:+.3f} | ATR={atr:.2f}"
)
result = self.mt5.place_order("SELL", lot_size, sl, tp, comment="AI_PPO_SELL")
if result:
self._last_trade_time = time.time() # Cooldown démarre ici
self._position_open_time = time.time() # Track position open time
sl_price = result["sl"]
tp_price = result["tp"]
self.dashboard.add_log(
f"SELL {lot_size:.2f} lots @ {price:.2f} | SL={sl:.2f} TP={tp:.2f}"
)
# ── CLOSE ─────────────────────────────────────────────
elif action_name == "CLOSE" and has_position:
# Blocage des fermetures trop rapides (minimum 5 bars M15 = 75 min)
min_hold_seconds = self._min_hold_bars * 15 * 60 # 5 bars * 15 min * 60 sec = 4500 sec
if self._position_open_time is not None:
hold_time = time.time() - self._position_open_time
if hold_time < min_hold_seconds:
bars_held = hold_time / (15 * 60)
logger.info(f"CLOSE ignore : position tenue {bars_held:.1f} bars < {self._min_hold_bars} min")
return
for pos in positions:
self.mt5.close_position(pos["ticket"])
self._position_open_time = None # Reset timer
reason = f"IA décide CLOSE | Prob={probs[3]:.3f} | PnL={sum(p['profit'] for p in positions):.2f}"
self.dashboard.add_log(
f"🔵 CLOSE {len(positions)} position(s) @ {price:.2f}"
)
else:
# HOLD ou action non applicable
return
# Logger la décision
self.dec_log.log_decision(
action_name = action_name,
price = price,
probs = probs.tolist(),
sentiment = sentiment,
account_stats = account_stats,
reason = reason,
lot_size = lot_size,
sl = sl_price,
tp = tp_price,
)
# ── Observation Live ───────────────────────────────────────
def _build_live_observation(
self,
df_feat,
sentiment: float,
positions: list
) -> np.ndarray:
"""Construit le vecteur d'observation pour l'inférence live."""
feat_cols = [c for c in self.fe.get_feature_columns() if c in df_feat.columns]
window = df_feat.iloc[-config.LOOKBACK_BARS:][feat_cols].values.astype(np.float32)
if len(window) < config.LOOKBACK_BARS:
pad = np.zeros((config.LOOKBACK_BARS - len(window), len(feat_cols)), dtype=np.float32)
window = np.vstack([pad, window])
mean = window.mean(axis=0)
std = window.std(axis=0) + 1e-8
window = (window - mean) / std
# Info position
position_code = 0
unrealized_pnl = 0.0
if positions:
pos = positions[0]
position_code = 1 if pos["type"] == "BUY" else -1
unrealized_pnl = pos["profit"] / (self.risk_mgr.start_balance + 1e-9)
drawdown = self.risk_mgr.get_current_drawdown()
extra = np.array([[
float(position_code),
float(sentiment),
float(np.clip(unrealized_pnl, -1, 1)),
float(np.clip(-drawdown, -1, 0)),
]] * config.LOOKBACK_BARS, dtype=np.float32)
obs = np.hstack([window, extra]).flatten()
# Ajouter les features macro (DXY, taux, sessions)
macro_vector = self.macro_mod.get_feature_vector()
obs = np.concatenate([obs, macro_vector])
return obs
# ── Utilitaires ────────────────────────────────────────────
def _wait_for_new_day(self):
"""Attend la prochaine journée de trading."""
today = date.today()
logger.info("En attente de la prochaine journée de trading...")
while date.today() == today and self._run_event.is_set():
time.sleep(60)
def stop(self):
"""Arrêt propre du bot depuis le dashboard web."""
if not self._run_event.is_set():
logger.warning("Bot deja arrete")
return
logger.info("Arret du bot demande...")
self._run_event.clear()
self.running = False
# Attendre max 5s que la boucle se termine
for _ in range(50):
if not self.running:
break
time.sleep(0.1)
logger.info("Bot arrete proprement.")
def _handle_shutdown(self, signum, frame):
"""Arrêt propre sur signal (Ctrl+C)."""
logger.warning(f"Signal {signum} recu. Arret en cours...")
self.running = False
self._run_event.clear()
print("\n\n⚠️ Arrêt demandé. Fermeture propre en cours...")
# Fermer les positions ouvertes ?
positions = self.mt5.get_open_positions()
if positions:
user_input = input(
f"\n{len(positions)} position(s) ouverte(s). Fermer tout ? [o/N] : "
).strip().lower()
if user_input == "o":
n_closed = self.mt5.close_all_positions()
print(f"{n_closed} position(s) fermée(s).")
self.news_mod.stop()
self.mt5.disconnect()
print("✅ Arrêt propre terminé.")
sys.exit(0)
def shutdown(self):
"""Arrêt programmatique."""
self.running = False
self.news_mod.stop()
self.mt5.disconnect()
# ── Point d'entrée ─────────────────────────────────────────────
def _idle_dashboard_loop(bot):
"""
Thread léger qui tourne en permanence même avant de cliquer Démarrer.
Envoie prix + balance MT5 au dashboard toutes les 2 secondes.
"""
logger.info("Thread idle dashboard démarre")
while True:
try:
# Seulement si le bot n'est PAS en cours (sinon _dashboard_update_loop gère)
if not bot._run_event.is_set():
# Connecter MT5 si besoin
if not bot.mt5.connected:
try:
bot.mt5.connect()
except Exception:
time.sleep(5)
continue
tick = bot.mt5.get_tick()
account_stats = bot.mt5.get_account_stats()
if tick and account_stats:
mid_price = (tick["bid"] + tick["ask"]) / 2
bot.web_dash.broadcast_sync({
"price": round(float(mid_price), 2),
"bid": round(float(tick["bid"]), 2),
"ask": round(float(tick["ask"]), 2),
"spread": round(float(tick.get("spread", 0)), 1),
"balance": round(float(account_stats.get("balance", 0)), 2),
"equity": round(float(account_stats.get("equity", 0)), 2),
"daily_pnl": 0.0,
"open_trades": 0,
"positions": [],
"status": "STOPPED",
"ai_action": "",
"ai_probs": [0.25, 0.25, 0.25, 0.25],
})
except Exception as e:
logger.debug(f"idle_dashboard_loop: {e}")
time.sleep(2)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--auto", action="store_true", help="Démarrer le bot automatiquement")
args = parser.parse_args()
bot = XAUUSDBot()
# ── Démarrer le dashboard web UNE SEULE FOIS ──────────────
bot.web_dash.set_bot(bot)
bot.web_dash.start()
# ── Démarrer le dashboard terminal UNE SEULE FOIS ─────────
bot.dashboard.start_background()
# ── Thread idle : affiche prix + balance avant démarrage ──
idle_thread = threading.Thread(
target=_idle_dashboard_loop,
args=(bot,),
daemon=True,
name="IdleDashboardThread"
)
idle_thread.start()
print("=" * 60)
print(" XAUUSD AI BOT — Dashboard Web")
print("=" * 60)
print("Dashboard : http://localhost:8765")
print("Clique sur [DEMARRER] dans le navigateur pour lancer le bot.")
print("Ctrl+C pour quitter.")
print("=" * 60)
if args.auto:
bot.start()
try:
while True:
time.sleep(0.5)
except KeyboardInterrupt:
print("\nArret demande...")
try:
if bot.running:
bot.stop()
except Exception: pass
print("Bye.")

313
macro_features.py Normal file
View File

@ -0,0 +1,313 @@
# ============================================================
# macro_features.py — Features Macro : DXY, Taux 10 ans, Sessions
# ============================================================
# Données récupérées via yfinance (Yahoo Finance) — gratuit, sans API key
# Mise à jour automatique toutes les heures en live
# ============================================================
import logging
import threading
import time
import numpy as np
import pandas as pd
from datetime import datetime, timezone
from typing import Dict, Optional, Tuple
import pytz
logger = logging.getLogger(__name__)
class MacroFeaturesModule:
"""
Récupère et met à jour les données macro en temps réel :
1. DXY (Dollar Index) Corrélation -0.85 avec l'or
2. US10Y (Taux 10 ans USA) Taux réels vs or
3. Session de trading Londres/NY/Asie/Hors-session
4. Jour de la semaine Patterns hebdomadaires
"""
def __init__(self):
self._data: Dict = {
# DXY
"dxy_price": 100.0, # Prix actuel DXY
"dxy_return_1d": 0.0, # Variation journalière DXY
"dxy_return_5d": 0.0, # Variation 5 jours DXY
"dxy_vs_sma20": 0.0, # DXY au-dessus/dessous SMA20
"dxy_rsi": 50.0, # RSI du DXY
# Taux 10 ans US
"us10y_rate": 4.0, # Taux en %
"us10y_change_1d": 0.0, # Variation journalière en bps
"us10y_change_5d": 0.0, # Variation 5 jours
"real_rate_proxy": 0.0, # Taux 10 ans - inflation proxy
# Session de trading
"session_asia": 0, # 1 si session Asie active
"session_london": 0, # 1 si session Londres active
"session_newyork": 0, # 1 si session New York active
"session_overlap": 0, # 1 si chevauchement Londres/NY
"hour_sin": 0.0, # Heure encodée cycliquement (sin)
"hour_cos": 1.0, # Heure encodée cycliquement (cos)
# Jour de la semaine
"day_monday": 0,
"day_tuesday": 0,
"day_wednesday": 0, # Souvent FOMC
"day_thursday": 0,
"day_friday": 0, # NFP + clôture positions
"day_sin": 0.0, # Jour encodé cycliquement
"day_cos": 1.0,
# Métadonnées
"last_update": None,
"data_available": False,
}
self._lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
# ── API Publique ───────────────────────────────────────────
def start(self):
"""Lance la mise à jour en arrière-plan (toutes les heures)."""
self._running = True
# Première mise à jour synchrone
self._update_all()
# Puis thread en arrière-plan
self._thread = threading.Thread(target=self._update_loop, daemon=True)
self._thread.start()
logger.info("MacroFeatures : module démarré.")
def stop(self):
self._running = False
if self._thread:
self._thread.join(timeout=5)
def get_features(self) -> Dict:
"""Retourne toutes les features macro (thread-safe)."""
with self._lock:
# Toujours mettre à jour les features temps-réel (session/heure)
self._update_time_features_inplace()
return dict(self._data)
def get_feature_vector(self) -> np.ndarray:
"""
Retourne un vecteur numpy normalisé des features macro.
Utilisé directement comme input supplémentaire du réseau IA.
"""
d = self.get_features()
vector = np.array([
# DXY (normalisé autour de 0)
np.clip(d["dxy_return_1d"] * 100, -3, 3), # % var jour
np.clip(d["dxy_return_5d"] * 100, -5, 5), # % var 5j
np.clip(d["dxy_vs_sma20"] * 100, -5, 5), # vs SMA20
np.clip((d["dxy_rsi"] - 50) / 50, -1, 1), # RSI centré
# Taux 10 ans (normalisé)
np.clip(d["us10y_rate"] / 10, 0, 1), # Niveau absolu
np.clip(d["us10y_change_1d"] / 20, -1, 1), # Variation bps/j
np.clip(d["us10y_change_5d"] / 50, -1, 1), # Variation bps/5j
np.clip(d["real_rate_proxy"] / 5, -1, 1), # Taux réels proxy
# Sessions (binaires)
float(d["session_asia"]),
float(d["session_london"]),
float(d["session_newyork"]),
float(d["session_overlap"]),
# Encodage temporel cyclique
d["hour_sin"],
d["hour_cos"],
d["day_sin"],
d["day_cos"],
# Jours spéciaux
float(d["day_wednesday"]), # FOMC souvent mercredi
float(d["day_friday"]), # NFP + clôture
], dtype=np.float32)
return np.clip(vector, -3, 3)
def get_feature_names(self) -> list:
"""Noms des features dans l'ordre du vecteur."""
return [
"dxy_return_1d", "dxy_return_5d", "dxy_vs_sma20", "dxy_rsi",
"us10y_rate", "us10y_change_1d", "us10y_change_5d", "real_rate_proxy",
"session_asia", "session_london", "session_newyork", "session_overlap",
"hour_sin", "hour_cos", "day_sin", "day_cos",
"day_wednesday", "day_friday",
]
# ── Mises à jour ───────────────────────────────────────────
def _update_loop(self):
"""Met à jour les données toutes les heures."""
while self._running:
time.sleep(3600) # 1 heure
try:
self._update_all()
except Exception as e:
logger.error(f"MacroFeatures update error: {e}")
def _update_all(self):
"""Récupère DXY + Taux 10 ans via yfinance."""
try:
import yfinance as yf
except ImportError:
logger.warning("yfinance non installé. pip install yfinance")
logger.warning("Utilisation des valeurs par défaut pour les features macro.")
with self._lock:
self._update_time_features_inplace()
return
dxy_ok = self._fetch_dxy(yf)
rates_ok = self._fetch_us10y(yf)
with self._lock:
self._update_time_features_inplace()
self._data["data_available"] = dxy_ok or rates_ok
self._data["last_update"] = datetime.utcnow()
logger.info(
f"MacroFeatures mis a jour | "
f"DXY={self._data['dxy_price']:.2f} | "
f"US10Y={self._data['us10y_rate']:.2f}% | "
f"Session={self._get_session_name()}"
)
def _fetch_dxy(self, yf) -> bool:
"""Récupère les données du Dollar Index (DX-Y.NYB)."""
try:
ticker = yf.Ticker("DX-Y.NYB")
hist = ticker.history(period="30d", interval="1d")
if hist.empty or len(hist) < 5:
logger.warning("DXY: données insuffisantes")
return False
closes = hist["Close"].values
price = float(closes[-1])
sma20 = float(np.mean(closes[-20:])) if len(closes) >= 20 else price
ret_1d = (closes[-1] / closes[-2] - 1) if len(closes) >= 2 else 0.0
ret_5d = (closes[-1] / closes[-5] - 1) if len(closes) >= 5 else 0.0
# RSI du DXY
delta = np.diff(closes[-15:])
gain = np.mean(delta[delta > 0]) if any(delta > 0) else 0
loss = abs(np.mean(delta[delta < 0])) if any(delta < 0) else 1e-9
rsi = 100 - (100 / (1 + gain / loss))
with self._lock:
self._data.update({
"dxy_price": price,
"dxy_return_1d": float(ret_1d),
"dxy_return_5d": float(ret_5d),
"dxy_vs_sma20": float((price - sma20) / sma20),
"dxy_rsi": float(rsi),
})
return True
except Exception as e:
logger.debug(f"DXY fetch error: {e}")
return False
def _fetch_us10y(self, yf) -> bool:
"""Récupère le taux des obligations US 10 ans (^TNX)."""
try:
ticker = yf.Ticker("^TNX")
hist = ticker.history(period="30d", interval="1d")
if hist.empty or len(hist) < 2:
logger.warning("US10Y: données insuffisantes")
return False
closes = hist["Close"].values
rate = float(closes[-1]) # En %
change_1d = float(closes[-1] - closes[-2]) * 100 # En bps
change_5d = float(closes[-1] - closes[-5]) * 100 if len(closes) >= 5 else 0.0
# Proxy taux réels = taux 10 ans - inflation estimée (CPI ~3%)
inflation_proxy = 3.0
real_rate = rate - inflation_proxy
with self._lock:
self._data.update({
"us10y_rate": rate,
"us10y_change_1d": change_1d,
"us10y_change_5d": change_5d,
"real_rate_proxy": real_rate,
})
return True
except Exception as e:
logger.debug(f"US10Y fetch error: {e}")
return False
def _update_time_features_inplace(self):
"""Met à jour les features temporelles (appelé sans lock)."""
now_utc = datetime.now(timezone.utc)
hour = now_utc.hour + now_utc.minute / 60.0
weekday = now_utc.weekday() # 0=lundi, 6=dimanche
# ── Sessions de trading (heures UTC) ──────────────────
# Asie : 00:00 - 08:00 UTC
# Londres : 08:00 - 17:00 UTC
# New York: 13:00 - 22:00 UTC
# Overlap : 13:00 - 17:00 UTC (Londres + NY)
asia_open = 0.0 <= hour < 8.0
london_open = 8.0 <= hour < 17.0
ny_open = 13.0 <= hour < 22.0
overlap = 13.0 <= hour < 17.0
# Week-end = marchés fermés
is_weekend = weekday >= 5
if is_weekend:
asia_open = london_open = ny_open = overlap = False
# ── Encodage cyclique heure (sin/cos) ──────────────────
hour_rad = (hour / 24.0) * 2 * np.pi
hour_sin = float(np.sin(hour_rad))
hour_cos = float(np.cos(hour_rad))
# ── Encodage cyclique jour semaine ─────────────────────
day_rad = (weekday / 7.0) * 2 * np.pi
day_sin = float(np.sin(day_rad))
day_cos = float(np.cos(day_rad))
self._data.update({
"session_asia": int(asia_open),
"session_london": int(london_open),
"session_newyork": int(ny_open),
"session_overlap": int(overlap),
"hour_sin": hour_sin,
"hour_cos": hour_cos,
"day_monday": int(weekday == 0),
"day_tuesday": int(weekday == 1),
"day_wednesday": int(weekday == 2),
"day_thursday": int(weekday == 3),
"day_friday": int(weekday == 4),
"day_sin": day_sin,
"day_cos": day_cos,
})
def _get_session_name(self) -> str:
"""Retourne le nom de la session active."""
d = self._data
if d["session_overlap"]: return "OVERLAP London/NY"
if d["session_newyork"]: return "NEW YORK"
if d["session_london"]: return "LONDRES"
if d["session_asia"]: return "ASIE"
return "HORS SESSION"
def get_dashboard_string(self) -> str:
"""Résumé pour le dashboard."""
d = self.get_features()
dxy_arrow = "" if d["dxy_return_1d"] > 0 else ""
us10y_arrow = "" if d["us10y_change_1d"] > 0 else ""
return (
f"DXY={d['dxy_price']:.2f}{dxy_arrow} | "
f"US10Y={d['us10y_rate']:.2f}%{us10y_arrow} | "
f"Session={self._get_session_name()}"
)

329
mt5_connector.py Normal file
View File

@ -0,0 +1,329 @@
# ============================================================
# mt5_connector.py — Interface MetaTrader5
# ============================================================
import MetaTrader5 as mt5
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
import pytz
import logging
from typing import Optional, Tuple, List, Dict
import config
logger = logging.getLogger(__name__)
# Mapping des timeframes
TIMEFRAME_MAP = {
"M1": mt5.TIMEFRAME_M1,
"M5": mt5.TIMEFRAME_M5,
"M15": mt5.TIMEFRAME_M15,
"M30": mt5.TIMEFRAME_M30,
"H1": mt5.TIMEFRAME_H1,
"H4": mt5.TIMEFRAME_H4,
"D1": mt5.TIMEFRAME_D1,
}
class MT5Connector:
"""Gère toutes les interactions avec MetaTrader 5."""
def __init__(self):
self.connected = False
self.account_info = None
# ── Connexion ──────────────────────────────────────────────
def connect(self) -> bool:
"""Initialise et connecte à MT5."""
if not mt5.initialize():
logger.error(f"Échec initialisation MT5 : {mt5.last_error()}")
return False
if config.MT5_LOGIN:
authorized = mt5.login(
login=config.MT5_LOGIN,
password=config.MT5_PASSWORD,
server=config.MT5_SERVER
)
if not authorized:
logger.error(f"Échec connexion compte : {mt5.last_error()}")
mt5.shutdown()
return False
self.account_info = mt5.account_info()
if self.account_info is None:
logger.error("Impossible de récupérer les infos du compte.")
return False
self.connected = True
logger.info(
f"[OK] Connecte MT5 | Compte: {self.account_info.login} | "
f"Broker: {self.account_info.company} | "
f"Balance: {self.account_info.balance:.2f} {self.account_info.currency}"
)
return True
def disconnect(self):
"""Ferme la connexion MT5."""
mt5.shutdown()
self.connected = False
logger.info("Déconnexion MT5.")
# ── Données de Marché ──────────────────────────────────────
def get_historical_data(
self,
symbol: str = config.SYMBOL,
timeframe: str = config.TIMEFRAME,
years: int = config.TRAINING_YEARS
) -> Optional[pd.DataFrame]:
"""Télécharge X années d'historique OHLCV."""
tf = TIMEFRAME_MAP.get(timeframe, mt5.TIMEFRAME_M15)
utc_to = datetime.now(pytz.utc)
utc_from = utc_to - timedelta(days=365 * years)
logger.info(f"[DL] Telechargement de {years} ans de données {symbol} [{timeframe}]...")
# S'assurer que le symbole est actif
mt5.symbol_select(symbol, True)
import time
time.sleep(0.5)
# Calculer le nombre de barres nécessaires selon le timeframe
bars_per_year = {
"M1": 525600, "M5": 105120, "M15": 35040,
"M30": 17520, "H1": 8760, "H4": 2190, "D1": 365
}
n_bars = bars_per_year.get(timeframe, 35040) * years
# Méthode fiable : copy_rates_from_pos (pas de problème de timezone)
rates = mt5.copy_rates_from_pos(symbol, tf, 0, n_bars)
if rates is None or len(rates) == 0:
logger.error(f"copy_rates_from_pos échoué : {mt5.last_error()}")
# Fallback : essayer avec moins de barres
rates = mt5.copy_rates_from_pos(symbol, tf, 0, 10000)
if rates is None or len(rates) == 0:
logger.error(f"Fallback échoué aussi : {mt5.last_error()}")
return None
logger.warning(f"Fallback utilisé : {len(rates)} barres seulement")
df = pd.DataFrame(rates)
df["time"] = pd.to_datetime(df["time"], unit="s")
df.set_index("time", inplace=True)
df.rename(columns={
"open": "Open", "high": "High",
"low": "Low", "close": "Close", "tick_volume": "Volume"
}, inplace=True)
df = df[["Open", "High", "Low", "Close", "Volume"]]
df.dropna(inplace=True)
logger.info(f"[OK] {len(df)} barres chargées ({df.index[0]}{df.index[-1]})")
return df
def get_latest_bars(
self,
symbol: str = config.SYMBOL,
timeframe: str = config.TIMEFRAME,
n_bars: int = config.LOOKBACK_BARS + 50
) -> Optional[pd.DataFrame]:
"""Récupère les N dernières barres OHLCV."""
tf = TIMEFRAME_MAP.get(timeframe, mt5.TIMEFRAME_M15)
rates = mt5.copy_rates_from_pos(symbol, tf, 0, n_bars)
if rates is None:
return None
df = pd.DataFrame(rates)
df["time"] = pd.to_datetime(df["time"], unit="s")
df.set_index("time", inplace=True)
df.rename(columns={
"open": "Open", "high": "High",
"low": "Low", "close": "Close", "tick_volume": "Volume"
}, inplace=True)
return df[["Open", "High", "Low", "Close", "Volume"]]
def get_tick(self, symbol: str = config.SYMBOL) -> Optional[Dict]:
"""Retourne le tick courant (bid/ask)."""
tick = mt5.symbol_info_tick(symbol)
if tick is None:
return None
return {
"bid": tick.bid,
"ask": tick.ask,
"last": tick.last,
"spread": round((tick.ask - tick.bid) / mt5.symbol_info(symbol).point, 1),
"time": datetime.fromtimestamp(tick.time)
}
def get_symbol_info(self, symbol: str = config.SYMBOL) -> Optional[Dict]:
"""Retourne les informations du symbole."""
info = mt5.symbol_info(symbol)
if info is None:
return None
return {
"point": info.point,
"digits": info.digits,
"trade_contract_size": info.trade_contract_size,
"volume_min": info.volume_min,
"volume_max": info.volume_max,
"volume_step": info.volume_step,
}
# ── Compte ─────────────────────────────────────────────────
def get_account_stats(self) -> Dict:
"""Retourne les statistiques du compte en temps réel."""
info = mt5.account_info()
if info is None:
return {}
return {
"balance": info.balance,
"equity": info.equity,
"margin": info.margin,
"free_margin": info.margin_free,
"profit": info.profit,
"leverage": info.leverage,
"currency": info.currency,
}
# ── Ordres & Positions ─────────────────────────────────────
def get_open_positions(self, symbol: str = config.SYMBOL) -> List[Dict]:
"""Retourne la liste des positions ouvertes."""
positions = mt5.positions_get(symbol=symbol, magic=config.MAGIC_NUMBER)
if positions is None:
return []
result = []
for p in positions:
result.append({
"ticket": p.ticket,
"type": "BUY" if p.type == mt5.ORDER_TYPE_BUY else "SELL",
"volume": p.volume,
"open_price": p.price_open,
"sl": p.sl,
"tp": p.tp,
"profit": p.profit,
"open_time": datetime.fromtimestamp(p.time),
})
return result
def place_order(
self,
action: str, # "BUY" ou "SELL"
lot_size: float,
sl: float,
tp: float,
comment: str = "AI_BOT"
) -> Optional[Dict]:
"""Place un ordre au marché avec SL/TP."""
symbol_info = mt5.symbol_info(config.SYMBOL)
if symbol_info is None:
logger.error(f"Symbole {config.SYMBOL} introuvable.")
return None
if not symbol_info.visible:
mt5.symbol_select(config.SYMBOL, True)
tick = mt5.symbol_info_tick(config.SYMBOL)
if tick is None:
logger.error("Impossible de récupérer le tick courant.")
return None
order_type = mt5.ORDER_TYPE_BUY if action == "BUY" else mt5.ORDER_TYPE_SELL
price = tick.ask if action == "BUY" else tick.bid
# Normaliser le lot_size
lot_size = round(
max(config.LOT_MIN, min(config.LOT_MAX,
round(lot_size / config.LOT_STEP) * config.LOT_STEP
)), 2
)
request = {
"action": mt5.TRADE_ACTION_DEAL,
"symbol": config.SYMBOL,
"volume": lot_size,
"type": order_type,
"price": price,
"sl": round(sl, symbol_info.digits),
"tp": round(tp, symbol_info.digits),
"deviation": config.DEVIATION,
"magic": config.MAGIC_NUMBER,
"comment": comment,
"type_time": mt5.ORDER_TIME_GTC,
"type_filling": mt5.ORDER_FILLING_IOC,
}
result = mt5.order_send(request)
if result.retcode != mt5.TRADE_RETCODE_DONE:
logger.error(f"[ERR] Ordre échoué ({action}): retcode={result.retcode}, comment={result.comment}")
return None
logger.info(
f"[OK] Ordre exécuté | {action} {lot_size} lots @ {price:.2f} | "
f"SL={sl:.2f} TP={tp:.2f} | Ticket={result.order}"
)
return {
"ticket": result.order,
"action": action,
"lot_size": lot_size,
"price": price,
"sl": sl,
"tp": tp,
}
def close_position(self, ticket: int) -> bool:
"""Ferme une position spécifique par son ticket."""
position = mt5.positions_get(ticket=ticket)
if not position:
logger.warning(f"Position {ticket} introuvable.")
return False
pos = position[0]
tick = mt5.symbol_info_tick(config.SYMBOL)
order_type = mt5.ORDER_TYPE_SELL if pos.type == mt5.ORDER_TYPE_BUY else mt5.ORDER_TYPE_BUY
price = tick.bid if pos.type == mt5.ORDER_TYPE_BUY else tick.ask
request = {
"action": mt5.TRADE_ACTION_DEAL,
"symbol": config.SYMBOL,
"volume": pos.volume,
"type": order_type,
"position": ticket,
"price": price,
"deviation": config.DEVIATION,
"magic": config.MAGIC_NUMBER,
"comment": "AI_BOT_CLOSE",
"type_time": mt5.ORDER_TIME_GTC,
"type_filling": mt5.ORDER_FILLING_IOC,
}
result = mt5.order_send(request)
if result.retcode == mt5.TRADE_RETCODE_DONE:
logger.info(f"[OK] Position {ticket} fermée @ {price:.2f}")
return True
else:
logger.error(f"[ERR] Fermeture {ticket} échouée: {result.retcode}")
return False
def close_all_positions(self) -> int:
"""Ferme toutes les positions ouvertes. Retourne le nombre de fermetures."""
positions = self.get_open_positions()
closed = 0
for pos in positions:
if self.close_position(pos["ticket"]):
closed += 1
logger.info(f"close_all_positions : {closed} position(s) fermee(s).")
return closed
def get_daily_pnl(self, start_balance: float) -> Tuple[float, float]:
"""Retourne (PnL absolu, PnL en %) depuis le début de journée."""
stats = self.get_account_stats()
equity = stats.get("equity", start_balance)
pnl_abs = equity - start_balance
pnl_pct = pnl_abs / start_balance if start_balance > 0 else 0.0
return pnl_abs, pnl_pct

295
news_sentiment.py Normal file
View File

@ -0,0 +1,295 @@
# ============================================================
# news_sentiment.py — Module News & Analyse de Sentiment
# ============================================================
import feedparser
import requests
import threading
import time
import logging
import re
from datetime import datetime, timedelta
from typing import List, Dict, Optional, Tuple
from bs4 import BeautifulSoup
from textblob import TextBlob
import numpy as np
import config
logger = logging.getLogger(__name__)
class NewsItem:
"""Représente un article de news parsé."""
def __init__(self, title: str, summary: str, published: datetime, source: str):
self.title = title
self.summary = summary
self.published = published
self.source = source
self.sentiment_score: float = 0.0
self.relevance_score: float = 0.0
def __repr__(self):
return (f"[{self.source}] {self.published:%H:%M} | "
f"Sent={self.sentiment_score:+.2f} | {self.title[:60]}...")
class SentimentAnalyzer:
"""
Analyse de sentiment combinant TextBlob et règles métier pour le trading de l'Or.
Peut être upgradé vers un modèle HuggingFace FinBERT.
"""
# Mots bullish pour l'or (gold monte)
BULLISH_GOLD_WORDS = {
"crisis", "war", "conflict", "geopolitical", "inflation", "recession",
"safe haven", "uncertainty", "fear", "rate cut", "dovish", "weak dollar",
"deficit", "debt ceiling", "bank failure", "panic", "crash", "risk off",
"gold rally", "buying gold", "gold surge", "gold rises", "gold climbs",
"central bank buying", "sanctions", "negative real rates"
}
# Mots bearish pour l'or (gold baisse)
BEARISH_GOLD_WORDS = {
"rate hike", "hawkish", "strong dollar", "risk on", "recovery",
"growth", "bull market", "gold falls", "gold drops", "gold decline",
"gold sell", "profit taking", "dollar rally", "yields rise",
"tightening", "fed hike", "inflation easing", "soft landing"
}
def __init__(self):
self._try_load_finbert()
def _try_load_finbert(self):
"""Tente de charger FinBERT pour une analyse plus précise."""
self.finbert = None
self.finbert_tokenizer = None
try:
from transformers import pipeline
logger.info("Chargement de FinBERT pour l'analyse de sentiment...")
self.finbert = pipeline(
"sentiment-analysis",
model="ProsusAI/finbert",
device=-1 # CPU (évite les conflits avec DirectML)
)
logger.info("✅ FinBERT chargé avec succès.")
except Exception as e:
logger.warning(f"FinBERT non disponible ({e}), utilisation de TextBlob.")
def analyze(self, text: str) -> float:
"""
Retourne un score de sentiment entre -1.0 (très bearish) et +1.0 (très bullish).
Combine TextBlob + règles spécifiques à l'or.
"""
text_lower = text.lower()
# 1) Score TextBlob (général)
blob = TextBlob(text)
textblob_score = blob.sentiment.polarity # [-1, 1]
# 2) Score FinBERT si disponible
if self.finbert is not None:
try:
# FinBERT attend max 512 tokens
truncated = text[:512]
result = self.finbert(truncated)[0]
label = result["label"].lower()
score = result["score"]
if label == "positive":
finbert_score = score
elif label == "negative":
finbert_score = -score
else:
finbert_score = 0.0
except Exception:
finbert_score = textblob_score
else:
finbert_score = textblob_score
# 3) Score spécifique Or (règles métier)
gold_score = 0.0
for word in self.BULLISH_GOLD_WORDS:
if word in text_lower:
gold_score += 0.15
for word in self.BEARISH_GOLD_WORDS:
if word in text_lower:
gold_score -= 0.15
# Clipper le score or entre -1 et 1
gold_score = max(-1.0, min(1.0, gold_score))
# 4) Pondération finale
if self.finbert is not None:
final = 0.40 * finbert_score + 0.35 * gold_score + 0.25 * textblob_score
else:
final = 0.50 * gold_score + 0.50 * textblob_score
return max(-1.0, min(1.0, final))
def compute_relevance(self, text: str) -> float:
"""Calcule un score de pertinence [0, 1] pour l'or."""
text_lower = text.lower()
score = 0.0
for keyword in config.GOLD_KEYWORDS:
if keyword in text_lower:
score += 1.0 / len(config.GOLD_KEYWORDS)
return min(1.0, score * 3) # Amplifier pour les articles très pertinents
class NewsSentimentModule:
"""
Module complet : scraping RSS + scoring sentiment + agrégation.
Tourne dans un thread séparé pour ne pas bloquer le bot.
"""
def __init__(self):
self.analyzer = SentimentAnalyzer()
self.news_items: List[NewsItem] = []
self.current_score: float = 0.0 # Score agrégé [-1, 1]
self.last_update: Optional[datetime] = None
self._lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
# ── API Publique ───────────────────────────────────────────
def start(self):
"""Lance le thread de scraping en arrière-plan."""
self._running = True
self._thread = threading.Thread(target=self._update_loop, daemon=True)
self._thread.start()
logger.info("📰 Module News démarré (thread arrière-plan).")
def stop(self):
"""Arrête le thread."""
self._running = False
if self._thread:
self._thread.join(timeout=5)
logger.info("📰 Module News arrêté.")
def get_current_sentiment(self) -> Tuple[float, List[NewsItem]]:
"""
Retourne (score_agrégé, liste_articles_récents).
Thread-safe.
"""
with self._lock:
return self.current_score, list(self.news_items[:5])
def force_update(self):
"""Force une mise à jour immédiate (bloquant)."""
self._fetch_and_score()
# ── Thread Interne ─────────────────────────────────────────
def _update_loop(self):
"""Boucle principale du thread news."""
while self._running:
try:
self._fetch_and_score()
except Exception as e:
logger.error(f"Erreur module news : {e}")
time.sleep(config.NEWS_UPDATE_INTERVAL)
def _fetch_and_score(self):
"""Scrape tous les feeds RSS et calcule le score agrégé."""
all_items: List[NewsItem] = []
for feed_url in config.RSS_FEEDS:
items = self._parse_rss_feed(feed_url)
all_items.extend(items)
if not all_items:
logger.warning("Aucun article news récupéré.")
return
# Filtrer les articles des dernières 6 heures
cutoff = datetime.utcnow() - timedelta(hours=6)
recent = [i for i in all_items if i.published >= cutoff]
if not recent:
recent = all_items[:20] # Fallback : 20 derniers articles
# Scorer chaque article
scored_items = []
for item in recent:
full_text = f"{item.title} {item.summary}"
item.sentiment_score = self.analyzer.analyze(full_text)
item.relevance_score = self.analyzer.compute_relevance(full_text)
if item.relevance_score > 0.05: # Ignorer les articles hors-sujet
scored_items.append(item)
if not scored_items:
logger.debug("Aucun article pertinent pour l'or.")
return
# Agréger avec pondération par pertinence et récence
weighted_scores = []
weights = []
now = datetime.utcnow()
for item in scored_items:
age_hours = (now - item.published).total_seconds() / 3600
time_decay = np.exp(-age_hours / 3) # Décroissance exponentielle
weight = item.relevance_score * time_decay
weighted_scores.append(item.sentiment_score * weight)
weights.append(weight)
total_weight = sum(weights)
if total_weight > 0:
aggregated = sum(weighted_scores) / total_weight
else:
aggregated = 0.0
with self._lock:
self.news_items = sorted(scored_items, key=lambda x: x.published, reverse=True)
self.current_score = round(max(-1.0, min(1.0, aggregated)), 4)
self.last_update = datetime.utcnow()
logger.info(
f"📰 News mise à jour | {len(scored_items)} articles pertinents | "
f"Score sentiment: {self.current_score:+.3f}"
)
def _parse_rss_feed(self, url: str) -> List[NewsItem]:
"""Parse un feed RSS et retourne les NewsItems."""
items = []
try:
headers = {"User-Agent": "Mozilla/5.0 (compatible; GoldBot/1.0)"}
response = requests.get(url, headers=headers, timeout=10)
feed = feedparser.parse(response.content)
for entry in feed.entries[:30]: # Max 30 par feed
title = entry.get("title", "")
summary = entry.get("summary", entry.get("description", ""))
# Nettoyer le HTML
summary = BeautifulSoup(summary, "html.parser").get_text()
# Parser la date
published = datetime.utcnow()
if hasattr(entry, "published_parsed") and entry.published_parsed:
try:
import calendar
published = datetime(*entry.published_parsed[:6])
except Exception:
pass
source = feed.feed.get("title", url.split("/")[2])
items.append(NewsItem(title, summary[:500], published, source))
except Exception as e:
logger.debug(f"Erreur RSS {url}: {e}")
return items
def get_summary_string(self) -> str:
"""Retourne une description textuelle du sentiment."""
score = self.current_score
if score > 0.5:
return f"🟢 TRÈS BULLISH ({score:+.2f})"
elif score > 0.2:
return f"🟡 BULLISH ({score:+.2f})"
elif score > -0.2:
return f"⚪ NEUTRE ({score:+.2f})"
elif score > -0.5:
return f"🟠 BEARISH ({score:+.2f})"
else:
return f"🔴 TRÈS BEARISH ({score:+.2f})"

419
ppo_agent.py Normal file
View File

@ -0,0 +1,419 @@
# ============================================================
# ppo_agent.py — Agent PPO avec support AMD DirectML
# ============================================================
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
import os
import logging
from typing import List, Tuple, Optional, Dict
import config
logger = logging.getLogger(__name__)
# ── Device Setup (AMD RX 6700 XT via DirectML) ────────────────
def get_device(force_cpu: bool = False) -> torch.device:
"""
Retourne le device de calcul optimal.
force_cpu=True : utilisé pendant l'entraînement (DirectML ne supporte
pas toutes les opérations backward de PPO).
force_cpu=False : utilisé en inférence live (DirectML OK).
"""
if force_cpu:
logger.info("Device : CPU (mode entrainement - DirectML incompatible avec PPO backward)")
return torch.device("cpu")
if config.USE_DIRECTML:
try:
import torch_directml
device = torch_directml.device()
logger.info(f"AMD DirectML active : {device}")
return device
except ImportError:
logger.warning("torch-directml non trouve. Fallback CPU.")
except Exception as e:
logger.warning(f"DirectML indisponible ({e}). Fallback CPU.")
if torch.cuda.is_available():
logger.info("CUDA disponible - GPU NVIDIA.")
return torch.device("cuda")
logger.info("Device : CPU")
return torch.device("cpu")
# ── Réseau Actor-Critic ────────────────────────────────────────
class ActorCriticNetwork(nn.Module):
"""
Réseau de neurones partagé Actor-Critic pour PPO.
Architecture :
Input LSTM Couches Fully-Connected [Actor Head | Critic Head]
- Actor Head : distribution de probabilité sur les 4 actions
- Critic Head : estimation de la valeur (V(s))
"""
def __init__(self, obs_size: int, n_actions: int = 4):
super().__init__()
hidden = config.HIDDEN_SIZE
# ── Couche d'embedding ─────────────────────────────────
self.embedding = nn.Sequential(
nn.Linear(obs_size, hidden),
nn.LayerNorm(hidden),
nn.LeakyReLU(0.01),
nn.Dropout(config.DROPOUT),
)
# ── Couches communes (trunk) ───────────────────────────
self.trunk = nn.Sequential(
nn.Linear(hidden, hidden),
nn.LayerNorm(hidden),
nn.LeakyReLU(0.01),
nn.Dropout(config.DROPOUT),
nn.Linear(hidden, hidden // 2),
nn.LayerNorm(hidden // 2),
nn.LeakyReLU(0.01),
)
# ── Actor Head ─────────────────────────────────────────
self.actor_head = nn.Sequential(
nn.Linear(hidden // 2, hidden // 4),
nn.LeakyReLU(0.01),
nn.Linear(hidden // 4, n_actions),
)
# ── Critic Head ────────────────────────────────────────
self.critic_head = nn.Sequential(
nn.Linear(hidden // 2, hidden // 4),
nn.LeakyReLU(0.01),
nn.Linear(hidden // 4, 1),
)
# Initialisation orthogonale (recommandée pour PPO)
self._init_weights()
def _init_weights(self):
for module in self.modules():
if isinstance(module, nn.Linear):
nn.init.orthogonal_(module.weight, gain=np.sqrt(2))
nn.init.constant_(module.bias, 0.0)
def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
"""Retourne (logits_actions, valeur_état)."""
emb = self.embedding(x)
trunk = self.trunk(emb)
logits = self.actor_head(trunk)
value = self.critic_head(trunk).squeeze(-1)
return logits, value
def get_action_and_value(
self, x: torch.Tensor, action: Optional[torch.Tensor] = None
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
"""
Retourne (action, log_prob, entropy, value).
Si action=None, échantillonne une nouvelle action.
"""
logits, value = self.forward(x)
dist = torch.distributions.Categorical(logits=logits)
if action is None:
action = dist.sample()
log_prob = dist.log_prob(action)
entropy = dist.entropy()
return action, log_prob, entropy, value
# ── Rollout Buffer ─────────────────────────────────────────────
class RolloutBuffer:
"""Stocke les expériences collectées avant la mise à jour PPO."""
def __init__(self, rollout_steps: int, obs_size: int, device):
self.rollout_steps = rollout_steps
self.obs_size = obs_size
self.device = device
self.reset()
def reset(self):
self.observations = np.zeros((self.rollout_steps, self.obs_size), dtype=np.float32)
self.actions = np.zeros(self.rollout_steps, dtype=np.int64)
self.log_probs = np.zeros(self.rollout_steps, dtype=np.float32)
self.rewards = np.zeros(self.rollout_steps, dtype=np.float32)
self.values = np.zeros(self.rollout_steps, dtype=np.float32)
self.dones = np.zeros(self.rollout_steps, dtype=np.float32)
self.ptr = 0
def store(
self,
obs: np.ndarray,
action: int,
log_prob: float,
reward: float,
value: float,
done: bool
):
self.observations[self.ptr] = obs
self.actions[self.ptr] = action
self.log_probs[self.ptr] = log_prob
self.rewards[self.ptr] = reward
self.values[self.ptr] = value
self.dones[self.ptr] = float(done)
self.ptr += 1
def is_full(self) -> bool:
return self.ptr >= self.rollout_steps
def compute_returns_and_advantages(
self,
last_value: float,
gamma: float = config.PPO_GAMMA,
gae_lambda: float = config.PPO_GAE_LAMBDA
) -> Tuple[np.ndarray, np.ndarray]:
"""Calcule les returns et advantages via GAE (Generalized Advantage Estimation)."""
advantages = np.zeros_like(self.rewards)
last_gae = 0.0
for t in reversed(range(self.rollout_steps)):
if t == self.rollout_steps - 1:
next_val = last_value
next_done = 0.0
else:
next_val = self.values[t + 1]
next_done = self.dones[t + 1]
delta = self.rewards[t] + gamma * next_val * (1 - next_done) - self.values[t]
last_gae = delta + gamma * gae_lambda * (1 - next_done) * last_gae
advantages[t] = last_gae
returns = advantages + self.values
return returns, advantages
def get_batches(
self,
returns: np.ndarray,
advantages: np.ndarray,
batch_size: int = config.PPO_BATCH_SIZE
):
"""Génère des mini-batches aléatoires."""
indices = np.random.permutation(self.rollout_steps)
for start in range(0, self.rollout_steps, batch_size):
batch_idx = indices[start:start + batch_size]
yield (
torch.FloatTensor(self.observations[batch_idx]).to(self.device),
torch.LongTensor(self.actions[batch_idx]).to(self.device),
torch.FloatTensor(self.log_probs[batch_idx]).to(self.device),
torch.FloatTensor(returns[batch_idx]).to(self.device),
torch.FloatTensor(advantages[batch_idx]).to(self.device),
)
# ── Agent PPO ─────────────────────────────────────────────────
class PPOAgent:
"""
Agent Proximal Policy Optimization (PPO) complet.
- Entrainement : CPU (DirectML ne supporte pas tous les ops backward)
- Inference live : AMD DirectML (RX 6700 XT)
"""
def __init__(self, obs_size: int, n_actions: int = 4, training_mode: bool = False):
self.device = get_device(force_cpu=training_mode)
self.network = ActorCriticNetwork(obs_size, n_actions).to(self.device)
self.optimizer = optim.Adam(
self.network.parameters(),
lr=config.PPO_LR,
eps=1e-5
)
self.buffer = RolloutBuffer(config.PPO_ROLLOUT_STEPS, obs_size, self.device)
self.n_actions = n_actions
# Métriques d'entraînement
self.total_steps = 0
self.training_history = {
"policy_loss": [], "value_loss": [],
"entropy": [], "kl_div": [],
"clip_frac": [],
}
logger.info(
f"🧠 Agent PPO initialisé | Device: {self.device} | "
f"Paramètres: {sum(p.numel() for p in self.network.parameters()):,}"
)
# ── Inférence ──────────────────────────────────────────────
@torch.no_grad()
def predict(self, obs: np.ndarray, deterministic: bool = False) -> Tuple[int, float, float]:
"""
Prédit une action à partir d'une observation.
Retourne (action, log_prob, value).
"""
obs_t = torch.FloatTensor(obs).unsqueeze(0).to(self.device)
action, log_prob, _, value = self.network.get_action_and_value(obs_t)
if deterministic:
logits, value = self.network(obs_t)
action = logits.argmax(dim=-1)
dist = torch.distributions.Categorical(logits=logits)
log_prob = dist.log_prob(action)
return int(action.item()), float(log_prob.item()), float(value.item())
@torch.no_grad()
def get_value(self, obs: np.ndarray) -> float:
obs_t = torch.FloatTensor(obs).unsqueeze(0).to(self.device)
_, value = self.network(obs_t)
return float(value.item())
# ── Entraînement ───────────────────────────────────────────
def collect_rollout(self, env) -> Dict:
"""Collecte PPO_ROLLOUT_STEPS steps d'expérience."""
self.buffer.reset()
obs, _ = env.reset()
done = False
ep_rewards = []
ep_reward = 0.0
while not self.buffer.is_full():
action, log_prob, value = self.predict(obs)
next_obs, reward, terminated, truncated, info = env.step(action)
self.buffer.store(obs, action, log_prob, reward, value, terminated or truncated)
obs = next_obs
ep_reward += reward
self.total_steps += 1
if terminated or truncated:
ep_rewards.append(ep_reward)
ep_reward = 0.0
obs, _ = env.reset()
# Valeur bootstrap du dernier état
last_value = self.get_value(obs)
returns, advantages = self.buffer.compute_returns_and_advantages(last_value)
return {
"mean_ep_reward": np.mean(ep_rewards) if ep_rewards else 0.0,
"returns": returns,
"advantages": advantages,
}
def update(self, returns: np.ndarray, advantages: np.ndarray) -> Dict:
"""Met à jour le réseau de neurones avec PPO."""
# Normaliser les advantages
adv_normalized = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
total_policy_loss = 0.0
total_value_loss = 0.0
total_entropy = 0.0
total_clip_frac = 0.0
n_batches = 0
for epoch in range(config.PPO_UPDATE_EPOCHS):
for obs_b, act_b, old_log_b, ret_b, adv_b in self.buffer.get_batches(
returns, adv_normalized
):
_, new_log_prob, entropy, new_value = self.network.get_action_and_value(obs_b, act_b)
# ── Policy Loss (PPO Clip) ─────────────────────
ratio = torch.exp(new_log_prob - old_log_b)
obj_clip = torch.clamp(ratio, 1 - config.PPO_CLIP_EPS, 1 + config.PPO_CLIP_EPS) * adv_b
obj_unclip = ratio * adv_b
policy_loss = -torch.min(obj_unclip, obj_clip).mean()
# ── Value Loss ─────────────────────────────────
value_loss = 0.5 * ((new_value - ret_b) ** 2).mean()
# ── Entropie (exploration) ─────────────────────
entropy_loss = entropy.mean()
# ── Loss Totale ────────────────────────────────
loss = (
policy_loss
+ config.PPO_VALUE_COEF * value_loss
- config.PPO_ENTROPY_COEF * entropy_loss
)
self.optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(self.network.parameters(), 0.5)
self.optimizer.step()
# Métriques
clip_frac = ((ratio - 1.0).abs() > config.PPO_CLIP_EPS).float().mean().item()
total_policy_loss += policy_loss.item()
total_value_loss += value_loss.item()
total_entropy += entropy_loss.item()
total_clip_frac += clip_frac
n_batches += 1
metrics = {
"policy_loss": total_policy_loss / max(n_batches, 1),
"value_loss": total_value_loss / max(n_batches, 1),
"entropy": total_entropy / max(n_batches, 1),
"clip_frac": total_clip_frac / max(n_batches, 1),
}
for k, v in metrics.items():
self.training_history[k].append(v)
return metrics
# ── Sauvegarde / Chargement ────────────────────────────────
def save(self, path: str = config.MODEL_PATH):
"""Sauvegarde le modèle et l'état de l'optimiseur."""
os.makedirs(os.path.dirname(path), exist_ok=True)
torch.save({
"network_state": self.network.state_dict(),
"optimizer_state": self.optimizer.state_dict(),
"total_steps": self.total_steps,
"history": self.training_history,
}, path)
logger.info(f"💾 Modèle sauvegardé : {path} ({self.total_steps:,} steps)")
def load(self, path: str = config.MODEL_PATH) -> bool:
"""Charge un modèle pré-entraîné."""
if not os.path.exists(path):
logger.warning(f"Aucun modèle trouvé à {path}.")
return False
try:
# Toujours charger sur CPU d'abord pour éviter les conflits de device
checkpoint = torch.load(path, map_location=torch.device("cpu"))
self.network.load_state_dict(checkpoint["network_state"])
self.network.to(self.device)
# Charger l'optimizer séparément (peut causer des erreurs de device)
try:
self.optimizer.load_state_dict(checkpoint["optimizer_state"])
except Exception as opt_e:
logger.warning(f"Optimizer non chargé ({opt_e}) — réinitialisé")
self.optimizer = torch.optim.Adam(
self.network.parameters(), lr=config.PPO_LR
)
self.total_steps = checkpoint.get("total_steps", 0)
self.training_history = checkpoint.get("history", self.training_history)
logger.info(f"Modele charge : {path} ({self.total_steps:,} steps)")
return True
except Exception as e:
logger.error(f"Erreur chargement modele : {e}")
return False
def get_action_probabilities(self, obs: np.ndarray) -> np.ndarray:
"""Retourne les probabilités pour chaque action (debug/logging)."""
with torch.no_grad():
obs_t = torch.FloatTensor(obs).unsqueeze(0).to(self.device)
logits, _ = self.network(obs_t)
probs = torch.softmax(logits, dim=-1)
return probs.cpu().numpy().flatten()
ACTION_NAMES = {0: "HOLD", 1: "BUY", 2: "SELL", 3: "CLOSE"}

45
requirements.txt Normal file
View File

@ -0,0 +1,45 @@
# ============================================================
# XAUUSD AI TRADING BOT - REQUIREMENTS
# ============================================================
# ÉTAPE 1 : Installer PyTorch CPU d'abord (base requise)
# pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
#
# ÉTAPE 2 : Installer torch-directml pour AMD RX 6700 XT
# pip install torch-directml
#
# ÉTAPE 3 : Installer le reste
# pip install -r requirements.txt
# ============================================================
# --- Connexion Marché ---
MetaTrader5>=5.0.45
# --- Deep Learning & RL ---
torch>=2.0.0
torch-directml>=0.2.0
numpy>=1.24.0
gymnasium>=0.29.0
# --- Data Science ---
pandas>=2.0.0
pandas-ta>=0.3.14b
scikit-learn>=1.3.0
scipy>=1.11.0
# --- News & Sentiment ---
textblob>=0.17.1
feedparser>=6.0.10
requests>=2.31.0
beautifulsoup4>=4.12.0
transformers>=4.35.0
torch-sentiment-analysis
# --- Visualisation Console ---
rich>=13.6.0
colorama>=0.4.6
# --- Utilitaires ---
python-dotenv>=1.0.0
schedule>=1.2.0
pytz>=2023.3
tqdm>=4.66.0

343
risk_manager.py Normal file
View File

@ -0,0 +1,343 @@
# ============================================================
# risk_manager.py — Gestion du Risque & Money Management
# ============================================================
import logging
import numpy as np
import pandas as pd
from typing import Optional, Tuple, Dict
import config
logger = logging.getLogger(__name__)
class RiskManager:
"""
Calcule les tailles de lots, SL/TP, et vérifie les règles de risque.
Tout ce qui protège le capital.
"""
def __init__(self, mt5_connector):
self.mt5 = mt5_connector
self.start_balance: float = 0.0
self.daily_high_equity: float = 0.0
self._init_day()
def _init_day(self):
"""Initialise les métriques de début de journée."""
stats = self.mt5.get_account_stats()
self.start_balance = stats.get("balance", 10000.0)
self.daily_high_equity = self.start_balance
logger.info(f"📊 Balance de départ journée : {self.start_balance:.2f}")
# ── Calculs de Lot ─────────────────────────────────────────
def calculate_lot_size(
self,
stop_loss_pips: float,
symbol_info: Dict,
equity: Optional[float] = None
) -> float:
"""
Lot sizing :
- Si lot manuel défini via dashboard utilise ce lot
- Sinon 1% du compte réel MT5
"""
lot_min = config.LOT_MIN
lot_max = config.LOT_MAX
# Lot manuel (défini via dashboard ou config)
use_manual = getattr(self, '_use_manual', False) or getattr(config, 'USE_MANUAL_LOT', False)
if use_manual:
lot = getattr(self, '_manual_lot', None) or getattr(config, 'MANUAL_LOT_SIZE', 0.05)
lot = float(lot)
lot = max(lot_min, min(lot_max, lot))
logger.info(f"Lot manuel : {lot:.2f}")
return lot
# Equity réelle MT5
if equity is None or equity <= 0:
stats = self.mt5.get_account_stats()
equity = stats.get("equity", 10000)
RISK_PCT = 0.01
risk_amount = equity * RISK_PCT
# Valeur d'un pip par lot pour XAUUSD
pip_value_per_lot = (
symbol_info.get("trade_contract_size", 100) *
symbol_info.get("point", 0.01)
)
if stop_loss_pips <= 0 or pip_value_per_lot <= 0:
logger.warning("SL ou pip_value invalide, lot minimum utilisé.")
return lot_min
lot_size = risk_amount / (stop_loss_pips * pip_value_per_lot)
# Arrondir au step du broker
vol_step = symbol_info.get("volume_step", 0.01)
lot_size = round(lot_size / vol_step) * vol_step
lot_size = max(
symbol_info.get("volume_min", lot_min),
min(symbol_info.get("volume_max", lot_max), lot_size)
)
logger.info(
f"Lot auto : {lot_size:.2f} | "
f"Capital={equity:.0f}$ | Risk=1% | "
f"Risque={risk_amount:.2f}$ | SL={stop_loss_pips:.1f} pips"
)
return lot_size
def calculate_sl_tp(
self,
action: str,
entry: float,
atr: float,
point: float,
) -> Tuple[float, float]:
"""
Calcule SL et TP basés sur l'ATR.
Lit STOP_LOSS_ATR_MULT et TAKE_PROFIT_ATR_MULT depuis config
en temps réel les changements R:R du dashboard sont immédiats.
"""
# Lecture config en temps réel
sl_mult = config.STOP_LOSS_ATR_MULT # ex: 1.5
tp_mult = config.TAKE_PROFIT_ATR_MULT # ex: 3.0 (RR 1:2)
sl_distance = atr * sl_mult
tp_distance = atr * tp_mult
if action == "BUY":
sl = entry - sl_distance
tp = entry + tp_distance
else:
sl = entry + sl_distance
tp = entry - tp_distance
rr = tp_mult / sl_mult
logger.info(
f"SL/TP | {action} @ {entry:.2f} | "
f"SL={sl:.2f} TP={tp:.2f} | "
f"ATR={atr:.2f} | RR=1:{rr:.1f}"
)
return round(sl, 2), round(tp, 2)
def sl_to_pips(self, entry: float, sl: float, point: float) -> float:
"""Convertit la distance SL en pips."""
return abs(entry - sl) / point
# ── Vérificateurs de Règles ────────────────────────────────
def check_daily_profit_target(self) -> bool:
"""
Retourne True si l'objectif de gain journalier est atteint.
Le bot doit s'arrêter.
"""
stats = self.mt5.get_account_stats()
equity = stats.get("equity", self.start_balance)
pnl_pct = (equity - self.start_balance) / self.start_balance
if pnl_pct >= config.DAILY_PROFIT_TARGET:
logger.warning(
f"🎯 Objectif journalier atteint ! "
f"+{pnl_pct*100:.2f}% ≥ {config.DAILY_PROFIT_TARGET*100:.2f}%"
)
return True
return False
def check_kill_switch(self) -> bool:
"""Obsolète — protection gérée dans live_bot.py directement."""
return False # Désactivé — live_bot utilise sa propre protection
def _check_kill_switch_legacy(self) -> bool:
"""
Retourne True si la perte journalière dépasse le seuil.
Kill switch : fermer tout et arrêter.
"""
# Garde-fou : start_balance doit être initialisé correctement
if self.start_balance <= 0:
logger.warning("Kill switch ignoré : start_balance non initialisé")
return False
stats = self.mt5.get_account_stats()
equity = stats.get("equity", self.start_balance)
pnl_pct = (equity - self.start_balance) / self.start_balance
# Ignorer si la différence est inférieure à 1$ (bruit)
if abs(equity - self.start_balance) < 1.0:
return False
if pnl_pct <= -config.DAILY_MAX_LOSS:
logger.critical(
f"KILL SWITCH ACTIVE ! "
f"{pnl_pct*100:.2f}% <= -{config.DAILY_MAX_LOSS*100:.2f}%"
)
return True
return False
def check_daily_profit_target(self) -> bool:
"""Retourne True si l objectif journalier est atteint."""
if self.start_balance <= 0:
return False
stats = self.mt5.get_account_stats()
equity = stats.get("equity", self.start_balance)
pnl_pct = (equity - self.start_balance) / self.start_balance
if pnl_pct >= config.DAILY_PROFIT_TARGET:
logger.warning(
f"Objectif journalier atteint ! "
f"+{pnl_pct*100:.2f}% >= {config.DAILY_PROFIT_TARGET*100:.2f}%"
)
return True
return False
def check_max_trades(self) -> bool:
"""Retourne True si le nombre maximum de trades simultanés est atteint."""
positions = self.mt5.get_open_positions()
if len(positions) >= config.MAX_OPEN_TRADES:
logger.debug(f"Max trades atteint ({len(positions)}/{config.MAX_OPEN_TRADES})")
return True
return False
def update_daily_high(self):
"""Met à jour le plus haut equity de la journée (pour le drawdown)."""
stats = self.mt5.get_account_stats()
equity = stats.get("equity", self.daily_high_equity)
if equity > self.daily_high_equity:
self.daily_high_equity = equity
def get_current_drawdown(self) -> float:
"""Retourne le drawdown courant depuis le plus haut de la journée."""
stats = self.mt5.get_account_stats()
equity = stats.get("equity", self.daily_high_equity)
if self.daily_high_equity > 0:
return (self.daily_high_equity - equity) / self.daily_high_equity
return 0.0
# ── Statistiques de Performance ────────────────────────────
def get_session_stats(self) -> Dict:
"""Retourne un dictionnaire de statistiques de session."""
stats = self.mt5.get_account_stats()
equity = stats.get("equity", self.start_balance)
pnl_abs = equity - self.start_balance
pnl_pct = pnl_abs / self.start_balance if self.start_balance > 0 else 0.0
drawdown = self.get_current_drawdown()
return {
"start_balance": self.start_balance,
"current_equity": equity,
"pnl_abs": round(pnl_abs, 2),
"pnl_pct": round(pnl_pct * 100, 3),
"daily_high": self.daily_high_equity,
"drawdown_pct": round(drawdown * 100, 3),
"open_trades": len(self.mt5.get_open_positions()),
"profit_target_pct": config.DAILY_PROFIT_TARGET * 100,
"kill_switch_pct": config.DAILY_MAX_LOSS * 100,
}
class FeatureEngineer:
"""
Calcule les indicateurs techniques utilisés comme features pour l'IA.
Utilise la bibliothèque `ta` (compatible Python 3.10).
"""
@staticmethod
def compute_features(df: pd.DataFrame) -> pd.DataFrame:
"""
Calcule un ensemble complet d'indicateurs techniques sur le DataFrame OHLCV.
Retourne un DataFrame enrichi pour le réseau de neurones.
"""
import ta as ta_lib
df = df.copy()
close = df["Close"]
high = df["High"]
low = df["Low"]
volume = df["Volume"]
# ── Trend ──────────────────────────────────────────────
df["ema_8"] = close.ewm(span=8, adjust=False).mean()
df["ema_21"] = close.ewm(span=21, adjust=False).mean()
df["ema_50"] = close.ewm(span=50, adjust=False).mean()
df["sma_200"] = close.rolling(200).mean()
# ── Momentum ───────────────────────────────────────────
df["rsi_14"] = ta_lib.momentum.rsi(close, window=14)
macd_ind = ta_lib.trend.MACD(close, window_fast=12, window_slow=26, window_sign=9)
df["macd"] = macd_ind.macd()
df["macd_signal"]= macd_ind.macd_signal()
df["macd_hist"] = macd_ind.macd_diff()
# ── Volatilité ─────────────────────────────────────────
df["atr_14"] = ta_lib.volatility.average_true_range(high, low, close, window=14)
bb = ta_lib.volatility.BollingerBands(close, window=20, window_dev=2)
df["bb_upper"] = bb.bollinger_hband()
df["bb_mid"] = bb.bollinger_mavg()
df["bb_lower"] = bb.bollinger_lband()
df["bb_pct"] = bb.bollinger_pband() # (close - lower) / (upper - lower)
# ── Volume ─────────────────────────────────────────────
df["volume_sma"] = volume.rolling(20).mean()
df["volume_ratio"] = volume / (df["volume_sma"] + 1e-9)
# ── Stochastique ───────────────────────────────────────
stoch = ta_lib.momentum.StochasticOscillator(high, low, close, window=14, smooth_window=3)
df["stoch_k"] = stoch.stoch()
df["stoch_d"] = stoch.stoch_signal()
# ── Retours ────────────────────────────────────────────
df["return_1"] = close.pct_change(1)
df["return_5"] = close.pct_change(5)
df["return_20"] = close.pct_change(20)
# ── Price Position ─────────────────────────────────────
df["close_vs_ema21"] = (close - df["ema_21"]) / (df["ema_21"] + 1e-9)
df["close_vs_sma200"] = (close - df["sma_200"]) / (df["sma_200"] + 1e-9)
# ── High/Low Ratio ─────────────────────────────────────
df["hl_ratio"] = (high - low) / (close + 1e-9)
df.dropna(inplace=True)
return df
@staticmethod
def get_feature_columns() -> list:
"""Retourne la liste des colonnes features techniques utilisées par l'IA."""
return [
"return_1", "return_5", "return_20",
"rsi_14",
"macd", "macd_signal", "macd_hist",
"atr_14",
"bb_pct",
"stoch_k", "stoch_d",
"volume_ratio",
"close_vs_ema21", "close_vs_sma200",
"hl_ratio",
]
@staticmethod
def get_macro_feature_size() -> int:
"""Taille du vecteur macro (DXY + taux + sessions)."""
return 18 # Voir macro_features.py get_feature_vector()
@staticmethod
def get_atr(df: pd.DataFrame) -> float:
"""Retourne l'ATR(14) de la dernière barre."""
if "atr_14" in df.columns and not df["atr_14"].empty:
return float(df["atr_14"].iloc[-1])
return float((df["High"] - df["Low"]).rolling(14).mean().iloc[-1])
@staticmethod
def normalize_features(features: np.ndarray) -> np.ndarray:
"""Normalisation Z-score par colonne (en-ligne pour le live)."""
mean = features.mean(axis=0)
std = features.std(axis=0) + 1e-8
return (features - mean) / std

397
trading_env.py Normal file
View File

@ -0,0 +1,397 @@
# ============================================================
# trading_env.py — Environnement Gymnasium REFACTORISÉ
# Version : Expert Institutionnel
# ============================================================
import gymnasium as gym
from gymnasium import spaces
import numpy as np
import pandas as pd
import logging
from typing import Optional, Tuple, Dict
from risk_manager import FeatureEngineer
import config
logger = logging.getLogger(__name__)
def hurst_exponent(ts: np.ndarray, max_lag: int = 20) -> float:
if len(ts) < max_lag + 1:
return 0.5
try:
lags = range(2, max_lag)
tau = np.array([np.std(np.subtract(ts[lag:], ts[:-lag])) for lag in lags])
tau[tau == 0] = 1e-10
poly = np.polyfit(np.log(list(lags)), np.log(tau), 1)
return float(np.clip(poly[0], 0.0, 1.0))
except Exception:
return 0.5
def detect_order_blocks(high, low, close, lookback=10):
if len(close) < lookback + 2:
return 0.0, 0.0
atr = np.mean(high[-lookback:] - low[-lookback:]) + 1e-8
current = close[-1]
bull_ob = bear_ob = current
for i in range(len(close) - lookback, len(close) - 1):
move = close[i+1] - close[i]
if move > atr * 1.5 and close[i] < close[i-1]:
bull_ob = low[i]
if move < -atr * 1.5 and close[i] > close[i-1]:
bear_ob = high[i]
dist_bull = np.clip((current - bull_ob) / atr, -3, 3)
dist_bear = np.clip((bear_ob - current) / atr, -3, 3)
return float(dist_bull), float(dist_bear)
def detect_fvg(high, low, lookback=5):
if len(high) < lookback + 3:
return 0.0
score = 0.0
for i in range(len(high) - lookback - 2, len(high) - 2):
if low[i+2] > high[i]:
score += 1.0
elif high[i+2] < low[i]:
score -= 1.0
return float(np.clip(score / lookback, -1, 1))
def find_pivot_sl(high, low, direction, lookback=20):
if len(high) < lookback:
return 0.0
h = high[-lookback:]
l = low[-lookback:]
return float(np.min(l)) if direction == 1 else float(np.max(h))
class XAUUSDTradingEnv(gym.Env):
metadata = {"render_modes": ["human"]}
HOLD = 0
BUY = 1
SELL = 2
CLOSE = 3
# Friction marché réelle
SPREAD_PIPS = 30.0
SLIPPAGE_PIPS = 5.0
COMMISSION_PIPS = 5.0
PIP_VALUE = 0.1
FRICTION = (30.0 + 5.0 + 5.0) * 0.1 # = 4.0$
# Reward
W_SORTINO = 0.3 # Léger signal régularité
W_HWM_PENALTY = 0.3 # Léger drawdown
W_CLOSE = 2.0 # Fort signal sur les clôtures
W_HOLD_PENALTY = 0.005 # 0.02→0.005 : Très faible, laisse liberté à l'IA
# Verrous risque
MAX_TRADES_PER_DAY = 3
BARS_PER_DAY = 96
BREAKEVEN_RR = 1.0
def __init__(self, df, lookback=config.LOOKBACK_BARS, sentiment_score=0.0):
super().__init__()
self.df = df.copy()
self.lookback = lookback
self.ext_sentiment = sentiment_score
self.macro_vector = np.zeros(18, dtype=np.float32)
self.fe = FeatureEngineer()
self.df_feat = self.fe.compute_features(self.df)
self.feature_cols = [c for c in self.fe.get_feature_columns() if c in self.df_feat.columns]
self.n_smc = 6
self.n_features = len(self.feature_cols) + self.n_smc + 4
self._feat_np = self.df_feat[self.feature_cols].values.astype(np.float32)
self._close_np = self.df_feat["Close"].values.astype(np.float32)
self._high_np = self.df_feat["High"].values.astype(np.float32)
self._low_np = self.df_feat["Low"].values.astype(np.float32)
if "atr_14" in self.df_feat.columns:
self._atr_np = self.df_feat["atr_14"].values.astype(np.float32)
else:
self._atr_np = np.full(len(self.df_feat), float(self.df_feat["Close"].std()), dtype=np.float32)
self._smc_np = self._precompute_smc()
obs_size = self.lookback * self.n_features + 18
self.observation_space = spaces.Box(low=-10.0, high=10.0, shape=(obs_size,), dtype=np.float32)
self.action_space = spaces.Discrete(4)
self.reset()
def _precompute_smc(self):
n = len(self._close_np)
smc = np.zeros((n, self.n_smc), dtype=np.float32)
win = min(30, self.lookback)
for i in range(win, n):
h = self._high_np[max(0, i-win):i+1]
l = self._low_np[max(0, i-win):i+1]
c = self._close_np[max(0, i-win):i+1]
atr = float(self._atr_np[i]) + 1e-8
ob_bull, ob_bear = detect_order_blocks(h, l, c, min(10, len(c)-1))
fvg = detect_fvg(h, l, min(5, max(1, len(h)-3)))
hurst = hurst_exponent(c, max_lag=min(20, len(c)//2))
if len(c) >= 50:
# EMA rapide sans pandas (alpha = 2/(span+1))
def fast_ema(arr, span):
alpha = 2.0 / (span + 1)
e = arr[0]
for x in arr[1:]:
e = alpha * x + (1 - alpha) * e
return e
ema20 = fast_ema(c, 20)
ema50 = fast_ema(c, 50)
ema_trend = float(np.clip((ema20 - ema50) / atr, -3, 3))
else:
ema_trend = 0.0
bb_squeeze = float(np.clip(np.std(c[-20:]) / atr, 0, 3)) if len(c) >= 20 else 1.0
smc[i] = [ob_bull, ob_bear, fvg, hurst - 0.5, ema_trend, bb_squeeze]
return smc
def reset(self, seed=None, options=None):
super().reset(seed=seed)
self.current_step = self.lookback + 200
self.position = 0
self.entry_price = 0.0
self.entry_step = 0
self.sl_price = 0.0
self.tp_price = 0.0
self.breakeven_done = False
self.initial_equity = 10_000.0
self.equity = self.initial_equity
self.max_equity = self.initial_equity
self.total_trades = 0
self.winning_trades = 0
self.sl_hits = 0
self.tp_hits = 0
self.episode_pnl = 0.0
self.trades_today = 0
self.day_start_step = self.current_step
self._ret_buf = np.zeros(50, dtype=np.float32) # Buffer circulaire
self._ret_idx = 0
self._ret_full = False
self.done = False
return self._get_observation(), {}
def step(self, action):
if self.done:
return self._get_observation(), 0.0, True, False, {}
prev_equity = self.equity
reward = 0.0
trade_info = ""
current_price = self._get_close_price(self.current_step)
atr = self._get_atr(self.current_step)
if self.current_step - self.day_start_step >= self.BARS_PER_DAY:
self.trades_today = 0
self.day_start_step = self.current_step
# ── Filtre de Session (22h-08h = HOLD forcé) ──────────
# Le spread XAUUSD est 3-5x plus large hors session London/NY
bar_hour = 0
if hasattr(self.df_feat.index, 'hour'):
try:
bar_hour = self.df_feat.index[min(self.current_step, len(self.df_feat)-1)].hour
except Exception:
bar_hour = 12 # fallback = heure de trading
dead_zone = (bar_hour >= 22) or (bar_hour < 8)
if dead_zone and action in (self.BUY, self.SELL):
action = self.HOLD # Force HOLD hors session
if action == self.BUY and self.position == 0:
if self.trades_today < self.MAX_TRADES_PER_DAY:
h = self._high_np[max(0, self.current_step-20):self.current_step+1]
l = self._low_np[max(0, self.current_step-20):self.current_step+1]
pivot_sl = find_pivot_sl(h, l, 1, min(20, len(h)))
sl_dist = max(current_price - pivot_sl, atr * 1.0)
tp_dist = sl_dist * config.TAKE_PROFIT_ATR_MULT / config.STOP_LOSS_ATR_MULT
self.position = 1
self.entry_price = current_price
self.entry_step = self.current_step
self.sl_price = current_price - sl_dist
self.tp_price = current_price + tp_dist
self.breakeven_done = False
self.trades_today += 1
reward -= self.FRICTION / self.initial_equity
trade_info = f"BUY@{current_price:.1f} SL={self.sl_price:.1f} TP={self.tp_price:.1f}"
elif action == self.SELL and self.position == 0:
if self.trades_today < self.MAX_TRADES_PER_DAY:
h = self._high_np[max(0, self.current_step-20):self.current_step+1]
l = self._low_np[max(0, self.current_step-20):self.current_step+1]
pivot_sl = find_pivot_sl(h, l, -1, min(20, len(h)))
sl_dist = max(pivot_sl - current_price, atr * 1.0)
tp_dist = sl_dist * config.TAKE_PROFIT_ATR_MULT / config.STOP_LOSS_ATR_MULT
self.position = -1
self.entry_price = current_price
self.entry_step = self.current_step
self.sl_price = current_price + sl_dist
self.tp_price = current_price - tp_dist
self.breakeven_done = False
self.trades_today += 1
reward -= self.FRICTION / self.initial_equity
trade_info = f"SELL@{current_price:.1f} SL={self.sl_price:.1f} TP={self.tp_price:.1f}"
elif action == self.CLOSE and self.position != 0:
pnl, r = self._close_position(current_price)
reward += r - self.FRICTION / self.initial_equity
self.equity += pnl
self.episode_pnl+= pnl
self.total_trades += 1
if pnl > 0:
self.winning_trades += 1
trade_info = f"CLOSE@{current_price:.1f} PnL={pnl:.2f}"
# Breakeven à RR 1:1
if self.position != 0 and not self.breakeven_done:
rr = self._current_rr(current_price)
if rr >= self.BREAKEVEN_RR:
self.sl_price = self.entry_price
self.breakeven_done = True
# SL / TP
if self.position != 0 and action != self.CLOSE:
hi = self._get_high_price(self.current_step)
lo = self._get_low_price(self.current_step)
sl_hit = (self.position == 1 and lo <= self.sl_price) or \
(self.position == -1 and hi >= self.sl_price)
tp_hit = (self.position == 1 and hi >= self.tp_price) or \
(self.position == -1 and lo <= self.tp_price)
if tp_hit:
pnl, r = self._close_position(self.tp_price)
reward += r - self.FRICTION / self.initial_equity
self.equity += pnl; self.episode_pnl += pnl
self.total_trades += 1; self.winning_trades += 1; self.tp_hits += 1
trade_info = f"TP@{self.tp_price:.1f} PnL={pnl:.2f}"
elif sl_hit:
pnl, r = self._close_position(self.sl_price)
reward += r - self.FRICTION / self.initial_equity
self.equity += pnl; self.episode_pnl += pnl
self.total_trades += 1; self.sl_hits += 1
trade_info = f"SL@{self.sl_price:.1f} PnL={pnl:.2f}"
# Reward ajustée au risque
step_return = (self.equity - prev_equity) / self.initial_equity
self._ret_buf[self._ret_idx] = step_return
self._ret_idx = (self._ret_idx + 1) % 50
if self._ret_idx == 0:
self._ret_full = True
if self.position != 0:
reward += self.W_SORTINO * self._sortino_step()
unr = self._compute_unrealized_pnl(current_price)
reward += float(np.clip(unr / self.initial_equity * 10, -1, 1))
else:
reward -= self.W_HOLD_PENALTY
# Pénalité HWM drawdown exponentielle
if self.equity > self.max_equity:
self.max_equity = self.equity
dd = (self.max_equity - self.equity) / (self.max_equity + 1e-8)
if dd > 0.05:
reward -= self.W_HWM_PENALTY * dd * 5 # linéaire = 10x plus rapide que exp
self.current_step += 1
max_step = len(self.df_feat) - 1
terminated = self.current_step >= max_step
self.done = terminated
if terminated and self.position != 0:
fp = self._get_close_price(min(self.current_step, max_step))
pnl, _ = self._close_position(fp)
self.equity += pnl
info = {
"equity": self.equity, "position": self.position,
"total_trades": self.total_trades,
"win_rate": self.winning_trades / max(1, self.total_trades),
"episode_pnl": self.episode_pnl,
"sl_hits": self.sl_hits, "tp_hits": self.tp_hits,
"trades_today": self.trades_today, "trade_info": trade_info,
}
# Clipping global de la reward (évite gradient explosion)
reward = float(np.clip(reward, -10.0, 10.0))
return self._get_observation(), reward, terminated, False, info
def _get_observation(self):
end = self.current_step
start = max(0, end - self.lookback)
window = self._feat_np[start:end]
if len(window) < self.lookback:
pad = np.zeros((self.lookback - len(window), len(self.feature_cols)), dtype=np.float32)
window = np.vstack([pad, window])
mean = window.mean(axis=0); std = window.std(axis=0); std[std < 1e-8] = 1e-8
window = (window - mean) / std
smc_idx = min(max(0, end), len(self._smc_np)-1)
smc_window = np.empty((self.lookback, self.n_smc), dtype=np.float32)
smc_window[:] = self._smc_np[smc_idx]
current_price = float(self._close_np[min(end, len(self._close_np)-1)])
unr = self._compute_unrealized_pnl(current_price) / (self.initial_equity + 1e-8)
dd = (self.max_equity - self.equity) / (self.max_equity + 1e-8)
extra = np.empty((self.lookback, 4), dtype=np.float32)
extra[:, 0] = float(self.position)
extra[:, 1] = float(self.ext_sentiment)
extra[:, 2] = float(np.clip(unr, -1, 1))
extra[:, 3] = float(np.clip(-dd, -1, 0))
return np.concatenate([
np.hstack([window, smc_window, extra]).flatten(),
self.macro_vector
]).astype(np.float32)
def _sortino_step(self):
if not self._ret_full and self._ret_idx < 5:
return 0.0
r = self._ret_buf # déjà numpy, pas de conversion
mu = r.mean()
neg = r[r < 0]
if len(neg) == 0:
return float(np.clip(mu * 100, 0, 1))
return float(np.clip(mu / (np.std(neg) + 1e-8), -1, 1))
def _current_rr(self, price):
if self.position == 0 or self.entry_price == 0:
return 0.0
sl_dist = abs(self.entry_price - self.sl_price) + 1e-8
return self.position * (price - self.entry_price) / sl_dist
def _get_close_price(self, step): return float(self._close_np[min(step, len(self._close_np)-1)])
def _get_high_price(self, step): return float(self._high_np[min(step, len(self._high_np)-1)])
def _get_low_price(self, step): return float(self._low_np[min(step, len(self._low_np)-1)])
def _get_atr(self, step): return float(self._atr_np[min(step, len(self._atr_np)-1)])
def _compute_unrealized_pnl(self, current_price):
if self.position == 0: return 0.0
return self.position * (current_price - self.entry_price) * self.PIP_VALUE
def _close_position(self, close_price):
pnl = self._compute_unrealized_pnl(close_price)
pnl_norm = pnl / self.initial_equity
reward = self.W_CLOSE * (1.0 + np.clip(pnl_norm * 50, 0, 2.0)) if pnl > 0 \
else -self.W_CLOSE * (1.0 + np.clip(abs(pnl_norm) * 50, 0, 2.0))
self.position = 0; self.entry_price = 0.0; self.breakeven_done = False
return pnl, float(reward)
def update_sentiment(self, score):
self.ext_sentiment = float(np.clip(score, -1, 1))
def update_macro(self, macro_vector):
if macro_vector is not None and len(macro_vector) == 18:
self.macro_vector = macro_vector.astype(np.float32)
def render(self):
pos = {1: "LONG", -1: "SHORT", 0: "FLAT"}[self.position]
print(f"Step={self.current_step} | {pos} | Equity={self.equity:.2f} | PnL={self.episode_pnl:.2f} | Trades={self.total_trades} ({self.trades_today}/day)")

316
train.py Normal file
View File

@ -0,0 +1,316 @@
# ============================================================
# train.py — Script d'Entraînement du Bot XAUUSD
# ============================================================
# Utilisation :
# python train.py
# python train.py --resume (reprend depuis le dernier checkpoint)
# python train.py --steps 500000 (nombre de steps custom)
# ============================================================
import sys
import os
import argparse
import logging
import time
import numpy as np
from datetime import datetime
from tqdm import tqdm
# Ajouter le répertoire courant au path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import torch
import multiprocessing
# ── Optimisation CPU ───────────────────────────────────────
n_cores = multiprocessing.cpu_count()
torch.set_num_threads(max(1, n_cores // 2))
torch.set_num_interop_threads(max(1, n_cores // 4))
import config
from mt5_connector import MT5Connector
from trading_env import XAUUSDTradingEnv
from ppo_agent import PPOAgent
from macro_features import MacroFeaturesModule
# ── Logging ────────────────────────────────────────────────────
os.makedirs("logs", exist_ok=True)
os.makedirs(config.CHECKPOINT_DIR, exist_ok=True)
os.makedirs("models", exist_ok=True)
import io, sys as _sys
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
handlers=[
logging.StreamHandler(stream=io.TextIOWrapper(
_sys.stdout.buffer, encoding="utf-8", errors="replace"
)),
logging.FileHandler("logs/training.log", encoding="utf-8"),
]
)
logger = logging.getLogger("TRAIN")
def evaluate_agent(agent: PPOAgent, env: XAUUSDTradingEnv, n_episodes: int = 5) -> dict:
"""Évalue l'agent sur N épisodes sans exploration."""
total_rewards = []
total_pnls = []
win_rates = []
for _ in range(n_episodes):
obs, _ = env.reset()
done = False
ep_reward = 0.0
while not done:
action, _, _ = agent.predict(obs, deterministic=True)
obs, reward, terminated, truncated, info = env.step(action)
ep_reward += reward
done = terminated or truncated
total_rewards.append(ep_reward)
total_pnls.append(info.get("episode_pnl", 0))
win_rates.append(info.get("win_rate", 0))
return {
"mean_reward": np.mean(total_rewards),
"std_reward": np.std(total_rewards),
"mean_pnl": np.mean(total_pnls),
"mean_winrate": np.mean(win_rates),
}
def train(total_steps: int = config.TOTAL_TRAIN_STEPS, resume: bool = False):
"""Boucle d'entraînement principale."""
print("=" * 70)
print(" 🤖 XAUUSD AI BOT — ENTRAÎNEMENT PPO")
print("=" * 70)
# ── 1. Chargement des données MT5 RÉELLES (priorité absolue) ──
# Le CSV synthétique est désactivé — trop différent du vrai marché
df = None
import pandas as pd
logger.info("Connexion a MetaTrader5 pour donnees historiques reelles...")
mt5 = MT5Connector()
if not mt5.connect():
logger.error("Impossible de se connecter a MT5. Lance MT5 + Algo Trading vert.")
sys.exit(1)
# Télécharger max de barres M15 disponibles (~2 ans = 70 000 barres)
df = mt5.get_historical_data(
symbol = config.SYMBOL,
timeframe= config.TIMEFRAME,
years = config.TRAINING_YEARS
)
mt5.disconnect()
# Fallback : essayer avec moins d'années si le broker limite
if df is None or len(df) < 5000:
logger.warning("Peu de donnees — tentative sur 1 an...")
mt5_2 = MT5Connector()
mt5_2.connect()
df = mt5_2.get_historical_data(
symbol = config.SYMBOL,
timeframe= config.TIMEFRAME,
years = 1
)
mt5_2.disconnect()
if df is not None:
logger.info(f"[OK] {len(df)} barres M15 reelles chargees ({df.index[0]} -> {df.index[-1]})")
else:
logger.error("Aucune donnee MT5 disponible.")
sys.exit(1)
if df is None or len(df) < 1000:
logger.error("Donnees insuffisantes pour l entrainement.")
sys.exit(1)
logger.info(f"[OK] {len(df)} barres de donnees XAUUSD chargees pour entrainement.")
# ── 2. Split Train / Validation ────────────────────────────
split_idx = int(len(df) * 0.85)
df_train = df.iloc[:split_idx].copy()
df_val = df.iloc[split_idx:].copy()
logger.info(
f"📊 Train: {len(df_train)} barres | "
f"Val: {len(df_val)} barres"
)
# ── 3. Environnements ──────────────────────────────────────
macro_mod = MacroFeaturesModule()
macro_mod.start()
macro_vector = macro_mod.get_feature_vector()
logger.info(f"MacroFeatures actives : {len(macro_vector)} features")
env_train = XAUUSDTradingEnv(df_train, lookback=config.LOOKBACK_BARS)
env_train.update_macro(macro_vector)
env_val = XAUUSDTradingEnv(df_val, lookback=config.LOOKBACK_BARS)
env_val.update_macro(macro_vector)
obs_size = env_train.observation_space.shape[0]
logger.info(f"Taille observation : {obs_size}")
# ── 4. Agent ───────────────────────────────────────────────
agent = PPOAgent(obs_size=obs_size, n_actions=4, training_mode=True)
if resume:
if agent.load(config.MODEL_PATH):
logger.info(f"▶️ Reprise depuis {agent.total_steps:,} steps.")
else:
logger.info("Démarrage d'un nouvel entraînement.")
# ── 5. Boucle d'entraînement ───────────────────────────────
best_eval_reward = -float("inf")
steps_done = agent.total_steps
update_count = 0
start_time = time.time()
# Early Stopping — basé sur la variance de la reward
eval_rewards_history = []
early_stop_patience = 20 # Nombre d'évaluations sans amélioration
early_stop_counter = 0
early_stop_min_delta = 0.5 # Amélioration minimale requise
pbar = tqdm(
total=total_steps,
initial=steps_done,
desc="Entrainement PPO",
unit="step",
ncols=100
)
logger.info(f"Demarrage entrainement — Objectif: {total_steps:,} steps | Early stop patience={early_stop_patience}")
while steps_done < total_steps:
rollout_info = agent.collect_rollout(env_train)
metrics = agent.update(rollout_info["returns"], rollout_info["advantages"])
steps_done = agent.total_steps
update_count += 1
pbar.update(config.PPO_ROLLOUT_STEPS)
pbar.set_postfix({
"rew": f"{rollout_info['mean_ep_reward']:.2f}",
"p_loss": f"{metrics['policy_loss']:.4f}",
"entropy": f"{metrics['entropy']:.3f}",
})
# Logging périodique
if update_count % 10 == 0:
elapsed = time.time() - start_time
fps = steps_done / elapsed
logger.info(
f"Update #{update_count:4d} | Steps: {steps_done:7,} | "
f"FPS: {fps:.0f} | "
f"Loss: {metrics['policy_loss']:.4f} | "
f"Entropy: {metrics['entropy']:.3f} | "
f"ClipFrac: {metrics['clip_frac']:.3f}"
)
# Évaluation + Early Stopping
if update_count % 50 == 0:
eval_metrics = evaluate_agent(agent, env_val)
eval_rewards_history.append(eval_metrics["mean_reward"])
# Variance de la reward (détecte l'overfitting = variance qui monte)
reward_variance = float(np.std(eval_rewards_history[-10:])) if len(eval_rewards_history) >= 10 else 0.0
logger.info(
f"EVAL | Steps={steps_done:,} | "
f"Reward={eval_metrics['mean_reward']:.2f}±{eval_metrics['std_reward']:.2f} | "
f"PnL={eval_metrics['mean_pnl']:.2f} | "
f"WR={eval_metrics['mean_winrate']*100:.1f}% | "
f"Variance={reward_variance:.3f}"
)
# Meilleur modèle
if eval_metrics["mean_reward"] > best_eval_reward + early_stop_min_delta:
best_eval_reward = eval_metrics["mean_reward"]
early_stop_counter = 0
agent.save(config.MODEL_PATH)
logger.info(f"Nouveau meilleur modele ! Reward={best_eval_reward:.2f}")
else:
early_stop_counter += 1
logger.info(f"Pas d'amelioration ({early_stop_counter}/{early_stop_patience})")
# Early stopping si variance trop haute (overfitting) ou stagnation
if early_stop_counter >= early_stop_patience:
logger.info(f"EARLY STOPPING a {steps_done:,} steps — stagnation detectee")
break
if len(eval_rewards_history) >= 10 and reward_variance > 500:
logger.info(f"EARLY STOPPING — variance trop haute ({reward_variance:.1f}) = overfitting")
break
# Checkpoint régulier
if steps_done % config.SAVE_EVERY_STEPS < config.PPO_ROLLOUT_STEPS:
ckpt_path = os.path.join(
config.CHECKPOINT_DIR,
f"ppo_xauusd_{steps_done:08d}.pt"
)
agent.save(ckpt_path)
pbar.close()
# ── 6. Sauvegarde Finale ───────────────────────────────────
agent.save(config.MODEL_PATH)
# ── 7. Rapport Final + Out-of-Sample ─────────────────────
elapsed = time.time() - start_time
eval_train = evaluate_agent(agent, env_train, n_episodes=5)
eval_val = evaluate_agent(agent, env_val, n_episodes=10)
# Profit Factor out-of-sample
pf_ratio = "N/A"
try:
wins = eval_val["mean_pnl"] * eval_val["mean_winrate"]
loss = abs(eval_val["mean_pnl"]) * (1 - eval_val["mean_winrate"])
pf = wins / (loss + 1e-8)
pf_ratio = f"{pf:.2f}"
except Exception:
pass
overfitting_gap = eval_train["mean_reward"] - eval_val["mean_reward"]
print("\n" + "=" * 70)
print(" RAPPORT D ENTRAINEMENT FINAL")
print("=" * 70)
print(f" Steps total : {steps_done:,}")
print(f" Duree : {elapsed/3600:.1f}h")
print(f" FPS moyen : {steps_done/elapsed:.0f}")
print(f" Early stop count : {early_stop_counter}/{early_stop_patience}")
print(f"")
print(f" -- IN-SAMPLE (train) --")
print(f" Reward moyen : {eval_train['mean_reward']:.3f}")
print(f" Win Rate : {eval_train['mean_winrate']*100:.1f}%")
print(f"")
print(f" -- OUT-OF-SAMPLE (val, donnees non vues) --")
print(f" Reward moyen : {eval_val['mean_reward']:.3f}")
print(f" PnL moyen : {eval_val['mean_pnl']:.2f}")
print(f" Win Rate : {eval_val['mean_winrate']*100:.1f}%")
print(f" Profit Factor : {pf_ratio}")
print(f"")
print(f" Overfitting gap : {overfitting_gap:.2f} (< 5 = bon)")
print(f" Modele final : {config.MODEL_PATH}")
print("=" * 70)
if overfitting_gap > 10:
print(" ATTENTION : Overfitting detecte (gap train/val > 10)")
elif eval_val["mean_winrate"] > 0.45:
print(" Modele pret pour le live trading !")
else:
print(" Continuer l entrainement ou ajuster la reward")
print("\nEntrainement termine ! Lance : python live_bot.py")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Entraînement PPO XAUUSD")
parser.add_argument("--steps", type=int, default=config.TOTAL_TRAIN_STEPS)
parser.add_argument("--resume", action="store_true", help="Reprendre depuis le dernier checkpoint")
args = parser.parse_args()
train(total_steps=args.steps, resume=args.resume)

1564
web_dashboard.py Normal file

File diff suppressed because it is too large Load Diff