xauusd_bot/mt5_connector.py

329 lines
12 KiB
Python

# ============================================================
# 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