2026-11-04 21:35:18 +00:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""
|
|
|
|
|
|
MT5 MCP Server for Linux/Devuan
|
|
|
|
|
|
================================
|
|
|
|
|
|
FastAPI-based HTTP server that provides MCP-compatible endpoints
|
|
|
|
|
|
for MetaTrader 5 integration on Linux via Wine + mt5linux.
|
|
|
|
|
|
|
|
|
|
|
|
Modes:
|
|
|
|
|
|
--mock : Run with simulated data (for testing)
|
|
|
|
|
|
--live : Connect to real MT5 via mt5linux rpyc bridge
|
|
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
|
python mcp_server.py --mock # Test mode
|
|
|
|
|
|
python mcp_server.py --live # Real MT5 connection
|
|
|
|
|
|
python mcp_server.py --mock --port 8000
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import json
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
import random
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
from typing import Dict, Any, Optional, List
|
|
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import FastAPI, HTTPException, Query
|
|
|
|
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
|
|
from fastapi.responses import JSONResponse
|
|
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
import uvicorn
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Logging
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
logging.basicConfig(
|
|
|
|
|
|
level=logging.INFO,
|
|
|
|
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
|
|
|
|
handlers=[
|
|
|
|
|
|
logging.StreamHandler(sys.stdout),
|
|
|
|
|
|
logging.FileHandler("/tmp/mt5_mcp_server.log", mode="a"),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
log = logging.getLogger("mt5-mcp")
|
|
|
|
|
|
|
2026-11-05 23:56:14 +00:00
|
|
|
|
# Sekunden, die auf eine Antwort aus dem Wine-Python gewartet wird
|
|
|
|
|
|
RPYC_TIMEOUT = int(os.getenv("RPYC_TIMEOUT", "120"))
|
|
|
|
|
|
|
2026-11-04 21:35:18 +00:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# MT5 Bridge (abstraction over mock / live)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class MT5Bridge:
|
|
|
|
|
|
"""Base class – subclassed by MockBridge and LiveBridge."""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
|
self.connected = False
|
|
|
|
|
|
self.mode = "base"
|
|
|
|
|
|
|
|
|
|
|
|
async def connect(self) -> bool:
|
|
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
|
|
|
|
async def disconnect(self):
|
|
|
|
|
|
self.connected = False
|
|
|
|
|
|
|
|
|
|
|
|
async def account_info(self) -> Dict[str, Any]:
|
|
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
|
|
|
|
async def symbols(self) -> List[str]:
|
|
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
|
|
|
|
async def symbol_info(self, symbol: str) -> Dict[str, Any]:
|
|
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
|
|
|
|
async def ohlc(self, symbol: str, timeframe: str, bars: int) -> List[Dict]:
|
|
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
|
|
|
|
async def tick(self, symbol: str) -> Dict[str, Any]:
|
|
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
|
|
|
|
async def positions(self) -> List[Dict]:
|
|
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
|
|
|
|
async def orders(self) -> List[Dict]:
|
|
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- Mock Bridge ----------------------------------------------------------
|
|
|
|
|
|
class MockBridge(MT5Bridge):
|
|
|
|
|
|
"""Generates realistic-looking fake data for testing."""
|
|
|
|
|
|
|
|
|
|
|
|
SYMBOLS = [
|
|
|
|
|
|
"EURUSD", "GBPUSD", "USDJPY", "USDCHF", "AUDUSD",
|
|
|
|
|
|
"USDCAD", "NZDUSD", "XAUUSD", "XAGUSD", "BTCUSD",
|
|
|
|
|
|
]
|
|
|
|
|
|
BASE_PRICES = {
|
|
|
|
|
|
"EURUSD": 1.0850, "GBPUSD": 1.2650, "USDJPY": 149.50,
|
|
|
|
|
|
"USDCHF": 0.8820, "AUDUSD": 0.6550, "USDCAD": 1.3580,
|
|
|
|
|
|
"NZDUSD": 0.6120, "XAUUSD": 2650.00, "XAGUSD": 31.50,
|
|
|
|
|
|
"BTCUSD": 67500.00,
|
|
|
|
|
|
}
|
|
|
|
|
|
SPREADS = {
|
|
|
|
|
|
"EURUSD": 0.00012, "GBPUSD": 0.00015, "USDJPY": 0.015,
|
|
|
|
|
|
"USDCHF": 0.00014, "AUDUSD": 0.00013, "USDCAD": 0.00016,
|
|
|
|
|
|
"NZDUSD": 0.00015, "XAUUSD": 0.30, "XAGUSD": 0.03,
|
|
|
|
|
|
"BTCUSD": 25.0,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
|
super().__init__()
|
|
|
|
|
|
self.mode = "mock"
|
|
|
|
|
|
|
|
|
|
|
|
async def connect(self) -> bool:
|
|
|
|
|
|
self.connected = True
|
|
|
|
|
|
log.info("MockBridge connected (simulated data)")
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
async def account_info(self) -> Dict[str, Any]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"login": 12345678,
|
|
|
|
|
|
"server": "MockBroker-Demo",
|
|
|
|
|
|
"balance": 10000.00,
|
|
|
|
|
|
"equity": 10025.50,
|
|
|
|
|
|
"margin": 125.00,
|
|
|
|
|
|
"free_margin": 9900.50,
|
|
|
|
|
|
"margin_level": 8020.40,
|
|
|
|
|
|
"leverage": 500,
|
|
|
|
|
|
"currency": "USD",
|
|
|
|
|
|
"name": "Demo Account",
|
|
|
|
|
|
"company": "MockBroker Ltd",
|
|
|
|
|
|
"trade_mode": "DEMO",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-06 09:53:22 +00:00
|
|
|
|
async def symbols(self, search: Optional[str] = None) -> List[str]:
|
|
|
|
|
|
return [x for x in self.SYMBOLS if not search or search.upper() in x]
|
2026-11-04 21:35:18 +00:00
|
|
|
|
|
|
|
|
|
|
async def symbol_info(self, symbol: str) -> Dict[str, Any]:
|
|
|
|
|
|
if symbol not in self.BASE_PRICES:
|
|
|
|
|
|
raise HTTPException(404, f"Symbol {symbol} not found")
|
|
|
|
|
|
price = self.BASE_PRICES[symbol]
|
|
|
|
|
|
spread = self.SPREADS.get(symbol, 0.0002)
|
|
|
|
|
|
digits = 5 if price < 10 else (3 if price < 200 else (2 if price < 5000 else 1))
|
|
|
|
|
|
return {
|
|
|
|
|
|
"symbol": symbol,
|
|
|
|
|
|
"bid": round(price - spread / 2, digits),
|
|
|
|
|
|
"ask": round(price + spread / 2, digits),
|
|
|
|
|
|
"spread": spread,
|
|
|
|
|
|
"digits": digits,
|
|
|
|
|
|
"point": 10 ** -digits,
|
|
|
|
|
|
"trade_contract_size": 100000 if "USD" in symbol and "XA" not in symbol else (100 if "XAU" in symbol else 1),
|
|
|
|
|
|
"volume_min": 0.01,
|
|
|
|
|
|
"volume_max": 100.0,
|
|
|
|
|
|
"volume_step": 0.01,
|
|
|
|
|
|
"description": f"{symbol} mock data",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async def ohlc(self, symbol: str, timeframe: str, bars: int, start_pos: int = 0) -> List[Dict]:
|
|
|
|
|
|
"""Generate mock OHLC bars."""
|
|
|
|
|
|
if symbol not in self.BASE_PRICES:
|
|
|
|
|
|
raise HTTPException(404, f"Symbol {symbol} not found")
|
|
|
|
|
|
|
|
|
|
|
|
tf_seconds = {
|
|
|
|
|
|
"S5": 5, "S10": 10, "S15": 15, "S30": 30,
|
|
|
|
|
|
"M1": 60, "M5": 300, "M15": 900, "M30": 1800,
|
|
|
|
|
|
"H1": 3600, "H4": 14400, "D1": 86400, "W1": 604800,
|
|
|
|
|
|
}
|
|
|
|
|
|
seconds = tf_seconds.get(timeframe, 3600)
|
|
|
|
|
|
price = self.BASE_PRICES[symbol]
|
|
|
|
|
|
volatility = price * (0.0002 if seconds < 60 else 0.001)
|
|
|
|
|
|
|
|
|
|
|
|
now = datetime.now(tz=None).replace(microsecond=0)
|
|
|
|
|
|
# Align to timeframe
|
|
|
|
|
|
now = now - timedelta(seconds=int(now.timestamp()) % max(seconds, 1))
|
|
|
|
|
|
|
|
|
|
|
|
data = []
|
|
|
|
|
|
current_price = price
|
|
|
|
|
|
for i in range(bars - 1, -1, -1):
|
|
|
|
|
|
bar_time = now - timedelta(seconds=seconds * i)
|
|
|
|
|
|
o = current_price
|
|
|
|
|
|
h = o + random.uniform(0, volatility)
|
|
|
|
|
|
l = o - random.uniform(0, volatility)
|
|
|
|
|
|
c = random.uniform(l, h)
|
|
|
|
|
|
v = random.randint(50, 5000)
|
|
|
|
|
|
digits = 5 if price < 10 else (3 if price < 200 else 2)
|
|
|
|
|
|
data.append({
|
|
|
|
|
|
"time": bar_time.isoformat() + "Z",
|
|
|
|
|
|
"timestamp": int(bar_time.timestamp()),
|
|
|
|
|
|
"open": round(o, digits),
|
|
|
|
|
|
"high": round(h, digits),
|
|
|
|
|
|
"low": round(l, digits),
|
|
|
|
|
|
"close": round(c, digits),
|
|
|
|
|
|
"volume": v,
|
|
|
|
|
|
})
|
|
|
|
|
|
current_price = c
|
|
|
|
|
|
return data
|
|
|
|
|
|
|
|
|
|
|
|
async def tick(self, symbol: str) -> Dict[str, Any]:
|
|
|
|
|
|
if symbol not in self.BASE_PRICES:
|
|
|
|
|
|
raise HTTPException(404, f"Symbol {symbol} not found")
|
|
|
|
|
|
price = self.BASE_PRICES[symbol]
|
|
|
|
|
|
spread = self.SPREADS.get(symbol, 0.0002)
|
|
|
|
|
|
jitter = price * random.uniform(-0.0005, 0.0005)
|
|
|
|
|
|
digits = 5 if price < 10 else (3 if price < 200 else 2)
|
|
|
|
|
|
bid = round(price + jitter - spread / 2, digits)
|
|
|
|
|
|
ask = round(price + jitter + spread / 2, digits)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"symbol": symbol,
|
|
|
|
|
|
"bid": bid,
|
|
|
|
|
|
"ask": ask,
|
|
|
|
|
|
"last": round((bid + ask) / 2, digits),
|
|
|
|
|
|
"volume": random.randint(1, 100),
|
|
|
|
|
|
"time": datetime.now(tz=None).isoformat() + "Z",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async def positions(self) -> List[Dict]:
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
"ticket": 100001,
|
|
|
|
|
|
"symbol": "EURUSD",
|
|
|
|
|
|
"type": "BUY",
|
|
|
|
|
|
"volume": 0.10,
|
|
|
|
|
|
"open_price": 1.0840,
|
|
|
|
|
|
"current_price": 1.0852,
|
|
|
|
|
|
"profit": 12.00,
|
|
|
|
|
|
"swap": -0.50,
|
|
|
|
|
|
"sl": 1.0800,
|
|
|
|
|
|
"tp": 1.0900,
|
|
|
|
|
|
"open_time": (datetime.now(tz=None) - timedelta(hours=3)).isoformat() + "Z",
|
|
|
|
|
|
"magic": 0,
|
|
|
|
|
|
"comment": "mock position",
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
async def orders(self) -> List[Dict]:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- Live Bridge (mt5linux) -----------------------------------------------
|
|
|
|
|
|
class LiveBridge(MT5Bridge):
|
|
|
|
|
|
"""Connects to real MT5 via mt5linux rpyc bridge."""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, host: str = "localhost", port: int = 8001):
|
|
|
|
|
|
super().__init__()
|
|
|
|
|
|
self.mode = "live"
|
|
|
|
|
|
self.rpyc_host = host
|
|
|
|
|
|
self.rpyc_port = port
|
|
|
|
|
|
self.mt5 = None
|
|
|
|
|
|
self._rpyc_conn = None # persistent rpyc connection for tick data
|
|
|
|
|
|
|
|
|
|
|
|
def _get_rpyc(self):
|
|
|
|
|
|
"""Get or create persistent rpyc connection for direct Wine-Python access."""
|
|
|
|
|
|
import rpyc
|
|
|
|
|
|
if self._rpyc_conn is None or self._rpyc_conn.closed:
|
|
|
|
|
|
try:
|
|
|
|
|
|
self._rpyc_conn = rpyc.classic.connect(self.rpyc_host, self.rpyc_port)
|
2026-11-05 23:56:14 +00:00
|
|
|
|
self._rpyc_conn._config['sync_request_timeout'] = RPYC_TIMEOUT
|
2026-11-04 21:35:18 +00:00
|
|
|
|
self._rpyc_conn.execute("import MetaTrader5 as mt5")
|
|
|
|
|
|
self._rpyc_conn.execute("from datetime import datetime, timedelta")
|
2026-11-06 09:21:21 +00:00
|
|
|
|
p = os.getenv("MT5_PATH")
|
|
|
|
|
|
self._rpyc_conn.execute(f"mt5.initialize(path={p!r})" if p else "mt5.initialize()")
|
2026-11-04 21:35:18 +00:00
|
|
|
|
log.info("Direct rpyc connection established for tick data")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
log.error(f"rpyc connection failed: {e}")
|
|
|
|
|
|
self._rpyc_conn = None
|
|
|
|
|
|
raise
|
|
|
|
|
|
return self._rpyc_conn
|
|
|
|
|
|
|
|
|
|
|
|
async def connect(self) -> bool:
|
|
|
|
|
|
try:
|
|
|
|
|
|
from mt5linux import MetaTrader5 as MT5
|
|
|
|
|
|
self.mt5 = MT5(host=self.rpyc_host, port=self.rpyc_port)
|
2026-11-05 23:56:14 +00:00
|
|
|
|
# initialize() mit Login startet/verbindet das Terminal unter Wine und
|
|
|
|
|
|
# dauert oft >30s (RPyC-Default) -> sonst "result expired"
|
|
|
|
|
|
self.mt5._MetaTrader5__conn._config['sync_request_timeout'] = RPYC_TIMEOUT
|
2026-11-06 09:21:21 +00:00
|
|
|
|
# Schritt 1: Bridge – nur ans Terminal anhängen, noch KEIN Login.
|
|
|
|
|
|
# Pfad nötig bei Broker-Terminals (sonst IPC timeout -10005).
|
|
|
|
|
|
init_kwargs = {"timeout": int(os.getenv("MT5_INIT_TIMEOUT_MS", "90000"))}
|
|
|
|
|
|
if os.getenv("MT5_PATH"):
|
|
|
|
|
|
init_kwargs["path"] = os.getenv("MT5_PATH")
|
|
|
|
|
|
log.info(f"Connecting to terminal {init_kwargs.get('path', '(auto-detect)')}")
|
|
|
|
|
|
if not self.mt5.initialize(**init_kwargs):
|
2026-11-04 21:35:18 +00:00
|
|
|
|
error = self.mt5.last_error()
|
|
|
|
|
|
log.error(f"MT5 initialize failed: {error}")
|
|
|
|
|
|
return False
|
2026-11-06 09:21:21 +00:00
|
|
|
|
log.info("Bridge OK – terminal attached")
|
|
|
|
|
|
|
|
|
|
|
|
# Schritt 2: Login nur, wenn nicht schon das richtige Konto angemeldet ist
|
|
|
|
|
|
want = os.getenv("MT5_LOGIN")
|
|
|
|
|
|
current = self.mt5.account_info()
|
|
|
|
|
|
current_login = current.login if current is not None else None
|
|
|
|
|
|
if want and current_login != int(want):
|
|
|
|
|
|
log.info(f"Account in terminal: {current_login} – logging in to {want} "
|
|
|
|
|
|
f"on {os.getenv('MT5_SERVER', '')}")
|
|
|
|
|
|
ok = self.mt5.login(int(want),
|
|
|
|
|
|
password=os.getenv("MT5_PASSWORD", ""),
|
|
|
|
|
|
server=os.getenv("MT5_SERVER", ""),
|
|
|
|
|
|
timeout=int(os.getenv("MT5_LOGIN_TIMEOUT_MS", "60000")))
|
|
|
|
|
|
if not ok:
|
|
|
|
|
|
log.error(f"MT5 login failed: {self.mt5.last_error()}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
elif current_login is None:
|
|
|
|
|
|
log.error("Terminal has no logged-in account and MT5_LOGIN is not set")
|
|
|
|
|
|
return False
|
|
|
|
|
|
else:
|
|
|
|
|
|
log.info(f"Account {current_login} already logged in – no login needed")
|
|
|
|
|
|
|
2026-11-04 21:35:18 +00:00
|
|
|
|
info = self.mt5.account_info()
|
|
|
|
|
|
log.info(f"LiveBridge connected: login={info.login}, server={info.server}")
|
|
|
|
|
|
self.connected = True
|
|
|
|
|
|
return True
|
2026-11-05 23:51:50 +00:00
|
|
|
|
except ImportError as e:
|
2026-11-05 23:56:14 +00:00
|
|
|
|
# Kann auch von der Windows-Seite kommen (z.B. MetaTrader5 vs. numpy 2)
|
2026-11-05 23:51:50 +00:00
|
|
|
|
log.error(f"ImportError: {e}")
|
2026-11-04 21:35:18 +00:00
|
|
|
|
return False
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
log.error(f"LiveBridge connection failed: {e}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2026-11-06 09:21:21 +00:00
|
|
|
|
async def ensure(self):
|
|
|
|
|
|
"""Prüft vor jedem Aufruf, ob die IPC-Verbindung zum Terminal noch steht,
|
|
|
|
|
|
und verbindet sonst neu (z.B. nach Terminal-Neustart im Web-UI oder wenn
|
|
|
|
|
|
ein anderer Client im selben Wine-Python mt5.initialize/shutdown aufruft)."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
if self.mt5 is not None and self.mt5.terminal_info() is not None:
|
|
|
|
|
|
return
|
|
|
|
|
|
if self.mt5 is not None:
|
|
|
|
|
|
log.warning(f"IPC connection lost: {self.mt5.last_error()} – reconnecting")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
log.warning(f"Bridge check failed ({e}) – reconnecting")
|
|
|
|
|
|
self.connected = False
|
|
|
|
|
|
self._rpyc_conn = None
|
|
|
|
|
|
if not await self.connect():
|
|
|
|
|
|
raise HTTPException(503, "MT5 not connected (reconnect failed)")
|
|
|
|
|
|
|
2026-11-04 21:35:18 +00:00
|
|
|
|
async def disconnect(self):
|
|
|
|
|
|
if self._rpyc_conn:
|
|
|
|
|
|
try:
|
|
|
|
|
|
self._rpyc_conn.close()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
self._rpyc_conn = None
|
|
|
|
|
|
if self.mt5:
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.mt5.shutdown()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
self.connected = False
|
|
|
|
|
|
|
|
|
|
|
|
async def account_info(self) -> Dict[str, Any]:
|
2026-11-06 09:21:21 +00:00
|
|
|
|
await self.ensure()
|
2026-11-04 21:35:18 +00:00
|
|
|
|
info = self.mt5.account_info()
|
|
|
|
|
|
if info is None:
|
|
|
|
|
|
raise HTTPException(500, f"account_info failed: {self.mt5.last_error()}")
|
|
|
|
|
|
return {
|
|
|
|
|
|
"login": info.login,
|
|
|
|
|
|
"server": info.server,
|
|
|
|
|
|
"balance": info.balance,
|
2026-11-06 10:10:48 +00:00
|
|
|
|
"credit": getattr(info, "credit", 0.0), # Broker-Bonus: zählt zur Equity/Margin, nicht zur Balance
|
2026-11-04 21:35:18 +00:00
|
|
|
|
"equity": info.equity,
|
|
|
|
|
|
"margin": info.margin,
|
|
|
|
|
|
"free_margin": info.margin_free,
|
|
|
|
|
|
"margin_level": info.margin_level,
|
|
|
|
|
|
"leverage": info.leverage,
|
|
|
|
|
|
"currency": info.currency,
|
|
|
|
|
|
"name": info.name,
|
|
|
|
|
|
"company": info.company,
|
|
|
|
|
|
"trade_mode": "DEMO" if info.trade_mode == 0 else "REAL",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-06 09:53:22 +00:00
|
|
|
|
async def symbols(self, search: Optional[str] = None) -> List[str]:
|
2026-11-06 09:21:21 +00:00
|
|
|
|
await self.ensure()
|
2026-11-06 09:53:22 +00:00
|
|
|
|
if search:
|
|
|
|
|
|
syms = self.mt5.symbols_get(group=f"*{search}*")
|
|
|
|
|
|
return [s.name for s in syms] if syms else []
|
2026-11-04 21:35:18 +00:00
|
|
|
|
syms = self.mt5.symbols_get()
|
|
|
|
|
|
if syms is None:
|
|
|
|
|
|
return []
|
|
|
|
|
|
return [s.name for s in syms if s.visible]
|
|
|
|
|
|
|
2026-11-06 09:53:22 +00:00
|
|
|
|
def _resolve(self, symbol: str) -> str:
|
|
|
|
|
|
"""Broker-Symbolnamen finden und in der Marktübersicht aktivieren.
|
|
|
|
|
|
PU Prime u.a. hängen Suffixe an (z.B. XAUUSD -> XAUUSD.s), und Symbole
|
|
|
|
|
|
ausserhalb der Marktübersicht liefern keine Ticks.
|
|
|
|
|
|
Reihenfolge: SYMBOL_MAP (bots.env) -> exakter Name -> Name mit Suffix."""
|
|
|
|
|
|
cache = getattr(self, "_symbol_cache", None)
|
|
|
|
|
|
if cache is None:
|
|
|
|
|
|
cache = self._symbol_cache = {}
|
|
|
|
|
|
for pair in os.getenv("SYMBOL_MAP", "").split(","):
|
|
|
|
|
|
if "=" in pair:
|
|
|
|
|
|
k, v = pair.split("=", 1)
|
|
|
|
|
|
cache[k.strip().upper()] = v.strip() # z.B. XAUUSD=XAUUSD.s
|
|
|
|
|
|
key = symbol.upper()
|
|
|
|
|
|
name = cache.get(key)
|
|
|
|
|
|
if name is None:
|
|
|
|
|
|
# Endpunkte schreiben Symbole gross ("XAUUSD.S"), der Broker-Name ist aber
|
|
|
|
|
|
# case-sensitiv ("XAUUSD.s") -> über den Basisnamen suchen, Gross/Klein egal
|
|
|
|
|
|
base = key.split(".")[0]
|
|
|
|
|
|
cands = [c.name for c in (self.mt5.symbols_get(group=f"*{base}*") or [])]
|
|
|
|
|
|
exact = [n for n in cands if n.upper() == key]
|
|
|
|
|
|
prefixed = sorted((n for n in cands if n.upper().startswith(base)), key=len)
|
|
|
|
|
|
if exact:
|
|
|
|
|
|
name = exact[0]
|
|
|
|
|
|
elif prefixed:
|
|
|
|
|
|
name = prefixed[0]
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise HTTPException(404, f"Symbol {symbol} not found at broker")
|
|
|
|
|
|
if name != symbol:
|
|
|
|
|
|
log.info(f"Symbol {symbol} -> {name} (broker name)")
|
|
|
|
|
|
cache[key] = name
|
|
|
|
|
|
if not self.mt5.symbol_select(name, True):
|
|
|
|
|
|
log.warning(f"symbol_select({name}) failed: {self.mt5.last_error()}")
|
|
|
|
|
|
return name
|
|
|
|
|
|
|
2026-11-06 10:00:00 +00:00
|
|
|
|
def _filling(self, symbol: str) -> int:
|
|
|
|
|
|
"""Filling-Modus, den der Broker für dieses Symbol erlaubt.
|
|
|
|
|
|
symbol_info.filling_mode ist ein Bitfeld: 1 = FOK, 2 = IOC erlaubt.
|
|
|
|
|
|
Rückgabe als ORDER_FILLING_*: 0 = FOK, 1 = IOC, 2 = RETURN."""
|
|
|
|
|
|
override = os.getenv("ORDER_FILLING") # optional: FOK / IOC / RETURN
|
|
|
|
|
|
if override:
|
|
|
|
|
|
return {"FOK": 0, "IOC": 1, "RETURN": 2}[override.upper()]
|
|
|
|
|
|
info = self.mt5.symbol_info(symbol)
|
|
|
|
|
|
flags = int(getattr(info, "filling_mode", 0) or 0)
|
|
|
|
|
|
mode = 1 if flags & 2 else (0 if flags & 1 else 2)
|
|
|
|
|
|
return mode
|
|
|
|
|
|
|
2026-11-04 21:35:18 +00:00
|
|
|
|
async def symbol_info(self, symbol: str) -> Dict[str, Any]:
|
2026-11-06 09:21:21 +00:00
|
|
|
|
await self.ensure()
|
2026-11-06 09:53:22 +00:00
|
|
|
|
symbol = self._resolve(symbol)
|
2026-11-04 21:35:18 +00:00
|
|
|
|
info = self.mt5.symbol_info(symbol)
|
|
|
|
|
|
if info is None:
|
|
|
|
|
|
raise HTTPException(404, f"Symbol {symbol} not found")
|
|
|
|
|
|
return {
|
|
|
|
|
|
"symbol": info.name,
|
|
|
|
|
|
"bid": info.bid,
|
|
|
|
|
|
"ask": info.ask,
|
|
|
|
|
|
"spread": info.spread,
|
|
|
|
|
|
"digits": info.digits,
|
|
|
|
|
|
"point": info.point,
|
|
|
|
|
|
"trade_contract_size": info.trade_contract_size,
|
|
|
|
|
|
"volume_min": info.volume_min,
|
|
|
|
|
|
"volume_max": info.volume_max,
|
|
|
|
|
|
"volume_step": info.volume_step,
|
2026-11-06 10:00:00 +00:00
|
|
|
|
"filling_mode": info.filling_mode,
|
|
|
|
|
|
"order_filling": {0: "FOK", 1: "IOC", 2: "RETURN"}[self._filling(info.name)],
|
2026-11-04 21:35:18 +00:00
|
|
|
|
"description": info.description,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async def ohlc(self, symbol: str, timeframe: str, bars: int, start_pos: int = 0) -> List[Dict]:
|
2026-11-06 09:21:21 +00:00
|
|
|
|
await self.ensure()
|
2026-11-06 09:53:22 +00:00
|
|
|
|
symbol = self._resolve(symbol)
|
2026-11-04 21:35:18 +00:00
|
|
|
|
import pandas as pd
|
|
|
|
|
|
|
|
|
|
|
|
# Check if seconds timeframe
|
|
|
|
|
|
seconds_map = {"S5": 5, "S10": 10, "S15": 15, "S30": 30}
|
|
|
|
|
|
if timeframe in seconds_map:
|
|
|
|
|
|
return await self._ohlc_from_ticks(symbol, seconds_map[timeframe], bars)
|
|
|
|
|
|
|
|
|
|
|
|
tf_map = {
|
|
|
|
|
|
"M1": 1, "M5": 5, "M15": 15, "M30": 30,
|
|
|
|
|
|
"H1": 16385, "H4": 16388, "D1": 16408, "W1": 32769,
|
|
|
|
|
|
}
|
|
|
|
|
|
tf = tf_map.get(timeframe, 16385) # default H1
|
|
|
|
|
|
rates = self.mt5.copy_rates_from_pos(symbol, tf, start_pos, bars)
|
|
|
|
|
|
if rates is None or len(rates) == 0:
|
|
|
|
|
|
raise HTTPException(404, f"No data for {symbol} {timeframe}")
|
|
|
|
|
|
|
|
|
|
|
|
df = pd.DataFrame(rates)
|
|
|
|
|
|
df["time"] = pd.to_datetime(df["time"], unit="s")
|
|
|
|
|
|
result = []
|
|
|
|
|
|
for _, row in df.iterrows():
|
|
|
|
|
|
result.append({
|
|
|
|
|
|
"time": row["time"].isoformat() + "Z",
|
|
|
|
|
|
"timestamp": int(row["time"].timestamp()),
|
|
|
|
|
|
"open": float(row["open"]),
|
|
|
|
|
|
"high": float(row["high"]),
|
|
|
|
|
|
"low": float(row["low"]),
|
|
|
|
|
|
"close": float(row["close"]),
|
|
|
|
|
|
"volume": int(row["tick_volume"]),
|
|
|
|
|
|
})
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def ohlc_range(self, symbol: str, timeframe: str,
|
|
|
|
|
|
from_ts: int, to_ts: int) -> List[Dict]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Hole OHLCV Bars für einen Zeitraum via copy_rates_range.
|
|
|
|
|
|
Ermöglicht chunked Downloads für beliebig lange History.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
symbol: z.B. "XAUUSD"
|
|
|
|
|
|
timeframe: M1, M5, M15, etc.
|
|
|
|
|
|
from_ts: Unix timestamp Start
|
|
|
|
|
|
to_ts: Unix timestamp Ende
|
|
|
|
|
|
"""
|
2026-11-06 09:21:21 +00:00
|
|
|
|
await self.ensure()
|
2026-11-06 09:53:22 +00:00
|
|
|
|
symbol = self._resolve(symbol)
|
2026-11-04 21:35:18 +00:00
|
|
|
|
import pandas as pd
|
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
|
|
tf_map = {
|
|
|
|
|
|
"M1": 1, "M5": 5, "M15": 15, "M30": 30,
|
|
|
|
|
|
"H1": 16385, "H4": 16388, "D1": 16408, "W1": 32769,
|
|
|
|
|
|
}
|
|
|
|
|
|
tf = tf_map.get(timeframe, 1)
|
|
|
|
|
|
|
|
|
|
|
|
# Convert timestamps to datetime for MT5
|
|
|
|
|
|
date_from = datetime.fromtimestamp(from_ts, tz=timezone.utc)
|
|
|
|
|
|
date_to = datetime.fromtimestamp(to_ts, tz=timezone.utc)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
rates = self.mt5.copy_rates_range(symbol, tf, date_from, date_to)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
# rpyc datetime workaround: pass as naive datetime
|
|
|
|
|
|
try:
|
|
|
|
|
|
date_from_naive = datetime.utcfromtimestamp(from_ts)
|
|
|
|
|
|
date_to_naive = datetime.utcfromtimestamp(to_ts)
|
|
|
|
|
|
rates = self.mt5.copy_rates_range(symbol, tf, date_from_naive, date_to_naive)
|
|
|
|
|
|
except Exception as e2:
|
|
|
|
|
|
raise HTTPException(500, f"copy_rates_range failed: {e2}")
|
|
|
|
|
|
|
|
|
|
|
|
if rates is None or len(rates) == 0:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
df = pd.DataFrame(rates)
|
|
|
|
|
|
df["time"] = pd.to_datetime(df["time"], unit="s")
|
|
|
|
|
|
|
|
|
|
|
|
result = []
|
|
|
|
|
|
for _, row in df.iterrows():
|
|
|
|
|
|
result.append({
|
|
|
|
|
|
"time": row["time"].isoformat() + "Z",
|
|
|
|
|
|
"timestamp": int(row["time"].timestamp()),
|
|
|
|
|
|
"open": float(row["open"]),
|
|
|
|
|
|
"high": float(row["high"]),
|
|
|
|
|
|
"low": float(row["low"]),
|
|
|
|
|
|
"close": float(row["close"]),
|
|
|
|
|
|
"volume": int(row["tick_volume"]),
|
|
|
|
|
|
})
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
async def _ohlc_from_ticks(self, symbol: str, period_seconds: int, bars: int) -> List[Dict]:
|
|
|
|
|
|
"""Build sub-minute OHLC bars from real tick data via persistent rpyc."""
|
|
|
|
|
|
import pandas as pd
|
|
|
|
|
|
|
|
|
|
|
|
conn = self._get_rpyc()
|
|
|
|
|
|
|
|
|
|
|
|
# Fetch ticks — all in Wine-Python, only pull converted tuples
|
|
|
|
|
|
ticks_needed = max(bars * 20, 5000)
|
|
|
|
|
|
minutes_back = max(int(bars * period_seconds / 60) * 2, 10)
|
|
|
|
|
|
|
|
|
|
|
|
conn.execute(
|
|
|
|
|
|
f'_ticks = mt5.copy_ticks_from("{symbol}", '
|
|
|
|
|
|
f'datetime.utcnow() - timedelta(minutes={minutes_back}), '
|
|
|
|
|
|
f'{ticks_needed}, mt5.COPY_TICKS_ALL)'
|
|
|
|
|
|
)
|
|
|
|
|
|
count = conn.eval("len(_ticks)")
|
|
|
|
|
|
log.info(f"Fetched {count} ticks for {symbol} ({minutes_back}min back)")
|
|
|
|
|
|
|
|
|
|
|
|
if count == 0:
|
|
|
|
|
|
raise HTTPException(404, f"No tick data for {symbol} — market may be closed")
|
|
|
|
|
|
|
|
|
|
|
|
# Convert on Wine side, pull as plain list
|
|
|
|
|
|
conn.execute(
|
|
|
|
|
|
"_td = [(int(_ticks[i][0]), float(_ticks[i][1]), float(_ticks[i][2])) "
|
|
|
|
|
|
"for i in range(len(_ticks))]"
|
|
|
|
|
|
)
|
|
|
|
|
|
tick_data = list(conn.eval("_td"))
|
|
|
|
|
|
|
|
|
|
|
|
# Build DataFrame locally
|
|
|
|
|
|
df = pd.DataFrame(tick_data, columns=["time", "bid", "ask"])
|
|
|
|
|
|
df["dt"] = pd.to_datetime(df["time"], unit="s")
|
|
|
|
|
|
df["mid"] = (df["bid"] + df["ask"]) / 2
|
|
|
|
|
|
|
|
|
|
|
|
# Resample into bars
|
|
|
|
|
|
rule = f"{period_seconds}s"
|
|
|
|
|
|
grouped = df.set_index("dt")["mid"].resample(rule)
|
|
|
|
|
|
ohlc_open = grouped.first().dropna()
|
|
|
|
|
|
ohlc_high = grouped.max().dropna()
|
|
|
|
|
|
ohlc_low = grouped.min().dropna()
|
|
|
|
|
|
ohlc_close = grouped.last().dropna()
|
|
|
|
|
|
vol = grouped.count()
|
|
|
|
|
|
|
|
|
|
|
|
idx = ohlc_open.index.intersection(ohlc_close.index)
|
2026-11-05 23:59:10 +00:00
|
|
|
|
idx = idx.intersection(ohlc_high.index).intersection(ohlc_low.index)
|
2026-11-04 21:35:18 +00:00
|
|
|
|
idx = idx[-bars:]
|
|
|
|
|
|
|
|
|
|
|
|
if len(idx) == 0:
|
|
|
|
|
|
raise HTTPException(404, f"No complete {period_seconds}s bars for {symbol}")
|
|
|
|
|
|
|
|
|
|
|
|
# Get current tick to fix last (incomplete) bar
|
|
|
|
|
|
try:
|
|
|
|
|
|
conn.execute(f'_curtick = mt5.symbol_info_tick("{symbol}")')
|
|
|
|
|
|
cur_bid = conn.eval("float(_curtick.bid)")
|
|
|
|
|
|
cur_ask = conn.eval("float(_curtick.ask)")
|
|
|
|
|
|
cur_mid = (cur_bid + cur_ask) / 2
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
cur_mid = None
|
|
|
|
|
|
|
|
|
|
|
|
result = []
|
|
|
|
|
|
last_idx = idx[-1]
|
|
|
|
|
|
for ts in idx:
|
|
|
|
|
|
o = float(ohlc_open[ts])
|
|
|
|
|
|
h = float(ohlc_high[ts])
|
|
|
|
|
|
l = float(ohlc_low[ts])
|
|
|
|
|
|
c = float(ohlc_close[ts])
|
|
|
|
|
|
|
|
|
|
|
|
# Fix last bar with current price
|
|
|
|
|
|
if ts == last_idx and cur_mid is not None:
|
|
|
|
|
|
c = cur_mid
|
|
|
|
|
|
if cur_mid > h:
|
|
|
|
|
|
h = cur_mid
|
|
|
|
|
|
if cur_mid < l:
|
|
|
|
|
|
l = cur_mid
|
|
|
|
|
|
|
|
|
|
|
|
result.append({
|
|
|
|
|
|
"time": ts.isoformat() + "Z",
|
|
|
|
|
|
"timestamp": int(ts.timestamp()),
|
|
|
|
|
|
"open": round(o, 6),
|
|
|
|
|
|
"high": round(h, 6),
|
|
|
|
|
|
"low": round(l, 6),
|
|
|
|
|
|
"close": round(c, 6),
|
|
|
|
|
|
"volume": int(vol.get(ts, 0)),
|
|
|
|
|
|
})
|
|
|
|
|
|
log.info(f"Built {len(result)} x {period_seconds}s bars from {len(df)} ticks")
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def order_open(self, symbol: str, side: str, volume: float,
|
|
|
|
|
|
sl: float = 0, tp: float = 0,
|
|
|
|
|
|
comment: str = "") -> Dict[str, Any]:
|
|
|
|
|
|
"""Open a market order."""
|
2026-11-06 09:21:21 +00:00
|
|
|
|
await self.ensure()
|
2026-11-06 09:53:22 +00:00
|
|
|
|
symbol = self._resolve(symbol)
|
2026-11-04 21:35:18 +00:00
|
|
|
|
|
|
|
|
|
|
# Get symbol info for filling
|
|
|
|
|
|
info = self.mt5.symbol_info(symbol)
|
|
|
|
|
|
if info is None:
|
|
|
|
|
|
raise HTTPException(404, f"Symbol {symbol} not found")
|
|
|
|
|
|
|
|
|
|
|
|
price = info.ask if side == "buy" else info.bid
|
|
|
|
|
|
order_type = 0 if side == "buy" else 1 # ORDER_TYPE_BUY / SELL
|
|
|
|
|
|
|
|
|
|
|
|
request = {
|
|
|
|
|
|
"action": 1, # TRADE_ACTION_DEAL
|
|
|
|
|
|
"symbol": symbol,
|
2026-11-06 10:00:00 +00:00
|
|
|
|
# MetaTrader5 verlangt Floats – ein int 0 ergibt (-2, 'Invalid "sl" argument')
|
|
|
|
|
|
"volume": float(volume),
|
2026-11-04 21:35:18 +00:00
|
|
|
|
"type": order_type,
|
2026-11-06 10:00:00 +00:00
|
|
|
|
"price": float(price),
|
|
|
|
|
|
"sl": float(sl or 0.0),
|
|
|
|
|
|
"tp": float(tp or 0.0),
|
2026-11-04 21:35:18 +00:00
|
|
|
|
"deviation": 20,
|
|
|
|
|
|
"magic": 202604,
|
|
|
|
|
|
"comment": comment or "xgboost_sweep",
|
|
|
|
|
|
"type_time": 0, # ORDER_TIME_GTC
|
2026-11-06 10:00:00 +00:00
|
|
|
|
"type_filling": self._filling(symbol),
|
2026-11-04 21:35:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result = self.mt5.order_send(request)
|
|
|
|
|
|
if result is None:
|
|
|
|
|
|
error = self.mt5.last_error()
|
|
|
|
|
|
raise HTTPException(500, f"order_send failed: {error}")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"retcode": result.retcode,
|
|
|
|
|
|
"deal": result.deal,
|
|
|
|
|
|
"order": result.order,
|
|
|
|
|
|
"volume": result.volume,
|
|
|
|
|
|
"price": result.price,
|
|
|
|
|
|
"comment": result.comment,
|
|
|
|
|
|
"success": result.retcode == 10009, # TRADE_RETCODE_DONE
|
|
|
|
|
|
"error": result.comment if result.retcode != 10009 else None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async def order_modify(self, ticket: int, sl: float = None,
|
|
|
|
|
|
tp: float = None) -> Dict[str, Any]:
|
|
|
|
|
|
"""Modify SL/TP of an existing position."""
|
2026-11-06 09:21:21 +00:00
|
|
|
|
await self.ensure()
|
2026-11-04 21:35:18 +00:00
|
|
|
|
|
|
|
|
|
|
# Get current position
|
|
|
|
|
|
positions = self.mt5.positions_get(ticket=ticket)
|
|
|
|
|
|
if positions is None or len(positions) == 0:
|
|
|
|
|
|
raise HTTPException(404, f"Position {ticket} not found")
|
|
|
|
|
|
|
|
|
|
|
|
pos = positions[0]
|
|
|
|
|
|
request = {
|
|
|
|
|
|
"action": 6, # TRADE_ACTION_SLTP
|
|
|
|
|
|
"position": ticket,
|
|
|
|
|
|
"symbol": pos.symbol,
|
2026-11-06 10:00:00 +00:00
|
|
|
|
"sl": float(sl if sl is not None else pos.sl),
|
|
|
|
|
|
"tp": float(tp if tp is not None else pos.tp),
|
2026-11-04 21:35:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result = self.mt5.order_send(request)
|
|
|
|
|
|
if result is None:
|
|
|
|
|
|
error = self.mt5.last_error()
|
|
|
|
|
|
raise HTTPException(500, f"order_modify failed: {error}")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"retcode": result.retcode,
|
|
|
|
|
|
"success": result.retcode == 10009,
|
|
|
|
|
|
"error": result.comment if result.retcode != 10009 else None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async def order_close(self, ticket: int) -> Dict[str, Any]:
|
|
|
|
|
|
"""Close an existing position."""
|
2026-11-06 09:21:21 +00:00
|
|
|
|
await self.ensure()
|
2026-11-04 21:35:18 +00:00
|
|
|
|
|
|
|
|
|
|
positions = self.mt5.positions_get(ticket=ticket)
|
|
|
|
|
|
if positions is None or len(positions) == 0:
|
|
|
|
|
|
raise HTTPException(404, f"Position {ticket} not found")
|
|
|
|
|
|
|
|
|
|
|
|
pos = positions[0]
|
|
|
|
|
|
close_type = 1 if pos.type == 0 else 0 # Reverse: BUY->SELL, SELL->BUY
|
|
|
|
|
|
price = self.mt5.symbol_info_tick(pos.symbol)
|
|
|
|
|
|
close_price = price.bid if pos.type == 0 else price.ask
|
|
|
|
|
|
|
|
|
|
|
|
request = {
|
|
|
|
|
|
"action": 1, # TRADE_ACTION_DEAL
|
|
|
|
|
|
"symbol": pos.symbol,
|
2026-11-06 10:00:00 +00:00
|
|
|
|
"volume": float(pos.volume),
|
2026-11-04 21:35:18 +00:00
|
|
|
|
"type": close_type,
|
2026-11-06 10:00:00 +00:00
|
|
|
|
"position": int(ticket),
|
|
|
|
|
|
"price": float(close_price),
|
2026-11-04 21:35:18 +00:00
|
|
|
|
"deviation": 20,
|
|
|
|
|
|
"magic": 202604,
|
|
|
|
|
|
"comment": "xgboost_close",
|
|
|
|
|
|
"type_time": 0,
|
2026-11-06 10:00:00 +00:00
|
|
|
|
"type_filling": self._filling(pos.symbol),
|
2026-11-04 21:35:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result = self.mt5.order_send(request)
|
|
|
|
|
|
if result is None:
|
|
|
|
|
|
error = self.mt5.last_error()
|
|
|
|
|
|
raise HTTPException(500, f"order_close failed: {error}")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"retcode": result.retcode,
|
|
|
|
|
|
"deal": result.deal,
|
|
|
|
|
|
"success": result.retcode == 10009,
|
|
|
|
|
|
"error": result.comment if result.retcode != 10009 else None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def tick(self, symbol: str) -> Dict[str, Any]:
|
2026-11-06 09:21:21 +00:00
|
|
|
|
await self.ensure()
|
2026-11-06 09:53:22 +00:00
|
|
|
|
symbol = self._resolve(symbol)
|
2026-11-04 21:35:18 +00:00
|
|
|
|
tick = self.mt5.symbol_info_tick(symbol)
|
|
|
|
|
|
if tick is None:
|
|
|
|
|
|
raise HTTPException(404, f"No tick for {symbol}")
|
|
|
|
|
|
return {
|
|
|
|
|
|
"symbol": symbol,
|
|
|
|
|
|
"bid": tick.bid,
|
|
|
|
|
|
"ask": tick.ask,
|
|
|
|
|
|
"last": tick.last,
|
|
|
|
|
|
"volume": tick.volume,
|
|
|
|
|
|
"time": datetime.fromtimestamp(tick.time, tz=timezone.utc).isoformat() + "Z",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async def positions(self) -> List[Dict]:
|
2026-11-06 09:21:21 +00:00
|
|
|
|
await self.ensure()
|
2026-11-04 21:35:18 +00:00
|
|
|
|
pos = self.mt5.positions_get()
|
|
|
|
|
|
if pos is None:
|
|
|
|
|
|
return []
|
|
|
|
|
|
result = []
|
|
|
|
|
|
for p in pos:
|
|
|
|
|
|
result.append({
|
|
|
|
|
|
"ticket": p.ticket,
|
|
|
|
|
|
"symbol": p.symbol,
|
|
|
|
|
|
"type": "BUY" if p.type == 0 else "SELL",
|
|
|
|
|
|
"volume": p.volume,
|
|
|
|
|
|
"open_price": p.price_open,
|
|
|
|
|
|
"current_price": p.price_current,
|
|
|
|
|
|
"profit": p.profit,
|
|
|
|
|
|
"swap": p.swap,
|
|
|
|
|
|
"sl": p.sl,
|
|
|
|
|
|
"tp": p.tp,
|
|
|
|
|
|
"open_time": datetime.fromtimestamp(p.time, tz=timezone.utc).isoformat() + "Z",
|
|
|
|
|
|
"magic": p.magic,
|
|
|
|
|
|
"comment": p.comment,
|
|
|
|
|
|
})
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
async def orders(self) -> List[Dict]:
|
2026-11-06 09:21:21 +00:00
|
|
|
|
await self.ensure()
|
2026-11-04 21:35:18 +00:00
|
|
|
|
ords = self.mt5.orders_get()
|
|
|
|
|
|
if ords is None:
|
|
|
|
|
|
return []
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
"ticket": o.ticket,
|
|
|
|
|
|
"symbol": o.symbol,
|
|
|
|
|
|
"type": o.type,
|
|
|
|
|
|
"volume": o.volume_current,
|
|
|
|
|
|
"price": o.price_open,
|
|
|
|
|
|
"sl": o.sl,
|
|
|
|
|
|
"tp": o.tp,
|
|
|
|
|
|
"time_setup": datetime.fromtimestamp(o.time_setup, tz=timezone.utc).isoformat() + "Z",
|
|
|
|
|
|
"comment": o.comment,
|
|
|
|
|
|
}
|
|
|
|
|
|
for o in ords
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Global bridge instance (set in lifespan)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
bridge: MT5Bridge = MockBridge()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@asynccontextmanager
|
|
|
|
|
|
async def lifespan(app: FastAPI):
|
|
|
|
|
|
global bridge
|
|
|
|
|
|
ok = await bridge.connect()
|
|
|
|
|
|
# Im Container startet MT5 beim ersten Mal mehrere Minuten – so lange warten statt
|
|
|
|
|
|
# dauerhaft "degraded" zu laufen. Healthcheck bleibt rot bis die Verbindung steht.
|
|
|
|
|
|
while not ok and bridge.mode == "live":
|
|
|
|
|
|
log.warning("MT5 not ready yet – retrying in 15s")
|
|
|
|
|
|
await asyncio.sleep(15)
|
|
|
|
|
|
ok = await bridge.connect()
|
|
|
|
|
|
if ok:
|
|
|
|
|
|
log.info(f"Server starting in {bridge.mode.upper()} mode")
|
|
|
|
|
|
else:
|
|
|
|
|
|
log.warning(f"Bridge connect failed ({bridge.mode}) – server runs but data may be unavailable")
|
|
|
|
|
|
yield
|
|
|
|
|
|
await bridge.disconnect()
|
|
|
|
|
|
log.info("Server shutdown complete")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# FastAPI App
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
app = FastAPI(
|
|
|
|
|
|
title="MT5 MCP Server",
|
|
|
|
|
|
version="3.0.0",
|
|
|
|
|
|
description="Linux MCP server for MetaTrader 5 – mock or live mode",
|
|
|
|
|
|
lifespan=lifespan,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
app.add_middleware(
|
|
|
|
|
|
CORSMiddleware,
|
|
|
|
|
|
allow_origins=["*"],
|
|
|
|
|
|
allow_credentials=True,
|
|
|
|
|
|
allow_methods=["*"],
|
|
|
|
|
|
allow_headers=["*"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
start_time = datetime.now(tz=None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Endpoints
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/")
|
|
|
|
|
|
async def root():
|
|
|
|
|
|
return {
|
|
|
|
|
|
"service": "MT5 MCP Server",
|
|
|
|
|
|
"version": "3.0.0",
|
|
|
|
|
|
"mode": bridge.mode,
|
|
|
|
|
|
"connected": bridge.connected,
|
|
|
|
|
|
"uptime_s": int((datetime.now(tz=None) - start_time).total_seconds()),
|
|
|
|
|
|
"endpoints": [
|
|
|
|
|
|
"GET /health",
|
|
|
|
|
|
"GET /account",
|
|
|
|
|
|
"GET /symbols",
|
|
|
|
|
|
"GET /symbol/{symbol}",
|
|
|
|
|
|
"GET /ohlc/{symbol}?timeframe=H1&bars=100",
|
|
|
|
|
|
"GET /tick/{symbol}",
|
|
|
|
|
|
"GET /positions",
|
|
|
|
|
|
"GET /orders",
|
|
|
|
|
|
"GET /mcp/tools",
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/health")
|
|
|
|
|
|
async def health():
|
2026-11-06 09:21:21 +00:00
|
|
|
|
if bridge.mode == "live":
|
|
|
|
|
|
try:
|
|
|
|
|
|
await bridge.ensure()
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
pass
|
2026-11-04 21:35:18 +00:00
|
|
|
|
return {
|
|
|
|
|
|
"status": "ok" if bridge.connected else "degraded",
|
|
|
|
|
|
"mode": bridge.mode,
|
|
|
|
|
|
"connected": bridge.connected,
|
|
|
|
|
|
"uptime_s": int((datetime.now(tz=None) - start_time).total_seconds()),
|
|
|
|
|
|
"timestamp": datetime.now(tz=None).isoformat() + "Z",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/account")
|
|
|
|
|
|
async def account():
|
|
|
|
|
|
return await bridge.account_info()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/symbols")
|
2026-11-06 09:53:22 +00:00
|
|
|
|
async def symbols(search: Optional[str] = Query(None, description="z.B. XAU – sucht in ALLEN Broker-Symbolen")):
|
|
|
|
|
|
syms = await bridge.symbols(search) if search else await bridge.symbols()
|
2026-11-04 21:35:18 +00:00
|
|
|
|
return {"count": len(syms), "symbols": syms}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/symbol/{symbol}")
|
|
|
|
|
|
async def symbol_info(symbol: str):
|
|
|
|
|
|
return await bridge.symbol_info(symbol.upper())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/ohlc/{symbol}")
|
|
|
|
|
|
async def ohlc(
|
|
|
|
|
|
symbol: str,
|
|
|
|
|
|
timeframe: str = Query("H1", pattern="^(S5|S10|S15|S30|M1|M5|M15|M30|H1|H4|D1|W1)$"),
|
|
|
|
|
|
bars: int = Query(100, ge=1, le=10000),
|
|
|
|
|
|
start: int = Query(0, ge=0, description="Offset: 0=latest, 10000=next page back"),
|
|
|
|
|
|
):
|
|
|
|
|
|
data = await bridge.ohlc(symbol.upper(), timeframe, bars, start_pos=start)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"symbol": symbol.upper(),
|
|
|
|
|
|
"timeframe": timeframe,
|
|
|
|
|
|
"bars": len(data),
|
|
|
|
|
|
"data": data,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/ohlc_range/{symbol}")
|
|
|
|
|
|
async def ohlc_range(
|
|
|
|
|
|
symbol: str,
|
|
|
|
|
|
timeframe: str = Query("M1", pattern="^(M1|M5|M15|M30|H1|H4|D1|W1)$"),
|
|
|
|
|
|
from_ts: int = Query(..., description="Unix timestamp start"),
|
|
|
|
|
|
to_ts: int = Query(..., description="Unix timestamp end"),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Fetch OHLCV bars for a specific time range (chunked downloads)."""
|
|
|
|
|
|
data = await bridge.ohlc_range(symbol.upper(), timeframe, from_ts, to_ts)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"symbol": symbol.upper(),
|
|
|
|
|
|
"timeframe": timeframe,
|
|
|
|
|
|
"from": from_ts,
|
|
|
|
|
|
"to": to_ts,
|
|
|
|
|
|
"bars": len(data),
|
|
|
|
|
|
"data": data,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Order Execution Endpoints
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
class OrderRequest(BaseModel):
|
|
|
|
|
|
symbol: str
|
|
|
|
|
|
side: str # "buy" or "sell"
|
|
|
|
|
|
volume: float = 0.01
|
|
|
|
|
|
sl: float = 0
|
|
|
|
|
|
tp: float = 0
|
|
|
|
|
|
comment: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
class ModifyRequest(BaseModel):
|
|
|
|
|
|
ticket: int
|
|
|
|
|
|
sl: float = None
|
|
|
|
|
|
tp: float = None
|
|
|
|
|
|
|
|
|
|
|
|
class CloseRequest(BaseModel):
|
|
|
|
|
|
ticket: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/order/open")
|
|
|
|
|
|
async def order_open(req: OrderRequest):
|
|
|
|
|
|
"""Open a market order."""
|
|
|
|
|
|
if req.side not in ("buy", "sell"):
|
|
|
|
|
|
raise HTTPException(400, "side must be 'buy' or 'sell'")
|
|
|
|
|
|
return await bridge.order_open(
|
|
|
|
|
|
req.symbol.upper(), req.side, req.volume,
|
|
|
|
|
|
req.sl, req.tp, req.comment
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/order/modify")
|
|
|
|
|
|
async def order_modify(req: ModifyRequest):
|
|
|
|
|
|
"""Modify SL/TP of existing position."""
|
|
|
|
|
|
return await bridge.order_modify(req.ticket, req.sl, req.tp)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/order/close")
|
|
|
|
|
|
async def order_close(req: CloseRequest):
|
|
|
|
|
|
"""Close existing position."""
|
|
|
|
|
|
return await bridge.order_close(req.ticket)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/tick/{symbol}")
|
|
|
|
|
|
async def tick(symbol: str):
|
|
|
|
|
|
return await bridge.tick(symbol.upper())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/positions")
|
|
|
|
|
|
async def positions():
|
|
|
|
|
|
pos = await bridge.positions()
|
|
|
|
|
|
return {"count": len(pos), "positions": pos}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/orders")
|
|
|
|
|
|
async def orders():
|
|
|
|
|
|
ords = await bridge.orders()
|
|
|
|
|
|
return {"count": len(ords), "orders": ords}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# MCP Tool Definitions (for AI integration / n8n)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@app.get("/mcp/tools")
|
|
|
|
|
|
async def mcp_tools():
|
|
|
|
|
|
"""Return MCP-compatible tool definitions."""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"tools": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "get_account_info",
|
|
|
|
|
|
"description": "Get MT5 trading account info (balance, equity, margin, leverage)",
|
|
|
|
|
|
"inputSchema": {"type": "object", "properties": {}},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "get_symbols",
|
|
|
|
|
|
"description": "List available trading symbols",
|
|
|
|
|
|
"inputSchema": {"type": "object", "properties": {}},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "get_symbol_info",
|
|
|
|
|
|
"description": "Get details for a specific symbol (bid, ask, spread, contract size)",
|
|
|
|
|
|
"inputSchema": {
|
|
|
|
|
|
"type": "object",
|
|
|
|
|
|
"properties": {"symbol": {"type": "string", "description": "Symbol name, e.g. EURUSD"}},
|
|
|
|
|
|
"required": ["symbol"],
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "get_ohlc",
|
|
|
|
|
|
"description": "Get OHLC candlestick data for a symbol",
|
|
|
|
|
|
"inputSchema": {
|
|
|
|
|
|
"type": "object",
|
|
|
|
|
|
"properties": {
|
|
|
|
|
|
"symbol": {"type": "string", "description": "Symbol name"},
|
|
|
|
|
|
"timeframe": {"type": "string", "enum": ["M1", "M5", "M15", "M30", "H1", "H4", "D1", "W1"]},
|
|
|
|
|
|
"bars": {"type": "integer", "description": "Number of bars (1-10000)", "default": 100},
|
|
|
|
|
|
},
|
|
|
|
|
|
"required": ["symbol"],
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "get_tick",
|
|
|
|
|
|
"description": "Get current tick (bid/ask) for a symbol",
|
|
|
|
|
|
"inputSchema": {
|
|
|
|
|
|
"type": "object",
|
|
|
|
|
|
"properties": {"symbol": {"type": "string"}},
|
|
|
|
|
|
"required": ["symbol"],
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "get_positions",
|
|
|
|
|
|
"description": "Get open trading positions",
|
|
|
|
|
|
"inputSchema": {"type": "object", "properties": {}},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "get_orders",
|
|
|
|
|
|
"description": "Get pending orders",
|
2026-11-05 23:59:10 +00:00
|
|
|
|
"inputSchema": {"type": "object", "properties": {}},
|
2026-11-04 21:35:18 +00:00
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Main
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def main():
|
|
|
|
|
|
global bridge
|
|
|
|
|
|
|
|
|
|
|
|
parser = argparse.ArgumentParser(description="MT5 MCP Server")
|
|
|
|
|
|
parser.add_argument("--mock", action="store_true", help="Use mock data (default)")
|
|
|
|
|
|
parser.add_argument("--live", action="store_true", help="Connect to real MT5 via mt5linux")
|
|
|
|
|
|
parser.add_argument("--host", default="0.0.0.0", help="Listen host (default: 0.0.0.0)")
|
|
|
|
|
|
parser.add_argument("--port", type=int, default=8000, help="Listen port (default: 8000)")
|
|
|
|
|
|
parser.add_argument("--rpyc-host", default="localhost", help="mt5linux rpyc host")
|
|
|
|
|
|
parser.add_argument("--rpyc-port", type=int, default=8001, help="mt5linux rpyc port")
|
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
if args.live:
|
|
|
|
|
|
bridge = LiveBridge(host=args.rpyc_host, port=args.rpyc_port)
|
|
|
|
|
|
else:
|
|
|
|
|
|
bridge = MockBridge()
|
|
|
|
|
|
|
|
|
|
|
|
log.info(f"Starting MT5 MCP Server on {args.host}:{args.port} [{bridge.mode} mode]")
|
|
|
|
|
|
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
2026-11-06 10:00:00 +00:00
|
|
|
|
main()
|