272 lines
9.1 KiB
Python
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 |