# version: v6 """ mt5_routes.py — MT5 Connection Management API ================================================ Endpoints for the consumer Settings → Providers panel. GET /api/mt5/config → read MT5 connection settings PATCH /api/mt5/config → save MT5 connection settings POST /api/mt5/connect → connect to MT5 with current config POST /api/mt5/disconnect → disconnect from MT5 Settings are stored in user_config.json under providers.accounts.mt5_default. This module does NOT introduce any calculation triggers. v2 — PATCH /config now disconnects and reinitializes the MT5 provider after saving, so the next /connect picks up the new terminal_path / server / login immediately. Previously the provider cached the old path in memory and stale connects kept firing until the backend was restarted — e.g. user couldn't revert an invalid GoDo path back to auto-detect. v3 — Added `use_tick_data` to the GET/PATCH config payload. When True, the backend tick loop polls MT5 every 0.2s instead of 1.0s, giving the chart a live MT5-style "tick feel" on the last candle. Default OFF. Toggle surfaces in Settings → PROVIDERS. v4 — PATCH /config no longer disconnects + reinits the MT5 provider on every change. Reinit now triggers ONLY when a connection-relevant field (connection_method / terminal_path / server / login / password) is touched. Flipping `use_tick_data` (and any other future cosmetic field) leaves the live connection alone. Without this, toggling USE TICK DATA in the UI caused a ~5s outage with a flood of 503 responses while the reconnect loop healed the connection. v5 — POST /browse-terminal opens a native Windows file-picker so users with multiple MT5 installations can select the correct terminal64.exe without manually typing (or pasting) a long path. Runs tkinter.filedialog on a worker thread, returns the chosen absolute path. """ import logging from fastapi import APIRouter, Request, HTTPException log = logging.getLogger("mt5_routes") def create_mt5_router(cfg_manager, app) -> APIRouter: router = APIRouter(prefix="/api/mt5", tags=["mt5"]) def _get_mt5_config() -> dict: """Read MT5 config from user_config providers section.""" config = cfg_manager.get_config() providers = config.get("providers", {}) accounts = providers.get("accounts", {}) mt5_acct = accounts.get("mt5_default", {}) return { "connection_method": mt5_acct.get("connection_method", "path"), "terminal_path": mt5_acct.get("terminal_path") or "", "server": mt5_acct.get("server", ""), "login": mt5_acct.get("login", ""), "password": "", # Never send password back "enabled": mt5_acct.get("enabled", True), # v3: USE TICK DATA — when ON, tick loop polls MT5 5×/sec instead # of 1×/sec. Default OFF; user opts in via Settings → PROVIDERS. "use_tick_data": bool(mt5_acct.get("use_tick_data", False)), } # ── GET /api/mt5/config ── @router.get("/config") async def get_mt5_config(): return _get_mt5_config() # ── PATCH /api/mt5/config ── @router.patch("/config") async def patch_mt5_config(request: Request): import asyncio try: patch = await request.json() except Exception: raise HTTPException(400, "Invalid JSON") # Build the nested update for providers.accounts.mt5_default mt5_update = {} for key in ["connection_method", "terminal_path", "server", "login", "password", "use_tick_data"]: # v3 if key in patch: mt5_update[key] = patch[key] if mt5_update: cfg_manager.update({ "providers": { "accounts": { "mt5_default": mt5_update } } }) log.info(f"MT5 config updated: {list(mt5_update.keys())}") # v4: reinit only when a connection-relevant field changed. # use_tick_data is read live by focus_tick_loop on every iteration # — flipping it must NOT bounce the MT5 connection (5s 503 outage). CONNECTION_FIELDS = {"connection_method", "terminal_path", "server", "login", "password"} if CONNECTION_FIELDS.intersection(mt5_update.keys()): try: prov = cfg_manager.get_provider("mt5_default") if prov is not None: try: if getattr(prov, "connected", False): await asyncio.to_thread(prov.disconnect) except Exception as e: log.warning(f"MT5 disconnect during reinit failed: {e}") await asyncio.to_thread(cfg_manager.init_providers) log.info("MT5 provider reinitialized with new config") except Exception as e: log.warning(f"MT5 provider reinit failed: {e}") return {"config": _get_mt5_config()} # ── POST /api/mt5/browse-terminal ── @router.post("/browse-terminal") async def browse_terminal(): """v5: Open a native OS file picker for the user to select terminal64.exe. Runs tkinter.filedialog on a worker thread (FastAPI's async loop can't block on a dialog). Returns {"path": "..."} on pick, {"path": null, "cancelled": true} on cancel. Windows-only for now — defaults to the most likely MT5 install dir if nothing is configured yet.""" import asyncio import os import platform if platform.system() != "Windows": raise HTTPException(501, "Browse dialog is Windows-only for now.") # Pick a sensible initial directory: current configured path → its dir, # or %ProgramFiles% as a fallback. current = _get_mt5_config().get("terminal_path") or "" if current and os.path.isfile(current): initial_dir = os.path.dirname(current) elif current and os.path.isdir(current): initial_dir = current else: pf = os.environ.get("ProgramFiles", r"C:\Program Files") initial_dir = pf def _pick() -> dict: # v6: switched tkinter → native comdlg32 via ctypes. # tkinter is not bundled in the PyInstaller build (strips by # default unless explicitly hidden-imported), so the installed # .exe would fail with "No module named 'tkinter'". comdlg32.dll # is core Windows — always available, no dependencies, no build # change needed. Still runs on a worker thread because the native # call blocks until the user picks or cancels. try: import ctypes from ctypes import wintypes, Structure, byref, sizeof, c_void_p class OPENFILENAMEW(Structure): _fields_ = [ ("lStructSize", wintypes.DWORD), ("hwndOwner", wintypes.HWND), ("hInstance", wintypes.HINSTANCE), ("lpstrFilter", wintypes.LPCWSTR), ("lpstrCustomFilter",wintypes.LPWSTR), ("nMaxCustFilter", wintypes.DWORD), ("nFilterIndex", wintypes.DWORD), ("lpstrFile", wintypes.LPWSTR), ("nMaxFile", wintypes.DWORD), ("lpstrFileTitle", wintypes.LPWSTR), ("nMaxFileTitle", wintypes.DWORD), ("lpstrInitialDir", wintypes.LPCWSTR), ("lpstrTitle", wintypes.LPCWSTR), ("Flags", wintypes.DWORD), ("nFileOffset", wintypes.WORD), ("nFileExtension", wintypes.WORD), ("lpstrDefExt", wintypes.LPCWSTR), ("lCustData", wintypes.LPARAM), ("lpfnHook", c_void_p), ("lpTemplateName", wintypes.LPCWSTR), ("pvReserved", c_void_p), ("dwReserved", wintypes.DWORD), ("FlagsEx", wintypes.DWORD), ] OFN_FILEMUSTEXIST = 0x00001000 OFN_PATHMUSTEXIST = 0x00000800 OFN_EXPLORER = 0x00080000 OFN_NOCHANGEDIR = 0x00000008 buf = ctypes.create_unicode_buffer(4096) ofn = OPENFILENAMEW() ctypes.memset(byref(ofn), 0, sizeof(ofn)) ofn.lStructSize = sizeof(OPENFILENAMEW) ofn.lpstrFile = ctypes.cast(buf, wintypes.LPWSTR) ofn.nMaxFile = 4096 # Filter string: pairs of "label\0pattern\0" terminated with an extra \0. ofn.lpstrFilter = ( "MT5 terminal\0terminal64.exe;terminal.exe\0" "Executables\0*.exe\0" "All files\0*.*\0\0" ) ofn.lpstrInitialDir = initial_dir ofn.lpstrTitle = "Select MT5 terminal (terminal64.exe)" ofn.Flags = (OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_EXPLORER | OFN_NOCHANGEDIR) ok = ctypes.windll.comdlg32.GetOpenFileNameW(byref(ofn)) if not ok: # User cancelled OR dialog failed. CommDlgExtendedError # returns 0 for cancel, non-zero for actual errors. err = ctypes.windll.comdlg32.CommDlgExtendedError() if err != 0: return {"path": None, "error": f"comdlg32 error {err:#x}"} return {"path": None, "cancelled": True} picked = buf.value if not picked: return {"path": None, "cancelled": True} return {"path": os.path.normpath(picked), "cancelled": False} except Exception as e: log.error(f"browse-terminal dialog failed: {e}") return {"path": None, "error": str(e)} try: result = await asyncio.to_thread(_pick) except Exception as e: raise HTTPException(500, f"Dialog launch failed: {e}") if result.get("error"): raise HTTPException(500, result["error"]) return result # ── POST /api/mt5/connect ── @router.post("/connect") async def connect_mt5(request: Request): import asyncio provider = cfg_manager.get_provider("mt5_default") if provider is None: # Try to init providers first try: await asyncio.to_thread(cfg_manager.init_providers) provider = cfg_manager.get_provider("mt5_default") except Exception as e: raise HTTPException(500, f"Failed to initialize MT5 provider: {e}") if provider is None: raise HTTPException(500, "MT5 provider not available") # If already connected, return current state if provider.connected: account = None try: import MetaTrader5 as mt5 info = mt5.account_info() if info: account = { "login": info.login, "server": info.server, "balance": info.balance, "equity": info.equity, "currency": info.currency, } except Exception: pass return {"connected": True, "account": account} # Try to connect try: connected = await asyncio.wait_for( asyncio.to_thread(provider.connect), timeout=15.0, ) except asyncio.TimeoutError: return {"connected": False, "error": "Connection timed out (15s)"} except Exception as e: return {"connected": False, "error": str(e)} if not connected: return {"connected": False, "error": "MT5 connection failed — check terminal path or credentials"} # Update app state app.state.provider = provider # Resolve symbols try: universe = cfg_manager.get_active_universe() await asyncio.to_thread(provider.resolve_universe, universe) except Exception: pass # Get account info account = None try: import MetaTrader5 as mt5 info = mt5.account_info() if info: account = { "login": info.login, "server": info.server, "balance": info.balance, "equity": info.equity, "currency": info.currency, } except Exception: pass log.info("MT5 connected via settings panel") return {"connected": True, "account": account} # ── POST /api/mt5/disconnect ── @router.post("/disconnect") async def disconnect_mt5(): import asyncio provider = cfg_manager.get_provider("mt5_default") if provider and provider.connected: try: await asyncio.to_thread(provider.disconnect) log.info("MT5 disconnected via settings panel") except Exception as e: log.warning(f"MT5 disconnect error: {e}") return {"connected": False} # ── GET /api/mt5/status ── @router.get("/status") async def get_mt5_status(): """Quick status check — used by settings panel.""" provider = cfg_manager.get_provider("mt5_default") connected = provider.connected if provider else False account = None if connected: try: import MetaTrader5 as mt5 info = mt5.account_info() if info: account = { "login": info.login, "server": info.server, "balance": info.balance, "equity": info.equity, } except Exception: pass return {"connected": connected, "account": account} return router