Initial commit: XAUUSD AI Trading Bot with proper gitignore
This commit is contained in:
commit
627ec0e766
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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.")
|
||||||
|
|
@ -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()}"
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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})"
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)")
|
||||||
|
|
@ -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)
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue