864 lines
38 KiB
Python
864 lines
38 KiB
Python
# ============================================================
|
|
# 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.") |