""" MT5 broker client. Thin async wrapper around the synchronous MetaTrader5 API. All blocking calls run in the default executor so the event loop stays free. """ import asyncio import logging from typing import Optional import pandas as pd from . import config logger = logging.getLogger(__name__) try: import MetaTrader5 as mt5 _MT5_AVAILABLE = True except ImportError: _MT5_AVAILABLE = False logger.warning("MetaTrader5 package not installed — MT5 client unavailable") ORDER_TYPE_BUY = 0 ORDER_TYPE_SELL = 1 _TF_MAP = { "M1": 1, "M5": 5, "M15": 15, "M30": 30, "H1": 16385, "H4": 16388, "D1": 16408, } def _run(fn): return asyncio.get_event_loop().run_in_executor(None, fn) def connect() -> bool: if not _MT5_AVAILABLE: logger.error("MetaTrader5 not installed") return False if not mt5.initialize( login=config.MT5_LOGIN, password=config.MT5_PASSWORD, server=config.MT5_SERVER, ): logger.error("MT5 init failed: %s", mt5.last_error()) return False info = mt5.terminal_info() logger.info("MT5 connected: %s", info.name if info else "unknown") return True def disconnect(): if _MT5_AVAILABLE: mt5.shutdown() async def get_candles(symbol: str, timeframe: str, count: int) -> Optional[pd.DataFrame]: if not _MT5_AVAILABLE: return None tf = _TF_MAP[timeframe] rates = await _run(lambda: mt5.copy_rates_from_pos(symbol, tf, 0, count)) if rates is None or len(rates) == 0: return None df = pd.DataFrame(rates) df["time"] = pd.to_datetime(df["time"], unit="s") df = df.rename(columns={"tick_volume": "tick_volume"}) return df.set_index("time") async def get_tick(symbol: str) -> Optional[dict]: if not _MT5_AVAILABLE: return None tick = await _run(lambda: mt5.symbol_info_tick(symbol)) return tick._asdict() if tick else None async def get_account_info() -> Optional[dict]: if not _MT5_AVAILABLE: return None info = await _run(mt5.account_info) return info._asdict() if info else None async def get_open_positions(magic: int = 234001) -> list[dict]: if not _MT5_AVAILABLE: return [] positions = await _run(lambda: mt5.positions_get()) if positions is None: return [] return [p._asdict() for p in positions if p.magic == magic] async def get_symbol_info(symbol: str) -> Optional[dict]: if not _MT5_AVAILABLE: return None info = await _run(lambda: mt5.symbol_info(symbol)) return info._asdict() if info else None async def place_order( symbol: str, order_type: int, volume: float, price: float, sl: float, tp: float, comment: str = "", magic: int = 234001, ) -> Optional[dict]: if not _MT5_AVAILABLE: return None request = { "action": mt5.TRADE_ACTION_DEAL, "symbol": symbol, "volume": round(volume, 2), "type": order_type, "price": price, "sl": sl, "tp": tp, "deviation": 20, "magic": magic, "comment": comment, "type_time": mt5.ORDER_TIME_GTC, "type_filling": mt5.ORDER_FILLING_IOC, } result = await _run(lambda: mt5.order_send(request)) if result is None or result.retcode != mt5.TRADE_RETCODE_DONE: logger.error("Order failed %s: %s", symbol, result) return None return result._asdict()