Files
QuantumTerminal/backend/mt5_routes.py
T

347 lines
14 KiB
Python
Raw Normal View History

# 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