"""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 asyncio 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 MT5_RETCODE_STATUS = { 10009: "open", 10010: "filled", 10011: "canceled", 10012: "canceled", 10013: "rejected", 10014: "rejected", 10015: "rejected", 10016: "rejected", 10017: "rejected", 10018: "rejected", 10019: "rejected", 10020: "rejected", 10021: "rejected", 10022: "rejected", 10023: "rejected", 10024: "rejected", 10025: "rejected", } 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 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.2.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, # ── Watching (polling-based, see watch_* methods) ── "watchBalance": False, "watchMyTrades": False, "watchOHLCV": True, # polling-based "watchOrderBook": False, "watchOrders": False, "watchPositions": False, "watchTicker": True, # polling-based "watchTickers": False, "watchTrades": False, } timeframes = { "1m": "TIMEFRAME_M1", "5m": "TIMEFRAME_M5", "15m": "TIMEFRAME_M15", "30m": "TIMEFRAME_M30", "1h": "TIMEFRAME_H1", "4h": "TIMEFRAME_H4", "1d": "TIMEFRAME_D1", } # Polling intervals per timeframe (used by watch_ohlcv as the minimum # interval to check for a new bar) WATCH_OHLCV_POLL_MS = { "1m": 5000, "5m": 10000, "15m": 15000, "30m": 30000, "1h": 60000, "4h": 120000, "1d": 300000, } 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) 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 {} self._mt5_to_ccxt = {v: k for k, v in self.symbol_map.items()} # HTTP session with retry 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}", }) self.mql5 = Mql5Bridge(self) # ──────── Internal helpers ──────── def _resolve_mt5_symbol(self, symbol): if not symbol: return symbol if symbol in self.symbol_map: return self.symbol_map[symbol] return symbol.replace("/", "") def _resolve_ccxt_symbol(self, mt5_symbol): if not mt5_symbol: return mt5_symbol if mt5_symbol in self._mt5_to_ccxt: return self._mt5_to_ccxt[mt5_symbol] return mt5_symbol def _request(self, method, path, params=None, json_body=None): 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 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): try: body = response.json() return body.get("detail") or fallback except Exception: return fallback or response.text[:200] def _parse_datetime(self, dt_str): 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): key = (order_type, side) if key in MT5_ORDER_TYPE: return MT5_ORDER_TYPE[key] 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") raise InvalidOrder(f"Unsupported combination: {order_type} + {side}") def _get_status_from_retcode(self, retcode): return MT5_RETCODE_STATUS.get(int(retcode), "rejected") def _format_volume(self, amount): if amount is None: return 0.0 try: return round(float(amount), 2) except (TypeError, ValueError): return 0.0 @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: status & markets ──────── def fetch_status(self, params={}): 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={}): if not self.symbol_map: 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): 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", "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 (fetch_*) ──────── def fetch_ticker(self, symbol, params={}): 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={}): 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) 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 # ──────── Public API: account & positions ──────── def fetch_balance(self, params={}): 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={}): 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={}): positions = self.fetch_positions([symbol], params) if not positions: return None 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={}): 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={}): return self.fetch_my_trades(symbol, since, limit, params) def fetch_my_trades(self, symbol=None, since=None, limit=None, params={}): 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: 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"): 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={}): 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)), } 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', '')}" ) 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): 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={}): ticket = int(id) result = self.mql5.send_cancel_command(ticket) return { "info": {"method": "gvar_signal", **result}, "id": str(id), "status": "canceling", } # ───────────────────────────────────────────────────────────────── # Watching (polling-based implementations of CCXT watch_* methods) # ───────────────────────────────────────────────────────────────── # # MT5Bridge doesn't expose WebSocket or SSE. The watch_* methods # below poll the synchronous fetch_* endpoints until a new value # is detected, then return it. This is functionally equivalent to # a real watch for low-frequency use cases. # # For high-frequency real-time streaming, upgrade MT5Bridge to # support WebSocket (see README of this package). async def watch_ticker(self, symbol, params={}): """Watch the ticker for a symbol by polling. Polls the /symbols/{symbol}/tick endpoint until a new tick is detected (timestamp changes, or bid/ask changes), then returns it. Args: symbol: CCXT symbol params: extra params: - poll_interval_ms: polling interval in ms (default 500) - max_wait_ms: max time to wait before returning current value even if no change (default 30000) Returns: dict: CCXT ticker Example: >>> while True: ... ticker = await exchange.watch_ticker("XAU/USD") ... print(ticker["bid"], ticker["ask"]) """ poll_interval_ms = params.get("poll_interval_ms", 500) max_wait_ms = params.get("max_wait_ms", 30000) last_signature = None loop = asyncio.get_event_loop() deadline = loop.time() + (max_wait_ms / 1000.0) while True: ticker = await loop.run_in_executor(None, self.fetch_ticker, symbol) # Use (timestamp, bid, ask) as the change signature signature = (ticker.get("timestamp"), ticker.get("bid"), ticker.get("ask")) if last_signature is None or signature != last_signature: last_signature = signature return ticker # No change yet now = loop.time() if now >= deadline: return ticker # return current value, even if unchanged await asyncio.sleep(poll_interval_ms / 1000.0) async def watch_ohlcv(self, symbol, timeframe="1m", since=None, limit=None, params={}): """Watch OHLCV for a symbol by polling. Polls fetch_ohlcv until a new bar is detected (latest bar's timestamp changes), then returns the new bars. Args: symbol: CCXT symbol timeframe: CCXT timeframe since: optional start timestamp in ms limit: number of bars to return (default 100) params: extra params: - poll_interval_ms: minimum polling interval in ms (default is auto-set per timeframe) - max_wait_ms: max time to wait before returning (default 600000) Returns: list of [timestamp, open, high, low, close, volume] """ poll_interval_ms = params.get("poll_interval_ms", self.WATCH_OHLCV_POLL_MS.get(timeframe, 30000)) max_wait_ms = params.get("max_wait_ms", 600000) last_bar_time = None loop = asyncio.get_event_loop() deadline = loop.time() + (max_wait_ms / 1000.0) fetch_limit = min(limit or 2, 5) # only need last few bars to detect new one def _fetch(): return self.fetch_ohlcv(symbol, timeframe, since=since, limit=fetch_limit, params=params) while True: ohlcv = await loop.run_in_executor(None, _fetch) if ohlcv: latest_time = ohlcv[-1][0] if last_bar_time is None or latest_time != last_bar_time: last_bar_time = latest_time # Return full requested limit, not just the last few if limit and limit > fetch_limit: return await loop.run_in_executor( None, lambda: self.fetch_ohlcv( symbol, timeframe, since=since, limit=limit, params=params ), ) return ohlcv now = loop.time() if now >= deadline: # Return what we have, even if no new bar if ohlcv: return ohlcv # No data at all; let the user see the empty result return ohlcv await asyncio.sleep(poll_interval_ms / 1000.0)