Initial commit: Quantum Terminal — Free & Open Source Trading Platform
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user