329 lines
12 KiB
Python
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 |