347 lines
14 KiB
Python
347 lines
14 KiB
Python
|
|
# 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
|