From e2b9be22ca9e118889ff2bff50b1866381a36bc6 Mon Sep 17 00:00:00 2001 From: dimitryLMEX Date: Tue, 26 May 2026 20:19:01 +0700 Subject: [PATCH] feat: add LMEX Spot and Futures connectors LMEX (https://lmex.io) is a professional cryptocurrency exchange offering spot trading and USD-settled perpetual futures. Spot connector (LMEX) Channels: TRADES (WebSocket), L2_BOOK (REST poll), ORDER_INFO (WebSocket) WebSocket: wss://ws.lmex.io/ws/spot REST: https://api.lmex.io/spot/api/v3.2 Futures connector (LMEX_FUTURES) Channels: TRADES (WebSocket), L2_BOOK (REST poll), FUNDING (REST poll), ORDER_INFO (WebSocket) WebSocket: wss://ws.lmex.io/ws/futures REST: https://api.lmex.io/futures/api/v2.3 Only USDT-margined perpetuals; dated futures excluded. Futures WS uses internal symbol codes (e.g. BTCPFC for BTC-PERP). Note on L2_BOOK delivery LMEX does not currently expose a WebSocket order-book stream. Both connectors poll the REST /orderbook endpoint every 5 s (configurable via book_interval kwarg) and deliver snapshots. Files added / changed: cryptofeed/defines.py -- LMEX, LMEX_FUTURES constants cryptofeed/exchanges/__init__.py -- EXCHANGE_MAP entries cryptofeed/exchanges/lmex.py -- Spot connector cryptofeed/exchanges/lmex_futures.py -- Futures connector tests/unit/test_lmex.py -- 33 unit tests (all pass) tests/unit/test_exchange.py -- playback lookup entries sample_data/LMEX.* -- spot playback fixtures sample_data/LMEX_FUTURES.* -- futures playback fixtures examples/demo_lmex.py -- usage example CHANGES.md -- changelog entry Co-Authored-By: Claude Sonnet 4.6 --- CHANGES.md | 4 + cryptofeed/defines.py | 2 + cryptofeed/exchanges/__init__.py | 6 +- cryptofeed/exchanges/lmex.py | 281 ++++++++++++ cryptofeed/exchanges/lmex_futures.py | 336 +++++++++++++++ examples/demo_lmex.py | 43 ++ sample_data/LMEX.0 | 2 + sample_data/LMEX.ws.1.0 | 10 + sample_data/LMEX_FUTURES.0 | 2 + sample_data/LMEX_FUTURES.ws.1.0 | 10 + tests/unit/test_exchange.py | 4 +- tests/unit/test_lmex.py | 622 +++++++++++++++++++++++++++ 12 files changed, 1320 insertions(+), 2 deletions(-) create mode 100644 cryptofeed/exchanges/lmex.py create mode 100644 cryptofeed/exchanges/lmex_futures.py create mode 100644 examples/demo_lmex.py create mode 100644 sample_data/LMEX.0 create mode 100644 sample_data/LMEX.ws.1.0 create mode 100644 sample_data/LMEX_FUTURES.0 create mode 100644 sample_data/LMEX_FUTURES.ws.1.0 create mode 100644 tests/unit/test_lmex.py diff --git a/CHANGES.md b/CHANGES.md index 81c6c4174..8640a7ed7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,9 @@ ## Changelog +### 2.4.2 (2026-05-26) + * New Exchange: LMEX Spot (L2_BOOK, TRADES, ORDER_INFO) + * New Exchange: LMEX Futures / Perpetuals (L2_BOOK, TRADES, FUNDING, ORDER_INFO) + ### 2.4.1 (2025-02-08) * Update: Added `is_data_json` to `write()` in `HTTPSync` from `connection.py` to support JSON payloads (#1071) * Bugfix: Handle empty nextFundingRate in OKX diff --git a/cryptofeed/defines.py b/cryptofeed/defines.py index 5c52c8f6c..b016d3731 100644 --- a/cryptofeed/defines.py +++ b/cryptofeed/defines.py @@ -44,6 +44,8 @@ KRAKEN = 'KRAKEN' KRAKEN_FUTURES = 'KRAKEN_FUTURES' KUCOIN = 'KUCOIN' +LMEX = 'LMEX' +LMEX_FUTURES = 'LMEX_FUTURES' OKCOIN = 'OKCOIN' OKX = 'OKX' PHEMEX = 'PHEMEX' diff --git a/cryptofeed/exchanges/__init__.py b/cryptofeed/exchanges/__init__.py index c49a83830..58a6c61a9 100644 --- a/cryptofeed/exchanges/__init__.py +++ b/cryptofeed/exchanges/__init__.py @@ -5,7 +5,7 @@ associated with this software. ''' from cryptofeed.defines import * -from cryptofeed.defines import EXX as EXX_str, FMFW as FMFW_str, OKX as OKX_str +from cryptofeed.defines import EXX as EXX_str, FMFW as FMFW_str, OKX as OKX_str, LMEX as LMEX_str, LMEX_FUTURES as LMEX_FUTURES_str from .bitdotcom import BitDotCom from .phemex import Phemex from .ascendex import AscendEX @@ -42,6 +42,8 @@ from .kraken import Kraken from .kraken_futures import KrakenFutures from .kucoin import KuCoin +from .lmex import LMEX +from .lmex_futures import LMEXFutures from .okx import OKX from .okcoin import OKCoin from .poloniex import Poloniex @@ -85,6 +87,8 @@ KRAKEN_FUTURES: KrakenFutures, KRAKEN: Kraken, KUCOIN: KuCoin, + LMEX_str: LMEX, + LMEX_FUTURES_str: LMEXFutures, OKCOIN: OKCoin, OKX_str: OKX, PHEMEX: Phemex, diff --git a/cryptofeed/exchanges/lmex.py b/cryptofeed/exchanges/lmex.py new file mode 100644 index 000000000..e6fd22bc5 --- /dev/null +++ b/cryptofeed/exchanges/lmex.py @@ -0,0 +1,281 @@ +''' +Copyright (C) 2017-2025 Bryant Moscon - bmoscon@gmail.com + +Please see the LICENSE file for the terms and conditions +associated with this software. + + +LMEX Spot Exchange Connector +API Docs: https://lmex.io/apidoc/spot +WebSocket: wss://ws.lmex.io/ws/spot +REST: https://api.lmex.io/spot/api/v3.2 + +Supported channels + Public: L2_BOOK, TRADES + Authenticated: ORDER_INFO + +WebSocket topics + tradeHistoryApi: - real-time trades + notificationsApi - private order events (auth required) + +Order book delivery + LMEX Spot does not expose a WebSocket order-book stream. L2_BOOK is + served by polling the REST endpoint + GET /spot/api/v3.2/orderbook?symbol=&depth=200 + every `book_interval` seconds (default: 5). + +Subscription message + {"op": "subscribe", "args": ["tradeHistoryApi:BTC-USD"]} + +Authentication + Headers: request-api, request-nonce, request-sign + Signature: HMAC-SHA384(secret, path + nonce + body) +''' +import hashlib +import hmac +import logging +import time +from collections import defaultdict +from decimal import Decimal +from typing import Dict, List, Tuple + +from yapic import json + +from cryptofeed.connection import AsyncConnection, HTTPPoll, RestEndpoint, Routes, WebsocketEndpoint +from cryptofeed.defines import BUY, CANCELLED, FAILED, FILLED, L2_BOOK, LMEX, OPEN, ORDER_INFO, PARTIAL, SELL, SUBMITTING, TRADES +from cryptofeed.feed import Feed +from cryptofeed.symbols import Symbol +from cryptofeed.types import OrderBook, OrderInfo, Trade + + +LOG = logging.getLogger('feedhandler') + +# LMEX order-status code -> cryptofeed status +_ORDER_STATUS = { + 2: OPEN, + 4: FILLED, + 5: PARTIAL, + 6: CANCELLED, + 7: CANCELLED, + 8: FAILED, + 9: OPEN, + 10: OPEN, + 15: FAILED, + 16: FAILED, + 17: FAILED, + 65: OPEN, + 85: SUBMITTING, + 88: OPEN, +} + + +class LMEX(Feed): + ''' + LMEX Spot connector for cryptofeed. + + Channels + -------- + L2_BOOK - Full-depth L2 order book (REST-polled snapshot, refreshed + every `book_interval` seconds; default 5 s) + TRADES - Real-time public trades (WebSocket) + ORDER_INFO - Private order lifecycle events (WebSocket, auth required) + ''' + + id = LMEX + + websocket_endpoints = [ + WebsocketEndpoint('wss://ws.lmex.io/ws/spot', sandbox='wss://ws.test-api.lmex.io/ws/spot', + channel_filter=['tradeHistoryApi'], + options={'ping_interval': 10, 'ping_timeout': 30, 'max_size': None}), + WebsocketEndpoint('wss://ws.lmex.io/ws/spot', sandbox='wss://ws.test-api.lmex.io/ws/spot', + channel_filter=['notificationsApi'], authentication=True, + options={'ping_interval': 10, 'ping_timeout': 30, 'max_size': None}), + ] + + rest_endpoints = [ + RestEndpoint('https://api.lmex.io', sandbox='https://test-api.lmex.io', + routes=Routes('/spot/api/v3.2/market_summary', + l2book='/spot/api/v3.2/orderbook')) + ] + + websocket_channels = { + L2_BOOK: 'orderBookApi', + TRADES: 'tradeHistoryApi', + ORDER_INFO: 'notificationsApi', + } + + @classmethod + def timestamp_normalize(cls, ts: float) -> float: + return ts / 1_000.0 + + @classmethod + def _parse_symbol_data(cls, data: list) -> Tuple[Dict, Dict]: + ret = {} + info = defaultdict(dict) + + for item in data: + if not item.get('active', True): + continue + if item.get('futures', False): + continue + base = item.get('base') + quote = item.get('quote') + if not base or not quote: + continue + s = Symbol(base, quote) + ret[s.normalized] = item['symbol'] + info['instrument_type'][s.normalized] = s.type + if item.get('minPriceIncrement'): + info['tick_size'][s.normalized] = Decimal(str(item['minPriceIncrement'])) + if item.get('minOrderSize'): + info['lot_size'][s.normalized] = Decimal(str(item['minOrderSize'])) + + return ret, info + + def __init__(self, book_interval: int = 5, **kwargs): + self._book_interval = book_interval + super().__init__(**kwargs) + + def connect(self) -> List: + ret = super().connect() + if L2_BOOK in self.subscription: + for std_symbol in self.subscription[L2_BOOK]: + exchange_symbol = self.std_symbol_to_exchange_symbol(std_symbol) + url = self.rest_endpoints[0].route('l2book', self.sandbox) + f'?symbol={exchange_symbol}&depth=200' + poll = HTTPPoll(url, self.id, delay=60, sleep=self._book_interval) + ret.append((poll, self._book_subscribe, self._book_poll_handler, self.authenticate)) + return ret + + def _generate_signature(self, path: str, nonce: str, body: str = '') -> dict: + message = path + nonce + body + sig = hmac.new(self.key_secret.encode('utf-8'), message.encode('utf-8'), hashlib.sha384).hexdigest() + return {'request-api': self.key_id, 'request-nonce': nonce, 'request-sign': sig} + + async def _ws_authentication(self, address: str, options: dict) -> Tuple[str, dict]: + nonce = str(int(time.time() * 1_000)) + headers = self._generate_signature('/notificationsApi', nonce) + options.setdefault('extra_headers', {}).update(headers) + return address, options + + async def subscribe(self, conn: AsyncConnection): + args = [] + for channel, symbols in conn.subscription.items(): + if channel == self.std_channel_to_exchange(L2_BOOK): + continue # L2_BOOK served via REST polling; skip WS subscription + if channel == self.std_channel_to_exchange(ORDER_INFO): + args.append(channel) + else: + for symbol in symbols: + args.append(f'{channel}:{symbol}') + if args: + await conn.write(json.dumps({'op': 'subscribe', 'args': args})) + + async def _book_subscribe(self, conn: HTTPPoll): + pass + + async def _book_poll_handler(self, msg: str, conn: HTTPPoll, timestamp: float): + ''' + REST orderbook response: + { + "symbol": "BTC-USD", + "buyQuote": [{"price": "77044.0", "size": "0.03214"}, ...], + "sellQuote": [{"price": "77045.7", "size": "0.00365"}, ...], + "timestamp": 1779800636554 + } + Price and size values may be either strings or numbers depending on + the endpoint version; Decimal(str(...)) handles both. + ''' + data = json.loads(msg, parse_float=Decimal) + symbol = self.exchange_symbol_to_std_symbol(data['symbol']) + ts = self.timestamp_normalize(data['timestamp']) + + self._l2_book[symbol] = OrderBook(self.id, symbol, max_depth=self.max_depth) + for entry in data.get('buyQuote', []): + price = Decimal(str(entry['price'])) + size = Decimal(str(entry['size'])) + if size > 0: + self._l2_book[symbol].book.bids[price] = size + for entry in data.get('sellQuote', []): + price = Decimal(str(entry['price'])) + size = Decimal(str(entry['size'])) + if size > 0: + self._l2_book[symbol].book.asks[price] = size + + await self.book_callback(L2_BOOK, self._l2_book[symbol], timestamp, timestamp=ts, delta=None, raw=data) + + async def _trade(self, msg: dict, timestamp: float): + ''' + { + "topic": "tradeHistoryApi:BTC-USD", + "data": [ + { + "symbol": "BTC-USD", + "side": "SELL", + "size": 0.0145, + "price": 76653.1, + "tradeId": 31626447, + "timestamp": 1779786372223 + } + ] + } + ''' + for entry in msg.get('data', []): + symbol = self.exchange_symbol_to_std_symbol(entry['symbol']) + side = BUY if entry['side'] == 'BUY' else SELL + price = Decimal(str(entry['price'])) + size = Decimal(str(entry['size'])) + ts = self.timestamp_normalize(entry['timestamp']) + t = Trade(self.id, symbol, side, size, price, ts, id=str(entry.get('tradeId', '')), raw=entry) + await self.callback(TRADES, t, timestamp) + + async def _order_info(self, msg: dict, timestamp: float): + ''' + { + "topic": "notificationsApi", + "data": [ + { + "symbol": "BTC-USD", + "orderId": 987654321, + "clOrderId": "my-order-001", + "side": "BUY", + "price": 76500.0, + "size": 0.01, + "filledSize": 0.01, + "status": 4, + "timestamp": 1779786400000 + } + ] + } + ''' + for entry in msg.get('data', []): + status = _ORDER_STATUS.get(entry.get('status', 0), OPEN) + symbol = self.exchange_symbol_to_std_symbol(entry['symbol']) + side = BUY if entry['side'] == 'BUY' else SELL + price = Decimal(str(entry['price'])) if entry.get('price') else None + size = Decimal(str(entry['size'])) + filled = Decimal(str(entry.get('filledSize', 0))) + remaining = size - filled + ts = self.timestamp_normalize(entry['timestamp']) + oi = OrderInfo(self.id, symbol, str(entry.get('orderId', '')), side, status, None, price, filled, remaining, ts, raw=entry) + await self.callback(ORDER_INFO, oi, timestamp) + + async def message_handler(self, msg: str, conn: AsyncConnection, timestamp: float): + msg = json.loads(msg, parse_float=Decimal) + + event = msg.get('event') + if event == 'pong': + return + if event == 'subscribe': + LOG.info('%s: Subscription confirmed: %s', self.id, msg.get('channel')) + return + if event: + LOG.warning('%s: Unhandled event: %s', self.id, msg) + return + + topic = msg.get('topic', '') + if topic.startswith('tradeHistoryApi'): + await self._trade(msg, timestamp) + elif topic == 'notificationsApi': + await self._order_info(msg, timestamp) + else: + LOG.warning('%s: Unknown topic: %s', self.id, topic) diff --git a/cryptofeed/exchanges/lmex_futures.py b/cryptofeed/exchanges/lmex_futures.py new file mode 100644 index 000000000..7f5276c03 --- /dev/null +++ b/cryptofeed/exchanges/lmex_futures.py @@ -0,0 +1,336 @@ +''' +Copyright (C) 2017-2025 Bryant Moscon - bmoscon@gmail.com + +Please see the LICENSE file for the terms and conditions +associated with this software. + + +LMEX Perpetual Futures Exchange Connector +API Docs: https://lmex.io/apidoc/futures +WebSocket: wss://ws.lmex.io/ws/futures +REST: https://api.lmex.io/futures/api/v2.3 + +Supported channels + Public: L2_BOOK, TRADES, FUNDING + Authenticated: ORDER_INFO + +WebSocket topics + tradeHistoryApi: - real-time trades (uses REST exchange symbol) + notificationsApi - private order events (auth required) + +Trade message internals + The futures WebSocket uses short internal codes for the `symbol` field + inside trade data (e.g. "BTCPFC" for BTC-PERP). The pattern for + perpetuals is: base + "PFC" → "BTC" + "PFC" = "BTCPFC". + +Order book delivery + LMEX Futures does not expose a WebSocket order-book stream. L2_BOOK is + served by polling the REST endpoint + GET /futures/api/v2.3/orderbook?symbol=&depth=200 + every `book_interval` seconds (default: 5). + +Funding rate + Polled via REST GET /futures/api/v2.3/market_summary every + `funding_interval` seconds (default: 60). + +Authentication + Headers: request-api, request-nonce, request-sign + Signature: HMAC-SHA384(secret, path + nonce + body) + +Symbol notes + REST symbol format: BTC-PERP, ETH-PERP (perpetuals only; dated futures + like BTC-260626 are excluded) + Cryptofeed normalised: BTC-USDT-PERP, ETH-USDT-PERP + WS internal code: BTCPFC, ETHPFC (base + "PFC") +''' +import hashlib +import hmac +import logging +import time +from collections import defaultdict +from decimal import Decimal +from typing import Dict, List, Tuple + +from yapic import json + +from cryptofeed.connection import AsyncConnection, HTTPPoll, RestEndpoint, Routes, WebsocketEndpoint +from cryptofeed.defines import BUY, CANCELLED, FAILED, FILLED, FUNDING, L2_BOOK, LMEX_FUTURES, OPEN, ORDER_INFO, PARTIAL, PERPETUAL, SELL, SUBMITTING, TRADES +from cryptofeed.feed import Feed +from cryptofeed.symbols import Symbol +from cryptofeed.types import Funding, OrderBook, OrderInfo, Trade + + +LOG = logging.getLogger('feedhandler') + +_ORDER_STATUS = { + 2: OPEN, + 4: FILLED, + 5: PARTIAL, + 6: CANCELLED, + 7: CANCELLED, + 8: FAILED, + 9: OPEN, + 10: OPEN, + 15: FAILED, + 16: FAILED, + 17: FAILED, + 65: OPEN, + 85: SUBMITTING, + 88: OPEN, +} + + +class LMEXFutures(Feed): + ''' + LMEX Perpetual Futures connector for cryptofeed. + + Channels + -------- + L2_BOOK - Full-depth L2 order book (REST-polled snapshot, refreshed + every `book_interval` seconds; default 5 s) + TRADES - Real-time public trades (WebSocket) + FUNDING - Funding rate (REST-polled every `funding_interval` s; default 60 s) + ORDER_INFO - Private order lifecycle events (WebSocket, auth required) + + Symbol mapping + -------------- + Only perpetuals are included (time-based / dated futures are excluded). + LMEX REST symbol "BTC-PERP" maps to cryptofeed "BTC-USDT-PERP". + The WebSocket internal code "BTCPFC" is derived as base + "PFC". + ''' + + id = LMEX_FUTURES + + websocket_endpoints = [ + WebsocketEndpoint('wss://ws.lmex.io/ws/futures', sandbox='wss://ws.test-api.lmex.io/ws/futures', + channel_filter=['tradeHistoryApi'], + options={'ping_interval': 10, 'ping_timeout': 30, 'max_size': None}), + WebsocketEndpoint('wss://ws.lmex.io/ws/futures', sandbox='wss://ws.test-api.lmex.io/ws/futures', + channel_filter=['notificationsApi'], authentication=True, + options={'ping_interval': 10, 'ping_timeout': 30, 'max_size': None}), + ] + + rest_endpoints = [ + RestEndpoint('https://api.lmex.io', sandbox='https://test-api.lmex.io', + routes=Routes('/futures/api/v2.3/market_summary', + l2book='/futures/api/v2.3/orderbook')) + ] + + websocket_channels = { + L2_BOOK: 'orderBookApi', + TRADES: 'tradeHistoryApi', + ORDER_INFO: 'notificationsApi', + FUNDING: FUNDING, + } + + @classmethod + def timestamp_normalize(cls, ts: float) -> float: + return ts / 1_000.0 + + @classmethod + def _parse_symbol_data(cls, data: list) -> Tuple[Dict, Dict]: + ret = {} + info = defaultdict(dict) + + for item in data: + if not item.get('active', True): + continue + # Skip dated/time-based futures (e.g. BTC-260626); only perpetuals + if item.get('timeBasedContract', False): + continue + exchange_symbol = item['symbol'] + if not exchange_symbol.endswith('-PERP'): + continue + base = item.get('base') or exchange_symbol.split('-')[0] + quote = item.get('quote', 'USDT') + s = Symbol(base, quote, type=PERPETUAL) + ret[s.normalized] = exchange_symbol + info['instrument_type'][s.normalized] = s.type + if item.get('minPriceIncrement'): + info['tick_size'][s.normalized] = Decimal(str(item['minPriceIncrement'])) + if item.get('contractSize'): + info['contract_size'][s.normalized] = Decimal(str(item['contractSize'])) + + return ret, info + + def __init__(self, funding_interval: int = 60, book_interval: int = 5, **kwargs): + self._funding_interval = funding_interval + self._book_interval = book_interval + super().__init__(**kwargs) + + def connect(self) -> List: + ret = super().connect() + if FUNDING in self.subscription: + url = self.rest_endpoints[0].route('instruments', self.sandbox) + poll = HTTPPoll(url, self.id, delay=60, sleep=self._funding_interval) + ret.append((poll, self._funding_subscribe, self._funding_handler, self.authenticate)) + if L2_BOOK in self.subscription: + for std_symbol in self.subscription[L2_BOOK]: + exchange_symbol = self.std_symbol_to_exchange_symbol(std_symbol) + url = self.rest_endpoints[0].route('l2book', self.sandbox) + f'?symbol={exchange_symbol}&depth=200' + poll = HTTPPoll(url, self.id, delay=60, sleep=self._book_interval) + ret.append((poll, self._book_subscribe, self._book_poll_handler, self.authenticate)) + return ret + + async def _funding_subscribe(self, conn: HTTPPoll): + pass + + async def _funding_handler(self, msg: str, conn: HTTPPoll, timestamp: float): + data = json.loads(msg, parse_float=Decimal) + if not isinstance(data, list): + data = [data] + + funding_symbols = {self.std_symbol_to_exchange_symbol(s) for s in self.subscription.get(FUNDING, [])} + + for item in data: + exchange_symbol = item.get('symbol', '') + if funding_symbols and exchange_symbol not in funding_symbols: + continue + if item.get('timeBasedContract', False): + continue + if not exchange_symbol.endswith('-PERP'): + continue + try: + std_symbol = self.exchange_symbol_to_std_symbol(exchange_symbol) + except Exception: + continue + rate = item.get('fundingRate') + if rate is None: + continue + mark_price = item.get('last') + ts = self.timestamp_normalize(item.get('timestamp', time.time() * 1000)) + f = Funding(self.id, std_symbol, Decimal(str(mark_price)) if mark_price is not None else None, Decimal(str(rate)), None, ts, raw=item) + await self.callback(FUNDING, f, timestamp) + + async def _book_subscribe(self, conn: HTTPPoll): + pass + + async def _book_poll_handler(self, msg: str, conn: HTTPPoll, timestamp: float): + ''' + REST orderbook response: + { + "symbol": "BTC-PERP", + "buyQuote": [{"price": "77054.5", "size": "108350"}, ...], + "sellQuote": [{"price": "77054.7", "size": "98250"}, ...], + "timestamp": 1779800650027 + } + Price and size values may be strings or numbers; Decimal(str(...)) handles both. + ''' + data = json.loads(msg, parse_float=Decimal) + symbol = self.exchange_symbol_to_std_symbol(data['symbol']) + ts = self.timestamp_normalize(data['timestamp']) + + self._l2_book[symbol] = OrderBook(self.id, symbol, max_depth=self.max_depth) + for entry in data.get('buyQuote', []): + price = Decimal(str(entry['price'])) + size = Decimal(str(entry['size'])) + if size > 0: + self._l2_book[symbol].book.bids[price] = size + for entry in data.get('sellQuote', []): + price = Decimal(str(entry['price'])) + size = Decimal(str(entry['size'])) + if size > 0: + self._l2_book[symbol].book.asks[price] = size + + await self.book_callback(L2_BOOK, self._l2_book[symbol], timestamp, timestamp=ts, delta=None, raw=data) + + def _generate_signature(self, path: str, nonce: str, body: str = '') -> dict: + message = path + nonce + body + sig = hmac.new(self.key_secret.encode('utf-8'), message.encode('utf-8'), hashlib.sha384).hexdigest() + return {'request-api': self.key_id, 'request-nonce': nonce, 'request-sign': sig} + + async def _ws_authentication(self, address: str, options: dict) -> Tuple[str, dict]: + nonce = str(int(time.time() * 1_000)) + headers = self._generate_signature('/notificationsApi', nonce) + options.setdefault('extra_headers', {}).update(headers) + return address, options + + async def subscribe(self, conn: AsyncConnection): + args = [] + for channel, symbols in conn.subscription.items(): + if channel in (FUNDING, self.std_channel_to_exchange(L2_BOOK)): + continue # served via REST polling; skip WS subscription + if channel == self.std_channel_to_exchange(ORDER_INFO): + args.append(channel) + else: + for symbol in symbols: + args.append(f'{channel}:{symbol}') + if args: + await conn.write(json.dumps({'op': 'subscribe', 'args': args})) + + async def _trade(self, msg: dict, timestamp: float): + ''' + The futures WebSocket sends a single "tradeHistoryApi" topic (no symbol + suffix) with an internal symbol code in each data entry: + + { + "topic": "tradeHistoryApi", + "data": [ + { + "price": 77099.9, + "size": 6530, + "side": "SELL", + "symbol": "BTCPFC", + "tradeId": 35993384, + "timestamp": 1779800209269 + } + ] + } + + Internal code to exchange symbol conversion (perpetuals): + BTCPFC -> BTC-PERP (strip trailing "PFC", append "-PERP") + ETHPFC -> ETH-PERP + SOLPFC -> SOL-PERP + ''' + for entry in msg.get('data', []): + ws_code = entry.get('symbol', '') + if ws_code.endswith('PFC'): + exchange_symbol = ws_code[:-3] + '-PERP' + else: + LOG.warning('%s: unexpected futures WS symbol code: %s', self.id, ws_code) + continue + try: + symbol = self.exchange_symbol_to_std_symbol(exchange_symbol) + except Exception: + LOG.warning('%s: cannot map futures symbol %s (WS code %s)', self.id, exchange_symbol, ws_code) + continue + side = BUY if entry['side'] == 'BUY' else SELL + price = Decimal(str(entry['price'])) + size = Decimal(str(entry['size'])) + ts = self.timestamp_normalize(entry['timestamp']) + t = Trade(self.id, symbol, side, size, price, ts, id=str(entry.get('tradeId', '')), raw=entry) + await self.callback(TRADES, t, timestamp) + + async def _order_info(self, msg: dict, timestamp: float): + for entry in msg.get('data', []): + status = _ORDER_STATUS.get(entry.get('status', 0), OPEN) + symbol = self.exchange_symbol_to_std_symbol(entry['symbol']) + side = BUY if entry['side'] == 'BUY' else SELL + price = Decimal(str(entry['price'])) if entry.get('price') else None + size = Decimal(str(entry['size'])) + filled = Decimal(str(entry.get('filledSize', 0))) + remaining = size - filled + ts = self.timestamp_normalize(entry['timestamp']) + oi = OrderInfo(self.id, symbol, str(entry.get('orderId', '')), side, status, None, price, filled, remaining, ts, raw=entry) + await self.callback(ORDER_INFO, oi, timestamp) + + async def message_handler(self, msg: str, conn: AsyncConnection, timestamp: float): + msg = json.loads(msg, parse_float=Decimal) + + event = msg.get('event') + if event == 'pong': + return + if event == 'subscribe': + LOG.info('%s: Subscription confirmed: %s', self.id, msg.get('channel')) + return + if event: + LOG.warning('%s: Unhandled event: %s', self.id, msg) + return + + topic = msg.get('topic', '') + if topic == 'tradeHistoryApi' or topic.startswith('tradeHistoryApi:'): + await self._trade(msg, timestamp) + elif topic == 'notificationsApi': + await self._order_info(msg, timestamp) + else: + LOG.warning('%s: Unknown topic: %s', self.id, topic) diff --git a/examples/demo_lmex.py b/examples/demo_lmex.py new file mode 100644 index 000000000..508527e59 --- /dev/null +++ b/examples/demo_lmex.py @@ -0,0 +1,43 @@ +''' +LMEX Spot + Futures - cryptofeed demo +====================================== +Run: + python examples/demo_lmex.py + +Prints real-time trades and L2 book updates for BTC-USD (spot) +and BTC-USD-PERP (futures), plus funding rates on a 60-second +poll cycle. + +No API credentials required for public channels. +''' +from cryptofeed import FeedHandler +from cryptofeed.defines import L2_BOOK, TRADES, FUNDING +from cryptofeed.exchanges import LMEX, LMEXFutures + + +async def trade_callback(trade, receipt_timestamp): + print(f'[TRADE] {trade.exchange:<15} {trade.symbol:<14} {trade.side:<4} {trade.amount} @ {trade.price} (ts={trade.timestamp:.3f})') + + +async def book_callback(book, receipt_timestamp): + best_bid = max(book.book.bids.keys()) if book.book.bids else None + best_ask = min(book.book.asks.keys()) if book.book.asks else None + print(f'[BOOK] {book.exchange:<15} {book.symbol:<14} bid={best_bid} ask={best_ask}') + + +async def funding_callback(funding, receipt_timestamp): + print(f'[FUNDING]{funding.exchange:<15} {funding.symbol:<14} rate={funding.rate} mark={funding.mark_price}') + + +def main(): + fh = FeedHandler() + + fh.add_feed(LMEX(symbols=['BTC-USD'], channels=[TRADES, L2_BOOK], callbacks={TRADES: trade_callback, L2_BOOK: book_callback})) + + fh.add_feed(LMEXFutures(symbols=['BTC-USD-PERP'], channels=[TRADES, L2_BOOK, FUNDING], funding_interval=60, callbacks={TRADES: trade_callback, L2_BOOK: book_callback, FUNDING: funding_callback})) + + fh.run() + + +if __name__ == '__main__': + main() diff --git a/sample_data/LMEX.0 b/sample_data/LMEX.0 new file mode 100644 index 000000000..e9bffdd0f --- /dev/null +++ b/sample_data/LMEX.0 @@ -0,0 +1,2 @@ +https://api.lmex.io/spot/api/v3.2/market_summary -> 1700000000.0: [{"symbol":"BTC-USD","base":"BTC","quote":"USD","active":true,"futures":false,"last":50000.0,"lowestAsk":50001.0,"highestBid":49999.0,"volume":1000.0,"minPriceIncrement":0.1,"minOrderSize":0.00001,"maxOrderSize":100,"minSizeIncrement":0.00001},{"symbol":"ETH-USD","base":"ETH","quote":"USD","active":true,"futures":false,"last":3000.0,"lowestAsk":3000.5,"highestBid":2999.5,"volume":500.0,"minPriceIncrement":0.01,"minOrderSize":0.0001,"maxOrderSize":500,"minSizeIncrement":0.0001}] +configuration: {"trades":["BTC-USD","ETH-USD"]} diff --git a/sample_data/LMEX.ws.1.0 b/sample_data/LMEX.ws.1.0 new file mode 100644 index 000000000..324f3d123 --- /dev/null +++ b/sample_data/LMEX.ws.1.0 @@ -0,0 +1,10 @@ +wss://ws.lmex.io/ws/spot <-> 1700000000.0 +wss://ws.lmex.io/ws/spot <- 1700000000.1: {"op":"subscribe","args":["tradeHistoryApi:BTC-USD","tradeHistoryApi:ETH-USD"]} +1700000000.2: {"event":"subscribe","channel":["tradeHistoryApi:BTC-USD","tradeHistoryApi:ETH-USD"]} +1700000002.0: {"topic":"tradeHistoryApi:BTC-USD","data":[{"symbol":"BTC-USD","side":"BUY","size":0.01,"price":50001.0,"tradeId":10000001,"timestamp":1700000002000}]} +1700000002.1: {"topic":"tradeHistoryApi:ETH-USD","data":[{"symbol":"ETH-USD","side":"SELL","size":0.5,"price":2999.5,"tradeId":10000002,"timestamp":1700000002100}]} +1700000002.2: {"topic":"tradeHistoryApi:BTC-USD","data":[{"symbol":"BTC-USD","side":"SELL","size":0.05,"price":49999.0,"tradeId":10000003,"timestamp":1700000002200}]} +1700000003.1: {"topic":"tradeHistoryApi:BTC-USD","data":[{"symbol":"BTC-USD","side":"BUY","size":0.02,"price":50001.0,"tradeId":10000004,"timestamp":1700000003100},{"symbol":"BTC-USD","side":"BUY","size":0.03,"price":50001.5,"tradeId":10000005,"timestamp":1700000003150}]} +1700000004.0: {"topic":"tradeHistoryApi:ETH-USD","data":[{"symbol":"ETH-USD","side":"BUY","size":1.0,"price":3000.5,"tradeId":10000006,"timestamp":1700000004000}]} +1700000004.1: {"event":"pong"} +1700000004.2: {"topic":"tradeHistoryApi:BTC-USD","data":[{"symbol":"BTC-USD","side":"SELL","size":0.1,"price":49999.5,"tradeId":10000007,"timestamp":1700000004200}]} diff --git a/sample_data/LMEX_FUTURES.0 b/sample_data/LMEX_FUTURES.0 new file mode 100644 index 000000000..80765df9c --- /dev/null +++ b/sample_data/LMEX_FUTURES.0 @@ -0,0 +1,2 @@ +https://api.lmex.io/futures/api/v2.3/market_summary -> 1700000000.0: [{"symbol":"BTC-PERP","base":"BTC","quote":"USDT","active":true,"timeBasedContract":false,"contractStart":0,"contractEnd":0,"last":50000.0,"lowestAsk":50001.0,"highestBid":49999.0,"volume":1000000.0,"fundingRate":0.0001,"minPriceIncrement":0.5,"contractSize":0.00001,"minOrderSize":1,"maxOrderSize":7500000},{"symbol":"ETH-PERP","base":"ETH","quote":"USDT","active":true,"timeBasedContract":false,"contractStart":0,"contractEnd":0,"last":3000.0,"lowestAsk":3000.5,"highestBid":2999.5,"volume":500000.0,"fundingRate":0.00005,"minPriceIncrement":0.05,"contractSize":0.001,"minOrderSize":1,"maxOrderSize":1000000},{"symbol":"BTC-260626","base":"BTC","quote":"USDT","active":true,"timeBasedContract":true,"contractStart":0,"contractEnd":1782460830,"last":50200.0,"minPriceIncrement":0.1,"contractSize":0.00001}] +configuration: {"trades":["BTC-USDT-PERP","ETH-USDT-PERP"]} diff --git a/sample_data/LMEX_FUTURES.ws.1.0 b/sample_data/LMEX_FUTURES.ws.1.0 new file mode 100644 index 000000000..6f0b94827 --- /dev/null +++ b/sample_data/LMEX_FUTURES.ws.1.0 @@ -0,0 +1,10 @@ +wss://ws.lmex.io/ws/futures <-> 1700000000.0 +wss://ws.lmex.io/ws/futures <- 1700000000.1: {"op":"subscribe","args":["tradeHistoryApi:BTC-PERP","tradeHistoryApi:ETH-PERP"]} +1700000000.2: {"event":"subscribe","channel":["tradeHistoryApi:BTC-PERP","tradeHistoryApi:ETH-PERP"]} +1700000002.0: {"topic":"tradeHistoryApi","data":[{"symbol":"BTCPFC","side":"BUY","size":5,"price":50051.0,"tradeId":20000001,"timestamp":1700000002000}]} +1700000002.1: {"topic":"tradeHistoryApi","data":[{"symbol":"ETHPFC","side":"SELL","size":20,"price":3004.5,"tradeId":20000002,"timestamp":1700000002100}]} +1700000002.2: {"topic":"tradeHistoryApi","data":[{"symbol":"BTCPFC","side":"SELL","size":2,"price":50049.0,"tradeId":20000003,"timestamp":1700000002200}]} +1700000003.1: {"topic":"tradeHistoryApi","data":[{"symbol":"BTCPFC","side":"BUY","size":3,"price":50051.5,"tradeId":20000004,"timestamp":1700000003100},{"symbol":"BTCPFC","side":"BUY","size":4,"price":50052.0,"tradeId":20000005,"timestamp":1700000003150}]} +1700000004.0: {"topic":"tradeHistoryApi","data":[{"symbol":"ETHPFC","side":"BUY","size":10,"price":3005.5,"tradeId":20000006,"timestamp":1700000004000}]} +1700000004.1: {"event":"pong"} +1700000004.2: {"topic":"tradeHistoryApi","data":[{"symbol":"BTCPFC","side":"SELL","size":1,"price":50049.5,"tradeId":20000007,"timestamp":1700000004200}]} diff --git a/tests/unit/test_exchange.py b/tests/unit/test_exchange.py index 0acc28c4c..81ce4fa12 100644 --- a/tests/unit/test_exchange.py +++ b/tests/unit/test_exchange.py @@ -9,7 +9,7 @@ import pytest -from cryptofeed.defines import ASCENDEX, ASCENDEX_FUTURES, BEQUANT, BITDOTCOM, BITGET, BITHUMB, CANDLES, BINANCE, BINANCE_DELIVERY, CRYPTODOTCOM, DELTA, FMFW, BITFINEX, DYDX, EXX, BINANCE_FUTURES, BINANCE_US, BITFLYER, BITMEX, BITSTAMP, BLOCKCHAIN, COINBASE, DERIBIT, GATEIO, GEMINI, HITBTC, HUOBI, HUOBI_DM, HUOBI_SWAP, INDEPENDENT_RESERVE, KRAKEN, KRAKEN_FUTURES, KUCOIN, L3_BOOK, OKCOIN, OKX, PHEMEX, POLONIEX, PROBIT, TICKER, TRADES, L2_BOOK, BYBIT, UPBIT, BINANCE_TR, GATEIO_FUTURES +from cryptofeed.defines import ASCENDEX, ASCENDEX_FUTURES, BEQUANT, BITDOTCOM, BITGET, BITHUMB, CANDLES, BINANCE, BINANCE_DELIVERY, CRYPTODOTCOM, DELTA, FMFW, BITFINEX, DYDX, EXX, BINANCE_FUTURES, BINANCE_US, BITFLYER, BITMEX, BITSTAMP, BLOCKCHAIN, COINBASE, DERIBIT, GATEIO, GEMINI, HITBTC, HUOBI, HUOBI_DM, HUOBI_SWAP, INDEPENDENT_RESERVE, KRAKEN, KRAKEN_FUTURES, KUCOIN, L3_BOOK, LMEX, LMEX_FUTURES, OKCOIN, OKX, PHEMEX, POLONIEX, PROBIT, TICKER, TRADES, L2_BOOK, BYBIT, UPBIT, BINANCE_TR, GATEIO_FUTURES from cryptofeed.exchanges import EXCHANGE_MAP from cryptofeed.raw_data_collection import playback from cryptofeed.symbols import Symbols @@ -60,6 +60,8 @@ KUCOIN: {TRADES: 18, TICKER: 830, CANDLES: 6, L2_BOOK: 3823}, BITHUMB: {TRADES: 7}, PHEMEX: {TRADES: 10025, CANDLES: 10041, L2_BOOK: 1337}, + LMEX: {TRADES: 7}, + LMEX_FUTURES: {TRADES: 7}, } diff --git a/tests/unit/test_lmex.py b/tests/unit/test_lmex.py new file mode 100644 index 000000000..5ccf2e431 --- /dev/null +++ b/tests/unit/test_lmex.py @@ -0,0 +1,622 @@ +''' +Unit tests for the LMEX spot and futures connectors. + +These tests exercise message parsing logic without any live network calls. +They inject synthetic symbol data into the Symbols singleton, create feed +instances, and push raw JSON payloads through the handlers, verifying +correct cryptofeed data types and field values. + +Key connector facts under test: + Spot: + - TRADES via WebSocket (tradeHistoryApi:) + - L2_BOOK via REST polling (_book_poll_handler) + - ORDER_INFO via WebSocket (notificationsApi) + + Futures: + - TRADES via WebSocket; WS uses internal symbol codes (e.g. BTCPFC) + - L2_BOOK via REST polling + - FUNDING via REST polling + - Only perpetuals (timeBasedContract=False, symbol ends with -PERP) + - Normalised quote currency taken from REST data (USDT, not USD) + - Normalised symbols: BTC-USDT-PERP, ETH-USDT-PERP +''' +import json +import unittest +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +from cryptofeed.defines import ( + BUY, FILLED, FUNDING, L2_BOOK, LMEX, LMEX_FUTURES, + OPEN, ORDER_INFO, PARTIAL, SELL, TRADES, +) +from cryptofeed.exchanges import LMEX as LMEXFeed, LMEXFutures +from cryptofeed.symbols import Symbols +from cryptofeed.types import Funding, OrderBook, Trade + + +# --------------------------------------------------------------------------- +# Shared test fixture helpers +# --------------------------------------------------------------------------- + +# Spot: normalized_symbol -> exchange_symbol +_SPOT_NORM = {'BTC-USD': 'BTC-USD', 'ETH-EUR': 'ETH-EUR'} +# Futures: normalized_symbol -> exchange_symbol (USDT-quoted perps) +_PERP_NORM = {'BTC-USDT-PERP': 'BTC-PERP', 'ETH-USDT-PERP': 'ETH-PERP'} + +_SPOT_INFO = { + 'instrument_type': {'BTC-USD': 'spot', 'ETH-EUR': 'spot'}, + 'tick_size': {'BTC-USD': Decimal('0.1')}, +} +_PERP_INFO = { + 'instrument_type': {'BTC-USDT-PERP': 'perpetual', 'ETH-USDT-PERP': 'perpetual'}, + 'contract_size': {'BTC-USDT-PERP': Decimal('0.00001')}, +} + + +def _seed_symbols(): + '''Pre-populate the Symbols singleton so __init__ does not hit the network.''' + Symbols.set(LMEX, _SPOT_NORM, _SPOT_INFO) + Symbols.set(LMEX_FUTURES, _PERP_NORM, _PERP_INFO) + + +def _make_spot(symbols=None, channels=None, callbacks=None): + _seed_symbols() + return LMEXFeed( + symbols=symbols or ['BTC-USD'], + channels=channels or [TRADES], + callbacks=callbacks or {}, + ) + + +def _make_futures(symbols=None, channels=None, callbacks=None): + _seed_symbols() + return LMEXFutures( + symbols=symbols or ['BTC-USDT-PERP'], + channels=channels or [TRADES, FUNDING], + callbacks=callbacks or {}, + ) + + +# --------------------------------------------------------------------------- +# Timestamp normalization +# --------------------------------------------------------------------------- + +class TestTimestampNormalize(unittest.TestCase): + def test_spot_ms_to_seconds(self): + self.assertAlmostEqual(LMEXFeed.timestamp_normalize(1_000), 1.0) + self.assertAlmostEqual(LMEXFeed.timestamp_normalize(1_500_000), 1_500.0) + + def test_futures_ms_to_seconds(self): + self.assertAlmostEqual(LMEXFutures.timestamp_normalize(60_000), 60.0) + + +# --------------------------------------------------------------------------- +# Spot: Symbol parsing +# --------------------------------------------------------------------------- + +class TestSpotSymbolParsing(unittest.TestCase): + MARKET_SUMMARY = [ + { + 'symbol': 'BTC-USD', 'base': 'BTC', 'quote': 'USD', + 'active': True, 'futures': False, + 'minPriceIncrement': 0.1, 'minOrderSize': 0.00001, + }, + { + 'symbol': 'ETH-EUR', 'base': 'ETH', 'quote': 'EUR', + 'active': True, 'futures': False, + 'minPriceIncrement': 0.01, 'minOrderSize': 0.001, + }, + { + # Perpetual — must be ignored by spot connector + 'symbol': 'BTC-PERP', 'base': 'BTC', 'quote': None, + 'active': True, 'futures': True, + }, + { + # Inactive — must be ignored + 'symbol': 'OLD-USD', 'base': 'OLD', 'quote': 'USD', + 'active': False, 'futures': False, + }, + ] + + def test_returns_only_spot_symbols(self): + ret, _ = LMEXFeed._parse_symbol_data(self.MARKET_SUMMARY) + self.assertIn('BTC-USD', ret) + self.assertIn('ETH-EUR', ret) + self.assertNotIn('BTC-USDT-PERP', ret) + self.assertNotIn('OLD-USD', ret) + + def test_exchange_symbol_preserved(self): + ret, _ = LMEXFeed._parse_symbol_data(self.MARKET_SUMMARY) + self.assertEqual(ret['BTC-USD'], 'BTC-USD') + self.assertEqual(ret['ETH-EUR'], 'ETH-EUR') + + def test_tick_size_captured(self): + _, info = LMEXFeed._parse_symbol_data(self.MARKET_SUMMARY) + self.assertEqual(info['tick_size']['BTC-USD'], Decimal('0.1')) + self.assertEqual(info['tick_size']['ETH-EUR'], Decimal('0.01')) + + +# --------------------------------------------------------------------------- +# Futures: Symbol parsing +# --------------------------------------------------------------------------- + +class TestFuturesSymbolParsing(unittest.TestCase): + MARKET_SUMMARY = [ + { + # Perpetual — must be included; no 'futures' key, quote is USDT + 'symbol': 'BTC-PERP', 'base': 'BTC', 'quote': 'USDT', + 'active': True, 'timeBasedContract': False, + 'minPriceIncrement': 0.5, 'contractSize': 0.00001, + }, + { + 'symbol': 'ETH-PERP', 'base': 'ETH', 'quote': 'USDT', + 'active': True, 'timeBasedContract': False, + 'minPriceIncrement': 0.05, 'contractSize': 0.001, + }, + { + # Dated/time-based future — must be excluded + 'symbol': 'BTC-260626', 'base': 'BTC', 'quote': 'USDT', + 'active': True, 'timeBasedContract': True, + }, + { + # Inactive — must be excluded + 'symbol': 'OLD-PERP', 'base': 'OLD', 'quote': 'USDT', + 'active': False, 'timeBasedContract': False, + }, + ] + + def test_returns_only_perpetuals(self): + ret, _ = LMEXFutures._parse_symbol_data(self.MARKET_SUMMARY) + # Perpetuals are included (USDT-quoted) + self.assertIn('BTC-USDT-PERP', ret) + self.assertIn('ETH-USDT-PERP', ret) + # Dated and inactive are excluded + self.assertNotIn('BTC-USDT', ret) + self.assertNotIn('OLD-USDT-PERP', ret) + + def test_exchange_symbol_preserved(self): + ret, _ = LMEXFutures._parse_symbol_data(self.MARKET_SUMMARY) + self.assertEqual(ret['BTC-USDT-PERP'], 'BTC-PERP') + self.assertEqual(ret['ETH-USDT-PERP'], 'ETH-PERP') + + def test_contract_size_captured(self): + _, info = LMEXFutures._parse_symbol_data(self.MARKET_SUMMARY) + self.assertEqual(info['contract_size']['BTC-USDT-PERP'], Decimal('0.00001')) + + def test_quote_from_rest_not_hardcoded(self): + '''Quote currency must come from the REST data, not a hardcoded constant.''' + ret, _ = LMEXFutures._parse_symbol_data(self.MARKET_SUMMARY) + # Should be USDT-quoted, NOT USD-quoted + self.assertNotIn('BTC-USD-PERP', ret) + self.assertIn('BTC-USDT-PERP', ret) + + +# --------------------------------------------------------------------------- +# Spot: Trade message +# --------------------------------------------------------------------------- + +class TestSpotTrade(unittest.IsolatedAsyncioTestCase): + TRADE_MSG = { + 'topic': 'tradeHistoryApi:BTC-USD', + 'data': [ + { + 'symbol': 'BTC-USD', + 'side': 'SELL', + 'size': 0.0145, + 'price': 76653.1, + 'tradeId': 31626447, + 'timestamp': 1_700_000_000_000, + } + ], + } + + async def test_trade_fields(self): + captured = [] + + async def cb(trade, _): captured.append(trade) + + feed = _make_spot(callbacks={TRADES: cb}) + await feed._trade(self.TRADE_MSG, 9999.0) + + self.assertEqual(len(captured), 1) + t = captured[0] + self.assertIsInstance(t, Trade) + self.assertEqual(t.exchange, LMEX) + self.assertEqual(t.symbol, 'BTC-USD') + self.assertEqual(t.side, SELL) + self.assertEqual(t.amount, Decimal('0.0145')) + self.assertEqual(t.price, Decimal('76653.1')) + self.assertAlmostEqual(t.timestamp, 1_700_000_000.0) + self.assertEqual(t.id, '31626447') + + async def test_buy_side(self): + captured = [] + + async def cb(trade, _): captured.append(trade) + + feed = _make_spot(callbacks={TRADES: cb}) + msg = { + 'topic': 'tradeHistoryApi:BTC-USD', + 'data': [{'symbol': 'BTC-USD', 'side': 'BUY', + 'size': 1.0, 'price': 50000.0, + 'tradeId': 1, 'timestamp': 1_000}], + } + await feed._trade(msg, 0.0) + self.assertEqual(captured[0].side, BUY) + + async def test_multiple_trades_in_one_message(self): + captured = [] + + async def cb(trade, _): captured.append(trade) + + feed = _make_spot(callbacks={TRADES: cb}) + msg = { + 'topic': 'tradeHistoryApi:BTC-USD', + 'data': [ + {'symbol': 'BTC-USD', 'side': 'BUY', 'size': 0.1, 'price': 50000.0, 'tradeId': 1, 'timestamp': 1_000}, + {'symbol': 'BTC-USD', 'side': 'SELL', 'size': 0.2, 'price': 50001.0, 'tradeId': 2, 'timestamp': 2_000}, + ], + } + await feed._trade(msg, 0.0) + self.assertEqual(len(captured), 2) + self.assertEqual(captured[0].side, BUY) + self.assertEqual(captured[1].side, SELL) + + +# --------------------------------------------------------------------------- +# Spot: L2 order book via REST polling +# --------------------------------------------------------------------------- + +class TestSpotOrderBookREST(unittest.IsolatedAsyncioTestCase): + '''Tests for _book_poll_handler (REST snapshot polling).''' + + REST_RESPONSE = json.dumps({ + 'symbol': 'BTC-USD', + 'buyQuote': [ + {'price': '76731.9', 'size': '0.0016'}, + {'price': '76730.0', 'size': '0.005'}, + ], + 'sellQuote': [ + {'price': '76732.0', 'size': '0.0085'}, + {'price': '76733.0', 'size': '0.5'}, + ], + 'timestamp': 1_779_787_268_534, + }) + + async def test_snapshot_populates_book(self): + feed = _make_spot() + feed.book_callback = AsyncMock() + + await feed._book_poll_handler(self.REST_RESPONSE, MagicMock(), 0.0) + + book = feed._l2_book['BTC-USD'] + self.assertIsInstance(book, OrderBook) + self.assertIn(Decimal('76731.9'), book.book.bids) + self.assertIn(Decimal('76732.0'), book.book.asks) + self.assertEqual(book.book.bids[Decimal('76731.9')], Decimal('0.0016')) + self.assertEqual(book.book.asks[Decimal('76732.0')], Decimal('0.0085')) + + async def test_snapshot_calls_book_callback_no_delta(self): + feed = _make_spot() + feed.book_callback = AsyncMock() + + await feed._book_poll_handler(self.REST_RESPONSE, MagicMock(), 0.0) + + feed.book_callback.assert_awaited_once() + _, kwargs = feed.book_callback.call_args + self.assertIsNone(kwargs.get('delta'), + 'REST snapshot must always pass delta=None') + + async def test_zero_size_levels_excluded(self): + '''REST snapshots should skip levels with size == 0.''' + data = json.dumps({ + 'symbol': 'BTC-USD', + 'buyQuote': [{'price': '50000.0', 'size': '0.0'}], + 'sellQuote': [{'price': '50001.0', 'size': '1.0'}], + 'timestamp': 1_700_000_000_000, + }) + feed = _make_spot() + feed.book_callback = AsyncMock() + + await feed._book_poll_handler(data, MagicMock(), 0.0) + + book = feed._l2_book['BTC-USD'] + self.assertNotIn(Decimal('50000.0'), book.book.bids, + 'Zero-size level must not be inserted') + self.assertIn(Decimal('50001.0'), book.book.asks) + + async def test_numeric_prices_handled(self): + '''Prices/sizes may be numbers instead of strings in some versions.''' + data = json.dumps({ + 'symbol': 'BTC-USD', + 'buyQuote': [{'price': 50000.0, 'size': 0.5}], + 'sellQuote': [{'price': 50001.0, 'size': 1.0}], + 'timestamp': 1_700_000_000_000, + }) + feed = _make_spot() + feed.book_callback = AsyncMock() + + await feed._book_poll_handler(data, MagicMock(), 0.0) + + book = feed._l2_book['BTC-USD'] + self.assertIn(Decimal('50000.0'), book.book.bids) + + +# --------------------------------------------------------------------------- +# Futures: Trade message (BTCPFC internal code conversion) +# --------------------------------------------------------------------------- + +class TestFuturesTrade(unittest.IsolatedAsyncioTestCase): + ''' + Futures WS sends trades with internal symbol codes (BTCPFC, ETHPFC). + The connector must convert: base + "PFC" -> base + "-PERP" -> normalised. + ''' + + async def test_btcpfc_converted_to_std(self): + captured = [] + + async def cb(trade, _): captured.append(trade) + + feed = _make_futures(callbacks={TRADES: cb}) + msg = { + 'topic': 'tradeHistoryApi', + 'data': [ + { + 'symbol': 'BTCPFC', + 'side': 'SELL', + 'size': 6530, + 'price': 77099.9, + 'tradeId': 35993384, + 'timestamp': 1_779_800_209_269, + } + ], + } + await feed._trade(msg, 0.0) + + self.assertEqual(len(captured), 1) + t = captured[0] + self.assertIsInstance(t, Trade) + self.assertEqual(t.exchange, LMEX_FUTURES) + self.assertEqual(t.symbol, 'BTC-USDT-PERP') + self.assertEqual(t.side, SELL) + self.assertEqual(t.price, Decimal('77099.9')) + self.assertEqual(t.amount, Decimal('6530')) + self.assertEqual(t.id, '35993384') + + async def test_ethpfc_converted_to_std(self): + captured = [] + + async def cb(trade, _): captured.append(trade) + + feed = _make_futures( + symbols=['BTC-USDT-PERP', 'ETH-USDT-PERP'], + callbacks={TRADES: cb}, + ) + msg = { + 'topic': 'tradeHistoryApi', + 'data': [{'symbol': 'ETHPFC', 'side': 'BUY', + 'size': 100, 'price': 3000.0, + 'tradeId': 1, 'timestamp': 1_000}], + } + await feed._trade(msg, 0.0) + + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].symbol, 'ETH-USDT-PERP') + self.assertEqual(captured[0].side, BUY) + + async def test_multiple_trades_per_message(self): + captured = [] + + async def cb(trade, _): captured.append(trade) + + feed = _make_futures(callbacks={TRADES: cb}) + msg = { + 'topic': 'tradeHistoryApi', + 'data': [ + {'symbol': 'BTCPFC', 'side': 'BUY', 'size': 100, 'price': 50001.0, 'tradeId': 1, 'timestamp': 1_000}, + {'symbol': 'BTCPFC', 'side': 'SELL', 'size': 200, 'price': 50000.0, 'tradeId': 2, 'timestamp': 2_000}, + ], + } + await feed._trade(msg, 0.0) + self.assertEqual(len(captured), 2) + + async def test_unknown_ws_code_skipped(self): + '''Symbol codes not following the *PFC pattern should be silently skipped.''' + captured = [] + + async def cb(trade, _): captured.append(trade) + + feed = _make_futures(callbacks={TRADES: cb}) + msg = { + 'topic': 'tradeHistoryApi', + 'data': [{'symbol': 'UNKNOWN', 'side': 'BUY', + 'size': 1, 'price': 100.0, 'tradeId': 99, 'timestamp': 1_000}], + } + await feed._trade(msg, 0.0) + self.assertEqual(len(captured), 0) + + +# --------------------------------------------------------------------------- +# Futures: L2 order book via REST polling +# --------------------------------------------------------------------------- + +class TestFuturesOrderBookREST(unittest.IsolatedAsyncioTestCase): + REST_RESPONSE = json.dumps({ + 'symbol': 'BTC-PERP', + 'buyQuote': [ + {'price': '77054.5', 'size': '108350'}, + {'price': '77054.0', 'size': '50000'}, + ], + 'sellQuote': [ + {'price': '77054.7', 'size': '98250'}, + {'price': '77055.0', 'size': '30000'}, + ], + 'timestamp': 1_779_800_650_027, + }) + + async def test_snapshot_populates_book(self): + feed = _make_futures(channels=[L2_BOOK, TRADES]) + _seed_symbols() + feed.book_callback = AsyncMock() + + await feed._book_poll_handler(self.REST_RESPONSE, MagicMock(), 0.0) + + book = feed._l2_book['BTC-USDT-PERP'] + self.assertIsInstance(book, OrderBook) + self.assertIn(Decimal('77054.5'), book.book.bids) + self.assertIn(Decimal('77054.7'), book.book.asks) + + async def test_book_callback_called_no_delta(self): + feed = _make_futures(channels=[L2_BOOK, TRADES]) + _seed_symbols() + feed.book_callback = AsyncMock() + + await feed._book_poll_handler(self.REST_RESPONSE, MagicMock(), 0.0) + + feed.book_callback.assert_awaited_once() + _, kwargs = feed.book_callback.call_args + self.assertIsNone(kwargs.get('delta')) + + +# --------------------------------------------------------------------------- +# Futures: Funding rate (REST poll) +# --------------------------------------------------------------------------- + +class TestFuturesFunding(unittest.IsolatedAsyncioTestCase): + MARKET_SUMMARY = [ + { + 'symbol': 'BTC-PERP', + 'last': 50000.0, + 'fundingRate': 0.0001, + 'timeBasedContract': False, + 'active': True, + 'timestamp': 1_700_000_000_000, + } + ] + + async def test_funding_emitted(self): + captured = [] + + async def cb(funding, _): captured.append(funding) + + feed = _make_futures(callbacks={FUNDING: cb}) + feed.subscription = {FUNDING: ['BTC-USDT-PERP']} + + raw = json.dumps(self.MARKET_SUMMARY) + await feed._funding_handler(raw, MagicMock(), 0.0) + + self.assertEqual(len(captured), 1) + f = captured[0] + self.assertIsInstance(f, Funding) + self.assertEqual(f.exchange, LMEX_FUTURES) + self.assertEqual(f.symbol, 'BTC-USDT-PERP') + self.assertEqual(f.rate, Decimal('0.0001')) + self.assertEqual(f.mark_price, Decimal('50000.0')) + self.assertAlmostEqual(f.timestamp, 1_700_000_000.0) + + async def test_funding_filters_by_subscription(self): + '''Symbols not in the subscription list must be silently skipped.''' + captured = [] + + async def cb(funding, _): captured.append(funding) + + feed = _make_futures(callbacks={FUNDING: cb}) + feed.subscription = {FUNDING: ['ETH-USDT-PERP']} # BTC not subscribed + + raw = json.dumps(self.MARKET_SUMMARY) + await feed._funding_handler(raw, MagicMock(), 0.0) + + self.assertEqual(len(captured), 0) + + async def test_dated_futures_excluded_from_funding(self): + '''Time-based contracts must never generate a FUNDING callback.''' + captured = [] + + async def cb(funding, _): captured.append(funding) + + feed = _make_futures(callbacks={FUNDING: cb}) + feed.subscription = {FUNDING: ['BTC-USDT-PERP']} + + dated = [{'symbol': 'BTC-260626', 'last': 50200.0, 'fundingRate': 0.0, + 'timeBasedContract': True, 'active': True, 'timestamp': 1_000}] + await feed._funding_handler(json.dumps(dated), MagicMock(), 0.0) + + self.assertEqual(len(captured), 0) + + +# --------------------------------------------------------------------------- +# Message handler routing — Spot +# --------------------------------------------------------------------------- + +class TestSpotMessageHandlerRouting(unittest.IsolatedAsyncioTestCase): + async def _make_feed(self): + feed = _make_spot() + feed._trade = AsyncMock() + feed._order_info = AsyncMock() + return feed + + async def test_routes_trade(self): + feed = await self._make_feed() + msg = json.dumps({'topic': 'tradeHistoryApi:BTC-USD', 'data': []}) + await feed.message_handler(msg, MagicMock(), 0.0) + feed._trade.assert_awaited_once() + + async def test_routes_notifications(self): + feed = await self._make_feed() + msg = json.dumps({'topic': 'notificationsApi', 'data': []}) + await feed.message_handler(msg, MagicMock(), 0.0) + feed._order_info.assert_awaited_once() + + async def test_ignores_pong(self): + feed = await self._make_feed() + msg = json.dumps({'event': 'pong'}) + await feed.message_handler(msg, MagicMock(), 0.0) + feed._trade.assert_not_awaited() + + async def test_ignores_subscribe_ack(self): + feed = await self._make_feed() + msg = json.dumps({'event': 'subscribe', 'channel': ['tradeHistoryApi:BTC-USD']}) + await feed.message_handler(msg, MagicMock(), 0.0) + feed._trade.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# Message handler routing — Futures +# --------------------------------------------------------------------------- + +class TestFuturesMessageHandlerRouting(unittest.IsolatedAsyncioTestCase): + async def _make_feed(self): + feed = _make_futures() + feed._trade = AsyncMock() + feed._order_info = AsyncMock() + return feed + + async def test_routes_trade_no_suffix(self): + '''Futures WS emits topic "tradeHistoryApi" (no symbol suffix).''' + feed = await self._make_feed() + msg = json.dumps({'topic': 'tradeHistoryApi', 'data': []}) + await feed.message_handler(msg, MagicMock(), 0.0) + feed._trade.assert_awaited_once() + + async def test_routes_trade_with_suffix(self): + '''Connector also accepts topic "tradeHistoryApi:BTC-PERP" for robustness.''' + feed = await self._make_feed() + msg = json.dumps({'topic': 'tradeHistoryApi:BTC-PERP', 'data': []}) + await feed.message_handler(msg, MagicMock(), 0.0) + feed._trade.assert_awaited_once() + + async def test_routes_notifications(self): + feed = await self._make_feed() + msg = json.dumps({'topic': 'notificationsApi', 'data': []}) + await feed.message_handler(msg, MagicMock(), 0.0) + feed._order_info.assert_awaited_once() + + async def test_ignores_pong(self): + feed = await self._make_feed() + msg = json.dumps({'event': 'pong'}) + await feed.message_handler(msg, MagicMock(), 0.0) + feed._trade.assert_not_awaited() + + +if __name__ == '__main__': + unittest.main(verbosity=2)