758 lines
31 KiB
Python
758 lines
31 KiB
Python
"""
|
|
AHAD QUANT — MT5 Bridge (CSV File Communication)
|
|
================================================
|
|
Assure la communication temps réel entre AHAD QUANT (Python) et un EA MetaTrader 5
|
|
via des fichiers CSV partagés dans le dossier MQL5/Files/.
|
|
|
|
Flux :
|
|
1. AHAD QUANT écrit un signal dans signals.csv
|
|
2. L'EA MT5 lit signals.csv via OnTimer(), exécute l'ordre
|
|
3. L'EA écrit le résultat dans reports.csv
|
|
4. MT5Bridge détecte le changement (watchdog <50ms) et appelle le callback
|
|
5. L'EA écrit status.csv toutes les 30s (balance, equity, positions)
|
|
|
|
Usage :
|
|
from mt5_bridge import MT5Bridge
|
|
|
|
bridge = MT5Bridge(files_path="C:/Users/NOM/.../MQL5/Files")
|
|
|
|
bridge.on_report_received(lambda r: print("Rapport reçu:", r))
|
|
bridge.on_status_updated(lambda s: print("Statut compte:", s))
|
|
|
|
bridge.start()
|
|
|
|
bridge.write_signal("EURUSD", "BUY", lot=0.1, sl_pips=30, tp_pips=60, confidence=0.82)
|
|
bridge.write_signal("GBPUSD", "SELL", lot=0.05, sl_pips=25, tp_pips=50, confidence=0.75)
|
|
|
|
bridge.stop()
|
|
|
|
Dépendances :
|
|
pip install watchdog filelock
|
|
"""
|
|
|
|
import csv
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass, field, asdict
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Callable, List, Optional
|
|
|
|
try:
|
|
from watchdog.events import FileSystemEventHandler, FileModifiedEvent
|
|
from watchdog.observers import Observer
|
|
HAS_WATCHDOG = True
|
|
except ImportError:
|
|
HAS_WATCHDOG = False
|
|
print("[MT5Bridge] AVERTISSEMENT : watchdog non installé. Utilisation du polling 200ms.")
|
|
print("[MT5Bridge] Pour installer : pip install watchdog")
|
|
|
|
try:
|
|
from filelock import FileLock, Timeout as FileLockTimeout
|
|
HAS_FILELOCK = True
|
|
except ImportError:
|
|
HAS_FILELOCK = False
|
|
print("[MT5Bridge] AVERTISSEMENT : filelock non installé.")
|
|
print("[MT5Bridge] Pour installer : pip install filelock")
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ─── Structures de données ────────────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class Signal:
|
|
"""Un signal de trading à envoyer à l'EA MT5."""
|
|
pair: str # Ex: "EURUSD"
|
|
action: str # "BUY" ou "SELL"
|
|
lot_size: float # Volume en lots (ex: 0.1)
|
|
sl_pips: int # Stop loss en pips (ex: 30)
|
|
tp_pips: int # Take profit en pips (ex: 60)
|
|
confidence: float # Score de confiance du modèle (0.0 à 1.0)
|
|
signal_id: str = field(default_factory=lambda: f"SIG_{uuid.uuid4().hex[:8].upper()}")
|
|
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
|
|
|
|
@dataclass
|
|
class TradeReport:
|
|
"""Un rapport de trade renvoyé par l'EA MT5."""
|
|
signal_id: str
|
|
ticket: int # Numéro de ticket MT5
|
|
status: str # "OPEN", "CLOSED", "ERROR", "REJECTED"
|
|
open_price: float
|
|
sl: float
|
|
tp: float
|
|
open_time: str
|
|
close_price: Optional[float] = None
|
|
close_time: Optional[str] = None
|
|
profit: Optional[float] = None
|
|
comment: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class AccountStatus:
|
|
"""État du compte MT5 mis à jour toutes les 30s par l'EA."""
|
|
timestamp: str
|
|
balance: float
|
|
equity: float
|
|
margin: float
|
|
free_margin: float
|
|
open_positions: int
|
|
daily_pnl: float
|
|
|
|
|
|
# ─── Watcher de fichier (watchdog ou polling) ─────────────────────────────────
|
|
|
|
class _CSVChangeHandler(FileSystemEventHandler):
|
|
"""Déclenche un callback dès qu'un fichier CSV est modifié."""
|
|
|
|
def __init__(self, filepath: str, callback: Callable):
|
|
super().__init__()
|
|
self._filepath = os.path.abspath(filepath)
|
|
self._callback = callback
|
|
|
|
def on_modified(self, event):
|
|
if not event.is_directory and os.path.abspath(event.src_path) == self._filepath:
|
|
self._callback()
|
|
|
|
|
|
class _PollingWatcher:
|
|
"""Fallback si watchdog n'est pas installé — poll toutes les 200ms."""
|
|
|
|
def __init__(self, filepath: str, callback: Callable, interval: float = 0.2):
|
|
self._filepath = filepath
|
|
self._callback = callback
|
|
self._interval = interval
|
|
self._last_mtime: float = 0.0
|
|
self._running = False
|
|
self._thread: Optional[threading.Thread] = None
|
|
|
|
def start(self):
|
|
self._running = True
|
|
self._thread = threading.Thread(target=self._loop, daemon=True)
|
|
self._thread.start()
|
|
|
|
def stop(self):
|
|
self._running = False
|
|
|
|
def _loop(self):
|
|
while self._running:
|
|
try:
|
|
mtime = os.path.getmtime(self._filepath) if os.path.exists(self._filepath) else 0
|
|
if mtime > self._last_mtime:
|
|
self._last_mtime = mtime
|
|
if mtime > 0:
|
|
self._callback()
|
|
except OSError:
|
|
pass
|
|
time.sleep(self._interval)
|
|
|
|
|
|
# ─── Classe principale MT5Bridge ──────────────────────────────────────────────
|
|
|
|
class MT5Bridge:
|
|
"""
|
|
Pont de communication temps réel entre AHAD QUANT (Python) et un EA MetaTrader 5.
|
|
|
|
Paramètres
|
|
----------
|
|
files_path : str
|
|
Chemin absolu vers le dossier MQL5/Files/ de MetaTrader 5.
|
|
Ex Windows : "C:/Users/NOM/AppData/Roaming/MetaQuotes/Terminal/XXXXX/MQL5/Files"
|
|
|
|
signal_file : str
|
|
Nom du fichier de signaux (défaut : "signals.csv")
|
|
|
|
report_file : str
|
|
Nom du fichier de rapports (défaut : "reports.csv")
|
|
|
|
status_file : str
|
|
Nom du fichier de statut (défaut : "status.csv")
|
|
|
|
signal_timeout : int
|
|
Délai en secondes après lequel un signal non exécuté est considéré périmé (défaut : 30)
|
|
"""
|
|
|
|
# En-têtes des fichiers CSV
|
|
_SIGNAL_HEADERS = ["timestamp", "pair", "action", "lot_size", "sl_pips",
|
|
"tp_pips", "signal_id", "confidence"]
|
|
_REPORT_HEADERS = ["signal_id", "ticket", "status", "open_price", "sl", "tp",
|
|
"open_time", "close_price", "close_time", "profit", "comment"]
|
|
_STATUS_HEADERS = ["timestamp", "balance", "equity", "margin",
|
|
"free_margin", "open_positions", "daily_pnl"]
|
|
|
|
def __init__(
|
|
self,
|
|
files_path: str,
|
|
signal_file: str = "signals.csv",
|
|
report_file: str = "reports.csv",
|
|
status_file: str = "status.csv",
|
|
signal_timeout: int = 30,
|
|
max_signal_rows: int = 500,
|
|
signal_rotate_check_every: int = 25,
|
|
):
|
|
self._dir = Path(files_path)
|
|
self._signal_path = self._dir / signal_file
|
|
self._report_path = self._dir / report_file
|
|
self._status_path = self._dir / status_file
|
|
self._signal_timeout = signal_timeout
|
|
self._max_signal_rows = max_signal_rows
|
|
self._signal_rotate_check_every = signal_rotate_check_every
|
|
self._signal_write_count = 0
|
|
|
|
# Callbacks enregistrés par l'utilisateur
|
|
self._report_callbacks: List[Callable[[TradeReport], None]] = []
|
|
self._status_callbacks: List[Callable[[AccountStatus], None]] = []
|
|
|
|
# IDs de rapports déjà traités (évite les doublons)
|
|
self._seen_report_ids: set = set()
|
|
|
|
# Watchers
|
|
self._report_observer = None
|
|
self._status_observer = None
|
|
self._running = False
|
|
self._lock = threading.Lock()
|
|
|
|
# ─── API publique ──────────────────────────────────────────────────────────
|
|
|
|
def on_report_received(self, callback: Callable[[TradeReport], None]) -> None:
|
|
"""Enregistre un callback appelé à chaque nouveau rapport de trade."""
|
|
self._report_callbacks.append(callback)
|
|
|
|
def on_status_updated(self, callback: Callable[[AccountStatus], None]) -> None:
|
|
"""Enregistre un callback appelé à chaque mise à jour du statut compte."""
|
|
self._status_callbacks.append(callback)
|
|
|
|
def start(self) -> None:
|
|
"""
|
|
Démarre le bridge : initialise les fichiers CSV et lance les watchers.
|
|
À appeler une fois au démarrage du bot.
|
|
"""
|
|
self._dir.mkdir(parents=True, exist_ok=True)
|
|
self._init_signal_file()
|
|
self._init_report_file()
|
|
|
|
self._running = True
|
|
self._start_watcher(self._report_path, self._on_report_file_changed, "report")
|
|
self._start_watcher(self._status_path, self._on_status_file_changed, "status")
|
|
|
|
logger.info(f"[MT5Bridge] Démarré — dossier : {self._dir}")
|
|
logger.info(f"[MT5Bridge] signals → {self._signal_path}")
|
|
logger.info(f"[MT5Bridge] reports ← {self._report_path}")
|
|
logger.info(f"[MT5Bridge] status ← {self._status_path}")
|
|
|
|
def stop(self) -> None:
|
|
"""Arrête proprement les watchers."""
|
|
self._running = False
|
|
if self._report_observer:
|
|
try:
|
|
self._report_observer.stop()
|
|
self._report_observer.join(timeout=2)
|
|
except Exception:
|
|
pass
|
|
if self._status_observer:
|
|
try:
|
|
self._status_observer.stop()
|
|
self._status_observer.join(timeout=2)
|
|
except Exception:
|
|
pass
|
|
logger.info("[MT5Bridge] Arrêté.")
|
|
|
|
def write_signal(
|
|
self,
|
|
pair: str,
|
|
action: str,
|
|
lot: float,
|
|
sl_pips: int,
|
|
tp_pips: int,
|
|
confidence: float,
|
|
) -> Signal:
|
|
"""
|
|
Écrit un signal de trading dans signals.csv pour l'EA MT5.
|
|
|
|
Paramètres
|
|
----------
|
|
pair : Paire Forex — ex: "EURUSD"
|
|
action : "BUY" ou "SELL"
|
|
lot : Volume en lots — ex: 0.1
|
|
sl_pips : Stop loss en pips — ex: 30
|
|
tp_pips : Take profit en pips — ex: 60
|
|
confidence : Score modèle entre 0 et 1 — ex: 0.82
|
|
|
|
Retourne
|
|
--------
|
|
Signal : l'objet signal créé, avec son signal_id unique
|
|
"""
|
|
action = action.upper()
|
|
if action not in ("BUY", "SELL"):
|
|
raise ValueError(f"action doit être 'BUY' ou 'SELL', reçu : '{action}'")
|
|
if not (0.0 < lot <= 100.0):
|
|
raise ValueError(f"lot invalide : {lot}")
|
|
if sl_pips <= 0 or tp_pips <= 0:
|
|
raise ValueError(f"sl_pips et tp_pips doivent être > 0")
|
|
|
|
# Bug #25 fix : ne pas uppercaser la paire entière.
|
|
# pair.upper() convertit "EURCHF.m" → "EURCHF.M" (M majuscule) alors que
|
|
# le broker utilise le suffixe minuscule ".m" → SYMBOL_NOT_FOUND côté EA.
|
|
# La paire de base (ex: "EURCHF") est déjà en majuscules côté appelant.
|
|
sig = Signal(
|
|
pair=pair,
|
|
action=action,
|
|
lot_size=lot,
|
|
sl_pips=sl_pips,
|
|
tp_pips=tp_pips,
|
|
confidence=round(confidence, 4),
|
|
)
|
|
|
|
self._append_to_csv(self._signal_path, self._SIGNAL_HEADERS, asdict(sig))
|
|
logger.info(f"[MT5Bridge] Signal écrit : {sig.signal_id} | {sig.pair} {sig.action} "
|
|
f"lot={sig.lot_size} SL={sig.sl_pips}p TP={sig.tp_pips}p conf={sig.confidence}")
|
|
self._signal_write_count += 1
|
|
if self._signal_write_count % self._signal_rotate_check_every == 0:
|
|
self._rotate_signal_file_if_needed()
|
|
return sig
|
|
|
|
def read_reports(self) -> List[TradeReport]:
|
|
"""
|
|
Lit tous les rapports présents dans reports.csv.
|
|
Retourne uniquement les rapports nouveaux (non encore traités).
|
|
"""
|
|
return self._read_new_reports()
|
|
|
|
def read_status(self) -> Optional[AccountStatus]:
|
|
"""
|
|
Lit le dernier statut du compte depuis status.csv.
|
|
Retourne None si le fichier n'existe pas encore.
|
|
"""
|
|
return self._read_latest_status()
|
|
|
|
def get_open_signals(self) -> List[dict]:
|
|
"""
|
|
Retourne les signaux envoyés à l'EA qui n'ont pas encore reçu de rapport.
|
|
Filtre les signaux périmés (plus vieux que signal_timeout secondes).
|
|
"""
|
|
if not self._signal_path.exists():
|
|
return []
|
|
|
|
now = datetime.now(timezone.utc).timestamp()
|
|
open_sigs = []
|
|
|
|
with self._safe_read(self._signal_path) as rows:
|
|
for row in rows:
|
|
try:
|
|
ts = datetime.fromisoformat(row["timestamp"]).timestamp()
|
|
if now - ts <= self._signal_timeout:
|
|
open_sigs.append(row)
|
|
except (KeyError, ValueError):
|
|
continue
|
|
|
|
return open_sigs
|
|
|
|
# ─── Initialisation des fichiers ──────────────────────────────────────────
|
|
|
|
def _init_signal_file(self) -> None:
|
|
"""Crée signals.csv avec les en-têtes s'il n'existe pas."""
|
|
if not self._signal_path.exists():
|
|
self._write_headers(self._signal_path, self._SIGNAL_HEADERS)
|
|
logger.info(f"[MT5Bridge] signals.csv créé : {self._signal_path}")
|
|
|
|
def _init_report_file(self) -> None:
|
|
"""Crée reports.csv avec les en-têtes s'il n'existe pas."""
|
|
if not self._report_path.exists():
|
|
self._write_headers(self._report_path, self._REPORT_HEADERS)
|
|
logger.info(f"[MT5Bridge] reports.csv créé : {self._report_path}")
|
|
|
|
def _rotate_signal_file_if_needed(self) -> None:
|
|
"""
|
|
Tronque signals.csv aux _max_signal_rows lignes les plus récentes.
|
|
Les signaux non encore acquittés (absents de reports.csv) sont TOUJOURS
|
|
conservés, même s'ils sont hors de la fenêtre des dernières lignes.
|
|
Protégé par FileLock pour cohérence avec le reste du bridge.
|
|
"""
|
|
if not self._signal_path.exists():
|
|
return
|
|
try:
|
|
lock_path = str(self._signal_path) + ".lock"
|
|
ctx = FileLock(lock_path, timeout=3) if HAS_FILELOCK else None
|
|
|
|
def _do_rotate():
|
|
with open(self._signal_path, encoding="utf-8-sig", newline="") as fh:
|
|
reader = csv.DictReader(fh)
|
|
rows = list(reader)
|
|
headers = reader.fieldnames or self._SIGNAL_HEADERS
|
|
|
|
if len(rows) <= self._max_signal_rows:
|
|
return # Pas assez de lignes — rien à faire
|
|
|
|
# Récupérer les signal_ids déjà ACK dans reports.csv
|
|
ack_ids: set = set()
|
|
if self._report_path.exists():
|
|
with open(self._report_path, encoding="utf-8-sig", newline="") as rh:
|
|
for r in csv.DictReader(rh):
|
|
sid = r.get("signal_id")
|
|
if sid:
|
|
ack_ids.add(sid)
|
|
|
|
# Garder les N dernières lignes + toutes les lignes non ACK hors fenêtre
|
|
tail = rows[-self._max_signal_rows:]
|
|
tail_ids = {r.get("signal_id") for r in tail}
|
|
pending_outside = [r for r in rows[:-self._max_signal_rows]
|
|
if r.get("signal_id") not in ack_ids
|
|
and r.get("signal_id") not in tail_ids]
|
|
kept = pending_outside + tail
|
|
|
|
# Réécriture atomique via fichier temporaire
|
|
tmp = str(self._signal_path) + ".tmp"
|
|
with open(tmp, "w", encoding="utf-8", newline="") as fh:
|
|
writer = csv.DictWriter(fh, fieldnames=headers)
|
|
writer.writeheader()
|
|
writer.writerows(kept)
|
|
os.replace(tmp, self._signal_path)
|
|
logger.info(f"[MT5Bridge] signals.csv rotation : {len(rows)} → {len(kept)} lignes"
|
|
f" ({len(pending_outside)} signaux pending conservés hors fenêtre)")
|
|
|
|
if ctx:
|
|
with ctx:
|
|
_do_rotate()
|
|
else:
|
|
_do_rotate()
|
|
|
|
except Exception as exc:
|
|
logger.warning(f"[MT5Bridge] Rotation signals.csv échouée : {exc}")
|
|
|
|
# ─── Watchers ─────────────────────────────────────────────────────────────
|
|
|
|
def _start_watcher(self, filepath: Path, callback: Callable, name: str) -> None:
|
|
"""Lance un watcher watchdog ou polling selon ce qui est disponible."""
|
|
if HAS_WATCHDOG:
|
|
handler = _CSVChangeHandler(str(filepath), callback)
|
|
observer = Observer()
|
|
observer.schedule(handler, path=str(filepath.parent), recursive=False)
|
|
observer.start()
|
|
if name == "report":
|
|
self._report_observer = observer
|
|
else:
|
|
self._status_observer = observer
|
|
logger.debug(f"[MT5Bridge] Watcher watchdog démarré pour {name}")
|
|
else:
|
|
watcher = _PollingWatcher(str(filepath), callback)
|
|
watcher.start()
|
|
if name == "report":
|
|
self._report_observer = watcher
|
|
else:
|
|
self._status_observer = watcher
|
|
logger.debug(f"[MT5Bridge] Watcher polling démarré pour {name}")
|
|
|
|
# ─── Callbacks de détection de changement ─────────────────────────────────
|
|
|
|
def _on_report_file_changed(self) -> None:
|
|
"""Appelé dès que reports.csv est modifié."""
|
|
time.sleep(0.02) # Laisser l'EA finir d'écrire
|
|
new_reports = self._read_new_reports()
|
|
for report in new_reports:
|
|
logger.info(f"[MT5Bridge] Rapport reçu : {report.signal_id} | "
|
|
f"ticket={report.ticket} statut={report.status} "
|
|
f"profit={report.profit}")
|
|
for cb in self._report_callbacks:
|
|
try:
|
|
cb(report)
|
|
except Exception as e:
|
|
logger.error(f"[MT5Bridge] Erreur callback rapport : {e}")
|
|
|
|
def _on_status_file_changed(self) -> None:
|
|
"""Appelé dès que status.csv est modifié."""
|
|
time.sleep(0.02)
|
|
status = self._read_latest_status()
|
|
if status:
|
|
logger.debug(f"[MT5Bridge] Statut compte : balance={status.balance} "
|
|
f"equity={status.equity} positions={status.open_positions}")
|
|
for cb in self._status_callbacks:
|
|
try:
|
|
cb(status)
|
|
except Exception as e:
|
|
logger.error(f"[MT5Bridge] Erreur callback statut : {e}")
|
|
|
|
# ─── Lecture des fichiers ─────────────────────────────────────────────────
|
|
|
|
def _read_new_reports(self) -> List[TradeReport]:
|
|
"""Lit reports.csv et retourne uniquement les lignes non encore traitées."""
|
|
if not self._report_path.exists():
|
|
return []
|
|
|
|
new_reports = []
|
|
with self._safe_read(self._report_path) as rows:
|
|
for row in rows:
|
|
try:
|
|
# Clé unique : signal_id + statut (un OPEN et un CLOSED pour le même signal)
|
|
key = f"{row['signal_id']}_{row['status']}"
|
|
if key in self._seen_report_ids:
|
|
continue
|
|
self._seen_report_ids.add(key)
|
|
|
|
report = TradeReport(
|
|
signal_id = row["signal_id"],
|
|
ticket = int(row["ticket"]) if row.get("ticket") else 0,
|
|
status = row["status"],
|
|
open_price = float(row["open_price"]) if row.get("open_price") else 0.0,
|
|
sl = float(row["sl"]) if row.get("sl") else 0.0,
|
|
tp = float(row["tp"]) if row.get("tp") else 0.0,
|
|
open_time = row.get("open_time", ""),
|
|
close_price = float(row["close_price"]) if row.get("close_price") else None,
|
|
close_time = row.get("close_time") or None,
|
|
profit = float(row["profit"]) if row.get("profit") else None,
|
|
comment = row.get("comment") or None,
|
|
)
|
|
new_reports.append(report)
|
|
except (KeyError, ValueError, TypeError) as e:
|
|
# Marquer la ligne comme vue pour éviter le spam toutes les 200ms
|
|
bad_key = f"_BAD_{hash(str(sorted(row.items())))}"
|
|
if bad_key not in self._seen_report_ids:
|
|
self._seen_report_ids.add(bad_key)
|
|
logger.warning(f"[MT5Bridge] Ligne rapport ignorée (format invalide) : {e}")
|
|
|
|
return new_reports
|
|
|
|
def _read_latest_status(self) -> Optional[AccountStatus]:
|
|
"""Lit la dernière ligne de status.csv (la plus récente)."""
|
|
if not self._status_path.exists():
|
|
return None
|
|
|
|
with self._safe_read(self._status_path) as rows:
|
|
valid_rows = []
|
|
for row in rows:
|
|
try:
|
|
valid_rows.append(AccountStatus(
|
|
timestamp = row["timestamp"],
|
|
balance = float(row["balance"]),
|
|
equity = float(row["equity"]),
|
|
margin = float(row["margin"]),
|
|
free_margin = float(row["free_margin"]),
|
|
open_positions = int(row["open_positions"]),
|
|
daily_pnl = float(row["daily_pnl"]),
|
|
))
|
|
except (KeyError, ValueError, TypeError):
|
|
continue
|
|
|
|
return valid_rows[-1] if valid_rows else None
|
|
|
|
# ─── Utilitaires CSV ──────────────────────────────────────────────────────
|
|
|
|
def _write_headers(self, filepath: Path, headers: List[str]) -> None:
|
|
"""Crée un fichier CSV avec uniquement la ligne d'en-tête."""
|
|
lock_path = str(filepath) + ".lock"
|
|
if HAS_FILELOCK:
|
|
with FileLock(lock_path, timeout=5):
|
|
self._open_with_retry(filepath, "w", headers, row=None)
|
|
else:
|
|
self._open_with_retry(filepath, "w", headers, row=None)
|
|
|
|
def _append_to_csv(self, filepath: Path, headers: List[str], row: dict) -> None:
|
|
"""
|
|
Ajoute une ligne à un fichier CSV existant (thread-safe).
|
|
|
|
Bug #24 fix : l'EA MT5 peut maintenir un lock Windows exclusif sur
|
|
signals.csv pendant sa lecture (timer 200ms). Le open() Python reçoit
|
|
alors PermissionError — non géré par FileLock (qui gère uniquement les
|
|
conflits Python↔Python via un .lock séparé).
|
|
Solution : retry avec backoff exponentiel (max 5 tentatives, 50ms base).
|
|
"""
|
|
lock_path = str(filepath) + ".lock"
|
|
if HAS_FILELOCK:
|
|
try:
|
|
with FileLock(lock_path, timeout=5):
|
|
self._open_with_retry(filepath, "a", headers, row)
|
|
except FileLockTimeout:
|
|
logger.error(f"[MT5Bridge] Impossible d'obtenir le verrou pour {filepath.name}")
|
|
else:
|
|
with self._lock:
|
|
self._open_with_retry(filepath, "a", headers, row)
|
|
|
|
def _open_with_retry(
|
|
self,
|
|
filepath: Path,
|
|
mode: str,
|
|
headers: List[str],
|
|
row: Optional[dict],
|
|
max_retries: int = 5,
|
|
base_delay: float = 0.05,
|
|
) -> None:
|
|
"""
|
|
Ouvre un fichier CSV et écrit (header ou ligne) avec retry sur PermissionError.
|
|
L'EA MT5 peut tenir un lock exclusif Windows jusqu'à ~200ms.
|
|
On attend donc 50ms, 100ms, 200ms, 400ms, 800ms avant d'abandonner.
|
|
"""
|
|
for attempt in range(max_retries):
|
|
try:
|
|
with open(filepath, mode, newline="", encoding="utf-8") as f:
|
|
writer = csv.DictWriter(f, fieldnames=headers, extrasaction="ignore")
|
|
if row is None:
|
|
writer.writeheader()
|
|
else:
|
|
writer.writerow(row)
|
|
return # succès
|
|
except PermissionError:
|
|
if attempt < max_retries - 1:
|
|
delay = base_delay * (2 ** attempt) # 50ms, 100ms, 200ms, 400ms
|
|
logger.debug(
|
|
f"[MT5Bridge] PermissionError sur {filepath.name} "
|
|
f"(tentative {attempt + 1}/{max_retries}) — retry dans {delay*1000:.0f}ms"
|
|
)
|
|
time.sleep(delay)
|
|
else:
|
|
logger.error(
|
|
f"[MT5Bridge] PermissionError persistant sur {filepath.name} "
|
|
f"après {max_retries} tentatives — signal perdu."
|
|
)
|
|
raise
|
|
|
|
class _safe_read:
|
|
"""Context manager pour lire un CSV en toute sécurité."""
|
|
def __init__(self, filepath: Path):
|
|
self._filepath = filepath
|
|
self._lock_path = str(filepath) + ".lock"
|
|
|
|
def __enter__(self) -> List[dict]:
|
|
if HAS_FILELOCK:
|
|
self._fl = FileLock(self._lock_path, timeout=5)
|
|
self._fl.acquire()
|
|
try:
|
|
# utf-8-sig supprime le BOM (\ufeff) des CSV Windows/MetaTrader 5
|
|
# Sans ca, le 1er header devient \ufeffsignal_id -> KeyError
|
|
with open(self._filepath, "r", newline="", encoding="utf-8-sig") as f:
|
|
reader = csv.DictReader(f)
|
|
self._rows = list(reader)
|
|
except (OSError, csv.Error):
|
|
self._rows = []
|
|
return self._rows
|
|
|
|
def __exit__(self, *args):
|
|
if HAS_FILELOCK:
|
|
try:
|
|
self._fl.release()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ─── Point d'entrée de test ───────────────────────────────────────────────────
|
|
|
|
if __name__ == "__main__":
|
|
"""
|
|
Test rapide du bridge en mode simulation locale.
|
|
Lance ce script pour vérifier que le bridge fonctionne
|
|
avant de le connecter à un vrai EA MT5.
|
|
|
|
Usage :
|
|
python mt5_bridge.py
|
|
"""
|
|
import tempfile
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)-7s %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
)
|
|
|
|
print("\n" + "="*60)
|
|
print(" AHAD QUANT MT5 Bridge — Test de simulation")
|
|
print("="*60 + "\n")
|
|
|
|
# Dossier temporaire simulant MQL5/Files/
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
bridge = MT5Bridge(files_path=tmpdir, signal_timeout=10)
|
|
|
|
received_reports = []
|
|
received_statuses = []
|
|
|
|
bridge.on_report_received(lambda r: received_reports.append(r))
|
|
bridge.on_status_updated(lambda s: received_statuses.append(s))
|
|
|
|
bridge.start()
|
|
|
|
# ── Test 1 : écriture de signaux ──────────────────────────────
|
|
print("[TEST 1] Écriture de signaux...")
|
|
sig1 = bridge.write_signal("EURUSD", "BUY", lot=0.10, sl_pips=30, tp_pips=60, confidence=0.82)
|
|
sig2 = bridge.write_signal("GBPUSD", "SELL", lot=0.05, sl_pips=25, tp_pips=50, confidence=0.75)
|
|
sig3 = bridge.write_signal("USDJPY", "BUY", lot=0.02, sl_pips=40, tp_pips=80, confidence=0.91)
|
|
print(f" ✅ {sig1.signal_id} | EURUSD BUY")
|
|
print(f" ✅ {sig2.signal_id} | GBPUSD SELL")
|
|
print(f" ✅ {sig3.signal_id} | USDJPY BUY")
|
|
|
|
# ── Test 2 : simulation réponse EA (rapport OPEN) ─────────────
|
|
print("\n[TEST 2] Simulation rapport OPEN depuis l'EA...")
|
|
report_open = {
|
|
"signal_id": sig1.signal_id,
|
|
"ticket": "123456",
|
|
"status": "OPEN",
|
|
"open_price": "1.08542",
|
|
"sl": "1.08242",
|
|
"tp": "1.09142",
|
|
"open_time": datetime.now(timezone.utc).isoformat(),
|
|
"close_price": "",
|
|
"close_time": "",
|
|
"profit": "",
|
|
"comment": "Ordre exécuté",
|
|
}
|
|
# Simule l'écriture de l'EA dans reports.csv
|
|
report_path = Path(tmpdir) / "reports.csv"
|
|
with open(report_path, "a", newline="", encoding="utf-8") as f:
|
|
writer = csv.DictWriter(f, fieldnames=MT5Bridge._REPORT_HEADERS)
|
|
writer.writerow(report_open)
|
|
|
|
time.sleep(0.3) # Laisser le watcher détecter
|
|
assert len(received_reports) == 1, f"Attendu 1 rapport, reçu {len(received_reports)}"
|
|
print(f" ✅ Rapport OPEN reçu : ticket={received_reports[0].ticket}")
|
|
|
|
# ── Test 3 : simulation rapport CLOSED ────────────────────────
|
|
print("\n[TEST 3] Simulation rapport CLOSED depuis l'EA...")
|
|
report_closed = {**report_open,
|
|
"status": "CLOSED",
|
|
"close_price": "1.09140",
|
|
"close_time": datetime.now(timezone.utc).isoformat(),
|
|
"profit": "60.0",
|
|
"comment": "TP hit"}
|
|
with open(report_path, "a", newline="", encoding="utf-8") as f:
|
|
writer = csv.DictWriter(f, fieldnames=MT5Bridge._REPORT_HEADERS)
|
|
writer.writerow(report_closed)
|
|
|
|
time.sleep(0.3)
|
|
assert len(received_reports) == 2, f"Attendu 2 rapports, reçu {len(received_reports)}"
|
|
print(f" ✅ Rapport CLOSED reçu : profit={received_reports[1].profit}")
|
|
|
|
# ── Test 4 : simulation status EA ─────────────────────────────
|
|
print("\n[TEST 4] Simulation status.csv depuis l'EA...")
|
|
status_path = Path(tmpdir) / "status.csv"
|
|
with open(status_path, "w", newline="", encoding="utf-8") as f:
|
|
writer = csv.DictWriter(f, fieldnames=MT5Bridge._STATUS_HEADERS)
|
|
writer.writeheader()
|
|
writer.writerow({
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"balance": "10060.00",
|
|
"equity": "10045.50",
|
|
"margin": "120.00",
|
|
"free_margin": "9925.50",
|
|
"open_positions": "2",
|
|
"daily_pnl": "60.0",
|
|
})
|
|
|
|
time.sleep(0.3)
|
|
status = bridge.read_status()
|
|
assert status is not None
|
|
print(f" ✅ Statut reçu : balance={status.balance} equity={status.equity}")
|
|
|
|
# ── Test 5 : validation des erreurs ───────────────────────────
|
|
print("\n[TEST 5] Validation des paramètres invalides...")
|
|
try:
|
|
bridge.write_signal("EURUSD", "HOLD", lot=0.1, sl_pips=30, tp_pips=60, confidence=0.8)
|
|
print(" ❌ Aurait dû lever une ValueError")
|
|
except ValueError as e:
|
|
print(f" ✅ ValueError correctement levée : {e}")
|
|
|
|
bridge.stop()
|
|
|
|
print("\n" + "="*60)
|
|
print(" Tous les tests passés ✅")
|
|
print("="*60 + "\n")
|