diff --git a/mt5bridge_ccxt/mt5bridge.py b/mt5bridge_ccxt/mt5bridge.py new file mode 100644 index 0000000..28a35c3 --- /dev/null +++ b/mt5bridge_ccxt/mt5bridge.py @@ -0,0 +1,1005 @@ +"""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", + }