""" MT5Fetcher — MetaTrader 5 data source adapter. Wraps mt5.copy_rates_range() and mt5.copy_rates_from_pos() with: - Automatic timezone conversion to UTC - Consistent lowercase column names - Graceful failure handling - Symbol alias resolution (GER40 → DE40 etc.) via server_config.SYMBOL_ALIASES """ import pandas as pd import numpy as np from datetime import datetime, timezone from typing import Optional, Dict, List from pathlib import Path from .base_fetcher import BaseFetcher import logging log = logging.getLogger("dmm.mt5_fetcher") # ── Load MT5 terminal path from local_config.ini (Rule 3 — no hardcoded paths) ── def _load_mt5_path_from_ini() -> Optional[str]: """Read MT5 terminal path from local_config.ini if it exists.""" import configparser ini = Path(__file__).resolve().parent.parent.parent / "local_config.ini" if not ini.exists(): return None cp = configparser.ConfigParser() cp.read(str(ini)) return cp.get("mt5", "terminal_path", fallback=None) DEFAULT_MT5_PATH = _load_mt5_path_from_ini() # ── Symbol alias map — imported from server_config if available ── _SYMBOL_ALIASES: Dict[str, List[str]] = {} def _load_aliases(): """Load SYMBOL_ALIASES from server_config.py (same project root).""" global _SYMBOL_ALIASES if _SYMBOL_ALIASES: return try: import sys project_root = str(Path(__file__).resolve().parent.parent.parent) if project_root not in sys.path: sys.path.insert(0, project_root) from server_config import SYMBOL_ALIASES _SYMBOL_ALIASES = SYMBOL_ALIASES except ImportError: log.info("server_config not found — symbol aliases unavailable in MT5Fetcher") # ── MT5 timeframe mapping ──────────────────────────────────────────────────── _TF_MAP = {} # Populated lazily after mt5 import def _ensure_tf_map(): """Build timeframe string → MT5 constant map. Safe to call repeatedly.""" global _TF_MAP if _TF_MAP: return try: import MetaTrader5 as mt5 _TF_MAP.update({ "M1": mt5.TIMEFRAME_M1, "M2": mt5.TIMEFRAME_M2, "M3": mt5.TIMEFRAME_M3, "M4": mt5.TIMEFRAME_M4, "M5": mt5.TIMEFRAME_M5, "M6": mt5.TIMEFRAME_M6, "M10": mt5.TIMEFRAME_M10, "M12": mt5.TIMEFRAME_M12, "M15": mt5.TIMEFRAME_M15, "M20": mt5.TIMEFRAME_M20, "M30": mt5.TIMEFRAME_M30, "H1": mt5.TIMEFRAME_H1, "H2": mt5.TIMEFRAME_H2, "H3": mt5.TIMEFRAME_H3, "H4": mt5.TIMEFRAME_H4, "H6": mt5.TIMEFRAME_H6, "H8": mt5.TIMEFRAME_H8, "H12": mt5.TIMEFRAME_H12, "D1": mt5.TIMEFRAME_D1, "W1": mt5.TIMEFRAME_W1, "MN": mt5.TIMEFRAME_MN1, }) except (ModuleNotFoundError, AttributeError): log.warning("MetaTrader5 module not available — MT5Fetcher will be non-functional") class MT5Fetcher(BaseFetcher): """ MetaTrader 5 data fetcher. Parameters ---------- terminal_path : str or None Path to terminal64.exe. If None, reads from local_config.ini. auto_init : bool If True, call mt5.initialize() on first fetch. If False, caller is responsible for ensuring MT5 is already initialized. """ name = "mt5" def __init__(self, terminal_path: Optional[str] = None, auto_init: bool = False): self.terminal_path = terminal_path or DEFAULT_MT5_PATH self.auto_init = auto_init self._initialized = False self._symbol_cache: Dict[str, str] = {} # canonical → broker symbol def _resolve_symbol(self, canonical: str) -> str: """ Resolve canonical ticker to broker symbol via alias lookup. Caches successful resolutions. Returns canonical if no alias found. """ if canonical in self._symbol_cache: return self._symbol_cache[canonical] _load_aliases() try: import MetaTrader5 as mt5 # Try canonical name first info = mt5.symbol_info(canonical) if info is not None: if not info.visible: mt5.symbol_select(canonical, True) self._symbol_cache[canonical] = canonical return canonical # Try aliases aliases = _SYMBOL_ALIASES.get(canonical, []) for alias in aliases: if alias == canonical: continue info = mt5.symbol_info(alias) if info is not None: if not info.visible: mt5.symbol_select(alias, True) log.info(f"Symbol alias resolved: {canonical} → {alias}") self._symbol_cache[canonical] = alias return alias # No alias found — return canonical (will fail downstream) log.warning(f"No broker symbol found for {canonical}") self._symbol_cache[canonical] = canonical return canonical except Exception: return canonical def _ensure_init(self) -> bool: """Initialize MT5 if auto_init is on and we haven't yet.""" if self._initialized: return True if not self.auto_init: # Assume caller already initialized MT5 self._initialized = True return True try: import MetaTrader5 as mt5 kwargs = {} if self.terminal_path: kwargs["path"] = self.terminal_path if mt5.initialize(**kwargs): self._initialized = True return True else: log.error(f"MT5 init failed: {mt5.last_error()}") return False except ModuleNotFoundError: log.error("MetaTrader5 package not installed") return False def is_available(self) -> bool: return self._ensure_init() def fetch_bars( self, symbol: str, timeframe: str, date_from: datetime, date_to: datetime, ) -> Optional[pd.DataFrame]: """ Fetch OHLCV bars via mt5.copy_rates_range(). """ _ensure_tf_map() if not self._ensure_init(): return None tf_const = _TF_MAP.get(timeframe.upper()) if tf_const is None: log.error(f"Unknown timeframe: {timeframe}") return None try: import MetaTrader5 as mt5 broker_sym = self._resolve_symbol(symbol) if date_from.tzinfo is None: date_from = date_from.replace(tzinfo=timezone.utc) if date_to.tzinfo is None: date_to = date_to.replace(tzinfo=timezone.utc) rates = mt5.copy_rates_range(broker_sym, tf_const, date_from, date_to) if rates is None or len(rates) == 0: log.warning(f"No data returned for {symbol} ({broker_sym}) {timeframe} " f"{date_from.date()}→{date_to.date()}") return None return self._to_dataframe(rates) except Exception as e: log.error(f"MT5 fetch_bars error for {symbol}: {e}") return None def fetch_bars_n( self, symbol: str, timeframe: str, n_bars: int, ) -> Optional[pd.DataFrame]: """ Fetch the most recent N bars via mt5.copy_rates_from_pos(). """ _ensure_tf_map() if not self._ensure_init(): return None tf_const = _TF_MAP.get(timeframe.upper()) if tf_const is None: log.error(f"Unknown timeframe: {timeframe}") return None try: import MetaTrader5 as mt5 broker_sym = self._resolve_symbol(symbol) rates = mt5.copy_rates_from_pos(broker_sym, tf_const, 0, n_bars) if rates is None or len(rates) == 0: log.warning(f"No data returned for {symbol} ({broker_sym}) {timeframe} (last {n_bars} bars)") return None return self._to_dataframe(rates) except Exception as e: log.error(f"MT5 fetch_bars_n error for {symbol}: {e}") return None # ── internal ───────────────────────────────────────────────────────────── @staticmethod def _to_dataframe(rates) -> pd.DataFrame: """Convert MT5 rates array to standardized DataFrame.""" df = pd.DataFrame(rates) df["time"] = pd.to_datetime(df["time"], unit="s", utc=True) df.set_index("time", inplace=True) df.index.name = "time" # Ensure lowercase column names df.columns = [c.lower() for c in df.columns] # Keep only standard columns if present keep = ["open", "high", "low", "close", "tick_volume", "spread", "real_volume"] df = df[[c for c in keep if c in df.columns]] return df