1006 lines
35 KiB
Python
1006 lines
35 KiB
Python
"""MT5Bridge CCXT Exchange implementation.
|
|
|
|
This module implements the main CCXT-compatible exchange class that
|
|
wraps the MT5Bridge HTTP API, allowing any CCXT-aware framework
|
|
(Freqtrade, Jesse, Hummingbot, backtrader, etc.) to use MetaTrader 5
|
|
through HTTP.
|
|
|
|
Architecture:
|
|
CCXT framework (Freqtrade, etc.)
|
|
|
|
|
v (standard CCXT methods: fetch_ticker, create_order, ...)
|
|
mt5bridge (this class)
|
|
|
|
|
v (HTTP requests: GET /symbols/{s}/tick, POST /order/send, ...)
|
|
MT5Bridge HTTP service (C# / .NET 8)
|
|
|
|
|
v (MtApi5 client, localhost:8228)
|
|
MtApi5 EA on MT5 chart
|
|
|
|
|
v
|
|
MetaTrader 5 client
|
|
"""
|
|
|
|
import json
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, List, Optional, Union
|
|
|
|
import ccxt
|
|
import requests
|
|
from ccxt.base.errors import (
|
|
ExchangeError,
|
|
InvalidOrder,
|
|
OrderNotFound,
|
|
BadSymbol,
|
|
BadRequest,
|
|
ArgumentsRequired,
|
|
InsufficientFunds,
|
|
RateLimitExceeded,
|
|
)
|
|
from requests.adapters import HTTPAdapter
|
|
from urllib3.util.retry import Retry
|
|
|
|
from mt5bridge_ccxt.exceptions import (
|
|
Mt5BridgeError,
|
|
Mt5BridgeConnectionError,
|
|
Mt5BridgeAuthError,
|
|
Mt5BridgeNotConnectedError,
|
|
Mt5BridgeInvalidRequestError,
|
|
Mt5BridgeTimeoutError,
|
|
)
|
|
from mt5bridge_ccxt.mql5_bridge import Mql5Bridge
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# MT5 Constants
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
# ENUM_TRADE_REQUEST_ACTIONS
|
|
MT5_ACTION_DEAL = 1 # Market execution (open/close position)
|
|
MT5_ACTION_SLTP = 2 # Modify SL/TP
|
|
MT5_ACTION_MODIFY = 4 # Modify pending order
|
|
MT5_ACTION_PENDING = 5 # Place pending order
|
|
MT5_ACTION_REMOVE = 6 # Remove pending order
|
|
MT5_ACTION_CLOSE_BY = -1 # Close by opposite position
|
|
|
|
# ENUM_ORDER_TYPE
|
|
MT5_TYPE_BUY = 0
|
|
MT5_TYPE_SELL = 1
|
|
MT5_TYPE_BUY_LIMIT = 2
|
|
MT5_TYPE_SELL_LIMIT = 3
|
|
MT5_TYPE_BUY_STOP = 4
|
|
MT5_TYPE_SELL_STOP = 5
|
|
MT5_TYPE_BUY_STOPLIMIT = 6
|
|
MT5_TYPE_SELL_STOPLIMIT = 7
|
|
|
|
# (ccxt type, ccxt side) -> MT5 order type code
|
|
MT5_ORDER_TYPE = {
|
|
("market", "buy"): MT5_TYPE_BUY,
|
|
("market", "sell"): MT5_TYPE_SELL,
|
|
("limit", "buy"): MT5_TYPE_BUY_LIMIT,
|
|
("limit", "sell"): MT5_TYPE_SELL_LIMIT,
|
|
("stop", "buy"): MT5_TYPE_BUY_STOP,
|
|
("stop", "sell"): MT5_TYPE_SELL_STOP,
|
|
("stop_limit", "buy"): MT5_TYPE_BUY_STOPLIMIT,
|
|
("stop_limit", "sell"): MT5_TYPE_SELL_STOPLIMIT,
|
|
}
|
|
|
|
# MT5 retcode -> CCXT-friendly status string
|
|
# Source: MQL5 docs, ENUM_TRADE_REQUEST_RETCODE
|
|
MT5_RETCODE_STATUS = {
|
|
10009: "open", # TRADE_RETCODE_DONE - request executed
|
|
10010: "filled", # TRADE_RETCODE_PLACED - pending order placed
|
|
10011: "canceled", # TRADE_RETCODE_DONE_PARTIAL
|
|
10012: "canceled", # TRADE_RETCODE_ERROR
|
|
10013: "rejected", # TRADE_RETCODE_TIMEOUT
|
|
10014: "rejected", # TRADE_RETCODE_INVALID
|
|
10015: "rejected", # TRADE_RETCODE_INVALID_VOLUME
|
|
10016: "rejected", # TRADE_RETCODE_INVALID_PRICE
|
|
10017: "rejected", # TRADE_RETCODE_BUSY
|
|
10018: "rejected", # TRADE_RETCODE_NO_CHANGES
|
|
10019: "rejected", # TRADE_RETCODE_LOCKED
|
|
10020: "rejected", # TRADE_RETCODE_FROZEN
|
|
10021: "rejected", # TRADE_RETCODE_INVALID_FILL
|
|
10022: "rejected", # TRADE_RETCODE_CONNECTION
|
|
10023: "rejected", # TRADE_RETCODE_ONLY_REAL
|
|
10024: "rejected", # TRADE_RETCODE_LIMIT_ORDERS
|
|
10025: "rejected", # TRADE_RETCODE_LIMIT_VOLUME
|
|
}
|
|
|
|
# MT5 retcode -> True if it indicates insufficient funds
|
|
MT5_RETCODE_INSUFFICIENT_FUNDS = {10019, 10020, 10021, 10022, 10025}
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Main CCXT Exchange class
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
class mt5bridge(ccxt.Exchange):
|
|
"""CCXT-compatible exchange wrapping the MT5Bridge HTTP API.
|
|
|
|
Configuration:
|
|
apiKey: your MT5Bridge API key (sent as X-API-Key header)
|
|
secret: not used (placeholder for CCXT compatibility)
|
|
host: base URL of MT5Bridge (default: http://localhost:8080)
|
|
timeout: request timeout in ms (default: 30000)
|
|
symbols: optional dict mapping CCXT symbols to MT5 symbols, e.g.
|
|
{"XAU/USD": "XAUUSDc", "EUR/USD": "EURUSDc"}
|
|
If not provided, the wrapper strips "/" from CCXT symbols.
|
|
default_volume_step: default lot rounding (default 0.01)
|
|
|
|
Example:
|
|
>>> import mt5bridge_ccxt
|
|
>>> exchange = mt5bridge_ccxt.mt5bridge({
|
|
... "apiKey": "your-api-key",
|
|
... "host": "http://localhost:8080",
|
|
... "symbols": {"XAU/USD": "XAUUSDc"},
|
|
... })
|
|
"""
|
|
|
|
# ──────── CCXT Exchange Metadata ────────
|
|
|
|
id = "mt5bridge"
|
|
name = "MT5 Bridge"
|
|
countries = ["*"]
|
|
version = "0.1.0"
|
|
rateLimit = 100
|
|
certified = False
|
|
pro = False
|
|
has = {
|
|
"publicAPI": False,
|
|
"privateAPI": True,
|
|
"CORS": True,
|
|
"spot": False,
|
|
"margin": True,
|
|
"swap": True,
|
|
"future": False,
|
|
"option": False,
|
|
"cancelOrder": True,
|
|
"cancelAllOrders": False,
|
|
"createOrder": True,
|
|
"createReduceOnlyOrder": False,
|
|
"createStopLimitOrder": True,
|
|
"createStopMarketOrder": True,
|
|
"createStopOrder": True,
|
|
"fetchBalance": True,
|
|
"fetchClosedOrders": True,
|
|
"fetchMarkets": True,
|
|
"fetchMyTrades": True,
|
|
"fetchOHLCV": True,
|
|
"fetchOpenOrders": True,
|
|
"fetchOrder": False,
|
|
"fetchOrderBook": False,
|
|
"fetchOrders": True,
|
|
"fetchPosition": True,
|
|
"fetchPositions": True,
|
|
"fetchStatus": True,
|
|
"fetchTicker": True,
|
|
"fetchTickers": False,
|
|
"fetchTime": False,
|
|
"fetchTrades": False,
|
|
"fetchTradingFee": False,
|
|
"fetchTradingFees": False,
|
|
"setLeverage": False,
|
|
"setMarginMode": False,
|
|
"transfer": False,
|
|
"withdraw": False,
|
|
}
|
|
|
|
timeframes = {
|
|
"1m": "TIMEFRAME_M1",
|
|
"5m": "TIMEFRAME_M5",
|
|
"15m": "TIMEFRAME_M15",
|
|
"30m": "TIMEFRAME_M30",
|
|
"1h": "TIMEFRAME_H1",
|
|
"4h": "TIMEFRAME_H4",
|
|
"1d": "TIMEFRAME_D1",
|
|
}
|
|
|
|
urls = {
|
|
"api": "http://localhost:8080",
|
|
}
|
|
|
|
api = {
|
|
"public": {
|
|
"get": {"health": 1},
|
|
},
|
|
"private": {
|
|
"get": {
|
|
"account": 1,
|
|
"symbols/{symbol}": 1,
|
|
"symbols/{symbol}/tick": 1,
|
|
"rates/from-pos": 1,
|
|
"rates/from-date": 1,
|
|
"positions": 1,
|
|
"orders": 1,
|
|
"history/deals": 1,
|
|
"gvar": 1,
|
|
"gvar/{name}": 1,
|
|
},
|
|
"post": {
|
|
"order/check": 1,
|
|
"order/send": 1,
|
|
"gvar/{name}": 1,
|
|
},
|
|
"delete": {
|
|
"gvar/{name}": 1,
|
|
},
|
|
},
|
|
}
|
|
|
|
requiredCredentials = {
|
|
"apiKey": True,
|
|
"secret": False,
|
|
}
|
|
|
|
# ──────── Init ────────
|
|
|
|
def __init__(self, config={}):
|
|
super().__init__(config)
|
|
|
|
# Configuration
|
|
self.host = (config.get("host") or self.urls["api"]).rstrip("/")
|
|
self.apiKey = config.get("apiKey") or self.apiKey or ""
|
|
self.timeout = config.get("timeout") or 30000
|
|
self.default_volume_step = config.get("default_volume_step", 0.01)
|
|
self.symbol_map = config.get("symbols", {}) or {}
|
|
# Reverse map for displaying MT5 symbols as CCXT symbols
|
|
self._mt5_to_ccxt = {v: k for k, v in self.symbol_map.items()}
|
|
|
|
# HTTP session with retry logic
|
|
self.session = requests.Session()
|
|
retry = Retry(
|
|
total=3,
|
|
backoff_factor=0.5,
|
|
status_forcelist=[502, 503, 504],
|
|
allowed_methods=["GET", "POST", "DELETE"],
|
|
)
|
|
adapter = HTTPAdapter(max_retries=retry, pool_connections=10, pool_maxsize=10)
|
|
self.session.mount("http://", adapter)
|
|
self.session.mount("https://", adapter)
|
|
self.session.headers.update({
|
|
"X-API-Key": self.apiKey,
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
"User-Agent": f"mt5bridge-ccxt/{self.version}",
|
|
})
|
|
|
|
# MQL5 helper
|
|
self.mql5 = Mql5Bridge(self)
|
|
|
|
# ──────── Internal helpers ────────
|
|
|
|
def _resolve_mt5_symbol(self, symbol):
|
|
"""Convert CCXT symbol (XAU/USD) to MT5 symbol (XAUUSDc)."""
|
|
if not symbol:
|
|
return symbol
|
|
if symbol in self.symbol_map:
|
|
return self.symbol_map[symbol]
|
|
# Fallback: strip slash
|
|
return symbol.replace("/", "")
|
|
|
|
def _resolve_ccxt_symbol(self, mt5_symbol):
|
|
"""Convert MT5 symbol back to CCXT format for display."""
|
|
if not mt5_symbol:
|
|
return mt5_symbol
|
|
if mt5_symbol in self._mt5_to_ccxt:
|
|
return self._mt5_to_ccxt[mt5_symbol]
|
|
# Best-effort fallback
|
|
return mt5_symbol
|
|
|
|
def _request(self, method, path, params=None, json_body=None):
|
|
"""Make HTTP request to MT5Bridge with proper error handling.
|
|
|
|
Args:
|
|
method: HTTP method (GET, POST, DELETE)
|
|
path: API path (without host)
|
|
params: query parameters
|
|
json_body: JSON request body for POST
|
|
|
|
Returns:
|
|
dict: response JSON
|
|
|
|
Raises:
|
|
Mt5BridgeAuthError: HTTP 401
|
|
Mt5BridgeInvalidRequestError: HTTP 400
|
|
Mt5BridgeNotConnectedError: HTTP 503
|
|
Mt5BridgeError: other failures
|
|
"""
|
|
url = f"{self.host}/{path.lstrip('/')}"
|
|
timeout_s = self.timeout / 1000.0
|
|
|
|
try:
|
|
response = self.session.request(
|
|
method=method,
|
|
url=url,
|
|
params=params,
|
|
json=json_body,
|
|
timeout=timeout_s,
|
|
)
|
|
except requests.exceptions.Timeout as e:
|
|
raise Mt5BridgeTimeoutError(
|
|
f"Request to {url} timed out after {timeout_s}s"
|
|
) from e
|
|
except requests.exceptions.ConnectionError as e:
|
|
raise Mt5BridgeConnectionError(
|
|
f"Failed to connect to {url}: {e}"
|
|
) from e
|
|
except requests.exceptions.RequestException as e:
|
|
raise Mt5BridgeError(f"Request failed: {e}") from e
|
|
|
|
# Map HTTP status codes to exceptions
|
|
if response.status_code == 401:
|
|
raise Mt5BridgeAuthError("Unauthorized: check your API key")
|
|
if response.status_code == 503:
|
|
raise Mt5BridgeNotConnectedError(
|
|
"MT5 is not connected to broker (HTTP 503)"
|
|
)
|
|
if response.status_code == 400:
|
|
detail = self._extract_detail(response, "Bad request")
|
|
raise Mt5BridgeInvalidRequestError(detail)
|
|
if response.status_code == 429:
|
|
raise RateLimitExceeded(f"Rate limited: {response.text}")
|
|
if response.status_code >= 500:
|
|
detail = self._extract_detail(response, "Server error")
|
|
raise Mt5BridgeError(f"MT5Bridge server error: {detail}")
|
|
if response.status_code >= 400:
|
|
detail = self._extract_detail(response, f"HTTP {response.status_code}")
|
|
raise Mt5BridgeError(detail)
|
|
|
|
try:
|
|
return response.json()
|
|
except json.JSONDecodeError as e:
|
|
raise Mt5BridgeError(
|
|
f"Invalid JSON response: {response.text[:200]}"
|
|
) from e
|
|
|
|
def _extract_detail(self, response, fallback):
|
|
"""Extract 'detail' field from a JSON error response."""
|
|
try:
|
|
body = response.json()
|
|
return body.get("detail") or fallback
|
|
except Exception:
|
|
return fallback or response.text[:200]
|
|
|
|
def _parse_datetime(self, dt_str):
|
|
"""Parse ISO datetime to millisecond timestamp.
|
|
|
|
Handles formats:
|
|
"yyyy-MM-ddTHH:mm:ss"
|
|
"yyyy-MM-ddTHH:mm:ss.fff000"
|
|
"yyyy-MM-dd"
|
|
"""
|
|
if not dt_str:
|
|
return None
|
|
try:
|
|
s = dt_str
|
|
if "T" not in s and " " in s:
|
|
s = s.replace(" ", "T")
|
|
if "." in s:
|
|
s = s.split(".")[0]
|
|
dt = datetime.fromisoformat(s)
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
return int(dt.timestamp() * 1000)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
def _get_order_type_code(self, order_type, side):
|
|
"""Map CCXT (order_type, side) to MT5 order type code."""
|
|
key = (order_type, side)
|
|
if key in MT5_ORDER_TYPE:
|
|
return MT5_ORDER_TYPE[key]
|
|
# Fallback: try the more general mapping
|
|
valid_types = ("market", "limit", "stop", "stop_limit")
|
|
if order_type not in valid_types:
|
|
raise InvalidOrder(
|
|
f"Unsupported order type '{order_type}'. "
|
|
f"Valid types: {valid_types}"
|
|
)
|
|
if side not in ("buy", "sell"):
|
|
raise InvalidOrder(f"Unsupported side '{side}'. Valid: buy, sell")
|
|
# Should not reach here given valid_types
|
|
raise InvalidOrder(f"Unsupported combination: {order_type} + {side}")
|
|
|
|
def _get_status_from_retcode(self, retcode):
|
|
"""Map MT5 retcode to CCXT status string."""
|
|
return MT5_RETCODE_STATUS.get(int(retcode), "rejected")
|
|
|
|
def _format_volume(self, amount):
|
|
"""Round volume to nearest step."""
|
|
if amount is None:
|
|
return 0.0
|
|
try:
|
|
return round(float(amount), 2)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
# ──────── Public API: status & markets ────────
|
|
|
|
def fetch_status(self, params={}):
|
|
"""Check MT5Bridge and MT5 connection status.
|
|
|
|
Returns:
|
|
dict: {"status": "ok"|"maintenance", "updated": ms, "info": ...}
|
|
"""
|
|
try:
|
|
r = self._request("GET", "/health")
|
|
except Mt5BridgeNotConnectedError:
|
|
return self._make_status("maintenance")
|
|
except Mt5BridgeError as e:
|
|
return self._make_status("maintenance", info={"error": str(e)})
|
|
|
|
if r.get("mt5_connected"):
|
|
return self._make_status("ok", info=r)
|
|
return self._make_status("maintenance", info=r)
|
|
|
|
def _make_status(self, status, info=None):
|
|
return {
|
|
"status": status,
|
|
"updated": self.milliseconds(),
|
|
"info": info or {},
|
|
}
|
|
|
|
def fetch_markets(self, params={}):
|
|
"""Fetch available markets.
|
|
|
|
Note: MT5Bridge doesn't expose a "list all symbols" endpoint.
|
|
Markets are built from the `symbols` config (CCXT -> MT5 mapping).
|
|
To add a market, populate the `symbols` config dict.
|
|
|
|
Returns:
|
|
list of market dicts (CCXT format)
|
|
"""
|
|
if not self.symbol_map:
|
|
# Try to discover from positions / tickers
|
|
return []
|
|
|
|
markets = []
|
|
for ccxt_symbol, mt5_symbol in self.symbol_map.items():
|
|
markets.append(self._build_market(ccxt_symbol, mt5_symbol))
|
|
return markets
|
|
|
|
def _build_market(self, ccxt_symbol, mt5_symbol):
|
|
"""Build a CCXT market dict from symbol info."""
|
|
# Try to get symbol info (optional - don't fail if unavailable)
|
|
info_data = {}
|
|
try:
|
|
r = self._request("GET", f"/symbols/{mt5_symbol}")
|
|
info_data = r.get("data", [{}])[0]
|
|
except Exception:
|
|
pass
|
|
|
|
if "/" in ccxt_symbol:
|
|
base, quote = ccxt_symbol.split("/", 1)
|
|
else:
|
|
base, quote = ccxt_symbol, ""
|
|
|
|
return {
|
|
"id": mt5_symbol,
|
|
"symbol": ccxt_symbol,
|
|
"base": base,
|
|
"quote": quote,
|
|
"baseId": base,
|
|
"quoteId": quote,
|
|
"active": True,
|
|
"type": "swap", # MT5 OTC/CFD
|
|
"spot": False,
|
|
"margin": True,
|
|
"swap": True,
|
|
"future": False,
|
|
"option": False,
|
|
"contract": False,
|
|
"linear": None,
|
|
"inverse": None,
|
|
"contractSize": info_data.get("trade_contract_size") or 100,
|
|
"expiry": None,
|
|
"expiryDatetime": None,
|
|
"strike": None,
|
|
"optionType": None,
|
|
"precision": {
|
|
"amount": 2,
|
|
"price": info_data.get("digits", 5),
|
|
},
|
|
"limits": {
|
|
"amount": {
|
|
"min": info_data.get("volume_min", 0.01),
|
|
"max": info_data.get("volume_max", 100.0),
|
|
},
|
|
"price": {"min": None, "max": None},
|
|
"cost": {"min": None, "max": None},
|
|
},
|
|
"info": info_data,
|
|
}
|
|
|
|
# ──────── Public API: market data ────────
|
|
|
|
def fetch_ticker(self, symbol, params={}):
|
|
"""Fetch current ticker for a symbol.
|
|
|
|
Args:
|
|
symbol: CCXT symbol (e.g. "XAU/USD")
|
|
params: extra params
|
|
|
|
Returns:
|
|
dict: CCXT ticker
|
|
"""
|
|
mt5_symbol = self._resolve_mt5_symbol(symbol)
|
|
r = self._request("GET", f"/symbols/{mt5_symbol}/tick")
|
|
data = r["data"][0]
|
|
ts = self._parse_datetime(data.get("time"))
|
|
|
|
bid = data.get("bid")
|
|
ask = data.get("ask")
|
|
last = data.get("last")
|
|
|
|
return {
|
|
"symbol": symbol,
|
|
"timestamp": ts,
|
|
"datetime": self.iso8601(ts) if ts else None,
|
|
"high": ask,
|
|
"low": bid,
|
|
"bid": bid,
|
|
"bidVolume": None,
|
|
"ask": ask,
|
|
"askVolume": None,
|
|
"vwap": None,
|
|
"open": None,
|
|
"close": last if last is not None else ask,
|
|
"last": last,
|
|
"previousClose": None,
|
|
"change": None,
|
|
"percentage": None,
|
|
"average": None,
|
|
"baseVolume": data.get("volume"),
|
|
"quoteVolume": None,
|
|
"info": data,
|
|
}
|
|
|
|
def fetch_ohlcv(self, symbol, timeframe="1h", since=None, limit=None, params={}):
|
|
"""Fetch OHLCV candlestick data.
|
|
|
|
Args:
|
|
symbol: CCXT symbol (e.g. "XAU/USD")
|
|
timeframe: "1m" | "5m" | "15m" | "30m" | "1h" | "4h" | "1d"
|
|
since: start timestamp in milliseconds (optional)
|
|
limit: max number of bars (default 100, max 10000)
|
|
params: extra params. Can include:
|
|
- "date_from": explicit start date "yyyy-MM-dd"
|
|
- "date_to": explicit end date "yyyy-MM-dd"
|
|
|
|
Returns:
|
|
list of [timestamp, open, high, low, close, volume]
|
|
"""
|
|
if timeframe not in self.timeframes:
|
|
raise BadRequest(
|
|
f"Unsupported timeframe '{timeframe}'. "
|
|
f"Supported: {sorted(self.timeframes.keys())}"
|
|
)
|
|
mt5_symbol = self._resolve_mt5_symbol(symbol)
|
|
mt5_tf = self.timeframes[timeframe]
|
|
limit = min(limit or 100, 10000)
|
|
|
|
# Decide endpoint
|
|
if "date_from" in params and "date_to" in params:
|
|
query = {
|
|
"symbol": mt5_symbol,
|
|
"timeframe": mt5_tf,
|
|
"date_from": params["date_from"],
|
|
"date_to": params["date_to"],
|
|
}
|
|
elif since is not None:
|
|
date_from = datetime.fromtimestamp(
|
|
since / 1000, tz=timezone.utc
|
|
).strftime("%Y-%m-%d")
|
|
query = {
|
|
"symbol": mt5_symbol,
|
|
"timeframe": mt5_tf,
|
|
"date_from": date_from,
|
|
"date_to": datetime.now(tz=timezone.utc).strftime("%Y-%m-%d"),
|
|
}
|
|
else:
|
|
query = {
|
|
"symbol": mt5_symbol,
|
|
"timeframe": mt5_tf,
|
|
"start_pos": 0,
|
|
"count": limit,
|
|
}
|
|
|
|
r = self._request("GET", "/rates/from-pos" if "start_pos" in query else "/rates/from-date", params=query)
|
|
bars = r.get("data", [])
|
|
|
|
result = []
|
|
for bar in bars:
|
|
ts = self._parse_datetime(bar.get("time"))
|
|
result.append([
|
|
ts,
|
|
self.safe_float(bar, "open"),
|
|
self.safe_float(bar, "high"),
|
|
self.safe_float(bar, "low"),
|
|
self.safe_float(bar, "close"),
|
|
self.safe_float(bar, "tick_volume"),
|
|
])
|
|
return result
|
|
|
|
@staticmethod
|
|
def safe_float(d, key, default=None):
|
|
try:
|
|
v = d.get(key)
|
|
if v is None:
|
|
return default
|
|
return float(v)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
# ──────── Public API: account & positions ────────
|
|
|
|
def fetch_balance(self, params={}):
|
|
"""Fetch account balance.
|
|
|
|
Returns:
|
|
dict: {info, timestamp, free, used, total, debt}
|
|
"""
|
|
r = self._request("GET", "/account")
|
|
acc = r["data"][0]
|
|
currency = acc.get("currency") or "USD"
|
|
ts = self.milliseconds()
|
|
|
|
return {
|
|
"info": acc,
|
|
"timestamp": ts,
|
|
"datetime": self.iso8601(ts),
|
|
"free": {currency: self.safe_float(acc, "margin_free", 0.0)},
|
|
"used": {currency: self.safe_float(acc, "margin", 0.0)},
|
|
"total": {currency: self.safe_float(acc, "balance", 0.0)},
|
|
"debt": None,
|
|
}
|
|
|
|
def fetch_positions(self, symbols=None, params={}):
|
|
"""Fetch open positions.
|
|
|
|
Args:
|
|
symbols: optional list of CCXT symbols to filter
|
|
params: extra params
|
|
|
|
Returns:
|
|
list of CCXT position dicts
|
|
"""
|
|
r = self._request("GET", "/positions")
|
|
positions = r.get("data", [])
|
|
|
|
target_mt5 = set()
|
|
if symbols:
|
|
for s in symbols:
|
|
target_mt5.add(self._resolve_mt5_symbol(s))
|
|
|
|
result = []
|
|
for p in positions:
|
|
mt5_sym = p.get("symbol", "")
|
|
if symbols and mt5_sym not in target_mt5:
|
|
continue
|
|
ccxt_sym = self._resolve_ccxt_symbol(mt5_sym)
|
|
side = "long" if int(p.get("type", 0)) == 0 else "short"
|
|
|
|
sl = self.safe_float(p, "sl", 0.0) or None
|
|
tp = self.safe_float(p, "tp", 0.0) or None
|
|
|
|
result.append({
|
|
"info": p,
|
|
"id": str(p.get("ticket", "")),
|
|
"symbol": ccxt_sym,
|
|
"timestamp": None,
|
|
"datetime": None,
|
|
"isolated": False,
|
|
"hedged": None,
|
|
"side": side,
|
|
"contracts": self.safe_float(p, "volume", 0.0),
|
|
"contractSize": None,
|
|
"entryPrice": self.safe_float(p, "price_open", 0.0),
|
|
"markPrice": self.safe_float(p, "price_current", 0.0),
|
|
"notional": None,
|
|
"leverage": None,
|
|
"collateral": None,
|
|
"initialMargin": None,
|
|
"initialMarginPercentage": None,
|
|
"maintenanceMargin": None,
|
|
"maintenanceMarginPercentage": None,
|
|
"unrealizedPnl": self.safe_float(p, "profit", 0.0),
|
|
"realizedPnl": None,
|
|
"percentage": None,
|
|
"marginRatio": None,
|
|
"liquidationPrice": None,
|
|
"marginMode": None,
|
|
"stopLossPrice": sl,
|
|
"takeProfitPrice": tp,
|
|
})
|
|
|
|
return result
|
|
|
|
def fetch_position(self, symbol, params={}):
|
|
"""Fetch a single position by symbol.
|
|
|
|
Note: MT5 can have multiple positions per symbol; this returns the
|
|
most recently opened one (or None if no position exists).
|
|
"""
|
|
positions = self.fetch_positions([symbol], params)
|
|
if not positions:
|
|
return None
|
|
# Return the one with highest ticket (most recent)
|
|
return max(positions, key=lambda p: int(p["info"].get("ticket", 0)))
|
|
|
|
# ──────── Public API: orders & trades ────────
|
|
|
|
def fetch_open_orders(self, symbol=None, since=None, limit=None, params={}):
|
|
"""Fetch open (pending) orders.
|
|
|
|
Args:
|
|
symbol: optional CCXT symbol to filter
|
|
since: not used by MT5Bridge
|
|
limit: not used
|
|
|
|
Returns:
|
|
list of order dicts
|
|
"""
|
|
mt5_symbol = self._resolve_mt5_symbol(symbol) if symbol else None
|
|
query = {"symbol": mt5_symbol} if mt5_symbol else {}
|
|
r = self._request("GET", "/orders", params=query)
|
|
orders = r.get("data", [])
|
|
return [self._parse_order(o, status="open") for o in orders]
|
|
|
|
def fetch_closed_orders(self, symbol=None, since=None, limit=None, params={}):
|
|
"""Fetch closed orders via history.
|
|
|
|
MT5Bridge exposes history as deals, not orders. We approximate
|
|
closed orders by reading the deal history and pairing entries
|
|
with exits.
|
|
"""
|
|
# For simplicity, return my_trades; for proper order tracking
|
|
# one would need to join deals with the order they belong to.
|
|
return self.fetch_my_trades(symbol, since, limit, params)
|
|
|
|
def fetch_my_trades(self, symbol=None, since=None, limit=None, params={}):
|
|
"""Fetch historical trades.
|
|
|
|
Args:
|
|
symbol: optional CCXT symbol
|
|
since: start timestamp in ms
|
|
limit: not used (returns all in range)
|
|
params: can include "date_from", "date_to"
|
|
|
|
Returns:
|
|
list of CCXT trade dicts
|
|
"""
|
|
if "date_from" in params and "date_to" in params:
|
|
date_from = params["date_from"]
|
|
date_to = params["date_to"]
|
|
else:
|
|
if since is None:
|
|
# Default: last 7 days
|
|
since_ts = int((time.time() - 7 * 86400) * 1000)
|
|
else:
|
|
since_ts = int(since)
|
|
date_from = datetime.fromtimestamp(
|
|
since_ts / 1000, tz=timezone.utc
|
|
).strftime("%Y-%m-%d")
|
|
date_to = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
|
|
query = {"date_from": date_from, "date_to": date_to}
|
|
if symbol:
|
|
query["symbol"] = self._resolve_mt5_symbol(symbol)
|
|
|
|
r = self._request("GET", "/history/deals", params=query)
|
|
deals = r.get("data", [])
|
|
|
|
trades = []
|
|
for d in deals:
|
|
ts = self._parse_datetime(d.get("time"))
|
|
ccxt_sym = self._resolve_ccxt_symbol(d.get("symbol", ""))
|
|
entry = int(d.get("entry", 0))
|
|
side = "buy" if entry == 0 else "sell"
|
|
price = self.safe_float(d, "price", 0.0)
|
|
amount = self.safe_float(d, "volume", 0.0)
|
|
commission = self.safe_float(d, "commission", 0.0)
|
|
swap = self.safe_float(d, "swap", 0.0)
|
|
|
|
trades.append({
|
|
"info": d,
|
|
"id": str(d.get("ticket", "")),
|
|
"order": str(d.get("magic", "")),
|
|
"timestamp": ts,
|
|
"datetime": self.iso8601(ts) if ts else None,
|
|
"symbol": ccxt_sym,
|
|
"type": None,
|
|
"side": side,
|
|
"takerOrMaker": None,
|
|
"price": price,
|
|
"amount": amount,
|
|
"cost": price * amount,
|
|
"fee": {
|
|
"cost": commission + swap,
|
|
"currency": None,
|
|
"rate": None,
|
|
},
|
|
})
|
|
|
|
return trades
|
|
|
|
def _parse_order(self, o, status="open"):
|
|
"""Convert MT5Bridge order dict to CCXT order dict."""
|
|
ts = self._parse_datetime(o.get("time"))
|
|
ccxt_sym = self._resolve_ccxt_symbol(o.get("symbol", ""))
|
|
mt5_type = int(o.get("type", 0))
|
|
if mt5_type in (0, 1):
|
|
type_str = "market"
|
|
elif mt5_type in (2, 3):
|
|
type_str = "limit"
|
|
elif mt5_type in (4, 5):
|
|
type_str = "stop"
|
|
elif mt5_type in (6, 7):
|
|
type_str = "stop_limit"
|
|
else:
|
|
type_str = "limit"
|
|
side = "buy" if mt5_type % 2 == 0 else "sell"
|
|
|
|
return {
|
|
"info": o,
|
|
"id": str(o.get("ticket", "")),
|
|
"clientOrderId": o.get("comment"),
|
|
"timestamp": ts,
|
|
"datetime": self.iso8601(ts) if ts else None,
|
|
"lastTradeTimestamp": None,
|
|
"symbol": ccxt_sym,
|
|
"type": type_str,
|
|
"timeInForce": None,
|
|
"postOnly": None,
|
|
"side": side,
|
|
"price": self.safe_float(o, "price_open", 0.0),
|
|
"stopPrice": None,
|
|
"amount": self.safe_float(o, "volume_initial", self.safe_float(o, "volume", 0.0)),
|
|
"filled": 0.0,
|
|
"remaining": self.safe_float(o, "volume_initial", self.safe_float(o, "volume", 0.0)),
|
|
"status": status,
|
|
"cost": None,
|
|
"trades": None,
|
|
"fee": None,
|
|
}
|
|
|
|
# ──────── Public API: trading ────────
|
|
|
|
def create_order(self, symbol, type, side, amount, price=None, params={}):
|
|
"""Create a new order.
|
|
|
|
Args:
|
|
symbol: CCXT symbol (e.g. "XAU/USD")
|
|
type: "market" | "limit" | "stop" | "stop_limit"
|
|
side: "buy" | "sell"
|
|
amount: volume in lots (e.g. 0.01)
|
|
price: required for limit/stop orders
|
|
params: extra params:
|
|
- sl: stop loss price (default 0)
|
|
- tp: take profit price (default 0)
|
|
- magic: magic number (default 0)
|
|
- comment: order comment, max 31 chars
|
|
- deviation: slippage in points (default 10)
|
|
- check_first: if True, validate via /order/check first
|
|
|
|
Returns:
|
|
dict: CCXT order
|
|
|
|
Raises:
|
|
InvalidOrder: order was rejected by broker
|
|
InsufficientFunds: retcode indicates not enough margin
|
|
ArgumentsRequired: missing required argument
|
|
"""
|
|
if type not in ("market", "limit", "stop", "stop_limit"):
|
|
raise InvalidOrder(
|
|
f"Unsupported order type '{type}'. "
|
|
f"Valid: market, limit, stop, stop_limit"
|
|
)
|
|
if type in ("limit", "stop", "stop_limit") and price is None:
|
|
raise ArgumentsRequired(f"Price is required for {type} orders")
|
|
if amount is None or amount <= 0:
|
|
raise InvalidOrder("Amount must be positive")
|
|
if side not in ("buy", "sell"):
|
|
raise InvalidOrder(f"Unsupported side '{side}'. Valid: buy, sell")
|
|
|
|
mt5_symbol = self._resolve_mt5_symbol(symbol)
|
|
order_type_code = self._get_order_type_code(type, side)
|
|
action = MT5_ACTION_DEAL if type == "market" else MT5_ACTION_PENDING
|
|
|
|
body = {
|
|
"action": action,
|
|
"symbol": mt5_symbol,
|
|
"volume": self._format_volume(amount),
|
|
"order_type": order_type_code,
|
|
"price": float(price) if price is not None else 0.0,
|
|
"sl": float(params.get("sl", 0)),
|
|
"tp": float(params.get("tp", 0)),
|
|
"magic": int(params.get("magic", 0)),
|
|
"comment": str(params.get("comment", ""))[:31],
|
|
"deviation": int(params.get("deviation", 10)),
|
|
}
|
|
|
|
# Optional pre-check
|
|
if params.get("check_first", False):
|
|
check = self._request("POST", "/order/check", json_body=body)
|
|
check_result = check.get("data", {})
|
|
retcode = int(check_result.get("retcode", -1))
|
|
if retcode not in (0, 10009, 10010) and retcode != 0:
|
|
if retcode in MT5_RETCODE_INSUFFICIENT_FUNDS:
|
|
raise InsufficientFunds(
|
|
check_result.get("comment", "Insufficient funds")
|
|
)
|
|
raise InvalidOrder(
|
|
f"Order check failed: retcode={retcode}, "
|
|
f"comment={check_result.get('comment', '')}"
|
|
)
|
|
|
|
# Send order
|
|
r = self._request("POST", "/order/send", json_body={"request": body})
|
|
result = r.get("data", {})
|
|
retcode = int(result.get("retcode", 0))
|
|
order_id = str(result.get("order", ""))
|
|
|
|
if retcode in MT5_RETCODE_INSUFFICIENT_FUNDS:
|
|
raise InsufficientFunds(result.get("comment", "Insufficient funds"))
|
|
if retcode not in (0, 10009, 10010) or not order_id or order_id == "0":
|
|
raise InvalidOrder(
|
|
f"Order rejected: retcode={retcode}, "
|
|
f"comment={result.get('comment', 'Unknown error')}"
|
|
)
|
|
|
|
return self._make_order_from_result(result, symbol, type, side, body)
|
|
|
|
def _make_order_from_result(self, result, symbol, type, side, body):
|
|
"""Build a CCXT order dict from a successful order result."""
|
|
ts = self.milliseconds()
|
|
order_id = str(result.get("order", ""))
|
|
return {
|
|
"info": result,
|
|
"id": order_id,
|
|
"clientOrderId": body.get("comment"),
|
|
"timestamp": ts,
|
|
"datetime": self.iso8601(ts),
|
|
"lastTradeTimestamp": None,
|
|
"symbol": symbol,
|
|
"type": type,
|
|
"timeInForce": None,
|
|
"postOnly": None,
|
|
"side": side,
|
|
"price": body["price"],
|
|
"stopPrice": None,
|
|
"amount": body["volume"],
|
|
"filled": 0.0,
|
|
"remaining": body["volume"],
|
|
"status": self._get_status_from_retcode(int(result.get("retcode", 0))),
|
|
"cost": None,
|
|
"trades": None,
|
|
"fee": None,
|
|
}
|
|
|
|
def cancel_order(self, id, symbol=None, params={}):
|
|
"""Cancel a pending order (async via MQL5 helper EA).
|
|
|
|
Note: MT5Bridge doesn't expose a synchronous cancel endpoint. This
|
|
method sends a GlobalVariable command that the MQL5 helper EA
|
|
(Mt5BridgeHelper.mq5) reads and acts on by calling OrderDelete().
|
|
|
|
For reliable cancellation, deploy the helper EA on a separate chart.
|
|
|
|
Args:
|
|
id: order ticket (string or int)
|
|
symbol: optional symbol (unused)
|
|
params: extra params
|
|
|
|
Returns:
|
|
dict: {"info": ..., "id": str, "status": "canceling"}
|
|
"""
|
|
ticket = int(id)
|
|
result = self.mql5.send_cancel_command(ticket)
|
|
return {
|
|
"info": {"method": "gvar_signal", **result},
|
|
"id": str(id),
|
|
"status": "canceling",
|
|
}
|