Files
QuantumTerminal/backend/data_manager/fetchers/mt5_fetcher.py
T

272 lines
9.1 KiB
Python

"""
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