commit 627ec0e766c0a9538ba0a43f59476493be085a1e Author: YouDgn Date: Mon May 11 19:43:11 2026 +0200 Initial commit: XAUUSD AI Trading Bot with proper gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c9c99a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/config.py b/config.py new file mode 100644 index 0000000..aad03d7 --- /dev/null +++ b/config.py @@ -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 \ No newline at end of file diff --git a/csv_data_loader.py b/csv_data_loader.py new file mode 100644 index 0000000..c7eeaf4 --- /dev/null +++ b/csv_data_loader.py @@ -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()) \ No newline at end of file diff --git a/dashboard.py b/dashboard.py new file mode 100644 index 0000000..f0da512 --- /dev/null +++ b/dashboard.py @@ -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 \ No newline at end of file diff --git a/diagnose_model.py b/diagnose_model.py new file mode 100644 index 0000000..1991181 --- /dev/null +++ b/diagnose_model.py @@ -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() diff --git a/evaluate_model.py b/evaluate_model.py new file mode 100644 index 0000000..ae797db --- /dev/null +++ b/evaluate_model.py @@ -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() diff --git a/live_bot.py b/live_bot.py new file mode 100644 index 0000000..3b4b722 --- /dev/null +++ b/live_bot.py @@ -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.") \ No newline at end of file diff --git a/macro_features.py b/macro_features.py new file mode 100644 index 0000000..c7b37e8 --- /dev/null +++ b/macro_features.py @@ -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()}" + ) \ No newline at end of file diff --git a/mt5_connector.py b/mt5_connector.py new file mode 100644 index 0000000..df6af23 --- /dev/null +++ b/mt5_connector.py @@ -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 \ No newline at end of file diff --git a/news_sentiment.py b/news_sentiment.py new file mode 100644 index 0000000..a01f4b3 --- /dev/null +++ b/news_sentiment.py @@ -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})" \ No newline at end of file diff --git a/ppo_agent.py b/ppo_agent.py new file mode 100644 index 0000000..2209c8b --- /dev/null +++ b/ppo_agent.py @@ -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"} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9a7beee --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/risk_manager.py b/risk_manager.py new file mode 100644 index 0000000..d04ab69 --- /dev/null +++ b/risk_manager.py @@ -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 \ No newline at end of file diff --git a/trading_env.py b/trading_env.py new file mode 100644 index 0000000..a9281cf --- /dev/null +++ b/trading_env.py @@ -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)") \ No newline at end of file diff --git a/train.py b/train.py new file mode 100644 index 0000000..700118f --- /dev/null +++ b/train.py @@ -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) \ No newline at end of file diff --git a/web_dashboard.py b/web_dashboard.py new file mode 100644 index 0000000..74cfb82 --- /dev/null +++ b/web_dashboard.py @@ -0,0 +1,1564 @@ +# ============================================================ +# web_dashboard.py — Dashboard Web Temps Réel +# ============================================================ +# Serveur FastAPI + WebSocket → s'ouvre automatiquement dans +# le navigateur. Contrôle total du bot (start/stop/positions). +# ============================================================ + +import asyncio +import json +import logging +import threading +import time +import webbrowser +from datetime import datetime +from typing import Optional, Set + +logger = logging.getLogger(__name__) + +# ── HTML Frontend ──────────────────────────────────────────── + +DASHBOARD_HTML = r""" + + + + +XAUUSD AI BOT + + + + + + + +
+ +
+ + +
+
+
+ ⚙ PARAMÈTRES TRADING + +
+ +
+ + +
Ratio SL/TP — plus élevé = gains plus grands mais moins fréquents
+
+ +
+ + +
0.01 = micro lot | 0.10 = mini lot | 1.00 = lot standard
+
+ +
+ + +
Bot s'arrête si perte session > ce % du compte
+
+ + +
+
+
+ +
+
+ +
--:--:--
+
+
+ Connexion... +
+
+
+
+
+ ARRÊTÉ +
+ + + + +
+
+ + +
+
+
Balance MT5
+
0.00 $
+
+
+
PnL Journalier
+
+0.00 $
+
+
+
Drawdown
+
0.00%
+
+
+
Positions Ouvertes
+
0
+
+
+ + +
+ + +
+
XAUUSD
+
0.00
+
+
+ +
0.00
+
+
+ +
0.00
+
+
+ +
0.0 pts
+
+
+
+ + +
+
Décision IA
+
+
+ HOLD +
+ 25% +
+
+ BUY +
+ 25% +
+
+ SELL +
+ 25% +
+
+ CLOSE +
+ 25% +
+
+
+ ACTION IA + HOLD +
+
+ + +
+
Sentiment & Macro
+
+
+
+
+ BEARISHNEUTREBULLISH +
+
0.00
+ +
+
ASIE
+
LONDRES
+
NEW YORK
+
+ +
+
+ +
--
+
+
+ +
--
+
+
+
+ + +
+
Positions Ouvertes
+ + + + + + + + + + +
TicketTypeLotsEntréeSLTPP&L
Aucune position ouverte
+
+ + +
+
Courbe PnL
+ +
+ Min: 0 + Max: 0 +
+
+ + +
+
Journal des Décisions
+
+
+ +
+ + + + + +""" + + +class WebDashboardServer: + """ + Serveur FastAPI + WebSocket pour le dashboard web. + S'ouvre automatiquement dans le navigateur. + """ + + def __init__(self, port: int = 8765, bot_ref=None): + self.port = port + self.bot_ref = bot_ref # Référence vers XAUUSDBot pour start/stop + self._clients: Set = set() + self._app = None + self._thread: Optional[threading.Thread] = None + self._loop = None + + def set_bot(self, bot): + self.bot_ref = bot + + def _build_app(self): + """Construit l'application FastAPI.""" + try: + from fastapi import FastAPI, WebSocket, WebSocketDisconnect + from fastapi.responses import HTMLResponse + except ImportError: + logger.error("FastAPI non installé. pip install fastapi uvicorn") + return None + + app = FastAPI(title="XAUUSD AI Bot Dashboard") + + @app.get("/", response_class=HTMLResponse) + async def root(): + return DASHBOARD_HTML + + + @app.websocket("/ws") + async def websocket_endpoint(websocket: WebSocket): + await websocket.accept() + self._clients.add(websocket) + logger.info(f"Dashboard: nouveau client connecté ({len(self._clients)} total)") + + # Envoyer le statut actuel au nouveau client + bot = self.bot_ref + is_running = bot and getattr(bot, "_run_event", None) and bot._run_event.is_set() + await websocket.send_text(json.dumps({ + "status": "EN COURS" if is_running else "STOPPED", + "log_message": "Connecte au dashboard" if is_running else "Bot en attente — clique sur Demarrer" + })) + + try: + while True: + data = await websocket.receive_text() + await self._handle_command(json.loads(data), websocket) + except Exception: + pass + finally: + self._clients.discard(websocket) + logger.info(f"Dashboard: client déconnecté ({len(self._clients)} restants)") + + return app + + async def _handle_command(self, data: dict, websocket): + """Traite les commandes envoyées depuis le dashboard.""" + cmd = data.get("command") + bot = self.bot_ref + logger.info(f"Commande reçue : {cmd} | bot={bot is not None}") + + if cmd == "start": + is_running = bot and getattr(bot, "_run_event", None) and bot._run_event.is_set() + logger.info(f"start cmd | is_running={is_running}") + if bot and not is_running: + await self.broadcast({"log_message": "Demarrage du bot...", "status": "EN COURS"}) + t = threading.Thread(target=bot.start, daemon=True, name="BotStartThread") + t.start() + logger.info(f"Thread demarrage lance : {t.name}") + else: + await self.broadcast({"log_message": "Bot deja en cours"}) + + elif cmd == "stop": + if bot and getattr(bot, "_run_event", None) and bot._run_event.is_set(): + await self.broadcast({ + "log_message": "Arret en cours...", + "status": "STOPPING" + }) + def do_stop(): + bot.stop() + self.broadcast_sync({ + "status": "STOPPED", + "log_message": "Bot arrete. Cliquez Demarrer pour relancer." + }) + threading.Thread(target=do_stop, daemon=True).start() + else: + await self.broadcast({"status": "STOPPED", "log_message": "Bot non demarre"}) + + elif cmd == "close_all": + if bot and hasattr(bot, 'mt5'): + await self.broadcast({"log_message": "Fermeture des positions en cours..."}) + def do_close(): + n = bot.mt5.close_all_positions() + self.broadcast_sync({"log_message": f"Positions fermees : {n}"}) + threading.Thread(target=do_close, daemon=True).start() + else: + await self.broadcast({"log_message": "MT5 non connecte"}) + + elif cmd == "mt5_login": + # Credentials envoyés depuis l'écran de login + login = data.get("login", 0) + password = data.get("password", "") + server = data.get("server", "") + try: + import config as _cfg + import MetaTrader5 as _mt5 + + # Nettoyer les données reçues + login = int(login) if login else 0 + password = str(password).strip() if password else "" + server = str(server).strip() if server else "" + + if not login or not password or not server: + await websocket.send_text(json.dumps({ + "login_error": "Tous les champs sont requis" + })) + return + + # Test connexion rapide + if not _mt5.initialize(): + await websocket.send_text(json.dumps({ + "login_error": "Impossible d'initialiser MT5" + })) + return + + try: + ok = _mt5.login(login=login, password=password, server=server) + if ok: + _cfg.MT5_LOGIN = login + _cfg.MT5_PASSWORD = password + _cfg.MT5_SERVER = server + _mt5.shutdown() + await websocket.send_text(json.dumps({"login_ok": True})) + logger.info(f"Login MT5 OK — compte {login} sur {server}") + else: + error_code = _mt5.last_error() + error_msg = f"Erreur MT5: {error_code[0]} - {error_code[1]}" if error_code else "Identifiants invalides" + _mt5.shutdown() + await websocket.send_text(json.dumps({"login_error": error_msg})) + logger.warning(f"Login MT5 échoué pour {login}: {error_msg}") + except Exception as ex: + _mt5.shutdown() + await websocket.send_text(json.dumps({ + "login_error": f"Erreur connexion: {str(ex)[:60]}" + })) + except Exception as e: + logger.error(f"mt5_login error: {e}") + await websocket.send_text(json.dumps({ + "login_error": f"Erreur: {str(e)[:60]}" + })) + + elif cmd == "set_params": + # Paramètres trading (RR, Lot, Protection) + rr = float(data.get("rr", 3.0)) + lot = float(data.get("lot", 0.05)) + protection = float(data.get("protection", 5.0)) + try: + import config as _cfg + _cfg.TAKE_PROFIT_ATR_MULT = rr * _cfg.STOP_LOSS_ATR_MULT + _cfg.DAILY_MAX_LOSS = protection / 100.0 + # Lot size → stocké dans risk_manager si bot actif + if bot and bot.risk_mgr: + bot.risk_mgr._manual_lot = lot + bot.risk_mgr._use_manual = True + else: + _cfg.MANUAL_LOT_SIZE = lot + _cfg.USE_MANUAL_LOT = True + await websocket.send_text(json.dumps({ + "params_applied": True, + "rr": rr, "lot": lot, "protection": protection + })) + await self.broadcast({"log_message": f"Params: RR 1:{rr} | Lot {lot} | Protection {protection}%"}) + logger.info(f"Params mis a jour : RR=1:{rr} | Lot={lot} | Protection={protection}%") + except Exception as e: + logger.error(f"set_params erreur : {e}") + + async def broadcast(self, data: dict): + """Envoie les données à tous les clients connectés.""" + if not self._clients: + return + msg = json.dumps(data) + dead = set() + for client in self._clients.copy(): + try: + await client.send_text(msg) + except Exception: + dead.add(client) + self._clients -= dead + + def broadcast_sync(self, data: dict): + """Version synchrone fire-and-forget (depuis threads externes).""" + if not self._loop: + return + try: + # Fire and forget — ne bloque JAMAIS le thread appelant + asyncio.run_coroutine_threadsafe(self.broadcast(data), self._loop) + except Exception as e: + logger.debug(f"broadcast_sync error: {e}") + + def start(self): + """Démarre le serveur dans un thread séparé et ouvre le navigateur.""" + self._thread = threading.Thread(target=self._run_server, daemon=True) + self._thread.start() + # Ouvrir le navigateur après 1.5 secondes (laisse le serveur démarrer) + threading.Timer(1.5, self._open_browser).start() + logger.info(f"Dashboard web démarré sur http://localhost:{self.port}") + + def _run_server(self): + """Lance uvicorn dans le thread.""" + try: + import uvicorn + except ImportError: + logger.error("uvicorn non installé. pip install uvicorn") + return + + app = self._build_app() + if app is None: + return + + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + + config = uvicorn.Config( + app, + host="127.0.0.1", + port=self.port, + log_level="error", + loop="asyncio", + ) + server = uvicorn.Server(config) + self._loop.run_until_complete(server.serve()) + + def _open_browser(self): + """Ouvre le dashboard dans le navigateur par défaut.""" + webbrowser.open(f"http://localhost:{self.port}") + logger.info("Dashboard ouvert dans le navigateur.") \ No newline at end of file