Files

962 lines
34 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 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)