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
|