diff --git a/hummingbot/client/command/balance_command.py b/hummingbot/client/command/balance_command.py index 7e04ebceed2..a00921ebda8 100644 --- a/hummingbot/client/command/balance_command.py +++ b/hummingbot/client/command/balance_command.py @@ -5,7 +5,7 @@ import pandas as pd -from hummingbot.client.config.config_validators import validate_decimal, validate_exchange +from hummingbot.client.config.config_validators import validate_decimal, validate_derivative, validate_exchange from hummingbot.client.performance import PerformanceMetrics from hummingbot.client.settings import AllConnectorSettings from hummingbot.core.rate_oracle.rate_oracle import RateOracle @@ -40,11 +40,13 @@ def balance(self, # type: HummingbotApplication if args is None or len(args) == 0: safe_ensure_future(self.show_asset_limits()) return - if len(args) != 3 or validate_exchange(args[0]) is not None or validate_decimal(args[2]) is not None: + exchange = args[0].lower() if len(args) > 0 else "" + is_invalid_exchange = validate_exchange(exchange) is not None + is_invalid_derivative = validate_derivative(exchange) is not None + if len(args) != 3 or (is_invalid_exchange and is_invalid_derivative) or validate_decimal(args[2]) is not None: self.notify("Error: Invalid command arguments") self.notify_balance_limit_set() return - exchange = args[0] asset = args[1].upper() amount = float(args[2]) if balance_asset_limit.get(exchange) is None: diff --git a/hummingbot/client/command/connect_command.py b/hummingbot/client/command/connect_command.py index b3177a2bda1..c5cba74b76d 100644 --- a/hummingbot/client/command/connect_command.py +++ b/hummingbot/client/command/connect_command.py @@ -18,6 +18,11 @@ if not cs.use_ethereum_wallet and not cs.uses_gateway_generic_connector() if cs.name != "probit_kr"} +def _lighter_account_index(api_keys: Dict[str, str]) -> Optional[str]: + """Return the account index from stored Lighter credentials, used as a fallback identifier.""" + return next((v for k, v in api_keys.items() if "account_index" in k and v), None) + + class ConnectCommand: def connect(self, # type: HummingbotApplication option: str): @@ -41,20 +46,43 @@ async def connect_exchange(self, # type: HummingbotApplication connector_config = ClientConfigAdapter(AllConnectorSettings.get_connector_config_keys(connector_name)) if Security.connector_config_file_exists(connector_name): await Security.wait_til_decryption_done() - api_key_config = [value for key, value in Security.api_keys(connector_name).items() if "api_key" in key] - if api_key_config: - api_key = api_key_config[0] - prompt = ( - f"Would you like to replace your existing {connector_name} API key {api_key} (Yes/No)? >>> " - ) + stored_keys = Security.api_keys(connector_name) + # For Lighter connectors, show the derived public address rather than the key index. + is_lighter = connector_name.startswith("lighter") + if is_lighter: + from hummingbot.connector.lighter_common.lighter_key_utils import fetch_lighter_public_key + acct_idx = stored_keys.get(f"{connector_name}_account_index") or "" + key_idx = stored_keys.get(f"{connector_name}_api_key_index") or "" + public_key = await fetch_lighter_public_key(connector_name, acct_idx, key_idx) + if public_key: + prompt = ( + f"Would you like to replace your existing {connector_name} key " + f"(public key: {public_key}) (Yes/No)? >>> " + ) + else: + account_id = _lighter_account_index(stored_keys) + if account_id: + prompt = ( + f"Would you like to replace your existing {connector_name} key " + f"(account index: {account_id}) (Yes/No)? >>> " + ) + else: + prompt = f"Would you like to replace your existing {connector_name} key (Yes/No)? >>> " else: - prompt = f"Would you like to replace your existing {connector_name} key (Yes/No)? >>> " + api_key_config = [v for k, v in stored_keys.items() if "api_key" in k] + if api_key_config: + prompt = ( + f"Would you like to replace your existing {connector_name} API key " + f"{api_key_config[0]} (Yes/No)? >>> " + ) + else: + prompt = f"Would you like to replace your existing {connector_name} key (Yes/No)? >>> " answer = await self.app.prompt(prompt=prompt) if self.app.to_stop_config: self.app.to_stop_config = False return if answer.lower() in ("yes", "y"): - previous_keys = Security.api_keys(connector_name) + previous_keys = stored_keys await self._perform_connect(connector_config, previous_keys) else: await self._perform_connect(connector_config) @@ -138,7 +166,23 @@ async def _perform_connect(self, connector_config: ClientConfigAdapter, previous Security.update_secure_config(connector_config) err_msg = await self.validate_n_connect_connector(connector_name) if err_msg is None: - self.notify(f"\nYou are now connected to {connector_name}.") + if connector_name.startswith("lighter"): + from hummingbot.connector.lighter_common.lighter_key_utils import fetch_lighter_public_key + new_keys = Security.api_keys(connector_name) + acct_idx = new_keys.get(f"{connector_name}_account_index") or "" + key_idx = new_keys.get(f"{connector_name}_api_key_index") or "" + public_key = await fetch_lighter_public_key(connector_name, acct_idx, key_idx) + if public_key: + self.notify(f"\nYou are now connected to {connector_name} (public key: {public_key}).") + else: + account_id = _lighter_account_index(new_keys) + msg = ( + f"\nYou are now connected to {connector_name} (account index: {account_id})." + if account_id else f"\nYou are now connected to {connector_name}." + ) + self.notify(msg) + else: + self.notify(f"\nYou are now connected to {connector_name}.") safe_ensure_future(TradingPairFetcher.get_instance(client_config_map=ClientConfigAdapter).fetch_all(client_config_map=ClientConfigAdapter)) else: self.notify(f"\nError: {err_msg}") diff --git a/hummingbot/connector/derivative/lighter_perpetual/__init__.py b/hummingbot/connector/derivative/lighter_perpetual/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/hummingbot/connector/derivative/lighter_perpetual/dummy.pxd b/hummingbot/connector/derivative/lighter_perpetual/dummy.pxd new file mode 100644 index 00000000000..fd97152f76e --- /dev/null +++ b/hummingbot/connector/derivative/lighter_perpetual/dummy.pxd @@ -0,0 +1,2 @@ +cdef class dummy(): + pass diff --git a/hummingbot/connector/derivative/lighter_perpetual/dummy.pyx b/hummingbot/connector/derivative/lighter_perpetual/dummy.pyx new file mode 100644 index 00000000000..fd97152f76e --- /dev/null +++ b/hummingbot/connector/derivative/lighter_perpetual/dummy.pyx @@ -0,0 +1,2 @@ +cdef class dummy(): + pass diff --git a/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_api_order_book_data_source.py b/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_api_order_book_data_source.py new file mode 100644 index 00000000000..eaa39b239fe --- /dev/null +++ b/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_api_order_book_data_source.py @@ -0,0 +1,472 @@ +import asyncio +import time +from decimal import Decimal +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from hummingbot.connector.derivative.lighter_perpetual import ( + lighter_perpetual_constants as CONSTANTS, + lighter_perpetual_web_utils as web_utils, +) +from hummingbot.core.data_type.common import TradeType +from hummingbot.core.data_type.funding_info import FundingInfo, FundingInfoUpdate +from hummingbot.core.data_type.order_book_message import OrderBookMessage, OrderBookMessageType +from hummingbot.core.data_type.perpetual_api_order_book_data_source import PerpetualAPIOrderBookDataSource +from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.core.web_assistant.connections.data_types import RESTMethod, WSJSONRequest +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory +from hummingbot.core.web_assistant.ws_assistant import WSAssistant +from hummingbot.logger import HummingbotLogger + +if TYPE_CHECKING: + from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative import ( + LighterPerpetualDerivative, + ) + + +class LighterPerpetualAPIOrderBookDataSource(PerpetualAPIOrderBookDataSource): + _logger: Optional[HummingbotLogger] = None + + def __init__( + self, + trading_pairs: List[str], + connector: "LighterPerpetualDerivative", + api_factory: WebAssistantsFactory, + domain: str = CONSTANTS.DEFAULT_DOMAIN, + ): + super().__init__(trading_pairs) + self._connector = connector + self._api_factory = api_factory + self._domain = domain + self._market_id_to_trading_pair: Dict[int, str] = {} + self._ping_task: Optional[asyncio.Task] = None + self._last_listen_error_log_ts: float = 0.0 + self._has_logged_subscription_info: bool = False + + async def listen_for_subscriptions(self): + """Override base loop to throttle repeated reconnect exception logs.""" + ws: Optional[WSAssistant] = None + while True: + try: + ws = await self._connected_websocket_assistant() + self._ws_assistant = ws + await self._subscribe_channels(ws) + await self._process_websocket_messages(websocket_assistant=ws) + except asyncio.CancelledError: + raise + except ConnectionError as connection_exception: + close_message = str(connection_exception) + if "close code = 1000" in close_message.lower(): + self.logger().debug(f"The websocket connection was closed ({connection_exception})") + else: + self.logger().warning(f"The websocket connection was closed ({connection_exception})") + except Exception as ex: + now = time.time() + if now - self._last_listen_error_log_ts >= 30.0: + self._last_listen_error_log_ts = now + self.logger().exception( + "Unexpected error occurred when listening to order book streams. Retrying in 5 seconds...", + ) + else: + self.logger().debug( + "Suppressing repeated order book listener error during reconnect storm: %s", + ex, + ) + await self._sleep(2.0) + finally: + self._ws_assistant = None + await self._on_order_stream_interruption(websocket_assistant=ws) + + async def get_last_traded_prices(self, trading_pairs: List[str], domain: Optional[str] = None) -> Dict[str, float]: + return await self._connector.get_last_traded_prices(trading_pairs=trading_pairs) + + def _get_headers(self) -> Dict[str, str]: + """Headers for WebSocket connections (X-Api-Key if available). + Not used for public REST calls to avoid triggering stricter auth + requirements on the Lighter API for main accounts.""" + headers = {} + if self._connector.rest_api_key: + headers["X-Api-Key"] = self._connector.rest_api_key + return headers + + def _get_public_headers(self) -> Dict[str, str]: + """Empty headers for public REST calls (no auth needed/wanted).""" + return {} + + async def _request_order_book_snapshot(self, trading_pair: str) -> Dict[str, Any]: + """ + https://docs.lighter.fi/api-documentation/api/rest-api/markets/get-orderbook + + { + "success": true, + "data": { + "s": "BTC", + "l": [ + [ + { + "p": "106504", + "a": "0.26203", + "n": 1 + }, + { + "p": "106498", + "a": "0.29281", + "n": 1 + } + ], + [ + { + "p": "106559", + "a": "0.26802", + "n": 1 + }, + { + "p": "106564", + "a": "0.3002", + "n": 1 + }, + ] + ], + "t": 1751370536325 + }, + "error": null, + "code": null + } + """ + rest_assistant = await self._api_factory.get_rest_assistant() + market_id, _, _, _ = await self._connector._get_market_spec(trading_pair) + params = {"market_id": market_id, "limit": 250} + + response = await rest_assistant.execute_request( + url=web_utils.public_rest_url(path_url=CONSTANTS.GET_MARKET_ORDER_BOOK_SNAPSHOT_PATH_URL, domain=self._domain), + params=params, + method=RESTMethod.GET, + throttler_limit_id=CONSTANTS.GET_MARKET_ORDER_BOOK_SNAPSHOT_PATH_URL, + headers=self._get_public_headers() + ) + + code = response.get("code") + is_success = response.get("success") is True + try: + is_success = is_success or int(code) == 200 + except Exception: + pass + + if not is_success: + raise ValueError(f"[get_order_book_snapshot] Failed to get order book snapshot for {trading_pair}: {response}") + + return response + + async def _order_book_snapshot(self, trading_pair: str) -> OrderBookMessage: + order_book_snapshot_data = await self._request_order_book_snapshot(trading_pair) + order_book_snapshot_timestamp = time.time() + + # Lighter returns snapshots in response["data"]["l"] where l[0]=bids and l[1]=asks. + # Keep backward compatibility with older top-level bids/asks payloads used in tests. + snapshot_payload = order_book_snapshot_data.get("data") or {} + levels = snapshot_payload.get("l") or [] + + if len(levels) >= 2: + bids = [(bid.get("p"), bid.get("a")) for bid in levels[0]] + asks = [(ask.get("p"), ask.get("a")) for ask in levels[1]] + update_id = snapshot_payload.get("li") or 1 + else: + bids = [ + (bid["price"], bid["remaining_base_amount"]) + for bid in order_book_snapshot_data.get("bids", []) + ] + asks = [ + (ask["price"], ask["remaining_base_amount"]) + for ask in order_book_snapshot_data.get("asks", []) + ] + update_id = 1 + + return OrderBookMessage(OrderBookMessageType.SNAPSHOT, { + "trading_pair": trading_pair, + "update_id": update_id, + "bids": bids, + "asks": asks, + }, timestamp=order_book_snapshot_timestamp) + + async def get_funding_info(self, trading_pair: str) -> FundingInfo: + """ + Uses /funding-rates to get the current funding rate per symbol. + /exchangeStats does not return mark/oracle/funding fields — those come from the WS prices stream. + + /funding-rates response (array of objects): + [ + {"market_id": 1, "symbol": "BTC", "rate": 2.779e-05}, + ... + ] + + Index/mark prices start at 0 here; the WS prices channel populates them after connection. + Next funding timestamp = :00 of next hour + """ + rest_assistant = await self._api_factory.get_rest_assistant() + # Try to get the exchange symbol from the symbol map; fall back to the base currency if not ready yet. + try: + symbol = await self._connector.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + except Exception: + symbol = trading_pair.split("-")[0] + base_currency = trading_pair.split("-")[0] + + response = await rest_assistant.execute_request( + url=web_utils.public_rest_url(path_url=CONSTANTS.GET_FUNDING_RATES_PATH_URL, domain=self._domain), + method=RESTMethod.GET, + throttler_limit_id=CONSTANTS.GET_FUNDING_RATES_PATH_URL, + headers=self._get_public_headers() + ) + + # /funding-rates returns a plain list in production. Keep legacy object parsing for backwards compatibility. + rate_entries = response if isinstance(response, list) else (response.get("funding_rates") or response.get("data") or []) + + rate_str = "0" + index_price_str = "0" + mark_price_str = "0" + + for entry in rate_entries: + entry_symbol = entry.get("symbol", "") + if entry_symbol == symbol or entry_symbol == base_currency: + rate_str = str(entry.get("rate") or entry.get("funding") or "0") + # Legacy payloads may include oracle/mark in data entries. + index_price_str = str(entry.get("oracle") or "0") + mark_price_str = str(entry.get("mark") or "0") + break + + # Some mock and legacy responses expose order_book_stats. + if rate_str == "0" and isinstance(response, dict): + legacy_entries = response.get("order_book_stats") or [] + for entry in legacy_entries: + entry_symbol = entry.get("symbol", "") + if entry_symbol == symbol or entry_symbol == base_currency: + rate_str = str(entry.get("funding") or "0") + index_price_str = str(entry.get("oracle") or "0") + mark_price_str = str(entry.get("mark") or "0") + break + + # Mark/index prices are typically populated by WS prices updates after startup. + return FundingInfo( + trading_pair=trading_pair, + index_price=Decimal(index_price_str), + mark_price=Decimal(mark_price_str), + next_funding_utc_timestamp=int((time.time() // 3600 + 1) * 3600), + rate=Decimal(rate_str), + ) + + async def _connected_websocket_assistant(self) -> WSAssistant: + ws: WSAssistant = await self._api_factory.get_ws_assistant() + + await ws.connect(ws_url=web_utils.wss_url(self._domain), ws_headers=self._get_headers()) + self._ping_task = safe_ensure_future(self._ping_loop(ws)) + return ws + + async def _subscribe_channels(self, ws: WSAssistant): + try: + for trading_pair in self._trading_pairs: + market_id, _, _, _ = await self._connector._get_market_spec(trading_pair) + self._market_id_to_trading_pair[market_id] = trading_pair + await ws.send(WSJSONRequest(payload={"type": "subscribe", "channel": f"order_book/{market_id}"})) + await ws.send(WSJSONRequest(payload={"type": "subscribe", "channel": f"trade/{market_id}"})) + await ws.send(WSJSONRequest(payload={"type": "subscribe", "channel": f"market_stats/{market_id}"})) + log_method = self.logger().debug + log_method("Subscribed to public order book, trade, and market_stats channels...") + self._has_logged_subscription_info = True + except asyncio.CancelledError: + raise + except Exception: + self.logger().exception("Unexpected error occurred subscribing to order book trading pairs.") + raise + + async def _on_order_stream_interruption(self, websocket_assistant: Optional[WSAssistant] = None): + await super()._on_order_stream_interruption(websocket_assistant) + if self._ping_task is not None: + self._ping_task.cancel() + self._ping_task = None + + async def _ping_loop(self, ws: WSAssistant): + while True: + try: + await asyncio.sleep(CONSTANTS.WS_PING_INTERVAL) + ping_request = WSJSONRequest(payload={"method": "ping"}) + await ws.send(ping_request) + except asyncio.CancelledError: + raise + except RuntimeError as e: + if "WS is not connected" in str(e): + return + raise + except Exception: + self.logger().warning("Error sending ping to LIGHTER WebSocket", exc_info=True) + await asyncio.sleep(5.0) # Wait before retrying + + @staticmethod + def _market_id_from_channel(channel: str) -> Optional[int]: + for separator in (":", "/"): + if separator in channel: + tail = channel.rsplit(separator, 1)[-1] + try: + return int(tail) + except Exception: + return None + return None + + async def _parse_order_book_snapshot_message(self, raw_message: Dict[str, Any], message_queue: asyncio.Queue): + channel = str(raw_message.get("channel", "")) + market_id = self._market_id_from_channel(channel) + if market_id is None: + return + trading_pair = self._market_id_to_trading_pair.get(market_id) + if trading_pair is None: + return + + order_book = raw_message.get("order_book") or {} + snapshot_timestamp = float(raw_message.get("timestamp") or raw_message.get("last_updated_at") or 0) / 1000 + update_id = int(order_book.get("nonce") or raw_message.get("nonce") or 0) + if update_id == 0: + update_id = int(raw_message.get("offset") or order_book.get("offset") or raw_message.get("last_updated_at") or 0) + + snapshot_msg = OrderBookMessage(OrderBookMessageType.SNAPSHOT, { + "trading_pair": trading_pair, + "update_id": update_id, + "bids": [(bid["price"], bid["size"]) for bid in order_book.get("bids", [])], + "asks": [(ask["price"], ask["size"]) for ask in order_book.get("asks", [])], + }, timestamp=snapshot_timestamp) + message_queue.put_nowait(snapshot_msg) + + async def _parse_order_book_diff_message(self, raw_message: Dict[str, Any], message_queue: asyncio.Queue): + channel = str(raw_message.get("channel", "")) + market_id = self._market_id_from_channel(channel) + if market_id is None: + return + trading_pair = self._market_id_to_trading_pair.get(market_id) + if trading_pair is None: + return + + order_book = raw_message.get("order_book") or {} + update_id = int(order_book.get("nonce") or raw_message.get("nonce") or 0) + if update_id == 0: + update_id = int(raw_message.get("offset") or order_book.get("offset") or 0) + + diff_msg = OrderBookMessage(OrderBookMessageType.DIFF, { + "trading_pair": trading_pair, + "first_update_id": int(order_book.get("begin_nonce") or update_id), + "update_id": update_id, + "bids": [(bid["price"], bid["size"]) for bid in order_book.get("bids", [])], + "asks": [(ask["price"], ask["size"]) for ask in order_book.get("asks", [])], + }, timestamp=float(raw_message.get("timestamp") or 0) / 1000) + message_queue.put_nowait(diff_msg) + + async def _parse_trade_message(self, raw_message: Dict[str, Any], message_queue: asyncio.Queue): + channel = str(raw_message.get("channel", "")) + market_id = self._market_id_from_channel(channel) + if market_id is None: + return + trading_pair = self._market_id_to_trading_pair.get(market_id) + if trading_pair is None: + return + + for trade_data in raw_message.get("trades", []): + is_maker_ask = bool(trade_data.get("is_maker_ask")) + trade_message = OrderBookMessage(OrderBookMessageType.TRADE, { + "trading_pair": trading_pair, + "trade_type": float(TradeType.BUY.value) if is_maker_ask else float(TradeType.SELL.value), + "trade_id": trade_data.get("nonce") or raw_message.get("nonce"), + "update_id": trade_data.get("nonce") or raw_message.get("nonce") or 0, + "price": trade_data.get("price", "0"), + "amount": trade_data.get("size", "0"), + }, timestamp=float(raw_message.get("timestamp") or 0) / 1000) + message_queue.put_nowait(trade_message) + + async def _parse_funding_info_message(self, raw_message: Dict[str, Any], message_queue: asyncio.Queue): + market_stats = raw_message.get("market_stats") or {} + if not market_stats: + return + + channel = str(raw_message.get("channel", "")) + market_id = self._market_id_from_channel(channel) + if market_id is None: + return + trading_pair = self._market_id_to_trading_pair.get(market_id) + if trading_pair is None: + return + + index_price = Decimal(str(market_stats.get("index_price") or "0")) + mark_price = Decimal(str(market_stats.get("mark_price") or "0")) + rate = Decimal(str(market_stats.get("current_funding_rate") or "0")) + funding_timestamp_ms = int(market_stats.get("funding_timestamp") or 0) + next_funding_utc_timestamp = (funding_timestamp_ms // 1000) if funding_timestamp_ms > 0 else int((time.time() // 3600 + 1) * 3600) + + info_update = FundingInfoUpdate( + trading_pair=trading_pair, + index_price=index_price, + mark_price=mark_price, + next_funding_utc_timestamp=next_funding_utc_timestamp, + rate=rate, + ) + message_queue.put_nowait(info_update) + + self._connector.set_LIGHTER_price( + trading_pair, + timestamp=float(raw_message.get("timestamp") or 0) / 1000, + index_price=index_price, + mark_price=mark_price, + ) + + def _channel_originating_message(self, event_message: Dict[str, Any]) -> str: + if "channel" not in event_message: + return "" + event_channel = str(event_message.get("channel")) + event_type = str(event_message.get("type", "")) + if ( + event_channel.startswith(f"{CONSTANTS.WS_ORDER_BOOK_SNAPSHOT_CHANNEL}:") + or event_channel.startswith(f"{CONSTANTS.WS_ORDER_BOOK_SNAPSHOT_CHANNEL}/") + ): + if event_type in {"subscribed/order_book", "snapshot/order_book"}: + return self._snapshot_messages_queue_key + if event_type == "update/order_book": + return self._diff_messages_queue_key + return self._snapshot_messages_queue_key + if ( + event_channel.startswith(f"{CONSTANTS.WS_TRADES_CHANNEL}:") + or event_channel.startswith(f"{CONSTANTS.WS_TRADES_CHANNEL}/") + ): + return self._trade_messages_queue_key + if ( + event_channel.startswith(f"{CONSTANTS.WS_MARKET_STATS_CHANNEL}:") + or event_channel.startswith(f"{CONSTANTS.WS_MARKET_STATS_CHANNEL}/") + ): + return self._funding_info_messages_queue_key + return "" + + async def subscribe_to_trading_pair(self, trading_pair: str) -> bool: + if self._ws_assistant is None: + return False + try: + market_id, _, _, _ = await self._connector._get_market_spec(trading_pair) + self._market_id_to_trading_pair[market_id] = trading_pair + self.add_trading_pair(trading_pair) + await self._ws_assistant.send(WSJSONRequest(payload={"type": "subscribe", "channel": f"order_book/{market_id}"})) + await self._ws_assistant.send(WSJSONRequest(payload={"type": "subscribe", "channel": f"trade/{market_id}"})) + await self._ws_assistant.send(WSJSONRequest(payload={"type": "subscribe", "channel": f"market_stats/{market_id}"})) + return True + except asyncio.CancelledError: + raise + except Exception: + self.logger().exception(f"Error subscribing to {trading_pair}") + return False + + async def unsubscribe_from_trading_pair(self, trading_pair: str) -> bool: + if self._ws_assistant is None: + return False + try: + market_id, _, _, _ = await self._connector._get_market_spec(trading_pair) + await self._ws_assistant.send(WSJSONRequest(payload={"type": "unsubscribe", "channel": f"order_book/{market_id}"})) + await self._ws_assistant.send(WSJSONRequest(payload={"type": "unsubscribe", "channel": f"trade/{market_id}"})) + await self._ws_assistant.send(WSJSONRequest(payload={"type": "unsubscribe", "channel": f"market_stats/{market_id}"})) + self._market_id_to_trading_pair.pop(market_id, None) + self.remove_trading_pair(trading_pair) + return True + except asyncio.CancelledError: + raise + except Exception: + self.logger().exception(f"Error unsubscribing from {trading_pair}") + return False diff --git a/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_auth.py b/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_auth.py new file mode 100644 index 00000000000..f1b20a0e987 --- /dev/null +++ b/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_auth.py @@ -0,0 +1,22 @@ +from hummingbot.core.web_assistant.auth import AuthBase +from hummingbot.core.web_assistant.connections.data_types import RESTRequest, WSRequest + + +class LighterPerpetualAuth(AuthBase): + def __init__(self, api_key: str, api_secret: str = "", account_identifier: str = ""): + self.api_key = api_key + self.api_secret = api_secret + self.user_wallet_public_key = account_identifier + + async def rest_authenticate(self, request: RESTRequest) -> RESTRequest: + headers = dict(request.headers or {}) + headers["accept"] = "application/json" + headers["Content-Type"] = "application/json" + # Do not expose the API key or index in headers — auth token is used + # for restricted endpoints via 'auth' query param; public endpoints need no key header. + request.headers = headers + + return request + + async def ws_authenticate(self, request: WSRequest) -> WSRequest: + return request diff --git a/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_constants.py b/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_constants.py new file mode 100644 index 00000000000..66a7c83fd54 --- /dev/null +++ b/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_constants.py @@ -0,0 +1,198 @@ +from hummingbot.core.api_throttler.data_types import LinkedLimitWeightPair, RateLimit +from hummingbot.core.data_type.in_flight_order import OrderState + +EXCHANGE_NAME = "lighter_perpetual" +DEFAULT_DOMAIN = "lighter_perpetual" +HB_OT_ID_PREFIX = "HBOT" + +# Base URLs +REST_URL = "https://mainnet.zklighter.elliot.ai/api/v1" +WSS_URL = "wss://mainnet.zklighter.elliot.ai/stream" + +TESTNET_DOMAIN = "lighter_perpetual_testnet" +TESTNET_REST_URL = "https://testnet.zklighter.elliot.ai/api/v1" +TESTNET_WSS_URL = "wss://testnet.zklighter.elliot.ai/stream" + +# order status mapping +ORDER_STATE = { + "open": OrderState.OPEN, + "in-progress": OrderState.OPEN, + "pending": OrderState.OPEN, + "filled": OrderState.FILLED, + "partially_filled": OrderState.PARTIALLY_FILLED, + "cancelled": OrderState.CANCELED, # British spelling from REST/WS + "canceled": OrderState.CANCELED, # American spelling variant (defensive) + "cancel": OrderState.CANCELED, # event_type value sometimes used in WS + "canceled-post-only": OrderState.CANCELED, + "canceled-reduce-only": OrderState.CANCELED, + "canceled-position-not-allowed": OrderState.CANCELED, + "canceled-margin-not-allowed": OrderState.CANCELED, + "canceled-too-much-slippage": OrderState.CANCELED, + "canceled-not-enough-liquidity": OrderState.CANCELED, + "canceled-self-trade": OrderState.CANCELED, + "canceled-expired": OrderState.CANCELED, + "canceled-oco": OrderState.CANCELED, + "canceled-child": OrderState.CANCELED, + "canceled-liquidation": OrderState.CANCELED, + "canceled-invalid-balance": OrderState.CANCELED, + "rejected": OrderState.FAILED, +} + +GET_MARKET_ORDER_BOOK_SNAPSHOT_PATH_URL = "/orderBookOrders" +GET_ORDER_HISTORY_PATH_URL = "/accountInactiveOrders" +GET_ACTIVE_ORDERS_PATH_URL = "/accountActiveOrders" +GET_CANDLES_PATH_URL = "/candles" +GET_PRICES_PATH_URL = "/exchangeStats" +GET_FUNDING_RATES_PATH_URL = "/funding-rates" +GET_POSITIONS_PATH_URL = "/positions" +GET_FUNDING_HISTORY_PATH_URL = "/positionFunding" +SET_LEVERAGE_PATH_URL = "/changeAccountTier" +CANCEL_ORDER_PATH_URL = "/sendTx" +EXCHANGE_INFO_PATH_URL = "/orderBooks" +GET_TOKENLIST_PATH_URL = "/tokenlist" +GET_ACCOUNT_INFO_PATH_URL = "/account" +GET_ACCOUNT_API_CONFIG_KEYS = "/apikeys" +CREATE_ACCOUNT_API_CONFIG_KEY = "/tokens_create" +GET_TRADE_HISTORY_PATH_URL = "/trades" +GET_FEES_INFO_PATH_URL = "/leaseOptions" +GET_NEXT_NONCE_PATH_URL = "/nextNonce" + +# the API endpoints for market / limit / stop orders are different +# the support for stop orders is out of the scope for this integration +CREATE_MARKET_ORDER_PATH_URL = "/sendTx" +CREATE_LIMIT_ORDER_PATH_URL = "/sendTx" + +# Default maximum slippage tolerance for market orders (percentage string, e.g. "5" = 5%) +MARKET_ORDER_MAX_SLIPPAGE = "5" + +# WebSocket Channels + +WS_ORDER_BOOK_SNAPSHOT_CHANNEL = "order_book" +WS_TRADES_CHANNEL = "trade" +WS_MARKET_STATS_CHANNEL = "market_stats" + +WS_ACCOUNT_ORDER_UPDATES_CHANNEL = "account_order_updates" +WS_ACCOUNT_POSITIONS_CHANNEL = "account_positions" +WS_ACCOUNT_INFO_CHANNEL = "account_info" +WS_ACCOUNT_TRADES_CHANNEL = "account_trades" +WS_ACCOUNT_ALL_CHANNEL = "account_all" +WS_ACCOUNT_ALL_ORDERS_CHANNEL = "account_all_orders" +WS_USER_STATS_CHANNEL = "user_stats" + +WS_PING_INTERVAL = 30 # Keep connection alive + +# the exchange has different "costs" of the calls for every endpoint +# plus there're exactly 2 tiers of rate limits: (1) Unidentified IP (2) Valid API Config Key +# below you could find (in the comments) -- the costs (aka "weight") of each endpoints group + +LIGHTER_LIMIT_ID = "LIGHTER_LIMIT" + +# Default throttler limits derived from the documented Lighter request budget. +LIGHTER_TIER_1_LIMIT = 24000 +LIGHTER_TIER_2_LIMIT = 24000 +LIGHTER_LIMIT_INTERVAL = 60 + +FEE_TIER_LIMITS = { + 0: 3000, # doc: 300 + 1: 6000, # doc: 600 + 2: 12000, # doc: 1200 + 3: 24000, # doc: 2400 + 4: 60000, # doc: 6000 + 5: 120000, # doc: 12000 + 6: 240000, # doc: 24000 + 7: 300000, # doc: 30000 +} + +# Costs (x10 of doc values) +STANDARD_REQUEST_COST = 10 # doc: 1 +ORDER_CANCELLATION_COST = 5 # doc: 0.5 +HEAVY_GET_REQUEST_COST_TIER_1 = 120 # Unidentified IP (doc: 12) +HEAVY_GET_REQUEST_COST_TIER_2 = 30 # Valid API Config Key (doc: 3) + +RATE_LIMITS = [ + RateLimit(limit_id=LIGHTER_LIMIT_ID, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL), + RateLimit(limit_id=GET_MARKET_ORDER_BOOK_SNAPSHOT_PATH_URL, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_1)]), + RateLimit(limit_id=CREATE_LIMIT_ORDER_PATH_URL, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=STANDARD_REQUEST_COST)]), + RateLimit(limit_id=CREATE_MARKET_ORDER_PATH_URL, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=STANDARD_REQUEST_COST)]), + RateLimit(limit_id=CANCEL_ORDER_PATH_URL, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=ORDER_CANCELLATION_COST)]), + RateLimit(limit_id=SET_LEVERAGE_PATH_URL, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=STANDARD_REQUEST_COST)]), + RateLimit(limit_id=GET_FUNDING_HISTORY_PATH_URL, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_1)]), + RateLimit(limit_id=GET_POSITIONS_PATH_URL, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_1)]), + RateLimit(limit_id=GET_ORDER_HISTORY_PATH_URL, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_1)]), + RateLimit(limit_id=GET_ACTIVE_ORDERS_PATH_URL, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_1)]), + RateLimit(limit_id=GET_CANDLES_PATH_URL, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_1)]), + RateLimit(limit_id=EXCHANGE_INFO_PATH_URL, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_1)]), + RateLimit(limit_id=GET_PRICES_PATH_URL, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_1)]), + RateLimit(limit_id=GET_FUNDING_RATES_PATH_URL, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_1)]), + RateLimit(limit_id=GET_ACCOUNT_INFO_PATH_URL, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_1)]), + RateLimit(limit_id=GET_ACCOUNT_API_CONFIG_KEYS, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_1)]), + RateLimit(limit_id=CREATE_ACCOUNT_API_CONFIG_KEY, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_1)]), + RateLimit(limit_id=GET_TRADE_HISTORY_PATH_URL, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_1)]), + RateLimit(limit_id=GET_FEES_INFO_PATH_URL, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_1)]), + RateLimit(limit_id=GET_NEXT_NONCE_PATH_URL, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=STANDARD_REQUEST_COST)]), + RateLimit(limit_id=GET_TOKENLIST_PATH_URL, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=STANDARD_REQUEST_COST)]), +] + +RATE_LIMITS_TIER_2 = [ + RateLimit(limit_id=LIGHTER_LIMIT_ID, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL), + RateLimit(limit_id=GET_MARKET_ORDER_BOOK_SNAPSHOT_PATH_URL, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_2)]), + RateLimit(limit_id=CREATE_LIMIT_ORDER_PATH_URL, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=STANDARD_REQUEST_COST)]), + RateLimit(limit_id=CREATE_MARKET_ORDER_PATH_URL, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=STANDARD_REQUEST_COST)]), + RateLimit(limit_id=CANCEL_ORDER_PATH_URL, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=ORDER_CANCELLATION_COST)]), + RateLimit(limit_id=SET_LEVERAGE_PATH_URL, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=STANDARD_REQUEST_COST)]), + RateLimit(limit_id=GET_FUNDING_HISTORY_PATH_URL, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_2)]), + RateLimit(limit_id=GET_POSITIONS_PATH_URL, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_2)]), + RateLimit(limit_id=GET_ORDER_HISTORY_PATH_URL, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_2)]), + RateLimit(limit_id=GET_ACTIVE_ORDERS_PATH_URL, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_2)]), + RateLimit(limit_id=GET_CANDLES_PATH_URL, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_2)]), + RateLimit(limit_id=EXCHANGE_INFO_PATH_URL, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_2)]), + RateLimit(limit_id=GET_PRICES_PATH_URL, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_2)]), + RateLimit(limit_id=GET_FUNDING_RATES_PATH_URL, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_2)]), + RateLimit(limit_id=GET_ACCOUNT_INFO_PATH_URL, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_2)]), + RateLimit(limit_id=GET_ACCOUNT_API_CONFIG_KEYS, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_2)]), + RateLimit(limit_id=CREATE_ACCOUNT_API_CONFIG_KEY, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_2)]), + RateLimit(limit_id=GET_TRADE_HISTORY_PATH_URL, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_2)]), + RateLimit(limit_id=GET_FEES_INFO_PATH_URL, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_2)]), + RateLimit(limit_id=GET_NEXT_NONCE_PATH_URL, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=STANDARD_REQUEST_COST)]), + RateLimit(limit_id=GET_TOKENLIST_PATH_URL, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=STANDARD_REQUEST_COST)]), +] diff --git a/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_derivative.py b/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_derivative.py new file mode 100644 index 00000000000..cf87209ce7f --- /dev/null +++ b/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_derivative.py @@ -0,0 +1,4427 @@ +import asyncio +import hashlib +import math +import time +from decimal import Decimal +from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple + +from bidict import bidict + +import hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_constants as CONSTANTS +import hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_web_utils as web_utils +from hummingbot.connector.constants import DAY +from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_api_order_book_data_source import ( + LighterPerpetualAPIOrderBookDataSource, +) +from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_auth import LighterPerpetualAuth +from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_user_stream_data_source import ( + LighterPerpetualUserStreamDataSource, +) +from hummingbot.connector.derivative.position import Position +from hummingbot.connector.perpetual_derivative_py_base import PerpetualDerivativePyBase +from hummingbot.connector.trading_rule import TradingRule +from hummingbot.connector.utils import combine_to_hb_trading_pair +from hummingbot.core.api_throttler.data_types import RateLimit +from hummingbot.core.data_type.common import OrderType, PositionAction, PositionMode, PositionSide, PriceType, TradeType +from hummingbot.core.data_type.in_flight_order import InFlightOrder, OrderState, OrderUpdate, TradeUpdate +from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource +from hummingbot.core.data_type.trade_fee import TokenAmount, TradeFeeBase, TradeFeeSchema +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather +from hummingbot.core.utils.estimate_fee import build_trade_fee +from hummingbot.core.web_assistant.connections.data_types import RESTMethod +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory + +s_decimal_0 = Decimal(0) +s_decimal_NaN = Decimal("nan") + + +class LighterPerpetualPriceRecord(NamedTuple): + """ + Price record for the specific trading pair + + :param timestamp: the timestamp of the price (in seconds) + :param index_price: the index price + :param mark_price: the mark price + """ + timestamp: float + index_price: Decimal + mark_price: Decimal + + +class LighterPerpetualDerivative(PerpetualDerivativePyBase): + + web_utils = web_utils + + TRADING_FEES_INTERVAL = DAY + EMPTY_MARKET_DATA_WARNING_INTERVAL = 30.0 + _CLIENT_ORDER_INDEX_MAX = (1 << 48) - 1 + _CLIENT_ORDER_INDEX_TIME_MULTIPLIER = 140 + _BALANCE_STATUS_MAX_AGE = 180.0 + _USER_STREAM_STATUS_MAX_AGE = 180.0 + _PRIVATE_ACCOUNT_EVENT_MAX_AGE = 180.0 + _POSITION_STATUS_MAX_AGE = 60.0 + _HEALTHY_PRIVATE_STREAM_POLL_INTERVAL = 30.0 + _LEVERAGE_SET_MAX_RETRIES = 3 + _LEVERAGE_SET_RETRY_INTERVAL = 1.0 + _TRADE_HISTORY_TIME_DRIFT_BUFFER = 10.0 # seconds + _SUB_MINIMUM_POSITION_WARNING_INTERVAL = 120.0 + # Lighter on-chain cancel TX takes ~29 s to confirm. Any CANCELED WS event for an + # order younger than this threshold is almost certainly a subscription snapshot replay + # (false cancel) rather than a real user-initiated cancellation. + _CANCEL_MIN_ORDER_AGE_SECS: float = 10.0 + + def __init__( + self, + lighter_perpetual_api_key_index: str, + lighter_perpetual_account_index: str, + lighter_perpetual_api_key_private_key: str, + lighter_perpetual_api_key_public_key: str = "", + lighter_perpetual_api_key: str = "", + lighter_perpetual_api_secret: str = "", + trading_pairs: Optional[List[str]] = None, + trading_required: bool = True, + domain: str = CONSTANTS.DEFAULT_DOMAIN, + balance_asset_limit: Optional[Dict[str, Dict[str, Decimal]]] = None, + rate_limits_share_pct: Decimal = Decimal("100"), + ): + self.api_key = lighter_perpetual_api_key_private_key + self.api_secret = lighter_perpetual_api_key_index + self.account_index = lighter_perpetual_account_index + self.api_key_index = lighter_perpetual_api_key_index + self.api_config_key = self.api_key + self.user_wallet_public_key = lighter_perpetual_api_key_public_key + + configured_api_key_index = next( + ( + str(int(str(candidate).strip())) + for candidate in ( + lighter_perpetual_api_key_index, + lighter_perpetual_api_secret, + lighter_perpetual_api_key, + ) + if self._is_int_string(candidate) + ), + "", + ) + self.api_key_index = configured_api_key_index + if not self.api_config_key: + self.api_config_key = configured_api_key_index + + self._domain = domain + self._trading_required = trading_required + self._trading_pairs = trading_pairs + + self._prices: Dict[str, Optional[LighterPerpetualPriceRecord]] = { + trading_pair: None for trading_pair in trading_pairs + } + + self._order_history_last_poll_timestamp: Dict[str, float] = {} + self._market_id_by_symbol: Dict[str, int] = {} + self._size_decimals_by_symbol: Dict[str, int] = {} + self._price_decimals_by_symbol: Dict[str, int] = {} + self._lighter_signer_client = None + self._signer_request_lock = asyncio.Lock() + + self._fee_tier = 0 + self._last_client_order_index: int = 0 + # Maps our client_order_index (str) -> exchange-assigned order_index (str) + # Populated by WS account_all order updates and REST active-order queries. + self._client_order_index_to_order_index: Dict[str, str] = {} + # Maps client_order_index (str) -> client_order_id (HBOT-... string) for O(1) WS lookup. + # Populated by _place_order immediately after a successful order submission. + self._client_order_index_to_client_order_id: Dict[str, str] = {} + # Cached auth token: (token_str, expiry_timestamp_seconds) + self._auth_token_cache: Optional[Tuple[str, float]] = None + self._last_balance_update_timestamp: float = 0.0 + self._last_position_update_timestamp: float = 0.0 + self._last_private_account_event_timestamp: float = 0.0 + self._last_empty_order_book_warning_timestamp: Dict[str, float] = {} + self._last_no_candle_warning_timestamp: Dict[str, float] = {} + self._last_unmatched_private_event_reconcile_ts: float = 0.0 + self._last_sub_minimum_position_warning_ts: Dict[str, float] = {} + self._status_poll_cycle_active: bool = False + self._active_orders_snapshot_by_market: Dict[int, List[Dict[str, Any]]] = {} + self._active_orders_snapshot_market_complete: Set[int] = set() + self._cancel_in_flight_client_order_ids: Set[str] = set() + # Maps client_order_id -> earliest timestamp to allow the next cancel retry. + # Set when a cancel reconciliation returns OPEN to prevent per-tick retry spam. + self._cancel_backoff_until: Dict[str, float] = {} + # Buffer for trade entries from the standalone account_trades channel that arrived before + # the account_all channel established the client_order_index -> order_index mapping. + # Each entry is (buffered_timestamp, normalized_trade_dict). + self._pending_trade_entries: List[Tuple[float, Dict[str, Any]]] = [] + super().__init__(balance_asset_limit=balance_asset_limit, rate_limits_share_pct=rate_limits_share_pct) + + @staticmethod + def _client_order_index_from_order_id(order_id: str) -> int: + digest = hashlib.sha256(order_id.encode()).digest() + return int.from_bytes(digest[:8], byteorder="big", signed=False) & 0x7FFFFFFFFFFFFFFF + + def _allocate_client_order_index(self) -> int: + """Allocate a unique client order index using current timestamp as base. + + Time-based allocation: base = int(time_ms) * TIME_MULTIPLIER. + Consecutive calls within the same millisecond bump the counter by 1. + """ + base = int(time.time() * 1000) * self._CLIENT_ORDER_INDEX_TIME_MULTIPLIER + if base > self._last_client_order_index: + self._last_client_order_index = base + else: + self._last_client_order_index += 1 + return self._last_client_order_index + + @staticmethod + def _is_int_string(value: str) -> bool: + if value is None: + return False + try: + int(str(value).strip()) + return True + except Exception: + return False + + def _should_emit_throttled_warning(self, warning_key: str, warning_timestamps: Dict[str, float]) -> bool: + now = time.time() + last_warning_timestamp = warning_timestamps.get(warning_key, 0.0) + if now - last_warning_timestamp >= self.EMPTY_MARKET_DATA_WARNING_INTERVAL: + warning_timestamps[warning_key] = now + return True + return False + + def _get_top_order_book_price(self, trading_pair: str, is_buy: bool) -> Decimal: + try: + order_book = self.get_order_book(trading_pair) + except Exception: + warning_key = f"{trading_pair}:{'ask' if is_buy else 'bid'}:missing" + if self._should_emit_throttled_warning(warning_key, self._last_empty_order_book_warning_timestamp): + self.logger().warning(f"{'Ask' if is_buy else 'Bid'} orderbook for {trading_pair} is empty.") + return s_decimal_NaN + + entries = order_book.ask_entries() if is_buy else order_book.bid_entries() + top_entry = next(entries, None) + + if top_entry is None: + warning_key = f"{trading_pair}:{'ask' if is_buy else 'bid'}" + if self._should_emit_throttled_warning(warning_key, self._last_empty_order_book_warning_timestamp): + self.logger().warning(f"{'Ask' if is_buy else 'Bid'} orderbook for {trading_pair} is empty.") + return s_decimal_NaN + + top_price = Decimal(str(top_entry.price)) + return self.quantize_order_price(trading_pair, top_price) + + def get_price(self, trading_pair: str, is_buy: bool) -> Decimal: + return self._get_top_order_book_price(trading_pair=trading_pair, is_buy=is_buy) + + def get_price_by_type(self, trading_pair: str, price_type: PriceType) -> Decimal: + if price_type is PriceType.BestBid: + return self._get_top_order_book_price(trading_pair=trading_pair, is_buy=False) + elif price_type is PriceType.BestAsk: + return self._get_top_order_book_price(trading_pair=trading_pair, is_buy=True) + elif price_type is PriceType.MidPrice: + ask_price = self._get_top_order_book_price(trading_pair=trading_pair, is_buy=True) + bid_price = self._get_top_order_book_price(trading_pair=trading_pair, is_buy=False) + if ask_price.is_nan() or bid_price.is_nan(): + return s_decimal_NaN + return (ask_price + bid_price) / Decimal("2") + elif price_type is PriceType.LastTrade: + try: + price = Decimal(str(self.get_order_book(trading_pair).last_trade_price)) + if price > s_decimal_0: + return price + except Exception: + pass + return s_decimal_NaN + else: + return s_decimal_NaN + + def _get_rest_api_key(self) -> str: + if self._is_int_string(self.api_key): + return self.api_key + if self.api_secret: + return self.api_secret + return self.api_key + + @staticmethod + def _is_hex_private_key(value: str) -> bool: + """Return True only if value is a 64+ char hex string (valid signer private key).""" + if not value: + return False + key = value[2:] if value.lower().startswith("0x") else value + return len(key) >= 64 and all(c in "0123456789abcdefABCDEF" for c in key) + + def _get_signer_private_key(self) -> str: + if self.api_key and not self._is_int_string(self.api_key) and self._is_hex_private_key(self.api_key): + return self.api_key + if self.api_secret and not self._is_int_string(self.api_secret) and self._is_hex_private_key(self.api_secret): + return self.api_secret + raise ValueError( + "Lighter signer private key is required for signed transactions. " + "Set lighter_perpetual_api_key to your API private key (64+ char hex string)." + ) + + @property + def rest_api_key(self) -> str: + return self._get_rest_api_key() + + def _api_host_for_signer(self) -> str: + url = CONSTANTS.REST_URL if self._domain == CONSTANTS.DEFAULT_DOMAIN else CONSTANTS.TESTNET_REST_URL + return url.split("/api/v1")[0] + + def _get_api_key_index(self) -> int: + if self._is_int_string(self.api_key_index): + return int(self.api_key_index) + if self._is_int_string(self.api_key): + return int(self.api_key) + if self._is_int_string(self.api_secret): + return int(self.api_secret) + raise ValueError( + "Lighter API key index must be provided as an integer string in lighter_perpetual_api_key " + "or lighter_perpetual_api_secret (compatibility mode)." + ) + + def _get_account_index(self) -> int: + try: + return int(self.account_index) + except Exception as e: + raise ValueError("Lighter account index must be an integer string") from e + + @staticmethod + def _is_ok_response(response: Any) -> bool: + if not isinstance(response, dict): + # Non-dict response (e.g., raw text or HTML error page) is never a success. + return False + if response.get("success") is True: + return True + code = response.get("code") + try: + # Lighter API uses code=0 for success; HTTP 200 is also accepted. + code_int = int(code) + return code_int == 0 or code_int == 200 + except Exception: + return False + + def _account_query_params(self) -> Dict[str, Any]: + return { + "by": "index", + "value": str(self._get_account_index()), + "active_only": "true", + } + + @staticmethod + def _first_not_none(*values: Any) -> Any: + for value in values: + if value is not None: + return value + return None + + @staticmethod + def _account_from_response(response: Dict[str, Any]) -> Optional[Dict[str, Any]]: + data = response.get("data") + if isinstance(data, dict): + return data + if isinstance(data, list) and len(data) > 0: + return data[0] + accounts = response.get("accounts") + if isinstance(accounts, list) and len(accounts) > 0: + return accounts[0] + # Top-level account response (no data/accounts wrapper) + if response.get("collateral") is not None or response.get("available_balance") is not None: + return response + if not response: + return None + return None + + def _get_lighter_signer_client(self): + if self._lighter_signer_client is None: + import lighter + + # connector-side signer override removed + + self._lighter_signer_client = lighter.signer_client.SignerClient( + url=self._api_host_for_signer(), + account_index=self._get_account_index(), + api_private_keys={self._get_api_key_index(): self._get_signer_private_key()}, + ) + + return self._lighter_signer_client + + async def _refresh_signer_client_async(self): + """Reset and recreate the signer client in a thread executor to avoid blocking the event loop. + + SignerClient.__init__ calls get_nonce_from_api() synchronously — a blocking HTTP call to + the Lighter network node. If the node is slow or temporarily unreachable, calling this + synchronously freezes the entire asyncio event loop (and therefore the strategy) for the + duration of the TCP timeout (potentially minutes). + + Must be called after a nonce failure so the next signing attempt uses a fresh nonce. + """ + self._lighter_signer_client = None + loop = asyncio.get_event_loop() + new_client = await loop.run_in_executor(None, self._get_lighter_signer_client) + return new_client + + async def _refresh_market_metadata(self): + response = await self._api_get( + path_url=CONSTANTS.EXCHANGE_INFO_PATH_URL, + return_err=True, + ) + + for market in response.get("order_books", []): + if market.get("market_type") != "perp": + continue + + symbol = market["symbol"] + self._market_id_by_symbol[symbol] = int(market["market_id"]) + self._size_decimals_by_symbol[symbol] = int(market.get("supported_size_decimals", 0)) + self._price_decimals_by_symbol[symbol] = int(market.get("supported_price_decimals", 0)) + + async def _get_market_spec(self, trading_pair: str) -> Tuple[int, int, int, str]: + symbol = await self.exchange_symbol_associated_to_pair(trading_pair) + + if symbol not in self._market_id_by_symbol: + await self._refresh_market_metadata() + + if symbol not in self._market_id_by_symbol: + raise ValueError(f"Market metadata not found for symbol {symbol}") + + return ( + self._market_id_by_symbol[symbol], + self._size_decimals_by_symbol.get(symbol, 0), + self._price_decimals_by_symbol.get(symbol, 0), + symbol, + ) + + @property + def name(self) -> str: + return self._domain + + @property + def authenticator(self) -> LighterPerpetualAuth: + return LighterPerpetualAuth( + api_key=self.rest_api_key, + api_secret=self.api_secret, + account_identifier=self.user_wallet_public_key or self.rest_api_key, + ) + + @property + def rate_limits_rules(self): + if not self.api_key: + return CONSTANTS.RATE_LIMITS + + tier2_limit = CONSTANTS.FEE_TIER_LIMITS.get(self._fee_tier, CONSTANTS.LIGHTER_TIER_2_LIMIT) + + global_limit = RateLimit( + limit_id=CONSTANTS.LIGHTER_LIMIT_ID, + limit=tier2_limit, + time_interval=CONSTANTS.LIGHTER_LIMIT_INTERVAL + ) + + return [global_limit] + CONSTANTS.RATE_LIMITS_TIER_2[1:] + + async def _api_request( + self, + path_url, + overwrite_url: Optional[str] = None, + method: RESTMethod = RESTMethod.GET, + params: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, Any]] = None, + is_auth_required: bool = False, + return_err: bool = False, + limit_id: Optional[str] = None, + headers: Optional[Dict[str, Any]] = None, + **kwargs + ) -> Dict[str, Any]: + + # Auth token is passed as the 'auth' query param by callers. + # Do not expose the api_key_index in a header — it is not a valid X-Api-Key value. + + return await super()._api_request( + path_url=path_url, + overwrite_url=overwrite_url, + method=method, + params=params, + data=data, + is_auth_required=is_auth_required, + return_err=return_err, + limit_id=limit_id, + headers=headers, + **kwargs + ) + + async def _api_request_url(self, path_url: str, is_auth_required: bool = False) -> str: + return web_utils.private_rest_url(path_url, domain=self._domain) + + async def _fetch_or_create_api_config_key(self): + configured_api_key_index = next( + ( + str(int(str(candidate).strip())) + for candidate in (self.api_key_index, self.api_secret, self.api_key) + if self._is_int_string(candidate) + ), + None, + ) + if configured_api_key_index is not None: + self.api_key_index = configured_api_key_index + if not self.api_config_key: + self.api_config_key = configured_api_key_index + else: + # api_config_key is already set — nothing more to configure. + return + # If wallet public key is already set, nothing more to do. + if self.user_wallet_public_key: + return + # api_key_index is configured but user_wallet_public_key is missing — resolve it. + if not self.account_index: + return + response = await self._api_get( + path_url=CONSTANTS.GET_ACCOUNT_API_CONFIG_KEYS, + params={"account_index": self._get_account_index(), "api_key_index": 255}, + is_auth_required=True, + return_err=True, + ) + api_keys = response.get("api_keys") or [] + for api_key in api_keys: + if str(api_key.get("api_key_index")) == str(self.api_key_index): + self.user_wallet_public_key = str(api_key.get("public_key", "")) + break + return + + if not self.account_index or not self.rest_api_key: + self.logger().warning("Lighter account index or REST API key is missing; skipping API key discovery") + return + + response = await self._api_get( + path_url=CONSTANTS.GET_ACCOUNT_API_CONFIG_KEYS, + params={"account_index": self._get_account_index(), "api_key_index": 255}, + is_auth_required=True, + return_err=True, + ) + + api_keys = response.get("api_keys") or [] + matching_key = next( + ( + api_key + for api_key in api_keys + if str(api_key.get("public_key", "")).lower() == str(self.rest_api_key).lower() + ), + None, + ) + + if matching_key is not None: + self.api_key_index = str(matching_key.get("api_key_index")) + self.api_config_key = self.rest_api_key + self.logger().info(f"Resolved Lighter API key index: {self.api_key_index}") + if self._throttler: + self._throttler.set_rate_limits(self.rate_limits_rules) + return + + self.logger().warning( + "Configured Lighter REST API key was not found in /apikeys response. " + "Provide lighter_perpetual_api_key_index explicitly or onboard/register the API key on Lighter first." + ) + + def generate_api_key_pair(self) -> Tuple[str, str]: + try: + import lighter + except Exception as e: + raise ImportError("lighter SDK package is required for Lighter API key generation") from e + + private_key, public_key, error = lighter.create_api_key() + if error: + raise ValueError(f"Failed to generate Lighter API key pair: {error}") + return private_key, public_key + + @property + def domain(self): + return self._domain + + @property + def client_order_id_max_length(self): + return 32 + + @property + def client_order_id_prefix(self): + return CONSTANTS.HB_OT_ID_PREFIX + + @property + def trading_rules_request_path(self): + return CONSTANTS.EXCHANGE_INFO_PATH_URL + + @property + def trading_pairs_request_path(self): + return CONSTANTS.EXCHANGE_INFO_PATH_URL + + @property + def check_network_request_path(self): + # LIGHTER does not expose a dedicated ping or time endpoint. + # Use the lighter market-stats route instead of the full metadata payload. + return CONSTANTS.GET_PRICES_PATH_URL + + @property + def trading_pairs(self) -> Optional[List[str]]: + return self._trading_pairs + + async def all_trading_pairs(self) -> List[str]: + """ + Returns all active perpetual trading pairs on Lighter. + Uses /orderBooks (same as _initialize_trading_pair_symbols_from_exchange_info) + filtered for market_type == 'perp'. Works on both mainnet and testnet. + """ + try: + result = await self._api_get(path_url=CONSTANTS.EXCHANGE_INFO_PATH_URL) + pairs = [] + for market in result.get("order_books") or []: + if str(market.get("market_type", "")).lower() != "perp": + continue + if str(market.get("status", "active")).lower() in { + "inactive", "disabled", "halted", "suspended", "delisted" + }: + continue + symbol = market.get("symbol", "") + if symbol: + pairs.append(combine_to_hb_trading_pair(symbol, "USDC")) + return pairs + except Exception: + return [] + + @property + def is_cancel_request_in_exchange_synchronous(self) -> bool: + return True + + @property + def is_trading_required(self) -> bool: + return self._trading_required + + @property + def funding_fee_poll_interval(self) -> int: + # actually it updates every hour + # but there's a chance that the bot was started 5 minutes before update + # so we would wait extra hour + # so query every 2 minutes should work + return 120 + + @property + def status_dict(self) -> Dict[str, bool]: + status = super().status_dict + if self.is_trading_required: + status["account_balance"] = status["account_balance"] and self._is_balance_info_fresh() + status["account_position"] = self._is_position_info_fresh() + return status + + def _is_user_stream_initialized(self): + if not self.is_trading_required: + return True + last_recv_time = self._user_stream_tracker.data_source.last_recv_time + return last_recv_time > 0 and (time.time() - last_recv_time) <= self._USER_STREAM_STATUS_MAX_AGE + + def _is_balance_info_fresh(self) -> bool: + # Connector is ready once balances have been fetched at least once on startup + return self._last_balance_update_timestamp > 0 + + def _is_position_info_fresh(self) -> bool: + if not self.is_trading_required: + return True + last_position_update = getattr(self, "_last_position_update_timestamp", 0.0) + return last_position_update > 0 and (time.time() - last_position_update) <= self._POSITION_STATUS_MAX_AGE + + def _mark_private_account_event_received(self): + self._last_private_account_event_timestamp = time.time() + + def supported_order_types(self) -> List[OrderType]: + return [OrderType.LIMIT, OrderType.LIMIT_MAKER, OrderType.MARKET] + + def supported_position_modes(self) -> List[PositionMode]: + return [PositionMode.ONEWAY] + + def get_buy_collateral_token(self, trading_pair: str) -> str: + return "USDC" + + def get_sell_collateral_token(self, trading_pair: str) -> str: + return "USDC" + + def _is_request_exception_related_to_time_synchronizer(self, request_exception: Exception): + return False + + def _is_order_not_found_during_status_update_error(self, status_update_exception: Exception) -> bool: + """ + e.g. + {"success":false,"data":null,"error":"Order history not found for order ID: 28416222569","code":404} + """ + return "not found" in str(status_update_exception) + + def _is_order_not_found_during_cancelation_error(self, cancelation_exception: Exception) -> bool: + """ + Lighter API error code 5 = order not found / already cancelled. + + https://docs.lighter.fi/api-documentation/api/error-codes + """ + err = str(cancelation_exception) + return ( + '"code":5' in err + or "may already be filled" in err + ) + + def _update_order_after_failure(self, order_id: str, trading_pair: str, exception: Optional[Exception] = None): + """Override to refresh positions whenever a CLOSE order fails. + + Special case — sub-minimum notional: when a CLOSE order fails because the position + is too small to meet the exchange's minimum notional (even after rounding up), CLOSE + retries may continue to fail until the position grows tradable. The position itself is + still kept in state so runtime status remains accurate. + + For all other CLOSE failures the position snapshot is eagerly refreshed from REST so + the TUI and strategy always reflect reality. + """ + super()._update_order_after_failure(order_id=order_id, trading_pair=trading_pair, exception=exception) + failed_order = self._order_tracker.all_orders.get(order_id) + if failed_order is not None and getattr(failed_order, "position", None) == PositionAction.CLOSE: + err_str = str(exception) if exception else "" + is_sub_minimum = ( + "below the minimum notional" in err_str + or "lower than minimum notional size" in err_str + or "below the minimum lot size" in err_str + or "invalid order base or quote amount" in err_str + ) + if is_sub_minimum: + now = time.time() + last_warning_ts = self._last_sub_minimum_position_warning_ts.get(trading_pair, 0.0) + if now - last_warning_ts >= self._SUB_MINIMUM_POSITION_WARNING_INTERVAL: + self._last_sub_minimum_position_warning_ts[trading_pair] = now + self.logger().warning( + "[_update_order_after_failure] Sub-minimum residual position for %s " + "cannot be closed right now (notional below exchange minimum). " + "Keeping position in state for accurate status; will retry after " + "future balance/position changes.", + trading_pair, + ) + else: + self.logger().debug( + "[_update_order_after_failure] Sub-minimum residual position for %s " + "cannot be closed; keeping position and suppressing repeated warning.", + trading_pair, + ) + safe_ensure_future(self._update_positions()) + else: + safe_ensure_future(self._update_positions()) + + @staticmethod + def _is_expected_order_rejection(error_message: str) -> bool: + normalized = (error_message or "").lower() + expected_patterns = ( + "minimum notional", + "minimum lot size", + "invalid order base or quote amount", + "below the minimum", + "order amount", + "order notional", + ) + return any(pattern in normalized for pattern in expected_patterns) + + def _on_order_failure( + self, + order_id: str, + trading_pair: str, + amount: Decimal, + trade_type: TradeType, + order_type: OrderType, + price: Optional[Decimal], + exception: Exception, + **kwargs, + ): + error_message = str(exception) + if self._is_expected_order_rejection(error_message=error_message): + self.logger().debug( + "Order rejected by exchange (expected validation) for %s %s %s @ %s: %s", + trade_type.name, + amount, + trading_pair, + price, + error_message, + ) + self._update_order_after_failure(order_id=order_id, trading_pair=trading_pair, exception=exception) + return + super()._on_order_failure( + order_id=order_id, + trading_pair=trading_pair, + amount=amount, + trade_type=trade_type, + order_type=order_type, + price=price, + exception=exception, + **kwargs, + ) + + def _is_sub_minimum_position_notional( + self, + trading_pair: str, + position_amount: Decimal, + reference_price: Decimal, + ) -> bool: + trading_rule = self._trading_rules.get(trading_pair) + if trading_rule is None: + return False + + notional = abs(position_amount) * max(reference_price, s_decimal_0) + return notional < trading_rule.min_notional_size + + def _create_web_assistants_factory(self) -> WebAssistantsFactory: + return web_utils.build_api_factory( + throttler=self._throttler, + auth=self._auth, + ) + + def _create_order_book_data_source(self) -> OrderBookTrackerDataSource: + return LighterPerpetualAPIOrderBookDataSource( + trading_pairs=self._trading_pairs, + connector=self, + api_factory=self._web_assistants_factory, + domain=self._domain, + ) + + def _create_user_stream_data_source(self) -> UserStreamTrackerDataSource: + return LighterPerpetualUserStreamDataSource( + connector=self, + api_factory=self._web_assistants_factory, + auth=self._auth, + domain=self._domain, + ) + + async def _format_trading_rules(self, exchange_info_dict: Dict[str, Any]) -> List[TradingRule]: + """ + https://docs.lighter.fi/api-documentation/api/rest-api/markets/get-market-info + + { + "success": true, + "data": [ + { + "symbol": "ETH", + "tick_size": "0.1", + "min_tick": "0", + "max_tick": "1000000", + "lot_size": "0.0001", + "max_leverage": 50, + "isolated_only": false, + "min_order_size": "10", + "max_order_size": "5000000", + "funding_rate": "0.0000125", + "next_funding_rate": "0.0000125", + "created_at": 1748881333944 + }, + { + "symbol": "BTC", + "tick_size": "1", + "min_tick": "0", + "max_tick": "1000000", + "lot_size": "0.00001", + "max_leverage": 50, + "isolated_only": false, + "min_order_size": "10", + "max_order_size": "5000000", + "funding_rate": "0.0000125", + "next_funding_rate": "0.0000125", + "created_at": 1748881333944 + }, + .... + ], + "error": null, + "code": null + } + """ + rules = [] + + order_books = exchange_info_dict.get("order_books") + if order_books: + for pair_info in order_books: + if pair_info.get("market_type") != "perp": + continue + + symbol = pair_info["symbol"] + size_decimals = int(pair_info.get("supported_size_decimals", 0)) + price_decimals = int(pair_info.get("supported_price_decimals", 0)) + lot_size = Decimal(f"1e-{size_decimals}") + tick_size = Decimal(f"1e-{price_decimals}") + min_notional = Decimal(str(pair_info.get("min_quote_amount", "10"))) + + self._market_id_by_symbol[symbol] = int(pair_info["market_id"]) + self._size_decimals_by_symbol[symbol] = size_decimals + self._price_decimals_by_symbol[symbol] = price_decimals + + try: + trading_pair = await self.trading_pair_associated_to_exchange_symbol(symbol=symbol) + except KeyError: + # Exchange has added a new perpetual market not in this connector's + # configured trading pairs. Skip it silently — crashing here causes + # the trading_rules_polling_loop to retry every 0.5 s, hammering the + # API with hundreds of calls per minute. + self.logger().debug( + "Skipping unknown perpetual symbol '%s' in trading rules update " + "(not in configured trading pairs).", + symbol, + ) + continue + + rules.append( + TradingRule( + trading_pair=trading_pair, + min_order_size=lot_size, + min_price_increment=tick_size, + min_base_amount_increment=lot_size, + min_notional_size=min_notional, + min_order_value=min_notional, + ) + ) + + return rules + + for pair_info in exchange_info_dict.get("data", []): + try: + trading_pair = await self.trading_pair_associated_to_exchange_symbol(symbol=pair_info["symbol"]) + except KeyError: + self.logger().debug( + "Skipping unknown perpetual symbol '%s' in trading rules update " + "(not in configured trading pairs).", + pair_info["symbol"], + ) + continue + rules.append( + TradingRule( + trading_pair=trading_pair, + min_order_size=Decimal(pair_info["lot_size"]), + min_price_increment=Decimal(pair_info["tick_size"]), + min_base_amount_increment=Decimal(pair_info["lot_size"]), + min_notional_size=Decimal(pair_info["min_order_size"]), + min_order_value=Decimal(pair_info["min_order_size"]), + ) + ) + + return rules + + async def _place_order( + self, + order_id: str, + trading_pair: str, + amount: Decimal, + trade_type: TradeType, + order_type: OrderType, + price: Decimal, + position_action: PositionAction = PositionAction.NIL, + **kwargs, + ) -> Tuple[str, float]: + """ + https://docs.lighter.fi/api-documentation/api/rest-api/orders/create-market-order + https://docs.lighter.fi/api-documentation/api/rest-api/orders/create-limit-order + """ + + if order_type not in self.supported_order_types(): + raise ValueError(f"Order type {order_type} is not supported by {self.name}.") + + market_id, size_decimals, price_decimals, _ = await self._get_market_spec(trading_pair) + signer_client = self._get_lighter_signer_client() + + # Resolve effective price; for MARKET orders use best ask/bid with slippage cap. + effective_price = price + if order_type == OrderType.MARKET or effective_price is None or effective_price.is_nan(): + order_book = self.get_order_book(trading_pair) + best_price = ( + Decimal(str(order_book.get_price(True))) + if trade_type == TradeType.BUY + else Decimal(str(order_book.get_price(False))) + ) + if best_price is None or best_price.is_nan() or best_price <= 0: + raise ValueError( + f"Unable to determine a valid execution price for {order_type.name} order on {trading_pair}." + ) + slippage = Decimal(CONSTANTS.MARKET_ORDER_MAX_SLIPPAGE) / Decimal("100") + if trade_type == TradeType.BUY: + effective_price = best_price * (Decimal("1") + slippage) + else: + effective_price = best_price * (Decimal("1") - slippage) + + base_amount_scaled = int((amount * Decimal(f"1e{size_decimals}")).to_integral_value()) + price_scaled = int((effective_price * Decimal(f"1e{price_decimals}")).to_integral_value()) + + # Pre-flight: validate minimum base amount and notional before consuming the signer + # lock and hitting the exchange network. Lighter rejects sub-minimums with code=21706 + # ("invalid order base or quote amount"), which the base class then misleadingly logs + # as "Check API key and network connection" — catching it here gives a clear early error. + # + # For CLOSE (reduce_only) orders below the minimum notional the amount is rounded UP + # to the minimum tradable size. Lighter's reduce_only flag caps execution at the actual + # position size, so submitting a larger-than-position amount safely closes the full + # residual. This breaks the infinite-retry loop caused by sub-minimum partial fills. + trading_rule = self._trading_rules.get(trading_pair) + if trading_rule is not None: + symbol = trading_pair.split("-")[0] if "-" in trading_pair else trading_pair + if amount < trading_rule.min_order_size: + raise IOError( + f"Order amount {amount} {symbol} is below the minimum lot size " + f"{trading_rule.min_order_size} {symbol}." + ) + notional = amount * effective_price + if notional < trading_rule.min_notional_size: + if position_action == PositionAction.CLOSE: + # Round amount UP to the minimum tradable notional so the order is accepted. + # reduce_only=True means Lighter will cap execution at the actual position. + lot = trading_rule.min_base_amount_increment or Decimal("0.001") + raw_min = trading_rule.min_notional_size / effective_price + lots_needed = Decimal(str(math.ceil(float(raw_min / lot)))) + rounded_up = max(lots_needed * lot, trading_rule.min_order_size) + self.logger().debug( + "[_place_order] CLOSE order for %s %s (notional %.4f USDC) is below the " + "%.2f USDC minimum. Rounding amount up to %s %s — reduce_only will cap " + "execution at the actual position size.", + amount, symbol, notional, trading_rule.min_notional_size, rounded_up, symbol, + ) + amount = rounded_up + # Recompute scaled amount after the adjustment. + base_amount_scaled = int((amount * Decimal(f"1e{size_decimals}")).to_integral_value()) + else: + raise IOError( + f"Order notional {notional:.4f} USDC is below the minimum notional " + f"{trading_rule.min_notional_size} USDC " + f"({amount} {symbol} @ {effective_price})." + ) + signer_order_type = signer_client.ORDER_TYPE_LIMIT + signer_tif = signer_client.ORDER_TIME_IN_FORCE_GOOD_TILL_TIME + order_expiry = signer_client.DEFAULT_28_DAY_ORDER_EXPIRY + if order_type == OrderType.MARKET: + signer_order_type = signer_client.ORDER_TYPE_MARKET + signer_tif = signer_client.ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL + order_expiry = signer_client.DEFAULT_IOC_EXPIRY + elif order_type == OrderType.LIMIT_MAKER: + signer_tif = signer_client.ORDER_TIME_IN_FORCE_POST_ONLY + + async with self._signer_request_lock: + signer_client = self._get_lighter_signer_client() + tx_response = None + error = None + for attempt in range(5): + client_order_index = self._allocate_client_order_index() + _, tx_response, error = await signer_client.create_order( + market_index=market_id, + client_order_index=client_order_index, + base_amount=base_amount_scaled, + price=price_scaled, + is_ask=(trade_type == TradeType.SELL), + order_type=signer_order_type, + time_in_force=signer_tif, + reduce_only=position_action == PositionAction.CLOSE, + order_expiry=order_expiry, + api_key_index=self._get_api_key_index(), + ) + if error is None and getattr(tx_response, "code", None) == 200: + break + if attempt < 4 and "invalid nonce" in str(error or tx_response).lower(): + signer_client = await self._refresh_signer_client_async() + await self._sleep(0.3) + continue + break + + if error is not None: + err_str = str(error) + if "invalid order base or quote amount" in err_str: + trading_rule = self._trading_rules.get(trading_pair) + extra = "" + if trading_rule is not None: + symbol = trading_pair.split("-")[0] if "-" in trading_pair else trading_pair + extra = ( + f" minimum base amount: {trading_rule.min_order_size} {symbol}," + f" minimum notional: {trading_rule.min_notional_size} USDC" + ) + raise IOError(f"Lighter create_order failed: {err_str}{extra}") + raise IOError(f"Lighter create_order signing/send failed: {error}") + if tx_response is None or getattr(tx_response, "code", None) != 200: + raise IOError(f"Lighter create_order failed: {tx_response}") + + # Register the reverse mapping for O(1) WS order lookup. + self._client_order_index_to_client_order_id[str(client_order_index)] = order_id + + # Refresh balance so locked margin / available balance display updates immediately. + # Use a shorter gate than the WS-event debounce (5 s) because order placement + # changes margin at the instant the transaction is confirmed. + self._schedule_fast_balance_sync(min_interval_seconds=2.0) + + return str(client_order_index), self.current_timestamp + + def _set_usdc_balances(self, total_balance: Decimal, available_balance: Decimal): + asset = "USDC" + + self._account_balances[asset] = total_balance + self._account_available_balances[asset] = available_balance + + for balances_dict in (self._account_balances, self._account_available_balances): + stale_assets = [tracked_asset for tracked_asset in balances_dict if tracked_asset != asset] + for stale_asset in stale_assets: + del balances_dict[stale_asset] + + self._last_balance_update_timestamp = time.time() + + def get_available_balance(self, currency: str) -> Decimal: + """Return exchange-authoritative available balance for Lighter perps. + + Lighter perp REST/WS available_balance is already net of margin consumed by + open positions and open orders (including orders not created by this bot). + The generic ConnectorBase path subtracts local in-flight orders again, which + under-reports available (double reservation). Here we return the connector + snapshot directly without local reservations or balance-limit caps so status + and strategy checks reflect the exchange-true perp available margin. + """ + available_balance = self._account_available_balances.get(currency, s_decimal_0) + total_balance = self._account_balances.get(currency) + if total_balance is not None: + available_balance = min(available_balance, total_balance) + return available_balance + + def _schedule_fast_balance_sync(self, min_interval_seconds: float = 5.0): + """Schedule a non-blocking REST balance refresh, throttled to one call per min_interval_seconds. + + The gate is applied at *schedule* time (not just at completion) so that multiple + near-simultaneous WS triggers (account_all, user_stats, account_info arriving within + the same event burst) coalesce into a single REST call rather than fanning out. + """ + if not self._trading_required: + return + + now = time.time() + if (now - self._last_balance_update_timestamp) < min_interval_seconds: + return + + # Claim the slot immediately so concurrent WS triggers that arrive before the + # async REST call completes are suppressed by the gate above. + self._last_balance_update_timestamp = now + + async def _safe_balance_sync(): + try: + await self._update_balances() + except asyncio.CancelledError: + raise + except Exception as err: + self.logger().debug("Post-order balance refresh failed: %s", err) + + safe_ensure_future(_safe_balance_sync()) + + async def _place_cancel(self, order_id: str, tracked_order: InFlightOrder) -> bool: + """ + https://docs.lighter.fi/api-documentation/api/rest-api/orders/cancel-order + """ + if tracked_order.exchange_order_id is None: + # Order placement is still awaiting blockchain confirmation. Set a + # short backoff so _execute_order_cancel skips the next few ticks + # rather than hammering every 1-second strategy tick. + self.logger().debug( + "[_place_cancel] exchange_order_id is None for order %s; " + "placement not yet confirmed — setting 2s backoff.", + order_id, + ) + self._cancel_backoff_until[order_id] = time.time() + 2.0 + return False + + market_id, _, _, _ = await self._get_market_spec(tracked_order.trading_pair) + + # Resolve the actual exchange order_index from our client_order_index. + # _place_order stores client_order_index as exchange_order_id initially; + # the real order_index is populated from WS updates or REST lookup. + client_oid = str(tracked_order.exchange_order_id) + + # Guard: exchange_order_id can become the string "None" when the base-class + # _place_order_and_process_update calls str() on a None value before the + # connector has had a chance to set the real id. Attempt to recover the + # real client_order_index via reverse lookup of the id→index map. + if client_oid == "None": + recovered_coi = next( + (coi for coi, oid in self._client_order_index_to_client_order_id.items() + if oid == order_id), + None, + ) + if recovered_coi is not None: + self.logger().info( + "[_place_cancel] exchange_order_id is 'None' for order %s; " + "recovered real client_order_index %s via reverse lookup.", + order_id, recovered_coi, + ) + client_oid = recovered_coi + else: + self.logger().warning( + "[_place_cancel] exchange_order_id is 'None' for order %s " + "and cannot recover real client_order_index; skipping cancel.", + order_id, + ) + return False + + # First attempt: use client_oid as the client_order_index key directly. + actual_order_index = self._client_order_index_to_order_index.get(client_oid) + + if actual_order_index is None: + # Second attempt: WS may have already replaced exchange_order_id with the real + # server order_index (e.g., "248885132237560"). In that case client_oid IS + # the order_index and the map key is the original small client_order_index. + # Recover it via reverse lookup: order_id → client_order_index → order_index. + original_coi = next( + (coi for coi, oid in self._client_order_index_to_client_order_id.items() + if oid == order_id), + None, + ) + if original_coi is not None and original_coi != client_oid: + actual_order_index = self._client_order_index_to_order_index.get(original_coi) + if actual_order_index is None: + # Map entry not yet written — use client_oid directly if it looks like a + # resolved numeric order_index (large integer, not the small sequential id). + if self._is_int_string(client_oid) and int(client_oid) > 1_000_000: + actual_order_index = client_oid + + if actual_order_index is None: + actual_order_index = await self._resolve_order_index_from_active_orders( + market_id=market_id, + client_order_index=client_oid, + ) + if actual_order_index is None: + # Cannot resolve the server order_index yet — most likely the order was just placed + # and hasn't propagated to the active-orders API (blockchain confirmation lag). + # Returning False keeps the order in-flight so the strategy retries on the next tick. + # DO NOT raise IOError here — that path calls _reconcile_order_state_after_cancel_error + # which can falsely match an unrelated inactive history entry and declare our order + # CANCELED while it is still open on the exchange (creating a real orphan). + self.logger().debug( + "[_place_cancel] Cannot resolve server order_index for client_order_index=%s " + "(order %s); deferring cancel — will retry when active-orders propagates.", + client_oid, + order_id, + ) + # Short backoff so we don't hammer the active-orders API on every strategy tick. + self._cancel_backoff_until[order_id] = time.time() + 5 + return False + + async with self._signer_request_lock: + signer_client = self._get_lighter_signer_client() + tx_response = None + error = None + for attempt in range(5): + _, tx_response, error = await signer_client.cancel_order( + market_index=market_id, + order_index=int(actual_order_index), + api_key_index=self._get_api_key_index(), + ) + if error is None and getattr(tx_response, "code", None) == 200: + break + if attempt < 4 and "invalid nonce" in str(error or tx_response).lower(): + signer_client = await self._refresh_signer_client_async() + await self._sleep(0.3) + continue + break + + if error is not None: + raise IOError(f"Lighter cancel_order signing/send failed: {error}") + if tx_response is None or getattr(tx_response, "code", None) != 200: + raise IOError(f"Lighter cancel_order failed: {tx_response}") + + return True + + async def _refresh_account_state( + self, + reason: str = "", + refresh_positions: bool = False, + refresh_balances: bool = False, + ) -> None: + """Refresh positions and/or balances after a significant event (e.g. fill of a CLOSE order).""" + if refresh_positions: + try: + await self._update_positions() + except Exception as e: + self.logger().warning(f"[_refresh_account_state] positions refresh error ({reason}): {e}") + if refresh_balances: + try: + await self._update_balances() + except Exception as e: + self.logger().warning(f"[_refresh_account_state] balances refresh error ({reason}): {e}") + + async def _reconcile_unmatched_private_event(self, reason: str) -> None: + """Recover state when a private WS event cannot be mapped to a tracked order.""" + now = time.time() + last_reconcile_ts = getattr(self, "_last_unmatched_private_event_reconcile_ts", 0.0) + if now - last_reconcile_ts < 2.0: + return + self._last_unmatched_private_event_reconcile_ts = now + + self.logger().warning( + "Unmatched private order/trade event detected (%s). Triggering status, position, and balance reconciliation.", + reason, + ) + + await safe_gather( + self._update_order_status(), + self._update_positions(), + self._update_balances(), + ) + + async def _execute_order_cancel(self, order) -> Optional[str]: + """Override cancel flow to reconcile order state with exchange before finalizing locally.""" + if order.client_order_id in self._cancel_in_flight_client_order_ids: + self.logger().debug( + "Skipping duplicate cancel attempt for %s because a previous cancel is still in-flight.", + order.client_order_id, + ) + return None + + backoff_until = self._cancel_backoff_until.get(order.client_order_id, 0.0) + if time.time() < backoff_until: + self.logger().debug( + "Skipping cancel for %s — in backoff until reconciliation resolves (%.0fs remaining).", + order.client_order_id, + backoff_until - time.time(), + ) + return None + + self._cancel_in_flight_client_order_ids.add(order.client_order_id) + try: + cancelled = await self._execute_order_cancel_and_process_update(order=order) + if cancelled: + return order.client_order_id + except asyncio.CancelledError: + raise + except asyncio.TimeoutError: + self.logger().warning( + "Failed to cancel the order %s because it does not have an exchange order id yet; " + "running reconciliation.", + order.client_order_id, + ) + await self._reconcile_unmatched_private_event( + reason=f"cancel timeout for {order.client_order_id}", + ) + except IOError as ex: + if self._is_order_not_found_during_cancelation_error(cancelation_exception=ex): + reconciled_state = await self._reconcile_order_state_after_cancel_error( + order=order, + error_message=str(ex), + ) + if reconciled_state in {OrderState.CANCELED, OrderState.FILLED, OrderState.FAILED}: + return order.client_order_id + else: + self.logger().error(f"Failed to cancel order {order.client_order_id}", exc_info=True) + except Exception as ex: + if self._is_order_not_found_during_cancelation_error(cancelation_exception=ex): + reconciled_state = await self._reconcile_order_state_after_cancel_error( + order=order, + error_message=str(ex), + ) + if reconciled_state in {OrderState.CANCELED, OrderState.FILLED, OrderState.FAILED}: + return order.client_order_id + else: + self.logger().error(f"Failed to cancel order {order.client_order_id}", exc_info=True) + finally: + self._cancel_in_flight_client_order_ids.discard(order.client_order_id) + # Only clear backoff if it was NOT just set by this cancel attempt + # (backoff expires naturally after 15s for the OPEN-reconciliation case). + if self._cancel_backoff_until.get(order.client_order_id, 0) <= time.time(): + self._cancel_backoff_until.pop(order.client_order_id, None) + return None + + def _begin_status_poll_cycle(self) -> None: + self._status_poll_cycle_active = True + self._active_orders_snapshot_by_market.clear() + self._active_orders_snapshot_market_complete.clear() + + def _end_status_poll_cycle(self) -> None: + self._status_poll_cycle_active = False + + async def _estimate_open_order_initial_margin(self, account_data: Dict[str, Any]) -> Optional[Decimal]: + positions = account_data.get("positions") + if not isinstance(positions, list): + return None + + total_initial_margin = s_decimal_0 + processed_positions = 0 + + for position in positions: + if not isinstance(position, dict): + continue + + try: + open_order_count = int(position.get("open_order_count") or 0) + except Exception: + open_order_count = 0 + if open_order_count <= 0: + continue + + market_id_raw = position.get("market_id") + initial_margin_fraction_raw = position.get("initial_margin_fraction") + if market_id_raw is None or initial_margin_fraction_raw is None: + continue + + try: + market_id = int(market_id_raw) + initial_margin_fraction = Decimal(str(initial_margin_fraction_raw)) / Decimal("100") + except Exception: + continue + + if initial_margin_fraction <= s_decimal_0: + continue + + rows = self._active_orders_snapshot_by_market.get(market_id) + if rows is None: + rows = await self._fetch_active_orders_rows_for_market(market_id=market_id) + if self._status_poll_cycle_active: + self._active_orders_snapshot_by_market[market_id] = rows + self._active_orders_snapshot_market_complete.add(market_id) + + position_order_initial_margin = s_decimal_0 + for row in rows: + if not isinstance(row, dict): + continue + + remaining_base_raw = ( + row.get("remaining_base_amount") + or row.get("remaining_amount") + or row.get("remaining_size") + ) + price_raw = row.get("price") or row.get("limit_price") + if remaining_base_raw is None or price_raw is None: + continue + + try: + remaining_base = Decimal(str(remaining_base_raw)) + price = Decimal(str(price_raw)) + notional_quote = abs(remaining_base * price) + except Exception: + continue + + if notional_quote <= s_decimal_0: + continue + + position_order_initial_margin += notional_quote * initial_margin_fraction + + if position_order_initial_margin > s_decimal_0: + total_initial_margin += position_order_initial_margin + processed_positions += 1 + + if processed_positions == 0: + return None + return max(s_decimal_0, total_initial_margin) + + async def _apply_balances_from_account_data(self, account_data: Dict[str, Any]) -> None: + # REST /account response fields (confirmed from live API): + # collateral = total USDC margin deposited (primary for total_balance) + # assets[].margin_balance (USDC) = fallback total margin when collateral is absent + # available_balance = exchange-computed available margin (primary for available) + # account_equity = fallback total equity (older / alternative API path) + # available_to_spend = fallback available (older API path) + usdc_asset_margin_balance_raw = None + assets = account_data.get("assets") + if isinstance(assets, list): + for asset_entry in assets: + if not isinstance(asset_entry, dict): + continue + if str(asset_entry.get("symbol", "")).upper() == "USDC": + usdc_asset_margin_balance_raw = asset_entry.get("margin_balance") + break + + total_balance_raw = self._first_not_none( + account_data.get("collateral"), + account_data.get("total_asset_value"), + account_data.get("cross_asset_value"), + usdc_asset_margin_balance_raw, + account_data.get("account_equity"), + account_data.get("equity"), + account_data.get("ae"), + account_data.get("b"), + ) + available_balance_raw = self._first_not_none( + account_data.get("available_balance"), + account_data.get("available_to_spend"), + account_data.get("as"), + ) + cross_asset_value_raw = account_data.get("cross_asset_value") + cross_initial_margin_requirement_raw = account_data.get("cross_initial_margin_requirement") + + if total_balance_raw is None and available_balance_raw is None: + self.logger().warning( + "[_update_balances] Account payload does not include recognized balance fields; " + "keeping previous balances unchanged." + ) + return + + existing_total = self._account_balances.get("USDC") + total_balance = ( + Decimal(str(total_balance_raw)) + if total_balance_raw is not None + else (existing_total if existing_total is not None else Decimal("0")) + ) + available_balance_from_field = ( + Decimal(str(available_balance_raw)) + if available_balance_raw is not None + else None + ) + available_balance_from_cross = None + if cross_asset_value_raw is not None and cross_initial_margin_requirement_raw is not None: + try: + cross_asset_value = Decimal(str(cross_asset_value_raw)) + cross_initial_margin_requirement = Decimal(str(cross_initial_margin_requirement_raw)) + available_balance_from_cross = max(s_decimal_0, cross_asset_value - cross_initial_margin_requirement) + except Exception: + available_balance_from_cross = None + + if available_balance_from_field is not None: + # Pure WS mode: Trust exchange-authoritative available_balance directly. + # Since connector uses WebStream only (no polling), the exchange's available_balance + # already reflects all orders (ours + others') and margin requirements in real-time. + # Do NOT apply local margin estimates; they would double-count against the + # exchange's already-accurate balance. + available_balance = available_balance_from_field + elif available_balance_from_cross is not None: + # Fallback: compute from cross-margin headroom if no direct available_balance field. + available_balance = available_balance_from_cross + else: + self.logger().debug( + "[_apply_balances_from_account_data] Missing available field; skipping balance update." + ) + return + + self._set_usdc_balances( + total_balance=total_balance, + available_balance=available_balance, + ) + self._fee_tier = account_data.get("fee_level", 0) + + def _build_account_auth_params(self) -> Dict[str, Any]: + params = self._account_query_params() + try: + now = time.time() + if self._auth_token_cache is not None: + cached_token, cached_expiry = self._auth_token_cache + if now < cached_expiry: + auth_token = cached_token + else: + self._auth_token_cache = None + auth_token = None + else: + auth_token = None + + if auth_token is None: + signer_client = self._get_lighter_signer_client() + auth_token, auth_error = signer_client.create_auth_token_with_expiry( + api_key_index=self._get_api_key_index() + ) + if auth_error or not auth_token: + raise IOError( + f"Cannot connect to Lighter Perpetual: failed to generate auth token. {auth_error} " + "Check your API private key and API key index." + ) + self._auth_token_cache = (auth_token, now + 270.0) + params["auth"] = auth_token + return params + except IOError: + raise + except Exception as e: + raise IOError( + f"Cannot connect to Lighter Perpetual: failed to build auth token — {e}. " + "Check your API private key and API key index." + ) + + async def _fetch_account_snapshot_data(self) -> Dict[str, Any]: + response = await self._api_get( + path_url=CONSTANTS.GET_ACCOUNT_INFO_PATH_URL, + params=self._build_account_auth_params(), + is_auth_required=True, + return_err=True, + ) + + if not self._is_ok_response(response): + code = response.get("code") if isinstance(response, dict) else "" + msg = response.get("message") or response.get("error") or "" if isinstance(response, dict) else str(response) + raise IOError( + f"Cannot connect to Lighter Perpetual: server returned code {code}. " + f"{msg} — check your account index, API key index, and API private key." + ) + + account_data = self._account_from_response(response) + if not account_data: + raise IOError( + f"Cannot connect to Lighter Perpetual: no account data returned. " + f"Verify your account index is correct (large number, e.g. 693751 — NOT the API key index). " + f"Response: {response}" + ) + + return account_data + + async def _fetch_active_orders_rows_for_market( + self, + market_id: int, + max_pages: int = 5, + ) -> List[Dict[str, Any]]: + signer_client = self._get_lighter_signer_client() + auth_token, _auth_err = signer_client.create_auth_token_with_expiry( + api_key_index=self._get_api_key_index() + ) + params: Dict[str, Any] = { + "account_index": self._get_account_index(), + "market_id": market_id, + "limit": 200, + "auth": auth_token or "", + } + + rows: List[Dict[str, Any]] = [] + for _ in range(max_pages): + response = await self._api_get( + path_url=CONSTANTS.GET_ACTIVE_ORDERS_PATH_URL, + params=params, + is_auth_required=True, + return_err=True, + ) + if not self._is_ok_response(response): + break + + page_rows = response.get("data") or response.get("orders") or [] + if not isinstance(page_rows, list): + break + rows.extend(page_rows) + + if not response.get("has_more") or not response.get("next_cursor"): + break + params["cursor"] = response["next_cursor"] + + return rows + + def _index_client_to_order_mapping_from_rows(self, rows: List[Dict[str, Any]]) -> None: + for row in rows: + row_oid = str(row.get("order_id") or row.get("order_index") or row.get("i") or "") + row_cid = str(row.get("client_order_id") or row.get("client_order_index") or row.get("I") or "") + if row_oid and row_cid: + self._client_order_index_to_order_index[row_cid] = row_oid + + async def _prime_active_orders_snapshot_cache_for_poll_cycle(self) -> None: + if not self._status_poll_cycle_active or not self._trading_pairs: + return + + for trading_pair in self._trading_pairs: + try: + market_id, _, _, _ = await self._get_market_spec(trading_pair) + if market_id in self._active_orders_snapshot_market_complete: + continue + rows = await self._fetch_active_orders_rows_for_market(market_id=market_id) + self._active_orders_snapshot_by_market[market_id] = rows + self._active_orders_snapshot_market_complete.add(market_id) + self._index_client_to_order_mapping_from_rows(rows) + except Exception as ex: + self.logger().warning( + "[_prime_active_orders_snapshot_cache_for_poll_cycle] Failed fetching active orders for %s: %s", + trading_pair, + ex, + ) + + async def _status_polling_loop_fetch_updates(self): + self._begin_status_poll_cycle() + try: + account_data: Optional[Dict[str, Any]] = None + should_fetch_snapshot = ( + not self._is_user_stream_initialized() + or (time.time() - float(getattr(self, "_last_balance_update_timestamp", 0.0) or 0.0)) + >= self._BALANCE_STATUS_MAX_AGE + ) + if should_fetch_snapshot: + try: + account_data = await self._fetch_account_snapshot_data() + except Exception as ex: + # Log at debug — the base-class polling loop already emits a user-visible + # NETWORK/WARNING pair when the exception propagates from the fallback path. + # Logging at WARNING here would double-report the same failure. + self.logger().debug( + "[_status_polling_loop_fetch_updates] Shared account snapshot fetch failed; " + "falling back to independent balance/position updates: %s", + ex, + ) + + if account_data is not None: + await self._update_positions(account_data=account_data) + await self._apply_balances_from_account_data(account_data=account_data) + elif should_fetch_snapshot: + await safe_gather( + self._update_positions(), + self._update_balances(), + ) + await self._update_order_status() + finally: + self._end_status_poll_cycle() + + async def _cleanup_startup_orphan_reduce_only_orders(self) -> None: + """Deprecated cleanup path kept as a no-op to protect manual exchange orders. + + Startup cancellation is intentionally limited to tracked bot orders + via ``_cancel_tracked_stale_orders``. + """ + return + + async def _cleanup_runtime_orphan_orders(self) -> None: + """Deprecated cleanup path kept as a no-op to protect manual exchange orders.""" + return + + async def _reconcile_order_state_after_cancel_error( + self, + order: InFlightOrder, + error_message: str, + ) -> Optional[OrderState]: + """Fetch exchange order status before applying any terminal local state after cancel errors.""" + try: + order_update = await self._request_order_status(order) + self._order_tracker.process_order_update(order_update) + self.logger().debug( + "Cancel reconciliation for %s after error '%s' -> exchange state %s", + order.client_order_id, + error_message, + order_update.new_state, + ) + return order_update.new_state + except Exception as status_error: + self.logger().warning( + "Cancel reconciliation for %s failed after error '%s': %s. " + "Order remains tracked until a later WS/REST update confirms terminal state.", + order.client_order_id, + error_message, + status_error, + ) + return None + + async def _update_balances(self): + """ + GET /api/v1/account?by=index&value=&active_only=true + ``` + { + "code": 200, + "accounts": [{ + "available_balance": "7.761967", + "collateral": "35.378937", + "cross_asset_value": "35.240937", + "cross_initial_margin_requirement": "27.478970", + "cross_maintenance_margin_requirement": "3.159010", + "positions": [...], + "assets": [...] + }] + } + ``` + Key fields: + collateral → total USDC margin (total_balance) + available_balance → exchange-computed available margin (available_balance) + """ + try: + account_data = await self._fetch_account_snapshot_data() + await self._apply_balances_from_account_data(account_data=account_data) + except asyncio.CancelledError: + raise + except Exception as ex: + # Swallow silently at debug — the base-class polling loop emits the single + # user-visible "Could not fetch account updates" warning when the overall + # poll cycle fails. A separate ERROR/WARNING here would double-report. + self.logger().debug("[_update_balances] balance refresh skipped: %s", ex) + + async def _update_positions(self, account_data: Optional[Dict[str, Any]] = None): + """ + https://docs.lighter.fi/api-documentation/api/rest-api/account/get-positions + Positions Info + ``` + { + "success": true, + "data": [ + { + "symbol": "AAVE", + "side": "ask", + "amount": "223.72", + "entry_price": "279.283134", + "margin": "0", // only shown for isolated margin + "funding": "13.159593", + "isolated": false, + "created_at": 1754928414996, + "updated_at": 1759223365538 + } + ], + "error": null, + "code": null, + "last_order_id": 1557431179 + } + ``` + + https://docs.lighter.fi/api-documentation/api/rest-api/markets/get-prices + Prices Info + ``` + { + "success": true, + "data": [ + { + "funding": "0.00010529", + "mark": "1.084819", + "mid": "1.08615", + "next_funding": "0.00011096", + "open_interest": "3634796", + "oracle": "1.084524", + "symbol": "XPL", + "timestamp": 1759222967974, + "volume_24h": "20896698.0672", + "yesterday_price": "1.3412" + } + ], + "error": null, + "code": null + } + ``` + """ + if account_data is None: + response = await self._api_get( + path_url=CONSTANTS.GET_ACCOUNT_INFO_PATH_URL, + params=self._account_query_params(), + return_err=True, + ) + + if not self._is_ok_response(response): + # Debug-level only — the base-class polling loop already shows the + # user-visible "Could not fetch account updates" warning when these + # errors propagate. Logging at ERROR here produces a duplicate. + self.logger().debug("[_update_positions] positions refresh skipped: api responded with failure") + return + + account_data = self._account_from_response(response) + if not account_data: + self.logger().debug("[_update_positions] positions refresh skipped: no account data in response") + return + + position_entries = account_data.get("positions") or [] + + position_symbols = [position_entry["symbol"] for position_entry in position_entries if position_entry.get("symbol")] + position_trading_pairs = [ + await self.trading_pair_associated_to_exchange_symbol(position_symbol) for position_symbol in position_symbols + ] + if any([self.get_LIGHTER_price(position_trading_pair) is None for position_trading_pair in position_trading_pairs]): + self.logger().debug("[_update_positions] Prices cache is empty. Going to fetch prices via HTTP.") + # Price cache is stale; refresh via HTTP before processing positions. + prices_response = await self._api_get( + path_url=CONSTANTS.GET_PRICES_PATH_URL, + return_err=True, + ) + if not self._is_ok_response(prices_response): + # Do not abort position restoration when prices endpoint is temporarily unavailable. + # We can still rebuild positions and use entry-price fallback for unrealized_pnl. + self.logger().warning( + "[_update_positions] Failed to update prices cache using HTTP API: %s. " + "Proceeding with position rebuild using available price fallbacks.", + prices_response, + ) + else: + price_entries = prices_response.get("data") or prices_response.get("order_book_stats") or [] + for price_entry in price_entries: + if price_entry["symbol"] not in position_symbols: + continue + hb_trading_pair = await self.trading_pair_associated_to_exchange_symbol(price_entry["symbol"]) + mark_price = price_entry.get("mark") or price_entry.get("mid") or price_entry.get("last_trade_price") or "0" + index_price = price_entry.get("oracle") or price_entry.get("mid") or price_entry.get("last_trade_price") or mark_price + timestamp = price_entry.get("timestamp") or int(time.time() * 1000) + self.set_LIGHTER_price( + trading_pair=hb_trading_pair, + timestamp=timestamp / 1000, + index_price=Decimal(str(index_price)), + mark_price=Decimal(str(mark_price)), + ) + + # Build new positions atomically — only replace existing snapshot on full success. + new_positions: Dict[str, Any] = {} + for position_entry in position_entries: + hb_trading_pair = await self.trading_pair_associated_to_exchange_symbol(position_entry["symbol"]) + if hb_trading_pair not in self._trading_pairs: + self.logger().debug( + "[_update_positions] Skipping position for unconfigured trading pair %s.", + hb_trading_pair, + ) + continue + # The REST /positions endpoint uses "side": "bid"/"ask" (not "sign" which is WS-only). + # "bid" = LONG (bot is the buyer / long holder), "ask" = SHORT. + # "sign" field is only present in older WS event formats — default to reading "side". + if "sign" in position_entry: + sign = int(position_entry.get("sign", 1) or 1) + is_long = sign >= 0 + else: + side_raw = str(position_entry.get("side") or "bid").lower() + is_long = side_raw in ("bid", "long", "buy") + position_side = PositionSide.LONG if is_long else PositionSide.SHORT + position_key = self._perpetual_trading.position_key(hb_trading_pair, position_side) + amount = Decimal(str(position_entry.get("amount") or position_entry.get("position") or "0")) + if amount == Decimal("0"): + # Skip closed positions (exchange sends trailing zero-amount entries after close) + continue + entry_price = Decimal(str(position_entry.get("entry_price") or position_entry.get("avg_entry_price") or "0")) + + price_record = self.get_LIGHTER_price(hb_trading_pair) + if price_record is not None: + mark_price = price_record.mark_price + else: + # Use the unrealized_pnl from the event if available, otherwise default to entry_price + upnl_str = position_entry.get("unrealized_pnl") + if upnl_str is not None: + unrealized_pnl = Decimal(str(upnl_str)) + else: + unrealized_pnl = Decimal("0") + mark_price = entry_price # fallback so PnL calc below yields zero if no event pnl + + reference_price = mark_price if mark_price > s_decimal_0 else entry_price + if self._is_sub_minimum_position_notional( + trading_pair=hb_trading_pair, + position_amount=amount, + reference_price=reference_price, + ): + now = time.time() + last_warning_ts = self._last_sub_minimum_position_warning_ts.get(hb_trading_pair, 0.0) + if now - last_warning_ts >= self._SUB_MINIMUM_POSITION_WARNING_INTERVAL: + self._last_sub_minimum_position_warning_ts[hb_trading_pair] = now + self.logger().warning( + "[_update_positions] Tracking sub-minimum residual position for %s " + "(amount=%s, reference_price=%s). Close attempts may fail until " + "notional reaches exchange minimum.", + hb_trading_pair, + amount, + reference_price, + ) + else: + self.logger().debug( + "[_update_positions] Sub-minimum residual position for %s tracking warning suppressed.", + hb_trading_pair, + ) + + if price_record is not None: + if position_side == PositionSide.LONG: + unrealized_pnl = (mark_price - entry_price) * amount + else: + unrealized_pnl = (entry_price - mark_price) * amount + + # Include cumulative funding P&L (positive = received, negative = paid) + cumulative_funding = Decimal(str(position_entry.get("funding") or "0")) + unrealized_pnl += cumulative_funding + + position = Position( + trading_pair=hb_trading_pair, + position_side=position_side, + unrealized_pnl=unrealized_pnl, + entry_price=entry_price, + amount=amount * (Decimal("-1.0") if position_side == PositionSide.SHORT else Decimal("1.0")), + leverage=Decimal(self.get_leverage(hb_trading_pair)) + ) + new_positions[position_key] = position + + # Apply the built positions to the account atomically after successful rebuild. + # Clear first (removes positions that are now closed/absent on the exchange) then set new ones. + self._perpetual_trading.account_positions.clear() + for key, position in new_positions.items(): + self._perpetual_trading.set_position(key, position) + self._last_position_update_timestamp = time.time() + + async def _fetch_and_apply_fills(self, order: InFlightOrder): + """Fetch fills for *order* immediately and apply them via process_trade_update. + + Called in the background when a FILLED or CANCELED state arrives via WebSocket so that + fill details reach the tracker before the order is evicted from cached_orders. + Handles the fast fill+cancel race where a WS trade event with I=null could not be matched + to the tracked order, so the REST fill-history is the fallback recovery path. + """ + try: + fills = await self._all_trade_updates_for_order(order) + for fill in fills: + self._order_tracker.process_trade_update(fill) + if fills: + self.logger().debug( + "[ws-fill] Applied %d fill(s) for order %s from eager REST fetch", + len(fills), + order.client_order_id, + ) + except asyncio.CancelledError: + raise + except Exception as err: + self.logger().debug( + "[ws-fill] Eager fill fetch failed for %s: %s", + order.client_order_id, + err, + ) + + async def _all_trade_updates_for_order(self, order: InFlightOrder) -> List[TradeUpdate]: + """ + Fetch fill history for a specific order via GET /trades. + + /trades requires sort_by (required) + auth (passed via is_auth_required). + Response: {"code": 200, "trades": [...], "next_cursor": "..."} + Each trade: trade_id, ask_id, bid_id, size, price, timestamp (ms), is_maker_ask, etc. + """ + trade_updates = [] + + last_poll_timestamp = self._order_history_last_poll_timestamp.get(order.exchange_order_id) + if last_poll_timestamp and not math.isnan(last_poll_timestamp): + from_ts = max(0, int(last_poll_timestamp - self._TRADE_HISTORY_TIME_DRIFT_BUFFER)) + else: + from_ts = max(0, int(order.creation_timestamp - self._TRADE_HISTORY_TIME_DRIFT_BUFFER)) + + try: + current_time = self.current_timestamp + except AttributeError: + current_time = None + current_time_is_valid = current_time is not None and not math.isnan(current_time) + if not current_time_is_valid: + current_time = time.time() + + market_id_trades, _, _, _ = await self._get_market_spec(order.trading_pair) + + signer_client_trades = self._get_lighter_signer_client() + auth_token_trades, _ = signer_client_trades.create_auth_token_with_expiry( + api_key_index=self._get_api_key_index() + ) + + params: Dict[str, Any] = { + "account_index": self._get_account_index(), + "market_id": market_id_trades, + "sort_by": "timestamp", + "from": from_ts, + "limit": 100, + "auth": auth_token_trades or "", + } + # Narrow to this specific order when exchange_order_id is numeric. + try: + params["order_index"] = int(order.exchange_order_id) + except (ValueError, TypeError): + pass + + try: + our_order_id_int = int(order.exchange_order_id) if order.exchange_order_id else None + except (ValueError, TypeError): + our_order_id_int = None + + while True: + response = await self._api_get( + path_url=CONSTANTS.GET_TRADE_HISTORY_PATH_URL, + params=params, + is_auth_required=True, + return_err=True, + ) + + if not isinstance(response, dict): + self.logger().debug( + "[_all_trade_updates_for_order] Unexpected /trades response type %s for order %s", + type(response).__name__, + order.client_order_id, + ) + break + + response_code = str(response.get("code") or "") + response_message = str(response.get("message") or response.get("error") or "") + if response_code in {"23000", "429"} or "too many requests" in response_message.lower(): + self.logger().debug( + "[_all_trade_updates_for_order] Rate-limited on /trades for order %s (code=%s).", + order.client_order_id, + response_code, + ) + break + + if response.get("success") is False and response_code not in {"0", "200"}: + self.logger().debug( + "[_all_trade_updates_for_order] Non-success /trades response for order %s: %s", + order.client_order_id, + response, + ) + break + + trades_raw = response.get("trades") + if trades_raw is None: + trades_raw = response.get("data") + + if isinstance(trades_raw, list): + trades_list = trades_raw + elif isinstance(trades_raw, dict): + # Some API variants return a single trade object under `data`. + trades_list = [trades_raw] + else: + trades_list = [] + + if not trades_list: + break + + for trade in trades_list: + if not isinstance(trade, dict): + continue + + ask_id = trade.get("ask_id") + bid_id = trade.get("bid_id") + + if our_order_id_int is not None: + # Primary match: server order_index (ask_id / bid_id). + our_is_ask = our_order_id_int == ask_id + our_is_bid = our_order_id_int == bid_id + # Fallback: client_order_index (ask_client_id / bid_client_id). + # Required when exchange_order_id was previously a client_order_index that the + # WS never promoted to a server order_index (I=null race), so the REST query + # may have been issued with the wrong order_index. The client fields in the + # response still uniquely identify our side of the trade. + if not our_is_ask and not our_is_bid: + our_is_ask = str(trade.get("ask_client_id") or "") == str(order.exchange_order_id) + our_is_bid = str(trade.get("bid_client_id") or "") == str(order.exchange_order_id) + else: + our_is_ask = str(trade.get("ask_client_id") or "") == str(order.exchange_order_id) + our_is_bid = str(trade.get("bid_client_id") or "") == str(order.exchange_order_id) + + if not our_is_ask and not our_is_bid: + continue + + fill_timestamp = float( + trade.get("timestamp") + or trade.get("created_at") + or trade.get("t") + or 0 + ) + if fill_timestamp > 1e12: + fill_timestamp /= 1000.0 + + fill_price = Decimal(str(trade.get("price") or "0")) + fill_base_amount = Decimal(str(trade.get("size") or "0")) + + is_maker_ask = trade.get("is_maker_ask", False) + is_taker = (our_is_ask and not is_maker_ask) or (our_is_bid and is_maker_ask) + + raw_trade_id = trade.get("trade_id") + if raw_trade_id: + # Use the real trade ID directly — same format as the WS path — to prevent + # duplicate fills when both WS and REST reconciliation deliver the same trade. + trade_id = str(raw_trade_id) + else: + trade_id = self.get_LIGHTER_finance_trade_id( + order_id=0, + timestamp=fill_timestamp, + fill_base_amount=fill_base_amount, + fill_price=fill_price, + ) + + _fee_schema_r = self.trade_fee_schema() + _fee_percent_r = ( + _fee_schema_r.taker_percent_fee_decimal if is_taker else _fee_schema_r.maker_percent_fee_decimal + ) + fee = TradeFeeBase.new_perpetual_fee( + fee_schema=_fee_schema_r, + position_action=order.position, + percent=_fee_percent_r, + percent_token=order.quote_asset, + ) + + trade_updates.append(TradeUpdate( + trade_id=trade_id, + client_order_id=order.client_order_id, + exchange_order_id=order.exchange_order_id, + trading_pair=order.trading_pair, + fill_timestamp=fill_timestamp, + fill_price=fill_price, + fill_base_amount=fill_base_amount, + fill_quote_amount=fill_price * fill_base_amount, + fee=fee, + is_taker=is_taker, + )) + + next_cursor = response.get("next_cursor") + if next_cursor: + params["cursor"] = next_cursor + else: + break + + # Guard: do not store NaN (or fallback wall-time) timestamps. + if current_time_is_valid: + self._order_history_last_poll_timestamp[order.exchange_order_id] = current_time + + return trade_updates + + async def _verify_cancel_not_false(self, order: InFlightOrder, delay: float = 2.0) -> None: + """REST-verify an order whose WS CANCELED event was suppressed as a likely false cancel. + + Waits *delay* seconds, then polls the order status once. If the order is truly CANCELED, + applies the update (late but correct). If still OPEN, the suppression was correct and + the order continues normal tracking. + """ + try: + await asyncio.sleep(delay) + order_update = await self._request_order_status(order) + if order_update.new_state == OrderState.CANCELED: + self.logger().debug( + "[ws-cancel guard] REST confirmed CANCELED for %s — applying state.", + order.client_order_id, + ) + self._order_tracker.process_order_update(order_update) + await self._refresh_account_state( + reason=f"ws-cancel guard confirmed CANCELED {order.client_order_id}", + refresh_positions=True, + refresh_balances=True, + ) + else: + self.logger().debug( + "[ws-cancel guard] REST confirmed %s is still %s — WS CANCELED was a false cancel " + "(subscription snapshot replay). Order tracking preserved.", + order.client_order_id, + order_update.new_state.name, + ) + except asyncio.CancelledError: + raise + except Exception as ex: + self.logger().debug( + "[ws-cancel guard] REST verification failed for %s: %s — " + "periodic status poll will reconcile.", + order.client_order_id, + ex, + ) + + async def _update_order_status(self): + """Override to skip the broad fills sweep when the private WS stream is healthy. + + The base implementation calls: + 1. _update_orders_fills → REST /trades for every fillable order + 2. _update_orders → REST /orderHistory per order (now WS-gated by _update_orders) + + When the private WS stream is alive, fills arrive via account_trades in real time. + Running the bulk REST scan redundantly wastes rate-limit quota; we skip it here. + The targeted rescue path inside _update_orders still fires for specific orders + that are terminal but missing fills, regardless of WS health. + """ + private_ws_healthy = self._is_user_stream_initialized() + if not private_ws_healthy: + # WS degraded — run full base behaviour including bulk fills scan. + await self._update_orders_fills(orders=list(self._order_tracker.all_fillable_orders.values())) + await self._update_orders() + + def _order_needs_rest_status_check(self, order: InFlightOrder) -> bool: + """Return True when an in-flight order genuinely needs a REST status check. + + Used to skip REST calls while the private WS stream is healthy. Orders that WS + is actively managing don't need a separate REST round-trip every poll cycle. + REST is required only for orders in genuinely ambiguous or unresolvable states: + - exchange_order_id not yet assigned (placement blockchain confirmation pending) + - order has been marked for cancel but no terminal state has arrived yet + - order is older than the staleness threshold with no recent WS touch + """ + # Placement not yet confirmed on the exchange — WS can't update what it doesn't know. + eid = str(order.exchange_order_id or "None") + if eid in ("None", "", "none"): + return True + + # Pending cancel: a cancel was issued but we haven't received a CANCELED WS event. + # REST confirms the cancel actually landed. + backoff_ts = self._cancel_backoff_until.get(order.client_order_id, 0) + if backoff_ts > time.time(): + # Still in backoff — order is in flight-cancel; REST will resolve it. + return True + + # Stale: order was created a long time ago and has never received a WS update. + # Threshold = 3× the healthy poll interval (≈ 36 s at default settings). + order_age = time.time() - float(order.creation_timestamp or 0) + stale_threshold = 3 * self._HEALTHY_PRIVATE_STREAM_POLL_INTERVAL + if order_age > stale_threshold: + # Only mark as stale if WS hasn't touched this order recently. + # If the exchange_order_id is a real server id (not the placement-time COI) + # it means the WS already processed it at least once — it's active, not lost. + # We only REST-check if we still only have the client_order_index as the exchange id + # OR the order state hasn't advanced beyond OPEN in a long time. + if order.current_state == OrderState.OPEN and not eid.startswith("0x"): + # Integer-format exchange IDs are server-assigned — the WS confirmed placement. + # A long-lived OPEN order with a real server ID is being managed by WS. + try: + int(eid) # real integer server order index → WS has it + return False + except ValueError: + return True # non-integer or unknown format → need REST to resolve + + return False + + async def _update_orders(self): + """Override to add WS-health-aware filtering and rescue fill fetch. + + When the private WS stream is healthy, skip REST status checks for orders that WS + is actively managing — only poll orders that are genuinely ambiguous or stale. + This reduces REST pressure by 70-90% in normal operation while preserving full + reconciliation coverage when WS is degraded. + + When the bulk trade-history poll in _update_orders_fills ran before the fill appeared on + the exchange REST API, the tracker times out waiting for fills and emits a BuyOrderCompletedEvent + with 0 amounts. This rescue fetch immediately re-queries fill history for the specific order + when we detect it is FILLED or CANCELED but still has no registered fills. + + Critically: the resolved exchange_order_id from _request_order_status (which performs REST + lookups to map client_order_index → server order_index) is applied to tracked_order BEFORE + calling _all_trade_updates_for_order. This ensures the fill-history API query uses the + correct server order_index even when the WS delivered I=null (no client_order_index) and the + local _client_order_index_to_order_index mapping was never populated. + """ + private_ws_healthy = self._is_user_stream_initialized() + for tracked_order in list(self.in_flight_orders.values()): + if private_ws_healthy and not self._order_needs_rest_status_check(tracked_order): + continue + try: + order_update = await self._request_order_status(tracked_order=tracked_order) + if ( + isinstance(order_update, OrderUpdate) + and order_update.new_state in (OrderState.FILLED, OrderState.CANCELED) + and not tracked_order.is_done + and tracked_order.executed_amount_base < tracked_order.amount + ): + try: + # Apply the resolved server order_index BEFORE fetching fills so that + # _all_trade_updates_for_order uses the correct ask_id/bid_id for + # matching — not the stale client_order_index stored as exchange_order_id + # when I=null in the WS events prevented the normal mapping update. + resolved_eid = order_update.exchange_order_id + if ( + resolved_eid + and resolved_eid != "None" + and resolved_eid != str(tracked_order.exchange_order_id) + ): + tracked_order.update_exchange_order_id(resolved_eid) + fill_updates = await self._all_trade_updates_for_order(tracked_order) + for fill_update in fill_updates: + self._order_tracker.process_trade_update(fill_update) + if fill_updates: + self.logger().debug( + "[_update_orders] Rescue fill fetch found %d fill(s) for %s (state=%s)", + len(fill_updates), + tracked_order.client_order_id, + order_update.new_state.name, + ) + except Exception as ex: + self.logger().warning( + "[_update_orders] Rescue fill fetch failed for %s: %s", + tracked_order.client_order_id, + ex, + ) + self._order_tracker.process_order_update(order_update) + except asyncio.CancelledError: + raise + except Exception as request_error: + await self._handle_update_error_for_active_order(tracked_order, request_error) + + async def _recover_exchange_order_id_from_active_orders( + self, + tracked_order: InFlightOrder, + ) -> Optional[str]: + """Scan the active-orders snapshot for an order matching price/side/amount of *tracked_order*. + + Used as a recovery path when exchange_order_id is the string "None" — the order was + successfully submitted to the exchange but the WS hasn't yet delivered the confirmation + that establishes the client_order_index → order_index mapping. + + Returns the string exchange order_index if a unique match is found, otherwise None. + """ + try: + market_id, size_decimals, price_decimals, _ = await self._get_market_spec(tracked_order.trading_pair) + except Exception: + return None + + rows = self._active_orders_snapshot_by_market.get(market_id, []) + if not rows: + return None + + expected_side = "ask" if tracked_order.trade_type == TradeType.SELL else "bid" + # Convert the tracked order price to the same integer representation the exchange uses + # so we can compare without floating-point drift. + try: + expected_price_scaled = int( + (tracked_order.price * Decimal(f"1e{price_decimals}")).to_integral_value() + ) + except Exception: + return None + + # Build set of already-tracked exchange_order_ids to skip them. + known_ids: Set[str] = { + str(o.exchange_order_id) + for o in self.in_flight_orders.values() + if o.exchange_order_id is not None and str(o.exchange_order_id) != "None" + } + + candidates = [] + for row in rows: + row_oid = str(row.get("order_id") or row.get("order_index") or row.get("i") or "") + if not row_oid or row_oid in known_ids: + continue + row_side = str(row.get("side") or row.get("s") or "").lower() + if row_side and row_side not in (expected_side, expected_side[0]): + continue + # Price comparison using the scaled integer + try: + row_price_raw = row.get("price") or row.get("p") or "0" + row_price_scaled = int( + (Decimal(str(row_price_raw)) * Decimal(f"1e{price_decimals}")).to_integral_value() + ) + except Exception: + continue + if row_price_scaled != expected_price_scaled: + continue + candidates.append(row_oid) + + if len(candidates) == 1: + return candidates[0] + if len(candidates) > 1: + self.logger().warning( + "[_recover_exchange_order_id_from_active_orders] Multiple active orders match " + "price=%s side=%s for %s; cannot safely recover exchange_order_id.", + tracked_order.price, + expected_side, + tracked_order.client_order_id, + ) + return None + + async def _request_order_status(self, tracked_order: InFlightOrder) -> OrderUpdate: + """ + https://docs.lighter.fi/api-documentation/api/rest-api/orders/get-order-history-by-id + + Example API response: + ``` + { + "success": true, + "data": [ + { + "history_id": 641452639, + "order_id": 315992721, + "client_order_id": "ade1aa6...", + "symbol": "XPL", + "side": "ask", + "price": "1.0865", + "initial_amount": "984", + "filled_amount": "0", + "cancelled_amount": "984", + "event_type": "cancel", + "order_type": "limit", + "order_status": "cancelled", + "stop_price": null, + "stop_parent_order_id": null, + "reduce_only": false, + "created_at": 1759224895038 + }, + { + "history_id": 641452513, + "order_id": 315992721, + "client_order_id": "ade1aa6...", + "symbol": "XPL", + "side": "ask", + "price": "1.0865", + "initial_amount": "984", + "filled_amount": "0", + "cancelled_amount": "0", + "event_type": "make", + "order_type": "limit", + "order_status": "open", + "stop_price": null, + "stop_parent_order_id": null, + "reduce_only": false, + "created_at": 1759224893638 + } + ], + "error": null, + "code": null + } + ``` + """ + # Step 1: check whether the order is still active. + client_oid = str(tracked_order.exchange_order_id) + + # Guard: when exchange_order_id is the string "None" we cannot look up the order by + # client_order_index. Apply a long grace period before declaring the order CANCELED so + # that the WS account_all has time to deliver the I→i mapping (or _place_order returns + # and registers the client_order_index in _client_order_index_to_client_order_id). + # NOTE: We intentionally do NOT attempt a price+side active-orders scan here because such + # a scan can mis-assign the exchange_order_id of a different order (e.g. a same-price + # orphan from a previous session), causing the cancel to target the wrong order and leave + # our real order permanently open on the exchange. + if client_oid == "None": + now = time.time() + order_creation = tracked_order.creation_timestamp or 0 + order_age = now - order_creation if (order_creation > 0) else float("inf") + extended_grace = 10 * self._HEALTHY_PRIVATE_STREAM_POLL_INTERVAL + if order_age < extended_grace: + self.logger().debug( + "[_request_order_status] Order %s has exchange_order_id='None' and is %.0fs old; " + "keeping as OPEN (waiting for WS mapping, grace period %.0fs).", + tracked_order.client_order_id, + order_age, + extended_grace, + ) + return OrderUpdate( + trading_pair=tracked_order.trading_pair, + update_timestamp=self.current_timestamp, + new_state=OrderState.OPEN, + client_order_id=tracked_order.client_order_id, + exchange_order_id=client_oid, + ) + self.logger().warning( + "[_request_order_status] Order %s has exchange_order_id='None' and is %.0fs old " + "(exceeded %.0fs grace); treating as CANCELED.", + tracked_order.client_order_id, + order_age, + extended_grace, + ) + return OrderUpdate( + trading_pair=tracked_order.trading_pair, + update_timestamp=self.current_timestamp, + new_state=OrderState.CANCELED, + client_order_id=tracked_order.client_order_id, + exchange_order_id=client_oid, + ) + + actual_order_index = self._client_order_index_to_order_index.get(client_oid) + + # Always verify against active orders to confirm the order is still open. + try: + market_id, _, _, _ = await self._get_market_spec(tracked_order.trading_pair) + active_order_index = await self._resolve_order_index_from_active_orders( + market_id=market_id, + client_order_index=actual_order_index or client_oid, + ) + except Exception: + active_order_index = None + + if active_order_index is not None: + # Order is still active – return OPEN with real exchange_order_id. + return OrderUpdate( + trading_pair=tracked_order.trading_pair, + update_timestamp=self.current_timestamp, + new_state=OrderState.OPEN, + client_order_id=tracked_order.client_order_id, + exchange_order_id=active_order_index, + ) + + # Step 2: order is not active – look in historical/inactive orders. + query_oid = client_oid + signer_client_oi = self._get_lighter_signer_client() + auth_token_oi, _ = signer_client_oi.create_auth_token_with_expiry( + api_key_index=self._get_api_key_index() + ) + market_id_oi, _, _, _ = await self._get_market_spec(tracked_order.trading_pair) + response = await self._api_get( + path_url=CONSTANTS.GET_ORDER_HISTORY_PATH_URL, + params={ + "account_index": self._get_account_index(), + "market_id": market_id_oi, + "limit": 50, + "auth": auth_token_oi or "", + }, + is_auth_required=True, + return_err=True, + ) + + data = response.get("data") or response.get("orders") or [] + if not data: + raise IOError( + f"Order status query returned empty data for order {tracked_order.exchange_order_id}: {response}" + ) + + # Filter to find the specific order by client_order_id or order_id. + # Prefer matching by client_order_id (our assigned ID, unique per order) to avoid + # false positives where a different order's server order_index coincidentally equals + # our client_order_index. Only fall back to row_oid matching when we know the + # server order_index via the _client_order_index_to_order_index mapping or when + # exchange_order_id has already been promoted to a server order_index (actual_order_index). + order_entry = None + for row in data: + row_cid = str(row.get("client_order_id") or row.get("client_order_index") or row.get("I") or "") + row_oid = str(row.get("order_id") or row.get("order_index") or row.get("i") or "") + if row_cid == query_oid: + order_entry = row + break + if actual_order_index is not None and row_oid == actual_order_index: + order_entry = row + break + # When exchange_order_id was promoted to server order_index (after WS fill) and the + # client→server mapping was not retained, allow row_oid fallback only if there is no + # client_order_index to cross-check (i.e., actual_order_index is None). + if actual_order_index is None and row_oid == query_oid and not row_cid: + order_entry = row + break + + if order_entry is None: + # Allow a grace period for newly placed orders that may not yet appear in the + # exchange's inactive orders (e.g. still propagating to chain or between poll cycles). + order_age = self.current_timestamp - (tracked_order.creation_timestamp or 0) + if order_age < 2 * self._HEALTHY_PRIVATE_STREAM_POLL_INTERVAL: + self.logger().debug( + f"Order {tracked_order.exchange_order_id} not found in active or inactive orders " + f"but was placed only {order_age:.0f}s ago; keeping as OPEN." + ) + return OrderUpdate( + trading_pair=tracked_order.trading_pair, + update_timestamp=self.current_timestamp, + new_state=OrderState.OPEN, + client_order_id=tracked_order.client_order_id, + exchange_order_id=str(tracked_order.exchange_order_id), + ) + self.logger().debug( + f"Order {tracked_order.exchange_order_id} not found in inactive orders response; treating as canceled." + ) + return OrderUpdate( + trading_pair=tracked_order.trading_pair, + update_timestamp=self.current_timestamp, + new_state=OrderState.CANCELED, + client_order_id=tracked_order.client_order_id, + exchange_order_id=str(tracked_order.exchange_order_id), + ) + + raw_status = order_entry.get("order_status", "") or order_entry.get("status", "") + order_status = CONSTANTS.ORDER_STATE.get(raw_status) + if order_status is None: + if not raw_status: + # Empty status from inactive orders � treat as cancelled + order_status = CONSTANTS.ORDER_STATE["cancelled"] + else: + raise IOError(f"Unknown order status '{raw_status}' for order {tracked_order.exchange_order_id}") + + resolved_eid = str( + order_entry.get("order_id") or order_entry.get("order_index") + or tracked_order.exchange_order_id + ) + # /accountInactiveOrders timestamps are in seconds; divide only if value looks like ms (>1e12). + ts_raw = float(order_entry.get("created_at") or order_entry.get("updated_at") or 0) + order_update_result = OrderUpdate( + trading_pair=tracked_order.trading_pair, + update_timestamp=ts_raw / 1000 if ts_raw > 1_000_000_000_000 else ts_raw, + new_state=order_status, + client_order_id=tracked_order.client_order_id, + exchange_order_id=resolved_eid, + ) + + # For terminal updates, eagerly refresh balances to avoid stale available margin + # during OPEN/CLOSE transitions; refresh positions only when snapshot can change. + is_terminal = order_status in (OrderState.FILLED, OrderState.CANCELED, OrderState.FAILED) + is_close = getattr(tracked_order, "position", None) == PositionAction.CLOSE + is_partial_open_cancel = ( + order_status == OrderState.CANCELED + and getattr(tracked_order, "position", None) == PositionAction.OPEN + and tracked_order.executed_amount_base > s_decimal_0 + ) + if is_terminal: + await self._refresh_account_state( + reason=f"inactive-order status {raw_status} ({tracked_order.client_order_id})", + refresh_positions=(is_close or is_partial_open_cancel), + refresh_balances=True, + ) + + return order_update_result + + async def _get_last_traded_price(self, trading_pair: str) -> float: + """ + https://docs.lighter.fi/api-documentation/api/rest-api/markets/get-candle-data + + Example API response: + ``` + { + "success": true, + "data": [ + { + "t": 1748954160000, + "T": 1748954220000, + "s": "BTC", + "i": "1m", + "o": "105376", + "c": "105376", + "h": "105376", + "l": "105376", + "v": "0.00022", + "n": 2 + } + ], + "error": null, + "code": null + } + ``` + """ + symbol = await self.exchange_symbol_associated_to_pair(trading_pair) + params = { + "symbol": symbol, + "interval": "1m", + "start_time": int(time.time() * 1000) - 5 * 60 * 1000, + } + + response = await self._api_get( + path_url=CONSTANTS.GET_CANDLES_PATH_URL, + params=params, + ) + + candles = response.get("data") or [] + if not candles: + warning_key = f"{trading_pair}:candles" + if self._should_emit_throttled_warning(warning_key, self._last_no_candle_warning_timestamp): + self.logger().warning(f"No candle data returned for {trading_pair}, returning 0.0") + return 0.0 + return float(candles[0]["c"]) + + async def _update_trading_fees(self): + """ + https://docs.lighter.fi/api-documentation/api/rest-api/account/get-account-info + ``` + { + "success": true, + "data": [{ + "balance": "2000.000000", + "fee_level": 0, + "maker_fee": "0.00015", + "taker_fee": "0.0004", + "account_equity": "2150.250000", + "available_to_spend": "1800.750000", + "available_to_withdraw": "1500.850000", + "pending_balance": "0.000000", + "total_margin_used": "349.500000", + "cross_mmr": "420.690000", + "positions_count": 2, + "orders_count": 3, + "stop_orders_count": 1, + "updated_at": 1716200000000, + "use_ltp_for_stop_orders": false + } + ], + "error": null, + "code": null + } + ``` + """ + response = await self._api_get( + path_url=CONSTANTS.GET_ACCOUNT_INFO_PATH_URL, + params=self._account_query_params(), + return_err=True + ) + + # comparison with True is needed, bc we might expect a string to be there + # while the only indicator of success here is True boolean value + if not self._is_ok_response(response): + self.logger().error(f"[_update_trading_fees] Failed to update trading fees (api responded with failure): {response}") + return + + data = self._account_from_response(response) + if not data: + self.logger().error(f"[_update_trading_fees] Failed to update trading fees (no data): {response}") + return + + maker_fee = data.get("maker_fee") + taker_fee = data.get("taker_fee") + if maker_fee is None or taker_fee is None: + return + + trade_fee_schema = TradeFeeSchema( + maker_percent_fee_decimal=Decimal(data["maker_fee"]), + taker_percent_fee_decimal=Decimal(data["taker_fee"]), + ) + + for trading_pair in self._trading_pairs: + self._trading_fees[trading_pair] = trade_fee_schema + + async def _fetch_last_fee_payment(self, trading_pair: str) -> Tuple[float, Decimal, Decimal]: + """ + Fetch the most recent funding payment for a trading pair. + + The /positionFunding endpoint returns: + {"code": 200, "position_fundings": [{"timestamp": , "market_id": ..., "change": ..., "rate": ..., ...}]} + """ + market_id, _, _, _ = await self._get_market_spec(trading_pair) + + signer_client_ff = self._get_lighter_signer_client() + auth_token_ff, _ = signer_client_ff.create_auth_token_with_expiry( + api_key_index=self._get_api_key_index() + ) + response = await self._api_get( + path_url=CONSTANTS.GET_FUNDING_HISTORY_PATH_URL, + params={ + "account_index": self._get_account_index(), + "market_id": market_id, + "limit": 100, + "auth": auth_token_ff or "", + }, + is_auth_required=True, + return_err=True + ) + + if not self._is_ok_response(response): + self.logger().error(f"Failed to fetch last fee payment (api responded with failure): {response}") + return 0, Decimal("-1"), Decimal("-1") + + # Support both response shapes: {"data": [...]} and {"position_fundings": [...]} + data = response.get("data") or response.get("position_fundings") + if not data: + self.logger().debug(f"Failed to fetch last fee payment (no data): {response}") + return 0, Decimal("-1"), Decimal("-1") + + for item in data: + if item.get("market_id") == market_id: + # timestamp may be in seconds; normalize to ms + ts = item.get("created_at") or item.get("timestamp", 0) + if ts < 1e12: + ts = int(ts) * 1000 + rate = item.get("rate", "0") + payout = item.get("payout") or item.get("change", "0") + return float(ts), Decimal(str(rate)), Decimal(str(payout)) + + return 0, Decimal("-1"), Decimal("-1") + + async def _set_trading_pair_leverage(self, trading_pair: str, leverage: int) -> Tuple[bool, str]: + """Set leverage using signer_client.update_leverage() (signed tx via /sendTx).""" + market_id, _, _, _ = await self._get_market_spec(trading_pair) + last_error: Optional[str] = None + + for attempt in range(1, self._LEVERAGE_SET_MAX_RETRIES + 1): + signer_client = self._get_lighter_signer_client() + margin_mode = signer_client.CROSS_MARGIN_MODE # 0 = cross + _, tx_response, error = await signer_client.update_leverage( + market_index=market_id, + margin_mode=margin_mode, + leverage=leverage, + api_key_index=self._get_api_key_index(), + ) + if error is None: + # Keep startup budget checks aligned with post-leverage available margin. + # Without this refresh, strategy may use a stale pre-leverage snapshot and + # emit transient "Insufficient balance" logs right after connector ready. + try: + await self._update_balances() + except Exception as refresh_error: + self.logger().warning( + "Leverage set for %s but balance refresh failed: %s", + trading_pair, + refresh_error, + ) + return True, "" + + last_error = str(error) + has_remaining_retry = attempt < self._LEVERAGE_SET_MAX_RETRIES + if has_remaining_retry and self._is_transient_leverage_error(last_error): + self.logger().warning( + "Transient leverage update error for %s (%s/%s): %s. Retrying...", + trading_pair, + attempt, + self._LEVERAGE_SET_MAX_RETRIES, + last_error, + ) + await self._sleep(self._LEVERAGE_SET_RETRY_INTERVAL) + continue + + return False, f"Error when setting leverage: {last_error}" + + return False, f"Error when setting leverage: {last_error}" + + @staticmethod + def _is_transient_leverage_error(error_message: str) -> bool: + normalized = error_message.lower() + transient_patterns = ( + "timeout", + "deadline exceeded", + "temporary failure in name resolution", + "cannot connect to host", + "connection reset", + "connection refused", + "no pong", + "network is unreachable", + ) + return any(pattern in normalized for pattern in transient_patterns) + + async def _trading_pair_position_mode_set(self, mode: PositionMode, trading_pair: str) -> Tuple[bool, str]: + return True, "" + + def _initialize_trading_pair_symbols_from_exchange_info(self, exchange_info: Dict[str, Any]): + mapping = bidict() + order_books = exchange_info.get("order_books") + if order_books: + for symbol_data in order_books: + if symbol_data.get("market_type") != "perp": + continue + + exchange_symbol = symbol_data["symbol"] + base = exchange_symbol + quote = "USDC" + trading_pair = combine_to_hb_trading_pair(base, quote) + mapping[exchange_symbol] = trading_pair + + self._market_id_by_symbol[exchange_symbol] = int(symbol_data["market_id"]) + self._size_decimals_by_symbol[exchange_symbol] = int(symbol_data.get("supported_size_decimals", 0)) + self._price_decimals_by_symbol[exchange_symbol] = int(symbol_data.get("supported_price_decimals", 0)) + else: + for symbol_data in exchange_info.get("data", []): + exchange_symbol = symbol_data["symbol"] + base = exchange_symbol + quote = "USDC" + trading_pair = combine_to_hb_trading_pair(base, quote) + mapping[exchange_symbol] = trading_pair + + self._set_trading_pair_symbol_map(mapping) + + def _get_fee(self, + base_currency: str, + quote_currency: str, + order_type: OrderType, + order_side: TradeType, + position_action: PositionAction, + amount: Decimal, + price: Decimal = Decimal("nan"), + is_maker: Optional[bool] = None) -> TradeFeeBase: + is_maker = is_maker or (order_type is OrderType.LIMIT_MAKER) + trading_pair = combine_to_hb_trading_pair(base=base_currency, quote=quote_currency) + # Use live fee schema from account API when available; fall back to DEFAULT_FEES. + fee_schema: Optional[TradeFeeSchema] = self._trading_fees.get(trading_pair) + if fee_schema is not None: + percent = (fee_schema.maker_percent_fee_decimal if is_maker + else fee_schema.taker_percent_fee_decimal) + return TradeFeeBase.new_perpetual_fee( + fee_schema=fee_schema, + position_action=position_action, + percent=percent, + percent_token=quote_currency, + flat_fees=[], + ) + fee = build_trade_fee( + self.name, + is_maker, + base_currency=base_currency, + quote_currency=quote_currency, + order_type=order_type, + order_side=order_side, + amount=amount, + price=price, + ) + return fee + + def _get_poll_interval(self, timestamp: float) -> float: + # Keep a failover-fast polling mode when private stream is stale, but + # reduce REST pressure when the private stream is healthy. + has_open_positions = len(self.account_positions) > 0 + if len(self.in_flight_orders) > 0 or has_open_positions: + private_is_healthy = self._is_user_stream_initialized() + return self._HEALTHY_PRIVATE_STREAM_POLL_INTERVAL if private_is_healthy else self.SHORT_POLL_INTERVAL + return super()._get_poll_interval(timestamp) + + async def _user_stream_event_listener(self): + """ + Wait for new messages from _user_stream_tracker.user_stream queue and processes them according to their + message channels. The respective UserStreamDataSource queues these messages. + """ + async for event_message in self._iter_user_event_queue(): + try: + channel = str(event_message.get("channel") or "") + event_type = str(event_message.get("type") or "") + + # Normalise scoped channels from either delimiter style used by the exchange, + # e.g. "account_order_updates/0xabc" or "account_order_updates:0xabc". + channel_base = channel.split("/", 1)[0].split(":", 1)[0] + + if self._should_ignore_scoped_private_event(channel=channel, channel_base=channel_base): + continue + + order_update_event_types = { + f"subscribed/{CONSTANTS.WS_ACCOUNT_ORDER_UPDATES_CHANNEL}", + f"update/{CONSTANTS.WS_ACCOUNT_ORDER_UPDATES_CHANNEL}", + } + position_event_types = { + f"subscribed/{CONSTANTS.WS_ACCOUNT_POSITIONS_CHANNEL}", + f"update/{CONSTANTS.WS_ACCOUNT_POSITIONS_CHANNEL}", + } + info_event_types = { + f"subscribed/{CONSTANTS.WS_ACCOUNT_INFO_CHANNEL}", + f"update/{CONSTANTS.WS_ACCOUNT_INFO_CHANNEL}", + } + trade_event_types = { + f"subscribed/{CONSTANTS.WS_ACCOUNT_TRADES_CHANNEL}", + f"update/{CONSTANTS.WS_ACCOUNT_TRADES_CHANNEL}", + } + + if channel_base == CONSTANTS.WS_ACCOUNT_ORDER_UPDATES_CHANNEL or event_type in order_update_event_types: + await self._process_account_order_updates_ws_event_message(event_message) + elif channel_base == CONSTANTS.WS_ACCOUNT_POSITIONS_CHANNEL or event_type in position_event_types: + await self._process_account_positions_ws_event_message(event_message) + elif channel_base == CONSTANTS.WS_ACCOUNT_INFO_CHANNEL or event_type in info_event_types: + await self._process_account_info_ws_event_message(event_message) + elif channel_base == CONSTANTS.WS_ACCOUNT_TRADES_CHANNEL or event_type in trade_event_types: + await self._process_account_trades_ws_event_message(event_message) + elif channel_base == CONSTANTS.WS_USER_STATS_CHANNEL or event_type in { + f"subscribed/{CONSTANTS.WS_USER_STATS_CHANNEL}", + f"update/{CONSTANTS.WS_USER_STATS_CHANNEL}", + }: + await self._process_user_stats_ws_event_message(event_message) + elif channel_base == CONSTANTS.WS_ACCOUNT_ALL_ORDERS_CHANNEL or event_type in { + f"subscribed/{CONSTANTS.WS_ACCOUNT_ALL_ORDERS_CHANNEL}", + f"update/{CONSTANTS.WS_ACCOUNT_ALL_ORDERS_CHANNEL}", + }: + await self._process_account_all_orders_ws_event_message(event_message) + elif ( + channel_base == CONSTANTS.WS_ACCOUNT_ALL_CHANNEL + or event_type in {"subscribed/account_all", "update/account_all"} + or str(channel).startswith(f"{CONSTANTS.WS_ACCOUNT_ALL_CHANNEL}:") + ): + await self._process_account_all_ws_event_message(event_message) + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().error(f"Unexpected error in user stream listener loop: {e}", exc_info=True) + await self._sleep(5.0) + + def _should_ignore_scoped_private_event(self, channel: str, channel_base: str) -> bool: + """Ignore private WS events scoped to a different numeric account identifier. + + Lighter may emit events for multiple identifiers (wallet key, account index, + API key index). Numeric-scoped channels for private account data should match + the configured account_index; otherwise they can carry unrelated snapshots. + """ + private_scoped_channels = { + CONSTANTS.WS_ACCOUNT_ALL_CHANNEL, + CONSTANTS.WS_ACCOUNT_ORDER_UPDATES_CHANNEL, + CONSTANTS.WS_ACCOUNT_POSITIONS_CHANNEL, + CONSTANTS.WS_ACCOUNT_TRADES_CHANNEL, + CONSTANTS.WS_ACCOUNT_INFO_CHANNEL, + CONSTANTS.WS_USER_STATS_CHANNEL, + CONSTANTS.WS_ACCOUNT_ALL_ORDERS_CHANNEL, + } + if channel_base not in private_scoped_channels: + return False + + scoped_identifier = "" + if "/" in channel: + scoped_identifier = channel.split("/", 1)[1].strip() + elif ":" in channel: + scoped_identifier = channel.split(":", 1)[1].strip() + + if not scoped_identifier or not scoped_identifier.isdigit(): + return False + + try: + expected_account_index = str(self._get_account_index()) + except Exception: + return False + + if scoped_identifier != expected_account_index: + self.logger().debug( + "Ignoring %s event for scoped identifier %s (expected account_index=%s)", + channel_base, + scoped_identifier, + expected_account_index, + ) + return True + + return False + + async def _process_account_all_ws_event_message(self, event_message: Dict[str, Any]): + self._mark_private_account_event_received() + # Process orders FIRST to populate client_order_index -> order_index mapping before + # processing trades — both read from the same "data" list in account_all events, so + # establishing the mapping first prevents fill events from being dropped as unmatched. + await self._process_account_all_orders_ws_event_message(event_message) + # Replay any fills that arrived from the standalone account_trades channel before the + # mapping was established — mirrors Hyperliquid's get_exchange_order_id() wait pattern. + await self._replay_pending_trade_entries() + # Now process trades from this account_all message (mapping already current, no buffering). + await self._process_account_trades_ws_event_message(event_message, buffer_on_miss=False) + await self._process_account_positions_ws_event_message(event_message) + # Keep balance updates simple and deterministic: account_all acts only as a trigger + # for a fast REST sync. REST /account is the single source of truth for available. + self._schedule_fast_balance_sync() + + @staticmethod + def _normalized_position_entries_from_event(event_message: Dict[str, Any]) -> List[Dict[str, Any]]: + raw_entries = None + + channel = str(event_message.get("channel") or "") + channel_base = channel.split("/", 1)[0].split(":", 1)[0] + + # Only account_positions channel should interpret top-level `data` as position rows. + # account_all may carry trades/orders in `data` and must not be interpreted as positions. + if channel_base == CONSTANTS.WS_ACCOUNT_POSITIONS_CHANNEL: + channel_data = event_message.get("data") + if isinstance(channel_data, list): + raw_entries = channel_data + + if raw_entries is None: + positions = event_message.get("positions") + if isinstance(positions, dict): + raw_entries = list(positions.values()) + elif isinstance(positions, list): + raw_entries = positions + + normalized_entries: List[Dict[str, Any]] = [] + for position_entry in raw_entries or []: + if "s" in position_entry: + normalized_entries.append(position_entry) + continue + + symbol = position_entry.get("symbol") + if not symbol: + continue + + raw_amount = Decimal(str(position_entry.get("position") or position_entry.get("amount") or "0")) + if raw_amount == s_decimal_0: + continue + + # Direction: prefer numeric "sign" (1=long, -1/0=short), fall back to "side" string ("bid"=long, "ask"=short) + if "sign" in position_entry: + sign = int(position_entry.get("sign", 1) or 1) + is_long = sign >= 0 + else: + side = str(position_entry.get("side") or "bid").lower() + is_long = side in ("bid", "long", "buy") + + avg_price = str(position_entry.get("avg_entry_price") or position_entry.get("entry_price") or "0") + normalized_entries.append({ + "s": symbol, + "d": "bid" if is_long else "ask", + "a": str(abs(raw_amount)), + "p": avg_price, + "upnl": str(position_entry.get("unrealized_pnl")) if position_entry.get("unrealized_pnl") is not None else None, + # cumulative funding P&L for this position (positive = received, negative = paid) + "f": str(position_entry.get("funding") or "0"), + }) + + return normalized_entries + + def _normalized_trade_entries_from_event(self, event_message: Dict[str, Any]) -> List[Dict[str, Any]]: + raw_entries = event_message.get("data") + if raw_entries is not None: + return list(raw_entries or []) + + trades = event_message.get("trades") or {} + if isinstance(trades, dict): + trade_buckets = trades.values() + elif isinstance(trades, list): + trade_buckets = trades + else: + trade_buckets = [] + + normalized_entries: List[Dict[str, Any]] = [] + for trade_bucket in trade_buckets: + entries = trade_bucket if isinstance(trade_bucket, list) else [trade_bucket] + for trade_entry in entries: + if not isinstance(trade_entry, dict): + continue + if "i" in trade_entry: + normalized_entries.append(trade_entry) + continue + + own_bid = str(trade_entry.get("bid_account_id")) == str(self._get_account_index()) + own_ask = str(trade_entry.get("ask_account_id")) == str(self._get_account_index()) + if not own_bid and not own_ask: + continue + + # Extract client_order_index (ask_client_id / bid_client_id) from Trade JSON. + # This is populated in _client_order_index_to_client_order_id at order placement + # time — before any WS event fires — so it eliminates the account_all race condition. + if own_bid: + client_order_index_raw = str( + trade_entry.get("bid_client_id_str") or trade_entry.get("bid_client_id") or "" + ) + # Prefer actual order_index (bid_id/ask_id) as exchange_order_id so lookup + # succeeds after exchange_order_id has been updated to the exchange-assigned + # order_index by account_all. Fall back to client_order_index. + exchange_order_id = str( + trade_entry.get("bid_id_str") or trade_entry.get("bid_id") or + client_order_index_raw or "" + ) + else: + client_order_index_raw = str( + trade_entry.get("ask_client_id_str") or trade_entry.get("ask_client_id") or "" + ) + exchange_order_id = str( + trade_entry.get("ask_id_str") or trade_entry.get("ask_id") or + client_order_index_raw or "" + ) + if not exchange_order_id: + continue + + is_taker = (own_bid and bool(trade_entry.get("is_maker_ask"))) or (own_ask and not bool(trade_entry.get("is_maker_ask"))) + fee_raw = trade_entry.get("taker_fee") if is_taker else trade_entry.get("maker_fee") + fee_rate_ppm = Decimal(str(fee_raw if fee_raw is not None else 0)) + fee_amount = Decimal(str(trade_entry.get("usd_amount") or "0")) * fee_rate_ppm / Decimal("1000000") + + normalized_entries.append({ + "i": exchange_order_id, + "s": str(trade_entry.get("symbol") or trade_entry.get("s") or ""), + # client_order_index allows direct O(1) lookup in _try_process_one_trade_entry + # via _client_order_index_to_client_order_id without needing account_all first. + "client_order_index": client_order_index_raw, + "p": str(trade_entry.get("price") or "0"), + "a": str(trade_entry.get("size") or "0"), + "f": str(fee_amount), + "t": trade_entry.get("timestamp") or trade_entry.get("transaction_time") or 0, + "ts": "open_long" if own_bid else "open_short", + "trade_id": trade_entry.get("trade_id_str") or trade_entry.get("trade_id"), + }) + + return normalized_entries + + async def _process_account_order_updates_ws_event_message(self, event_message: Dict[str, Any]): + self._mark_private_account_event_received() + """ + https://docs.lighter.fi/api-documentation/api/websocket/subscriptions/account-order-updates + { + "channel": "account_order_updates", + "data": [ + { + "i": 1559665358, + "I": null, + "u": "BrZp5bidJ3WUvceSq7X78bhjTfZXeezzGvGEV4hAYKTa", + "s": "BTC", + "d": "bid", + "p": "89501", + "ip": "89501", + "lp": "89501", + "a": "0.00012", + "f": "0.00012", + "oe": "fulfill_limit", + "os": "filled", + "ot": "limit", + "sp": null, + "si": null, + "r": false, + "ct": 1765017049008, + "ut": 1765017219639, + "li": 1559696133 + } + ] + } + """ + # Build indices for fast O(1) order matching across both ID shapes. + all_updatable_orders = self._order_tracker.all_updatable_orders + tracked_orders_by_oid = { + str(order.exchange_order_id): order for order in all_updatable_orders.values() + } + tracked_orders_by_client_index: Dict[str, InFlightOrder] = { + str(order.exchange_order_id): order for order in all_updatable_orders.values() + } + for known_client_index, known_client_order_id in self._client_order_index_to_client_order_id.items(): + known_tracked_order = all_updatable_orders.get(known_client_order_id) + if known_tracked_order is not None: + tracked_orders_by_client_index[str(known_client_index)] = known_tracked_order + # Reverse lookup allows resolving updates that only include exchange order_index (i) + # after we previously learned client_index -> order_index mapping. + order_index_to_client_index = { + str(order_index): str(client_index) + for client_index, order_index in self._client_order_index_to_order_index.items() + if order_index is not None and str(order_index) != "" + } + + raw_entries = event_message.get("data") + if not isinstance(raw_entries, list): + return + + for order_update_message in raw_entries: + symbol = str(order_update_message.get("s") or order_update_message.get("symbol") or "") + if symbol: + update_pair = None + try: + update_pair = await self.trading_pair_associated_to_exchange_symbol(symbol) + except KeyError: + update_pair = None + if update_pair is not None and update_pair not in self._trading_pairs: + continue + + exchange_order_id = str(order_update_message.get("i") or "") + client_order_index = str(order_update_message.get("I") or "") + raw_status = order_update_message.get("os", "") + + # Populate mapping regardless of whether we have a tracked order. + if exchange_order_id and client_order_index: + self._client_order_index_to_order_index[client_order_index] = exchange_order_id + order_index_to_client_index[exchange_order_id] = client_order_index + + # Try direct lookup by exchange order_index first (works once exchange_order_id is updated). + tracked_order = tracked_orders_by_oid.get(exchange_order_id) + # Then try by client_order_index (works on first WS update after placement). + if tracked_order is None and client_order_index: + tracked_order = tracked_orders_by_client_index.get(client_order_index) + # Finally, use explicit client_order_index -> client_order_id map when available. + if tracked_order is None and client_order_index: + client_order_id = self._client_order_index_to_client_order_id.get(client_order_index) + if client_order_id is not None: + tracked_order = all_updatable_orders.get(client_order_id) + # If WS omits client_order_index, try the reverse map learned from account_all + # or prior active-order reconciliation. + if tracked_order is None and exchange_order_id: + mapped_client_order_index = order_index_to_client_index.get(exchange_order_id) + if mapped_client_order_index is not None: + tracked_order = tracked_orders_by_client_index.get(mapped_client_order_index) + if tracked_order is None: + mapped_client_order_id = self._client_order_index_to_client_order_id.get(mapped_client_order_index) + if mapped_client_order_id is not None: + tracked_order = all_updatable_orders.get(mapped_client_order_id) + + if tracked_order is None: + resolved_state = CONSTANTS.ORDER_STATE.get(raw_status) + if resolved_state in {OrderState.FILLED, OrderState.CANCELED, OrderState.FAILED}: + # Skip reconciliation if this is a delayed WS echo of a state we already + # processed (e.g. WS cancel arriving after REST cancel already set CANCELED). + known_coid = ( + self._client_order_index_to_client_order_id.get(client_order_index) + if client_order_index + else None + ) + is_known_terminal = ( + known_coid is not None + and known_coid not in self._order_tracker.all_updatable_orders + ) + if not is_known_terminal: + await self._reconcile_unmatched_private_event( + reason=f"order_update status={raw_status} exchange_order_id={exchange_order_id} client_order_index={client_order_index}", + ) + continue + + # Keep the index map warm so later updates can always resolve in O(1). + if client_order_index and tracked_order.client_order_id: + self._client_order_index_to_client_order_id[client_order_index] = tracked_order.client_order_id + tracked_orders_by_client_index[client_order_index] = tracked_order + + order_status = CONSTANTS.ORDER_STATE.get(raw_status) + if order_status is None: + self.logger().warning(f"Unknown order status '{raw_status}' in WS update") + continue + + # Guard against terminal-state regressions from delayed WS echoes. + # If an order was already finalized as FILLED, a later CANCELED status is stale + # and must not emit a second terminal event/log line. + if tracked_order.current_state == OrderState.FILLED and order_status == OrderState.CANCELED: + self.logger().debug( + "Ignoring stale canceled WS order update for already-filled order %s (channel=account_order_updates)", + tracked_order.client_order_id, + ) + continue + + # Use real exchange order_index as exchange_order_id going forward. + resolved_eid = exchange_order_id if exchange_order_id else tracked_order.exchange_order_id + order_update = OrderUpdate( + trading_pair=tracked_order.trading_pair, + update_timestamp=order_update_message.get("ut", 0) / 1000, + new_state=order_status, + client_order_id=tracked_order.client_order_id, + exchange_order_id=resolved_eid, + ) + # Snapshot executed amount before process_order_update (it is immutable on InFlightOrder). + _pre_executed = tracked_order.executed_amount_base + _pre_amount = tracked_order.amount + self._order_tracker.process_order_update(order_update) + + # For terminal updates, eagerly refresh balances to avoid stale available margin + # during OPEN/CLOSE transitions; refresh positions only when snapshot can change. + is_terminal = order_status in (OrderState.FILLED, OrderState.CANCELED, OrderState.FAILED) + is_close = getattr(tracked_order, "position", None) == PositionAction.CLOSE + # Refresh positions when a partially-filled OPEN order is cancelled: the residual + # position (= filled_amount that was never closed) must appear in account_positions + # immediately so the strategy creates the correct close/stop-loss order at the + # next clock tick, rather than seeing 0 and orphaning the partial position. + is_partial_open_cancel = ( + order_status == OrderState.CANCELED + and getattr(tracked_order, "position", None) == PositionAction.OPEN + and tracked_order.executed_amount_base > s_decimal_0 + ) + if is_terminal: + await self._refresh_account_state( + reason=f"ws order update {raw_status} ({tracked_order.client_order_id})", + refresh_positions=(is_close or is_partial_open_cancel), + refresh_balances=True, + ) + + # Eagerly fetch fills for terminal orders that have no recorded fills yet. + # This catches the fast fill+cancel race where a WS trade event with I=null + # (no client_order_index) could not be matched to the tracked order, so the fill + # was never delivered via the WS path. The eager REST fetch here — using the + # now-correct resolved_eid — recovers the fill before the order is evicted from + # cached_orders, and before the next scheduled poll cycle runs. + should_fetch_terminal_fills = False + if is_terminal: + try: + pre_executed_dec = Decimal(str(_pre_executed)) + pre_amount_dec = Decimal(str(_pre_amount)) + should_fetch_terminal_fills = pre_executed_dec < pre_amount_dec + except Exception: + # Keep WS order processing resilient when mocked or malformed values are present. + should_fetch_terminal_fills = False + + if should_fetch_terminal_fills: + _ws_fill_order = ( + self._order_tracker.all_fillable_orders.get(tracked_order.client_order_id) + or self._order_tracker.all_fillable_orders_by_exchange_order_id.get(resolved_eid or "") + ) + if _ws_fill_order is not None: + safe_ensure_future(self._fetch_and_apply_fills(_ws_fill_order)) + + async def _process_account_positions_ws_event_message(self, event_message: Dict[str, Any]): + self._mark_private_account_event_received() + """ + https://docs.lighter.fi/api-documentation/api/websocket/subscriptions/account-positions + { + "channel": "subscribe", + "data": { + "source": "account_positions", + "account": "BrZp5..." + } + } + // this is the initialization snapshot + { + "channel": "account_positions", + "data": [ + { + "s": "BTC", + "d": "bid", + "a": "0.00022", + "p": "87185", + "m": "0", + "f": "-0.00023989", + "i": false, + "l": null, + "t": 1764133203991 + } + ], + "li": 1559395580 + } + // this shows the position being increased by an order filling + { + "channel": "account_positions", + "data": [ + { + "s": "BTC", + "d": "bid", + "a": "0.00044", + "p": "87285.5", + "m": "0", + "f": "-0.00023989", + "i": false, + "l": "-95166.79231", + "t": 1764133656974 + } + ], + "li": 1559412952 + } + // this shows the position being closed + { + "channel": "account_positions", + "data": [], + "li": 1559438203 + } + """ + channel = str(event_message.get("channel") or "") + channel_base = channel.split("/", 1)[0].split(":", 1)[0] + has_explicit_positions_snapshot = ( + (channel_base == CONSTANTS.WS_ACCOUNT_POSITIONS_CHANNEL and isinstance(event_message.get("data"), list)) + or ("positions" in event_message) + ) + + # Ignore private updates that do not carry a positions snapshot. + # Otherwise non-position updates can clear account_positions and hide open positions in TUI. + if not has_explicit_positions_snapshot: + return + + # LIGHTER provides full snapshot of positions. + # if there're 2 positions available, it will only show those 2. + # if one of those 2 positions is closed -- you will see only 1. + # Build new_positions atomically: clear the old snapshot ONLY AFTER a successful + # rebuild, mirroring the REST _update_positions() fix. If trading_pair resolution + # raises mid-loop, the existing positions are preserved (no TUI blank-out). + new_ws_positions: Dict[str, Any] = {} + + for position_entry in self._normalized_position_entries_from_event(event_message): + hb_trading_pair = await self.trading_pair_associated_to_exchange_symbol(position_entry["s"]) + if hb_trading_pair not in self._trading_pairs: + self.logger().debug( + "[_process_account_positions_ws_event_message] Skipping position for unconfigured trading pair %s.", + hb_trading_pair, + ) + continue + position_side = PositionSide.LONG if position_entry["d"] == "bid" else PositionSide.SHORT + position_key = self._perpetual_trading.position_key(hb_trading_pair, position_side) + amount = Decimal(position_entry["a"]) + if amount == Decimal("0"): + # Skip closed positions (exchange may still send a trailing zero-amount entry) + continue + entry_price = Decimal(position_entry["p"]) + price_record = self.get_LIGHTER_price(hb_trading_pair) + mark_price = price_record.mark_price if price_record is not None else entry_price + + provided_unrealized_pnl = position_entry.get("upnl") + if provided_unrealized_pnl is not None: + unrealized_pnl = Decimal(str(provided_unrealized_pnl)) + else: + if position_side == PositionSide.LONG: + unrealized_pnl = (mark_price - entry_price) * amount + else: + unrealized_pnl = (entry_price - mark_price) * amount + + reference_price = mark_price if mark_price > s_decimal_0 else entry_price + if self._is_sub_minimum_position_notional( + trading_pair=hb_trading_pair, + position_amount=amount, + reference_price=reference_price, + ): + now = time.time() + last_warning_ts = self._last_sub_minimum_position_warning_ts.get(hb_trading_pair, 0.0) + if now - last_warning_ts >= self._SUB_MINIMUM_POSITION_WARNING_INTERVAL: + self._last_sub_minimum_position_warning_ts[hb_trading_pair] = now + self.logger().warning( + "[_process_account_positions_ws_event_message] Tracking sub-minimum residual " + "position for %s (amount=%s, reference_price=%s). Close attempts may fail " + "until notional reaches exchange minimum.", + hb_trading_pair, + amount, + reference_price, + ) + else: + self.logger().debug( + "[_process_account_positions_ws_event_message] Sub-minimum residual position for %s " + "tracking warning suppressed.", + hb_trading_pair, + ) + + # "f" field = cumulative funding P&L (positive = received, negative = paid) + cumulative_funding = Decimal(str(position_entry.get("f") or "0")) + unrealized_pnl += cumulative_funding + + position = Position( + trading_pair=hb_trading_pair, + position_side=position_side, + unrealized_pnl=unrealized_pnl, + entry_price=entry_price, + amount=amount * (Decimal("-1.0") if position_side == PositionSide.SHORT else Decimal("1.0")), + leverage=Decimal(self.get_leverage(hb_trading_pair)) + ) + new_ws_positions[position_key] = position + + # Atomic apply: stale positions are cleared only after successful rebuild. + self._perpetual_trading.account_positions.clear() + for key, position in new_ws_positions.items(): + self._perpetual_trading.set_position(key, position) + self._last_position_update_timestamp = time.time() + + async def _process_user_stats_ws_event_message(self, event_message: Dict[str, Any]): + """ + Handles the user_stats WS channel which signals that account margins have changed. + + Rather than using the WS fields directly (which may not account for open-order margin), + we schedule a REST poll so that available_to_spend — the exchange-computed value that + deducts BOTH open-position initial margin AND open-order margin — is used as the + authoritative available balance, consistent with the SPOT connector pattern. + """ + self._mark_private_account_event_received() + # Keep balance updates simple and deterministic: user_stats acts only as a trigger + # for a fast REST sync. REST /account is the single source of truth for available. + self._schedule_fast_balance_sync() + + async def _process_account_info_ws_event_message(self, event_message: Dict[str, Any]): + self._mark_private_account_event_received() + """ + https://docs.lighter.fi/api-documentation/api/websocket/subscriptions/account-info + { + "channel": "account_info", + "data": { + "ae": "2000", + "as": "1500", + "aw": "1400", + "b": "2000", + "f": 1, + "mu": "500", + "cm": "400", + "oc": 10, + "pb": "0", + "pc": 2, + "sc": 2, + "t": 1234567890 + } + } + """ + data = event_message.get("data") or {} + has_balance_hint = any( + data.get(k) is not None + for k in ("ae", "as", "b", "available_to_spend", "available_balance", "collateral") + ) + if has_balance_hint: + self._schedule_fast_balance_sync() + self._fee_tier = int(data.get("f", self._fee_tier)) + + async def _process_account_trades_ws_event_message(self, event_message: Dict[str, Any], buffer_on_miss: bool = True): + self._mark_private_account_event_received() + """ + https://docs.lighter.fi/api-documentation/api/websocket/subscriptions/account-trades + { + "channel": "account_trades", + "data": [ + { + "h": 80063441, + "i": 1559912767, + "I": null, + "u": "BrZp5bidJ3WUvceSq7X78bhjTfZXeezzGvGEV4hAYKTa", + "s": "BTC", + "p": "89477", + "o": "89505", + "a": "0.00036", + "te": "fulfill_taker", + "ts": "close_long", + "tc": "normal", + "f": "0.012885", + "n": "-0.022965", + "t": 1765018588190, + "li": 1559912767 + } + ] + } + """ + tracked_orders = { + str(order.exchange_order_id): order for order in self._order_tracker.all_fillable_orders.values() + } + all_fillable_orders = self._order_tracker.all_fillable_orders + order_index_to_client_index = { + str(order_index): str(client_index) + for client_index, order_index in self._client_order_index_to_order_index.items() + if order_index is not None and str(order_index) != "" + } + + for trade_message in self._normalized_trade_entries_from_event(event_message): + symbol = str(trade_message.get("s") or "") + if symbol: + trade_pair = None + try: + trade_pair = await self.trading_pair_associated_to_exchange_symbol(symbol) + except KeyError: + trade_pair = None + if trade_pair is not None and trade_pair not in self._trading_pairs: + continue + + matched = await self._try_process_one_trade_entry( + trade_message, tracked_orders, all_fillable_orders, order_index_to_client_index + ) + if not matched: + if self._should_ignore_unmatched_trade_message( + trade_message=trade_message, + tracked_orders=tracked_orders, + order_index_to_client_index=order_index_to_client_index, + ): + self.logger().debug( + "Ignoring unmatched external/manual trade update " + "(exchange_order_id=%s, symbol=%s).", + trade_message.get("i"), + trade_message.get("s", ""), + ) + continue + if buffer_on_miss: + # Buffer and wait for account_all to establish the client_order_index mapping, + # then replay — mirrors Hyperliquid's get_exchange_order_id() wait pattern. + self._pending_trade_entries.append((time.time(), trade_message)) + else: + await self._reconcile_unmatched_private_event( + reason=f"trade_update exchange_order_id={trade_message.get('i')} symbol={trade_message.get('s', '')}", + ) + + def _should_ignore_unmatched_trade_message( + self, + trade_message: Dict[str, Any], + tracked_orders: Dict[str, Any], + order_index_to_client_index: Dict[str, str], + ) -> bool: + """Return True when an unmatched trade update is clearly external/manual. + + Some account streams can include private trades not created by this bot (e.g. manual + position closes). If those payloads omit both symbol and client order index and the + exchange order id is not mappable to any tracked order, reconciling every such event + produces REST pressure and log spam without improving bot correctness. + """ + client_order_index = str(trade_message.get("client_order_index") or trade_message.get("I") or "") + symbol = str(trade_message.get("s") or "") + exchange_order_id = str(trade_message.get("i") or "") + + # If the payload carries explicit symbol or client order index, keep the existing + # reconciliation path because it may correspond to a delayed bot order update. + if symbol or client_order_index: + return False + + # Do not ignore symbol-less unmatched trades when we currently hold a position in one + # of this connector's configured trading pairs. Manual/external position adjustments can + # arrive without symbol metadata; forcing reconciliation here prevents stale positions. + has_tracked_position = any( + position.trading_pair in self._trading_pairs + for position in self._perpetual_trading.account_positions.values() + ) + if has_tracked_position: + return False + + if not exchange_order_id: + return True + + if exchange_order_id in tracked_orders: + return False + + if exchange_order_id in self._order_tracker.all_fillable_orders_by_exchange_order_id: + return False + + mapped_client_index = order_index_to_client_index.get(exchange_order_id) + if mapped_client_index is not None: + return False + + # Reverse lookup fallback: values in client->exchange map may contain this ID. + if any(str(v) == exchange_order_id for v in self._client_order_index_to_order_index.values()): + return False + + return True + + async def _try_process_one_trade_entry( + self, + trade_message: Dict[str, Any], + tracked_orders: Dict[str, Any], + all_fillable_orders: Dict[str, Any], + order_index_to_client_index: Dict[str, str], + ) -> bool: + """Try to match and process one normalized trade entry. + + Returns True if the order was found and the fill was processed; False if unmatched. + Mutates *tracked_orders* to cache newly resolved mappings within the same batch. + """ + exchange_order_id = str(trade_message["i"]) + + # Path 0: Direct client_order_index lookup — populated in _place_order immediately + # after a successful order submission, before any WS event fires. This eliminates + # the account_all race condition entirely for orders placed this session. + client_order_index = str(trade_message.get("client_order_index", "")) + tracked_order = None + if client_order_index: + mapped_client_order_id = self._client_order_index_to_client_order_id.get(client_order_index) + if mapped_client_order_id: + tracked_order = all_fillable_orders.get(mapped_client_order_id) + + # Path 1: exchange_order_id direct lookup (works once exchange_order_id has been + # updated to the exchange-assigned order_index by account_all). + if tracked_order is None: + tracked_order = tracked_orders.get(exchange_order_id) + + if tracked_order is None: + mapped_client_index = order_index_to_client_index.get(exchange_order_id) + if mapped_client_index is not None: + mapped_client_order_id = self._client_order_index_to_client_order_id.get(mapped_client_index) + if mapped_client_order_id is not None: + tracked_order = all_fillable_orders.get(mapped_client_order_id) + + if tracked_order is None: + for candidate_order in all_fillable_orders.values(): + candidate_exchange_id = str(candidate_order.exchange_order_id) + if candidate_exchange_id == exchange_order_id: + tracked_order = candidate_order + break + mapped_candidate_exchange_id = self._client_order_index_to_order_index.get(candidate_exchange_id) + if mapped_candidate_exchange_id is not None and str(mapped_candidate_exchange_id) == exchange_order_id: + tracked_order = candidate_order + break + + if not tracked_order: + return False + + if str(tracked_order.exchange_order_id) != exchange_order_id: + tracked_order.update_exchange_order_id(exchange_order_id) + tracked_orders[exchange_order_id] = tracked_order + + trade_timestamp = Decimal(str(trade_message.get("t") or self.current_timestamp)) + fill_timestamp = float(trade_timestamp / Decimal("1000")) if trade_timestamp > Decimal("1000000000000") else float(trade_timestamp) + + trade_id = trade_message.get("trade_id") or self.get_LIGHTER_finance_trade_id( + order_id=trade_message["i"], + timestamp=fill_timestamp, + fill_base_amount=Decimal(trade_message["a"]), + fill_price=Decimal(trade_message["p"]), + ) + + # it would always be USDC + fee_asset = tracked_order.quote_asset + + fee = TradeFeeBase.new_perpetual_fee( + fee_schema=self.trade_fee_schema(), + position_action=tracked_order.position, + percent_token=fee_asset, + flat_fees=[TokenAmount( + amount=Decimal(trade_message["f"]), + token=fee_asset + )] + ) + + trade_update = TradeUpdate( + trade_id=trade_id, + client_order_id=tracked_order.client_order_id, + exchange_order_id=exchange_order_id, + trading_pair=tracked_order.trading_pair, + fee=fee, + fill_base_amount=Decimal(trade_message["a"]), + fill_quote_amount=Decimal(trade_message["p"]) * Decimal(trade_message["a"]), + fill_price=Decimal(trade_message["p"]), + fill_timestamp=fill_timestamp, + ) + + self._order_tracker.process_trade_update(trade_update) + + # After recording the fill, check if the order is now fully filled. + total_executed = tracked_order.executed_amount_base + order_amount = tracked_order.amount + try: + is_fully_filled = ( + order_amount is not None + and not Decimal(str(order_amount)).is_nan() + and total_executed >= Decimal(str(order_amount)) + ) + except Exception: + is_fully_filled = False + if is_fully_filled: + order_update_obj = OrderUpdate( + trading_pair=tracked_order.trading_pair, + update_timestamp=fill_timestamp, + new_state=OrderState.FILLED, + client_order_id=tracked_order.client_order_id, + exchange_order_id=exchange_order_id, + ) + self._order_tracker.process_order_update(order_update_obj) + + # For fully filled CLOSE orders, eagerly refresh positions and balances. + if getattr(tracked_order, "position", None) == PositionAction.CLOSE: + await self._update_positions() + await self._update_balances() + + return True + + async def _replay_pending_trade_entries(self) -> None: + """Replay trade fills buffered from the standalone account_trades channel. + + Called immediately after _process_account_all_orders_ws_event_message populates + _client_order_index_to_order_index so that fills which arrived before the mapping + was established are now matched and processed correctly. Any entry still unmatched + after 5 s is escalated to reconciliation (same path as before), matching the + Hyperliquid pattern of awaiting get_exchange_order_id() with a timeout. + """ + if not self._pending_trade_entries: + return + + tracked_orders = { + str(o.exchange_order_id): o for o in self._order_tracker.all_fillable_orders.values() + } + all_fillable_orders = self._order_tracker.all_fillable_orders + order_index_to_client_index = { + str(oi): str(ci) + for ci, oi in self._client_order_index_to_order_index.items() + if oi is not None and str(oi) != "" + } + + now = time.time() + still_pending: List[Tuple[float, Dict[str, Any]]] = [] + for buffered_ts, trade_message in self._pending_trade_entries: + matched = await self._try_process_one_trade_entry( + trade_message, tracked_orders, all_fillable_orders, order_index_to_client_index + ) + if matched: + continue + + if self._should_ignore_unmatched_trade_message( + trade_message=trade_message, + tracked_orders=tracked_orders, + order_index_to_client_index=order_index_to_client_index, + ): + continue + + age = now - buffered_ts + if age >= 5.0: + # Stale unmatched fill — escalate to reconciliation and discard + await self._reconcile_unmatched_private_event( + reason=( + f"trade_update exchange_order_id={trade_message.get('i')} " + f"(buffered {age:.1f}s, still unmatched)" + ), + ) + else: + still_pending.append((buffered_ts, trade_message)) + self._pending_trade_entries = still_pending + + def set_LIGHTER_price(self, trading_pair: str, timestamp: float, index_price: Decimal, mark_price: Decimal): + """ + Set the price information for the given trading pair + + :param trading_pair: the trading pair + :param timestamp: the timestamp of the price (in seconds) + :param index_price: the index price + :param mark_price: the mark price + """ + existing = self._prices.get(trading_pair) + if existing is None or timestamp >= existing.timestamp: + self._prices[trading_pair] = LighterPerpetualPriceRecord( + timestamp=timestamp, + index_price=index_price, + mark_price=mark_price + ) + + def get_LIGHTER_price(self, trading_pair: str) -> Optional[LighterPerpetualPriceRecord]: + """ + Get the price information for the given trading pair + + :param trading_pair: the trading pair + + :return: the price information for the given trading pair or None if the trading pair is not found + """ + return self._prices.get(trading_pair) + + def get_LIGHTER_finance_trade_id(self, order_id: int, timestamp: float, fill_base_amount: Decimal, fill_price: Decimal) -> str: + """ + Generate a trade ID for the given order ID, timestamp, base amount, and price + + :param order_id: the order ID + :param timestamp: the timestamp of the trade (in seconds) + :param fill_base_amount: the base amount of the trade + :param fill_price: the price of the trade + + :return: the trade ID + """ + return f"{order_id}_{timestamp}_{fill_base_amount}_{fill_price}" + + def round_amount(self, trading_pair: str, amount: Decimal) -> Decimal: + """ + Round the given amount to the lot size defined in the trading rules for the given symbol + Sample lot size is 0.001 + + :param trading_pair: the trading pair + :param amount: the amount to round + + :return: the rounded amount + """ + return amount.quantize(self._trading_rules[trading_pair].min_base_amount_increment) + + def round_fee(self, fee_amount: Decimal) -> Decimal: + """ + Round the given fee amount to the lot size defined in the trading rules for the given symbol + + :param fee_amount: the fee amount to round + + :return: the rounded fee amount + """ + return round(fee_amount, 6) + + async def start_network(self): + self._last_private_account_event_timestamp = 0.0 + await self._fetch_or_create_api_config_key() + # status polling is already started in super().start_network() -> _status_polling_loop() + # _update_balances is called first to ensure fee tier and rate limits are configured before the periodic loops start. + await self._update_balances() + + # super().start_network() calls restore_tracking_states() which re-populates the order tracker + # with orders from the previous session. We must call it first so that we only cancel the + # bot-tracked stale orders and NOT any manually-placed orders on the exchange. + await super().start_network() + + # Warm up positions at startup so status/strategy reflects existing exposure immediately + # after a restart (before the first periodic position poll/user-stream delta arrives). + if self._trading_required and self._trading_pairs: + try: + await self._update_positions() + except Exception as ex: + self.logger().warning(f"[start_network] initial position sync error (non-fatal): {ex}") + + # Cancel only tracked stale orders from the previous session (not user-placed orders). + # This avoids wiping manual orders while still cleaning up bot orders that survived a crash. + if self._trading_required and self._trading_pairs: + try: + await self._cancel_tracked_stale_orders() + except Exception as ex: + self.logger().warning(f"[start_network] stale order cleanup error (non-fatal): {ex}") + + # Refresh balances once more after the stale-order cleanup so the strategy starts with an + # accurate view of available margin (stale orders may have freed up collateral). + await self._update_balances() + + # Refresh positions once more after stale-order cleanup for an up-to-date startup snapshot. + if self._trading_required and self._trading_pairs: + try: + await self._update_positions() + except Exception as ex: + self.logger().warning(f"[start_network] post-cleanup position sync error (non-fatal): {ex}") + + async def stop_network(self): + self._last_private_account_event_timestamp = 0.0 + # If any in-flight orders are still awaiting exchange confirmation (exchange_order_id + # is None), briefly wait so they land on the exchange before we sweep. This prevents + # orders placed within ~3 s of "stop" from being silently abandoned on the exchange. + pending = [o for o in self.in_flight_orders.values() if o.exchange_order_id is None] + if pending: + self.logger().info( + "[stop_network] Waiting up to 3 s for %d in-flight order(s) to be confirmed " + "before final cancel sweep.", + len(pending), + ) + await asyncio.sleep(3.0) + try: + await self._cancel_tracked_orders_on_stop() + except Exception as ex: + self.logger().warning(f"[stop_network] Exchange order sweep error (non-fatal): {ex}") + await super().stop_network() + + async def _cancel_tracked_orders_on_stop(self) -> int: + """Cancel only currently tracked bot orders during shutdown. + + Never performs an exchange-wide sweep, so manually created orders are preserved. + """ + tracked_orders = list(self.in_flight_orders.values()) + if not tracked_orders: + return 0 + + canceled_count = 0 + for order in tracked_orders: + try: + cancelled_client_order_id = await self._execute_order_cancel(order) + if cancelled_client_order_id is not None: + canceled_count += 1 + except asyncio.CancelledError: + raise + except Exception as ex: + self.logger().warning( + "[_cancel_tracked_orders_on_stop] Failed cancel for %s: %s", + order.client_order_id, + ex, + ) + + if canceled_count > 0: + self.logger().info( + "[stop_network] canceled %d tracked bot orders before shutdown.", + canceled_count, + ) + + return canceled_count + + async def _place_modify( + self, + tracked_order, + amount: Decimal, + price: Decimal, + ) -> bool: + """Modify an existing order via the lighter signer client. + + :param tracked_order: the InFlightOrder (or compatible SimpleNamespace) to modify + :param amount: new base amount + :param price: new price + :return: True if modify succeeded + :raises IOError: if the signing/send operation fails + """ + market_id, size_decimals, price_decimals, _ = await self._get_market_spec(tracked_order.trading_pair) + signer_client = self._get_lighter_signer_client() + + base_amount_int = int(amount * Decimal(10 ** size_decimals)) + price_int = int(price * Decimal(10 ** price_decimals)) + + _, response_obj, error = await signer_client.modify_order( + market_index=market_id, + order_index=int(tracked_order.exchange_order_id), + base_amount=base_amount_int, + price=price_int, + ) + + if error is not None: + raise IOError(f"modify_order signing/send failed: {error}") + + return True + + async def _cancel_tracked_stale_orders(self) -> int: + """ + Cancel only orders that are tracked in the local order tracker (restored from the previous + session via restore_tracking_states). This is the startup sweep — it cleans up bot-placed + orders from a crashed or stopped previous session WITHOUT cancelling any orders the user + placed manually on the exchange. + + Returns the number of orders successfully cancelled. + """ + stale_orders = list(self._order_tracker.all_updatable_orders.values()) + if not stale_orders: + return 0 + + canceled_count = 0 + now = self.current_timestamp + signer_client = self._get_lighter_signer_client() + + for stale_order in stale_orders: + exchange_order_id = stale_order.exchange_order_id + if not exchange_order_id: + # Order wasn't confirmed by the exchange — mark cancelled locally, nothing to cancel on exchange + self._order_tracker.process_order_update(OrderUpdate( + trading_pair=stale_order.trading_pair, + update_timestamp=now, + new_state=OrderState.CANCELED, + client_order_id=stale_order.client_order_id, + exchange_order_id=None, + )) + continue + + is_confirmed_terminal = False + + try: + market_id, _, _, _ = await self._get_market_spec(stale_order.trading_pair) + except Exception: + market_id = None + + if market_id is None: + self.logger().warning( + "[_cancel_tracked_stale_orders] Cannot resolve market for %s, skipping", + stale_order.client_order_id, + ) + continue + + try: + async with self._signer_request_lock: + _, _, error = await signer_client.cancel_order( + market_index=int(market_id), + order_index=int(exchange_order_id), + api_key_index=self._get_api_key_index(), + ) + if error is None: + canceled_count += 1 + is_confirmed_terminal = True + self.logger().info( + "[start_network] Canceled stale bot order %s (exchange_id=%s)", + stale_order.client_order_id, + exchange_order_id, + ) + else: + # Do not drop local tracking blindly. Reconcile state below. + self.logger().debug( + "[_cancel_tracked_stale_orders] cancel_order error for %s: %s", + exchange_order_id, error, + ) + except Exception as ex: + self.logger().warning( + "[_cancel_tracked_stale_orders] Exception cancelling %s: %s", + exchange_order_id, ex, + ) + + if not is_confirmed_terminal: + try: + reconciled_update = await self._request_order_status(stale_order) + self._order_tracker.process_order_update(reconciled_update) + is_confirmed_terminal = reconciled_update.new_state in { + OrderState.CANCELED, + OrderState.FILLED, + OrderState.FAILED, + } + except Exception as reconcile_ex: + self.logger().warning( + "[_cancel_tracked_stale_orders] Could not reconcile stale order %s status after cancel error: %s", + stale_order.client_order_id, + reconcile_ex, + ) + + if is_confirmed_terminal: + self._order_tracker.process_order_update(OrderUpdate( + trading_pair=stale_order.trading_pair, + update_timestamp=now, + new_state=OrderState.CANCELED, + client_order_id=stale_order.client_order_id, + exchange_order_id=exchange_order_id, + )) + else: + self.logger().warning( + "[_cancel_tracked_stale_orders] Keeping stale order %s tracked because terminal state could not be confirmed.", + stale_order.client_order_id, + ) + + if canceled_count > 0: + self.logger().info( + "[start_network] startup sweep canceled %d tracked stale orders", canceled_count + ) + return canceled_count + + async def _cancel_all_exchange_active_orders(self) -> int: + """ + Cancel all currently active exchange orders for configured trading pairs. + This is a safety net for orders not tracked in local in-flight state. + """ + if not self._trading_pairs: + return 0 + + signer_client = self._get_lighter_signer_client() + active_orders_by_id: Dict[str, int] = {} + + for trading_pair in self._trading_pairs: + try: + market_id, _, _, _ = await self._get_market_spec(trading_pair) + signer_client = self._get_lighter_signer_client() + auth_token, _auth_err = signer_client.create_auth_token_with_expiry( + api_key_index=self._get_api_key_index() + ) + params: Dict[str, Any] = { + "account_index": self._get_account_index(), + "market_id": market_id, + "limit": 200, + "auth": auth_token or "", + } + while True: + response = await self._api_get( + path_url="/accountActiveOrders", + params=params, + is_auth_required=True, + return_err=True, + ) + if not self._is_ok_response(response): + self.logger().warning( + f"[_cancel_all_exchange_active_orders] Failed fetching active orders for {trading_pair}: {response}" + ) + break + + rows = response.get("data") or response.get("orders") or [] + for row in rows: + order_id = str(row.get("order_id") or row.get("order_index") or row.get("i") or "") + if order_id: + active_orders_by_id[order_id] = market_id + + if response.get("has_more") and response.get("next_cursor"): + params["cursor"] = response["next_cursor"] + else: + break + except Exception as ex: + self.logger().warning( + f"[_cancel_all_exchange_active_orders] Error fetching active orders for {trading_pair}: {ex}" + ) + + # Reset the signer client once so all cancels use a fresh nonce sequence. + # This prevents stale-nonce (21104) failures when the bot has been running + # for a long time before the stop command is issued. + if active_orders_by_id: + signer_client = await self._refresh_signer_client_async() + + canceled_count = 0 + for order_id, market_id in active_orders_by_id.items(): + try: + _, _, error = await signer_client.cancel_order( + market_index=int(market_id), + order_index=int(order_id), + api_key_index=self._get_api_key_index(), + ) + if error is None: + canceled_count += 1 + else: + self.logger().warning( + f"[_cancel_all_exchange_active_orders] Failed to cancel order {order_id}: {error}" + ) + except Exception as ex: + self.logger().warning( + f"[_cancel_all_exchange_active_orders] Exception cancelling order {order_id}: {ex}" + ) + + return canceled_count + + async def _resolve_order_index_from_active_orders( + self, + market_id: int, + client_order_index: str, + max_pages: int = 5, + ) -> Optional[str]: + """Query /accountActiveOrders to resolve client_order_index -> actual order_index. + + Returns the exchange-assigned order_index string, or None if not found. + Also populates self._client_order_index_to_order_index as a side-effect. + """ + try: + cached_rows = self._active_orders_snapshot_by_market.get(market_id) + if cached_rows is not None: + self._index_client_to_order_mapping_from_rows(cached_rows) + for row in cached_rows: + row_oid = str(row.get("order_id") or row.get("order_index") or row.get("i") or "") + row_cid = str(row.get("client_order_id") or row.get("client_order_index") or row.get("I") or "") + if row_oid and (row_cid == client_order_index or row_oid == client_order_index): + return row_oid + if self._status_poll_cycle_active and market_id in self._active_orders_snapshot_market_complete: + return None + + rows = await self._fetch_active_orders_rows_for_market(market_id=market_id, max_pages=max_pages) + if self._status_poll_cycle_active: + self._active_orders_snapshot_by_market[market_id] = rows + self._active_orders_snapshot_market_complete.add(market_id) + + self._index_client_to_order_mapping_from_rows(rows) + for row in rows: + row_oid = str(row.get("order_id") or row.get("order_index") or row.get("i") or "") + row_cid = str(row.get("client_order_id") or row.get("client_order_index") or row.get("I") or "") + if row_oid and (row_cid == client_order_index or row_oid == client_order_index): + return row_oid + except Exception as ex: + self.logger().warning(f"[_resolve_order_index_from_active_orders] Error: {ex}") + return None + + async def _refresh_signer_nonce(self) -> None: + """Fetch fresh nonce from /nextNonce and update the signer client state. + + Called on 21104 (stale nonce) errors to resynchronise with the exchange. + """ + try: + response = await self._api_get( + path_url=CONSTANTS.GET_NEXT_NONCE_PATH_URL, + params={ + "account_index": self._get_account_index(), + "api_key_index": self._get_api_key_index(), + }, + return_err=True, + ) + nonce = response.get("nonce") or response.get("next_nonce") + if nonce is not None: + new_base = int(nonce) * self._CLIENT_ORDER_INDEX_TIME_MULTIPLIER + if new_base > self._last_client_order_index: + self._last_client_order_index = new_base + self.logger().debug(f"[_refresh_signer_nonce] Synced client_order_index base to {new_base}") + except Exception as ex: + self.logger().warning(f"[_refresh_signer_nonce] Failed: {ex}") + + async def _process_account_all_orders_ws_event_message(self, event_message: Dict[str, Any]) -> None: + """Process orders from an account_all or account_all_orders WS event. + + The account_all channel sends: + "orders": { "{MARKET_INDEX}": [ Order, ... ], ... } (dict keyed by market_id) + The account_all_orders channel sends (snapshot and incremental): + "orders": [ Order, ... ] (flat list) + "order": { Order } (single object) + where every Order has both order_index (exchange-assigned) and client_order_index (ours). + We use this to populate _client_order_index_to_order_index and emit OrderUpdates. + """ + # Collect all order entries from any payload variant. + all_order_entries = [] + payload = event_message.get("data") + + for _src in (event_message, payload if isinstance(payload, dict) else {}): + orders_field = _src.get("orders") + if isinstance(orders_field, list): + all_order_entries.extend([o for o in orders_field if isinstance(o, dict)]) + elif isinstance(orders_field, dict): + # Dict keyed by market_id — values may be a list or single order + for market_val in orders_field.values(): + if isinstance(market_val, list): + all_order_entries.extend([o for o in market_val if isinstance(o, dict)]) + elif isinstance(market_val, dict): + all_order_entries.append(market_val) + # Single order object + order_field = _src.get("order") + if isinstance(order_field, dict): + all_order_entries.append(order_field) + + for order_entry in all_order_entries: + order_index = str(order_entry.get("order_index") or order_entry.get("order_id") or order_entry.get("i") or "") + client_index = str(order_entry.get("client_order_index") or order_entry.get("client_order_id") or order_entry.get("I") or "") + if order_index and client_index: + self._client_order_index_to_order_index[client_index] = order_index + + # Find the matching tracked order using O(1) map first, then fallback scan. + tracked_order = None + if client_index and client_index in self._client_order_index_to_client_order_id: + coid = self._client_order_index_to_client_order_id[client_index] + tracked_order = self._order_tracker.all_updatable_orders.get(coid) + if tracked_order is None: + for candidate in list(self._order_tracker.all_updatable_orders.values()): + tracked_eid = str(candidate.exchange_order_id) + if tracked_eid == client_index or tracked_eid == order_index: + tracked_order = candidate + break + + if tracked_order is None: + continue + + # Support both "order_status" (new) and "status" (old) field names. + raw_status = str( + order_entry.get("order_status") or order_entry.get("status") or "open" + ).replace("-", "_") + order_status = CONSTANTS.ORDER_STATE.get(raw_status) + if order_status is None: + continue + + # Guard against terminal-state regressions from delayed WS echoes. + # account_all_orders can replay snapshot rows after we already processed a FILLED. + if tracked_order.current_state == OrderState.FILLED and order_status == OrderState.CANCELED: + self.logger().debug( + "Ignoring stale canceled WS order update for already-filled order %s (channel=account_all_orders)", + tracked_order.client_order_id, + ) + continue + + resolved_eid = order_index if order_index else str(tracked_order.exchange_order_id) + ts_raw = order_entry.get("updated_at") or order_entry.get("timestamp") or 0 + order_update = OrderUpdate( + trading_pair=tracked_order.trading_pair, + update_timestamp=float(ts_raw) / 1000 if ts_raw > 1_000_000_000_000 else float(ts_raw), + new_state=order_status, + client_order_id=tracked_order.client_order_id, + exchange_order_id=resolved_eid, + ) + + # ── False-cancel guard ──────────────────────────────────────────────────── + # account_all_orders delivers a full history snapshot on subscription. On WS + # reconnect, old CANCELED orders can replay and coincidentally match a newly + # tracked order — firing a false CANCELED within milliseconds of placement. + # A real cancel TX takes ~29 s on-chain, so any CANCELED arriving within + # _CANCEL_MIN_ORDER_AGE_SECS is almost certainly a snapshot replay. + if order_status == OrderState.CANCELED: + _order_age = time.time() - float(tracked_order.creation_timestamp or 0) + if _order_age < self._CANCEL_MIN_ORDER_AGE_SECS: + self.logger().debug( + "[ws-cancel guard] Suppressing CANCELED WS event for %s " + "(age=%.2fs < %.0fs — likely subscription snapshot replay). " + "Scheduling REST verification.", + tracked_order.client_order_id, + _order_age, + self._CANCEL_MIN_ORDER_AGE_SECS, + ) + safe_ensure_future(self._verify_cancel_not_false(tracked_order)) + continue # Do NOT pass this CANCELED event to process_order_update + # ── End false-cancel guard ──────────────────────────────────────────────── + + self._order_tracker.process_order_update(order_update) + + # For terminal updates, eagerly refresh balances to avoid stale available margin + # during OPEN/CLOSE transitions; refresh positions only when snapshot can change. + is_terminal = order_status in (OrderState.FILLED, OrderState.CANCELED, OrderState.FAILED) + is_close = getattr(tracked_order, "position", None) == PositionAction.CLOSE + is_partial_open_cancel = ( + order_status == OrderState.CANCELED + and getattr(tracked_order, "position", None) == PositionAction.OPEN + and tracked_order.executed_amount_base > s_decimal_0 + ) + if is_terminal: + await self._refresh_account_state( + reason=f"account_all order update {raw_status} ({tracked_order.client_order_id})", + refresh_positions=(is_close or is_partial_open_cancel), + refresh_balances=True, + ) + + # For terminal fills arriving from the account_all_orders dedicated channel, + # eagerly fetch fills to ensure TradeUpdate is emitted even if account_trades is delayed. + if is_terminal and order_status == OrderState.FILLED: + await self._fetch_and_apply_fills(tracked_order) + + async def get_all_pairs_prices(self) -> List[Dict[str, Any]]: + """ + Retrieves the prices (mark price) for all trading pairs. + Required for Rate Oracle support. + + https://docs.lighter.fi/api-documentation/api/rest-api/markets/get-prices + Prices Info + ``` + { + "success": true, + "data": [ + { + "funding": "0.00010529", + "mark": "1.084819", + "mid": "1.08615", + "next_funding": "0.00011096", + "open_interest": "3634796", + "oracle": "1.084524", + "symbol": "XPL", + "timestamp": 1759222967974, + "volume_24h": "20896698.0672", + "yesterday_price": "1.3412" + } + ], + "error": null, + "code": null + } + ``` + + Sample output: + ``` + [ + { + "symbol": "XPL", + "price": "1.084819" + }, + ] + ``` + + :return: A list of dictionaries containing symbol and a price + """ + response = await self._api_get( + path_url=CONSTANTS.GET_PRICES_PATH_URL, + return_err=True, + ) + + if not response.get("success") is True: + self.logger().error(f"[get_all_pairs_prices] Failed to fetch all pairs prices: {response}") + return [] + + results = [] + for price_data in response.get("data", []): + results.append({ + "trading_pair": await self.trading_pair_associated_to_exchange_symbol(symbol=price_data["symbol"]), + "price": price_data["mark"] + }) + + return results diff --git a/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_user_stream_data_source.py b/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_user_stream_data_source.py new file mode 100644 index 00000000000..b4863c4e554 --- /dev/null +++ b/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_user_stream_data_source.py @@ -0,0 +1,218 @@ +import asyncio +import time +from typing import TYPE_CHECKING, Any, Dict, Optional + +from hummingbot.connector.derivative.lighter_perpetual import ( + lighter_perpetual_constants as CONSTANTS, + lighter_perpetual_web_utils as web_utils, +) +from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_auth import LighterPerpetualAuth +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.core.web_assistant.connections.data_types import WSJSONRequest, WSResponse +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory +from hummingbot.core.web_assistant.ws_assistant import WSAssistant +from hummingbot.logger import HummingbotLogger + +if TYPE_CHECKING: + from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative import ( + LighterPerpetualDerivative, + ) + + +class LighterPerpetualUserStreamDataSource(UserStreamTrackerDataSource): + _logger: Optional[HummingbotLogger] = None + + def __init__( + self, + connector: "LighterPerpetualDerivative", + api_factory: WebAssistantsFactory, + auth: LighterPerpetualAuth, + domain: str = CONSTANTS.DEFAULT_DOMAIN, + ): + super().__init__() + self._connector = connector + self._api_factory = api_factory + self._auth = auth + self._domain = domain + self._ping_task: Optional[asyncio.Task] = None + self._last_listen_error_log_ts: float = 0.0 + self._has_logged_subscription_info: bool = False + + async def listen_for_user_stream(self, output: asyncio.Queue): + """Override base loop to throttle repeated reconnect exception logs.""" + while True: + try: + self._ws_assistant = await self._connected_websocket_assistant() + await self._subscribe_channels(websocket_assistant=self._ws_assistant) + await self._send_ping(websocket_assistant=self._ws_assistant) + await self._process_websocket_messages(websocket_assistant=self._ws_assistant, queue=output) + except asyncio.CancelledError: + raise + except ConnectionError as connection_exception: + close_message = str(connection_exception) + if "close code = 1000" in close_message.lower(): + self.logger().debug(f"The websocket connection was closed ({connection_exception})") + else: + self.logger().warning(f"The websocket connection was closed ({connection_exception})") + except Exception as ex: + now = time.time() + if now - self._last_listen_error_log_ts >= 30.0: + self._last_listen_error_log_ts = now + self.logger().exception("Unexpected error while listening to user stream. Retrying after 5 seconds...") + else: + self.logger().debug( + "Suppressing repeated user stream listener error during reconnect storm: %s", + ex, + ) + await self._sleep(2.0) + finally: + await self._on_user_stream_interruption(websocket_assistant=self._ws_assistant) + self._ws_assistant = None + + async def _connected_websocket_assistant(self) -> WSAssistant: + ws: WSAssistant = await self._api_factory.get_ws_assistant() + + ws_headers = {} + if self._connector.rest_api_key: + ws_headers["X-Api-Key"] = self._connector.rest_api_key + + await ws.connect(ws_url=web_utils.wss_url(self._domain), ws_headers=ws_headers) + self._ping_task = safe_ensure_future(self._ping_loop(ws)) + return ws + + async def _subscribe_channels(self, websocket_assistant: WSAssistant) -> None: + try: + response: Optional[WSResponse] = await websocket_assistant.receive() + message: Dict[str, Any] = response.data if response is not None else {} + if message.get("type") != "connected": + raise IOError("Private websocket connection did not acknowledge the session") + + # Some environments emit private events for different account identifiers + # (account index, wallet/public key, api key index). Subscribe to all known + # candidates and both delimiter styles to avoid missing manual exchange updates. + account_identifiers = { + str(self._auth.user_wallet_public_key), + str(getattr(self._connector, "account_index", "") or ""), + str(getattr(self._connector, "api_key_index", "") or ""), + } + account_identifiers.discard("") + + channels = ( + CONSTANTS.WS_ACCOUNT_ALL_CHANNEL, + CONSTANTS.WS_ACCOUNT_ORDER_UPDATES_CHANNEL, + CONSTANTS.WS_ACCOUNT_POSITIONS_CHANNEL, + CONSTANTS.WS_ACCOUNT_TRADES_CHANNEL, + CONSTANTS.WS_ACCOUNT_INFO_CHANNEL, + CONSTANTS.WS_USER_STATS_CHANNEL, + ) + + sent_channels = set() + for account_identifier in account_identifiers: + for channel_const in channels: + for channel in (f"{channel_const}/{account_identifier}", f"{channel_const}:{account_identifier}"): + if channel in sent_channels: + continue + await websocket_assistant.send(WSJSONRequest({ + "type": "subscribe", + "channel": channel, + })) + sent_channels.add(channel) + + # account_all_orders requires numeric account_index + auth token (per Lighter WS API docs). + # It delivers full Order JSON with client_order_index always populated — critical for + # establishing the client_order_index → order_id mapping used by fill matching. + account_index_str = str(getattr(self._connector, "_account_index", "") or "").strip() + if account_index_str: + auth_token = "" + try: + auth_params = self._connector._build_account_auth_params() + auth_token = str(auth_params.get("auth") or "") + except Exception: + pass + for channel in ( + f"{CONSTANTS.WS_ACCOUNT_ALL_ORDERS_CHANNEL}/{account_index_str}", + f"{CONSTANTS.WS_ACCOUNT_ALL_ORDERS_CHANNEL}:{account_index_str}", + ): + if channel in sent_channels: + continue + payload = {"type": "subscribe", "channel": channel} + if auth_token: + payload["auth"] = auth_token + await websocket_assistant.send(WSJSONRequest(payload)) + sent_channels.add(channel) + + log_method = self.logger().debug + log_method( + "Subscribed to private account channels for identifiers=%s (%d subscriptions)", + sorted(account_identifiers), + len(sent_channels), + ) + self._has_logged_subscription_info = True + except asyncio.CancelledError: + raise + except Exception: + self.logger().exception("Unexpected error occurred subscribing to order book trading and delta streams") + raise + + async def _process_websocket_messages(self, websocket_assistant: WSAssistant, queue: asyncio.Queue): + async for ws_response in websocket_assistant.iter_messages(): + data = ws_response.data + if data.get("type") == "ping": + await websocket_assistant.send(WSJSONRequest(payload={"type": "pong"})) + continue + await self._process_event_message(event_message=data, queue=queue) + + _ACCEPTED_CHANNEL_PREFIXES = ( + "account_all", + "account_all_orders", + "account_order_updates", + "account_positions", + "account_trades", + "account_info", + "user_stats", + ) + + async def _process_event_message(self, event_message: Dict[str, Any], queue: asyncio.Queue): + if event_message.get("error") is not None: + err_msg = event_message.get("error", {}).get("message", event_message.get("error")) + if "invalid channel" in str(err_msg).lower(): + self.logger().debug("Ignoring late 'Invalid Channel' response from server: %s", err_msg) + return + raise IOError({ + "label": "WSS_ERROR", + "message": f"Error received via websocket - {err_msg}.", + }) + + message_type = str(event_message.get("type", "")) + channel = str(event_message.get("channel", "")) + event_type_name = message_type.split("/", 1)[1] if "/" in message_type else message_type + # Forward account_all messages (subscribed/update variants) AND all dedicated channel messages. + if ( + event_type_name in self._ACCEPTED_CHANNEL_PREFIXES + or "account_all" in message_type + or any(channel.startswith(f"{prefix}/") or channel.startswith(f"{prefix}:") for prefix in self._ACCEPTED_CHANNEL_PREFIXES) + or channel in self._ACCEPTED_CHANNEL_PREFIXES + ): + queue.put_nowait(event_message) + + async def _on_user_stream_interruption(self, websocket_assistant: Optional[WSAssistant]): + await super()._on_user_stream_interruption(websocket_assistant) + if self._ping_task is not None: + self._ping_task.cancel() + self._ping_task = None + + async def _ping_loop(self, ws: WSAssistant): + while True: + try: + await asyncio.sleep(CONSTANTS.WS_PING_INTERVAL) + await ws.send(WSJSONRequest(payload={"type": "ping"})) + except asyncio.CancelledError: + raise + except RuntimeError as e: + if "WS is not connected" in str(e): + return + raise + except Exception: + self.logger().warning("Error sending ping to LIGHTER WebSocket", exc_info=True) + await asyncio.sleep(5.0) diff --git a/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_utils.py b/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_utils.py new file mode 100644 index 00000000000..bfe7d31a5b0 --- /dev/null +++ b/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_utils.py @@ -0,0 +1,262 @@ +from decimal import Decimal +from typing import Any + +from pydantic import AliasChoices, ConfigDict, Field, SecretStr, field_validator, model_validator + +from hummingbot.client.config.config_data_types import BaseConnectorConfigMap +from hummingbot.core.data_type.trade_fee import TradeFeeSchema + +CENTRALIZED = True +EXAMPLE_PAIR = "BTC-USDC" + +# Default fee values aligned with the currently observed base tier. +DEFAULT_FEES = TradeFeeSchema( + maker_percent_fee_decimal=Decimal("0.00015"), + taker_percent_fee_decimal=Decimal("0.0004"), +) + + +class LighterPerpetualConfigMap(BaseConnectorConfigMap): + connector: str = "lighter_perpetual" + + lighter_perpetual_api_key_index: SecretStr = Field( + default=..., + validation_alias=AliasChoices( + "lighter_perpetual_api_key_index", + "lighter_perpetual_api_secret", + "lighter_api_secret", + "lighter_api_key_index", + ), + json_schema_extra={ + "prompt": "Enter your API Key Index", + "is_secure": True, + "is_connect_key": True, + "prompt_on_new": True, + }, + ) + + lighter_perpetual_account_index: SecretStr = Field( + default=..., + validation_alias=AliasChoices( + "lighter_perpetual_account_index", + "lighter_account_index", + ), + json_schema_extra={ + "prompt": "Enter your Account Index", + "is_secure": True, + "is_connect_key": True, + "prompt_on_new": True, + }, + ) + + lighter_perpetual_api_key_private_key: SecretStr = Field( + default=..., + validation_alias=AliasChoices( + "lighter_perpetual_api_key_private_key", + "lighter_perpetual_api_key", + "lighter_api_key", + ), + json_schema_extra={ + "prompt": "Enter your Private Key", + "is_secure": True, + "is_connect_key": True, + "prompt_on_new": True, + }, + ) + + @staticmethod + def _is_encrypted_blob(raw: str) -> bool: + return len(raw) > 64 and all(c in "0123456789abcdefABCDEF" for c in raw) + + @staticmethod + def _is_hex_key(raw: str) -> bool: + key = raw[2:] if raw.lower().startswith("0x") else raw + return len(key) >= 64 and all(c in "0123456789abcdefABCDEF" for c in key) + + @field_validator("lighter_perpetual_api_key_index", mode="before") + @classmethod + def validate_api_key_index(cls, v: Any) -> Any: + raw = v.get_secret_value() if hasattr(v, "get_secret_value") else str(v) + if raw == "": + return v + if cls._is_encrypted_blob(raw): + return v + try: + int(raw) + except (ValueError, TypeError): + raise ValueError( + f"Lighter API key index must be an integer (e.g. 4), got: {raw!r}. " + "Find your key index on the Lighter exchange API keys page." + ) + return v + + @field_validator("lighter_perpetual_account_index", mode="before") + @classmethod + def validate_account_index(cls, v: Any) -> Any: + raw = v.get_secret_value() if hasattr(v, "get_secret_value") else str(v) + if raw == "": + return v + if cls._is_encrypted_blob(raw): + return v + try: + int(raw) + except (ValueError, TypeError): + raise ValueError( + f"Lighter account index must be an integer (e.g. 693751), got: {raw!r}. " + "Find your account index on the Lighter exchange account page." + ) + return v + + @field_validator("lighter_perpetual_api_key_private_key", mode="before") + @classmethod + def validate_api_key(cls, v: Any) -> Any: + raw = v.get_secret_value() if hasattr(v, "get_secret_value") else str(v) + if raw == "": + return v + if cls._is_encrypted_blob(raw): + return v + if not cls._is_hex_key(raw): + raise ValueError( + "Lighter API Private Key must be a hex string (64+ characters, with or without 0x prefix). " + "Copy it from the Lighter exchange API keys page." + ) + return v + + @model_validator(mode="before") + @classmethod + def migrate_legacy_fields(cls, data): + """Discard removed fields from saved YAML configs.""" + if not isinstance(data, dict): + return data + data.pop("lighter_perpetual_api_key_public_key", None) + return data + + model_config = ConfigDict(title="lighter_perpetual") + + +KEYS = LighterPerpetualConfigMap.model_construct() + +OTHER_DOMAINS = ["lighter_perpetual_testnet"] +OTHER_DOMAINS_PARAMETER = {"lighter_perpetual_testnet": "lighter_perpetual_testnet"} +OTHER_DOMAINS_EXAMPLE_PAIR = {"lighter_perpetual_testnet": "BTC-USDC"} +OTHER_DOMAINS_DEFAULT_FEES = {"lighter_perpetual_testnet": [0.00015, 0.0004]} + + +class LighterPerpetualTestnetConfigMap(BaseConnectorConfigMap): + connector: str = "lighter_perpetual_testnet" + + lighter_perpetual_testnet_api_key_index: SecretStr = Field( + default=..., + validation_alias=AliasChoices( + "lighter_perpetual_testnet_api_key_index", + "lighter_perpetual_testnet_api_secret", + "lighter_testnet_api_secret", + "lighter_testnet_api_key_index", + ), + json_schema_extra={ + "prompt": "Enter your API Key Index", + "is_secure": True, + "is_connect_key": True, + "prompt_on_new": True, + }, + ) + + lighter_perpetual_testnet_account_index: SecretStr = Field( + default=..., + validation_alias=AliasChoices( + "lighter_perpetual_testnet_account_index", + "lighter_testnet_account_index", + ), + json_schema_extra={ + "prompt": "Enter your Account Index", + "is_secure": True, + "is_connect_key": True, + "prompt_on_new": True, + }, + ) + + lighter_perpetual_testnet_api_key_private_key: SecretStr = Field( + default=..., + validation_alias=AliasChoices( + "lighter_perpetual_testnet_api_key_private_key", + "lighter_perpetual_testnet_api_key", + "lighter_testnet_api_key", + ), + json_schema_extra={ + "prompt": "Enter your Private Key", + "is_secure": True, + "is_connect_key": True, + "prompt_on_new": True, + }, + ) + + model_config = ConfigDict(title="lighter_perpetual_testnet") + + @staticmethod + def _is_encrypted_blob(raw: str) -> bool: + return len(raw) > 64 and all(c in "0123456789abcdefABCDEF" for c in raw) + + @staticmethod + def _is_hex_key(raw: str) -> bool: + key = raw[2:] if raw.lower().startswith("0x") else raw + return len(key) >= 64 and all(c in "0123456789abcdefABCDEF" for c in key) + + @field_validator("lighter_perpetual_testnet_api_key_index", mode="before") + @classmethod + def validate_testnet_api_key_index(cls, v: Any) -> Any: + raw = v.get_secret_value() if hasattr(v, "get_secret_value") else str(v) + if raw == "": + return v + if cls._is_encrypted_blob(raw): + return v + try: + int(raw) + except (ValueError, TypeError): + raise ValueError( + f"Lighter API key index must be an integer (e.g. 4), got: {raw!r}." + ) + return v + + @field_validator("lighter_perpetual_testnet_account_index", mode="before") + @classmethod + def validate_testnet_account_index(cls, v: Any) -> Any: + raw = v.get_secret_value() if hasattr(v, "get_secret_value") else str(v) + if raw == "": + return v + if cls._is_encrypted_blob(raw): + return v + try: + int(raw) + except (ValueError, TypeError): + raise ValueError( + f"Lighter account index must be an integer (e.g. 693751), got: {raw!r}." + ) + return v + + @model_validator(mode="before") + @classmethod + def migrate_legacy_fields(cls, data): + """Discard removed fields from saved YAML configs.""" + if not isinstance(data, dict): + return data + data.pop("lighter_perpetual_testnet_api_key_public_key", None) + return data + + @field_validator("lighter_perpetual_testnet_api_key_private_key", mode="before") + @classmethod + def validate_testnet_api_key(cls, v: Any) -> Any: + raw = v.get_secret_value() if hasattr(v, "get_secret_value") else str(v) + if raw == "": + return v + if cls._is_encrypted_blob(raw): + return v + if not cls._is_hex_key(raw): + raise ValueError( + "Lighter API Private Key must be a hex string (64+ characters, with or without 0x prefix)." + ) + return v + + +OTHER_DOMAINS_KEYS = { + "lighter_perpetual_testnet": LighterPerpetualTestnetConfigMap.model_construct() +} diff --git a/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_web_utils.py b/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_web_utils.py new file mode 100644 index 00000000000..a39e34de4c9 --- /dev/null +++ b/hummingbot/connector/derivative/lighter_perpetual/lighter_perpetual_web_utils.py @@ -0,0 +1,39 @@ +import time +from typing import Optional + +from hummingbot.connector.derivative.lighter_perpetual import lighter_perpetual_constants as CONSTANTS +from hummingbot.core.api_throttler.async_throttler import AsyncThrottler +from hummingbot.core.web_assistant.auth import AuthBase +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory + + +def public_rest_url(path_url: str, domain: str = CONSTANTS.DEFAULT_DOMAIN) -> str: + base_url = CONSTANTS.REST_URL if domain == CONSTANTS.DEFAULT_DOMAIN else CONSTANTS.TESTNET_REST_URL + return base_url + path_url + + +def private_rest_url(path_url: str, domain: str = CONSTANTS.DEFAULT_DOMAIN) -> str: + return public_rest_url(path_url, domain) + + +def wss_url(domain: str = CONSTANTS.DEFAULT_DOMAIN) -> str: + return CONSTANTS.WSS_URL if domain == CONSTANTS.DEFAULT_DOMAIN else CONSTANTS.TESTNET_WSS_URL + + +def build_api_factory( + throttler: Optional[AsyncThrottler] = None, + auth: Optional[AuthBase] = None, +) -> WebAssistantsFactory: + throttler = throttler or AsyncThrottler(CONSTANTS.RATE_LIMITS) + api_factory = WebAssistantsFactory( + throttler=throttler, + auth=auth, + ) + return api_factory + + +async def get_current_server_time( + throttler: Optional[AsyncThrottler] = None, + domain: str = CONSTANTS.DEFAULT_DOMAIN, +) -> float: + return time.time() diff --git a/hummingbot/connector/exchange/lighter/__init__.py b/hummingbot/connector/exchange/lighter/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/hummingbot/connector/exchange/lighter/dummy.pxd b/hummingbot/connector/exchange/lighter/dummy.pxd new file mode 100644 index 00000000000..fd97152f76e --- /dev/null +++ b/hummingbot/connector/exchange/lighter/dummy.pxd @@ -0,0 +1,2 @@ +cdef class dummy(): + pass diff --git a/hummingbot/connector/exchange/lighter/dummy.pyx b/hummingbot/connector/exchange/lighter/dummy.pyx new file mode 100644 index 00000000000..fd97152f76e --- /dev/null +++ b/hummingbot/connector/exchange/lighter/dummy.pyx @@ -0,0 +1,2 @@ +cdef class dummy(): + pass diff --git a/hummingbot/connector/exchange/lighter/lighter_api_order_book_data_source.py b/hummingbot/connector/exchange/lighter/lighter_api_order_book_data_source.py new file mode 100644 index 00000000000..d47e9476c61 --- /dev/null +++ b/hummingbot/connector/exchange/lighter/lighter_api_order_book_data_source.py @@ -0,0 +1,283 @@ +import asyncio +import time +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from hummingbot.connector.exchange.lighter import lighter_constants as CONSTANTS, lighter_web_utils as web_utils +from hummingbot.connector.exchange.lighter.lighter_order_book import LighterOrderBook +from hummingbot.core.data_type.order_book_message import OrderBookMessage +from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource +from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.core.web_assistant.connections.data_types import RESTMethod, WSJSONRequest +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory +from hummingbot.core.web_assistant.ws_assistant import WSAssistant +from hummingbot.logger import HummingbotLogger + +if TYPE_CHECKING: + from hummingbot.connector.exchange.lighter.lighter_exchange import LighterExchange + + +class LighterAPIOrderBookDataSource(OrderBookTrackerDataSource): + _logger: Optional[HummingbotLogger] = None + + def __init__( + self, + trading_pairs: List[str], + connector: "LighterExchange", + api_factory: WebAssistantsFactory, + domain: str = CONSTANTS.DEFAULT_DOMAIN, + ): + super().__init__(trading_pairs) + self._connector = connector + self._api_factory = api_factory + self._domain = domain + self._market_id_to_trading_pair: Dict[int, str] = {} + self._ping_task: Optional[asyncio.Task] = None + self._last_listen_error_log_ts: float = 0.0 + + async def listen_for_subscriptions(self): + """Override base loop to throttle repeated reconnect exception logs.""" + ws: Optional[WSAssistant] = None + while True: + try: + ws = await self._connected_websocket_assistant() + self._ws_assistant = ws + await self._subscribe_channels(ws) + await self._process_websocket_messages(websocket_assistant=ws) + except asyncio.CancelledError: + raise + except ConnectionError as connection_exception: + close_message = str(connection_exception) + if "close code = 1000" in close_message.lower(): + self.logger().debug(f"The websocket connection was closed ({connection_exception})") + else: + self.logger().warning(f"The websocket connection was closed ({connection_exception})") + except Exception as ex: + now = time.time() + if now - self._last_listen_error_log_ts >= 30.0: + self._last_listen_error_log_ts = now + self.logger().exception( + "Unexpected error occurred when listening to order book streams. Retrying in 5 seconds...", + ) + else: + self.logger().debug( + "Suppressing repeated order book listener error during reconnect storm: %s", + ex, + ) + await self._sleep(2.0) + finally: + self._ws_assistant = None + await self._on_order_stream_interruption(websocket_assistant=ws) + + async def get_last_traded_prices(self, trading_pairs: List[str], domain: Optional[str] = None) -> Dict[str, float]: + return await self._connector.get_last_traded_prices(trading_pairs=trading_pairs) + + def _get_headers(self) -> Dict[str, str]: + headers = {} + if self._connector.rest_api_key: + headers["X-Api-Key"] = self._connector.rest_api_key + return headers + + def _get_public_headers(self) -> Dict[str, str]: + return {} + + async def _request_order_book_snapshot(self, trading_pair: str) -> Dict[str, Any]: + rest_assistant = await self._api_factory.get_rest_assistant() + market_id, _, _, _ = await self._connector._get_market_spec(trading_pair) + params = {"market_id": market_id, "limit": 250} + + response = await rest_assistant.execute_request( + url=web_utils.public_rest_url(path_url=CONSTANTS.GET_MARKET_ORDER_BOOK_SNAPSHOT_PATH_URL, domain=self._domain), + params=params, + method=RESTMethod.GET, + throttler_limit_id=CONSTANTS.GET_MARKET_ORDER_BOOK_SNAPSHOT_PATH_URL, + headers=self._get_public_headers(), + ) + + code = response.get("code") + is_success = response.get("success") is True + try: + is_success = is_success or int(code) == 200 + except Exception: + pass + + if not is_success: + raise ValueError(f"Failed to fetch order book snapshot for {trading_pair}: {response}") + + return response + + async def _order_book_snapshot(self, trading_pair: str) -> OrderBookMessage: + order_book_snapshot_data = await self._request_order_book_snapshot(trading_pair) + timestamp = time.time() + return LighterOrderBook.snapshot_message_from_exchange( + msg={ + "update_id": int(timestamp * 1000), + "bids": [(bid["price"], bid["remaining_base_amount"]) for bid in order_book_snapshot_data.get("bids", [])], + "asks": [(ask["price"], ask["remaining_base_amount"]) for ask in order_book_snapshot_data.get("asks", [])], + }, + metadata={"trading_pair": trading_pair}, + timestamp=timestamp, + ) + + async def _connected_websocket_assistant(self) -> WSAssistant: + ws: WSAssistant = await self._api_factory.get_ws_assistant() + await ws.connect(ws_url=web_utils.wss_url(self._domain), ws_headers=self._get_headers()) + self._ping_task = safe_ensure_future(self._ping_loop(ws)) + return ws + + async def _on_order_stream_interruption(self, websocket_assistant: Optional[WSAssistant] = None): + await super()._on_order_stream_interruption(websocket_assistant) + if self._ping_task is not None: + self._ping_task.cancel() + self._ping_task = None + + async def _ping_loop(self, ws: WSAssistant): + while True: + try: + await asyncio.sleep(CONSTANTS.WS_PING_INTERVAL) + await ws.send(WSJSONRequest(payload={"method": "ping"})) + except asyncio.CancelledError: + raise + except RuntimeError as e: + if "WS is not connected" in str(e): + return + raise + except Exception: + self.logger().warning("Error sending ping to Lighter WebSocket", exc_info=True) + await asyncio.sleep(5.0) + + async def _subscribe_channels(self, ws: WSAssistant): + for trading_pair in self._trading_pairs: + market_id, _, _, _ = await self._connector._get_market_spec(trading_pair) + self._market_id_to_trading_pair[market_id] = trading_pair + await ws.send(WSJSONRequest(payload={"type": "subscribe", "channel": f"order_book/{market_id}"})) + await ws.send(WSJSONRequest(payload={"type": "subscribe", "channel": f"trade/{market_id}"})) + + async def subscribe_to_trading_pair(self, trading_pair: str) -> bool: + if self._ws_assistant is None: + return False + + market_id, _, _, _ = await self._connector._get_market_spec(trading_pair) + self._market_id_to_trading_pair[market_id] = trading_pair + self.add_trading_pair(trading_pair) + + await self._ws_assistant.send(WSJSONRequest(payload={"type": "subscribe", "channel": f"order_book/{market_id}"})) + await self._ws_assistant.send(WSJSONRequest(payload={"type": "subscribe", "channel": f"trade/{market_id}"})) + return True + + async def unsubscribe_from_trading_pair(self, trading_pair: str) -> bool: + if self._ws_assistant is None: + return False + + market_id, _, _, _ = await self._connector._get_market_spec(trading_pair) + await self._ws_assistant.send(WSJSONRequest(payload={"type": "unsubscribe", "channel": f"order_book/{market_id}"})) + await self._ws_assistant.send(WSJSONRequest(payload={"type": "unsubscribe", "channel": f"trade/{market_id}"})) + self._market_id_to_trading_pair.pop(market_id, None) + self.remove_trading_pair(trading_pair) + return True + + @staticmethod + def _extract_market_id_from_channel(channel: str) -> Optional[int]: + """Accept both 'prefix:123' and 'prefix/123' channel formats.""" + if not channel: + return None + try: + if ":" in channel: + return int(channel.rsplit(":", 1)[1]) + if "/" in channel: + return int(channel.rsplit("/", 1)[1]) + except Exception: + return None + return None + + async def _parse_order_book_snapshot_message(self, raw_message: Dict[str, Any], message_queue: asyncio.Queue): + channel = str(raw_message.get("channel", "")) + market_id = self._extract_market_id_from_channel(channel) + if market_id is None: + return + trading_pair = self._market_id_to_trading_pair.get(market_id) + if trading_pair is None: + return + + order_book = raw_message.get("order_book") or {} + snapshot_timestamp = float(raw_message.get("timestamp") or raw_message.get("last_updated_at") or 0) / 1000 + update_id = int(order_book.get("nonce") or raw_message.get("nonce") or 0) + if update_id == 0: + update_id = int(raw_message.get("offset") or order_book.get("offset") or raw_message.get("last_updated_at") or 0) + + snapshot_msg = LighterOrderBook.snapshot_message_from_exchange( + msg={ + "update_id": update_id, + "bids": [(bid["price"], bid["size"]) for bid in order_book.get("bids", [])], + "asks": [(ask["price"], ask["size"]) for ask in order_book.get("asks", [])], + }, + metadata={"trading_pair": trading_pair}, + timestamp=snapshot_timestamp, + ) + message_queue.put_nowait(snapshot_msg) + + async def _parse_trade_message(self, raw_message: Dict[str, Any], message_queue: asyncio.Queue): + channel = str(raw_message.get("channel", "")) + market_id = self._extract_market_id_from_channel(channel) + if market_id is None: + return + trading_pair = self._market_id_to_trading_pair.get(market_id) + if trading_pair is None: + return + + for trade_data in raw_message.get("trades", []): + trade_message = LighterOrderBook.trade_message_from_exchange( + msg={ + **trade_data, + "nonce": trade_data.get("nonce") or raw_message.get("nonce"), + }, + metadata={"trading_pair": trading_pair}, + timestamp=float(raw_message.get("timestamp") or 0) / 1000, + ) + message_queue.put_nowait(trade_message) + + async def _parse_order_book_diff_message(self, raw_message: Dict[str, Any], message_queue: asyncio.Queue): + channel = str(raw_message.get("channel", "")) + market_id = self._extract_market_id_from_channel(channel) + if market_id is None: + return + trading_pair = self._market_id_to_trading_pair.get(market_id) + if trading_pair is None: + return + + order_book = raw_message.get("order_book") or {} + update_id = int(order_book.get("nonce") or raw_message.get("nonce") or 0) + if update_id == 0: + update_id = int(raw_message.get("offset") or order_book.get("offset") or 0) + + diff_message = LighterOrderBook.diff_message_from_exchange( + msg={ + "update_id": update_id, + "first_update_id": int(order_book.get("begin_nonce") or update_id), + "bids": [(bid["price"], bid["size"]) for bid in order_book.get("bids", [])], + "asks": [(ask["price"], ask["size"]) for ask in order_book.get("asks", [])], + }, + metadata={"trading_pair": trading_pair}, + timestamp=float(raw_message.get("timestamp") or 0) / 1000, + ) + message_queue.put_nowait(diff_message) + + def _channel_originating_message(self, event_message: Dict[str, Any]) -> str: + if "channel" not in event_message: + return "" + event_channel = str(event_message.get("channel")) + event_type = str(event_message.get("type", "")) + if ( + event_channel.startswith(f"{CONSTANTS.WS_ORDER_BOOK_SNAPSHOT_CHANNEL}:") + or event_channel.startswith(f"{CONSTANTS.WS_ORDER_BOOK_SNAPSHOT_CHANNEL}/") + ): + if event_type in {"subscribed/order_book", "snapshot/order_book"}: + return self._snapshot_messages_queue_key + if event_type in {"update/order_book"}: + return self._diff_messages_queue_key + return self._snapshot_messages_queue_key + if ( + event_channel.startswith(f"{CONSTANTS.WS_TRADES_CHANNEL}:") + or event_channel.startswith(f"{CONSTANTS.WS_TRADES_CHANNEL}/") + ): + return self._trade_messages_queue_key + return "" diff --git a/hummingbot/connector/exchange/lighter/lighter_api_user_stream_data_source.py b/hummingbot/connector/exchange/lighter/lighter_api_user_stream_data_source.py new file mode 100644 index 00000000000..78a3f637bc8 --- /dev/null +++ b/hummingbot/connector/exchange/lighter/lighter_api_user_stream_data_source.py @@ -0,0 +1,185 @@ +import asyncio +import time +from typing import TYPE_CHECKING, Any, Dict, Optional + +from hummingbot.connector.exchange.lighter import lighter_constants as CONSTANTS, lighter_web_utils as web_utils +from hummingbot.connector.exchange.lighter.lighter_auth import LighterAuth +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.core.web_assistant.connections.data_types import WSJSONRequest, WSResponse +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory +from hummingbot.core.web_assistant.ws_assistant import WSAssistant +from hummingbot.logger import HummingbotLogger + +if TYPE_CHECKING: + from hummingbot.connector.exchange.lighter.lighter_exchange import LighterExchange + + +class LighterAPIUserStreamDataSource(UserStreamTrackerDataSource): + _logger: Optional[HummingbotLogger] = None + + def __init__( + self, + connector: "LighterExchange", + api_factory: WebAssistantsFactory, + auth: LighterAuth, + domain: str = CONSTANTS.DEFAULT_DOMAIN, + ): + super().__init__() + self._connector = connector + self._api_factory = api_factory + self._auth = auth + self._domain = domain + self._ping_task: Optional[asyncio.Task] = None + self._last_listen_error_log_ts: float = 0.0 + self._has_logged_subscription_info: bool = False + + async def listen_for_user_stream(self, output: asyncio.Queue): + """Override base loop to throttle repeated reconnect exception logs.""" + while True: + try: + self._ws_assistant = await self._connected_websocket_assistant() + await self._subscribe_channels(websocket_assistant=self._ws_assistant) + await self._send_ping(websocket_assistant=self._ws_assistant) + await self._process_websocket_messages(websocket_assistant=self._ws_assistant, queue=output) + except asyncio.CancelledError: + raise + except ConnectionError as connection_exception: + close_message = str(connection_exception) + if "close code = 1000" in close_message.lower(): + self.logger().debug(f"The websocket connection was closed ({connection_exception})") + else: + self.logger().warning(f"The websocket connection was closed ({connection_exception})") + except Exception as ex: + now = time.time() + if now - self._last_listen_error_log_ts >= 30.0: + self._last_listen_error_log_ts = now + self.logger().exception("Unexpected error while listening to user stream. Retrying after 5 seconds...") + else: + self.logger().debug( + "Suppressing repeated user stream listener error during reconnect storm: %s", + ex, + ) + await self._sleep(2.0) + finally: + await self._on_user_stream_interruption(websocket_assistant=self._ws_assistant) + self._ws_assistant = None + + async def _connected_websocket_assistant(self) -> WSAssistant: + ws: WSAssistant = await self._api_factory.get_ws_assistant() + ws_headers = {} + if self._connector.rest_api_key: + ws_headers["X-Api-Key"] = self._connector.rest_api_key + await ws.connect( + ws_url=web_utils.wss_url(self._domain), + ws_headers=ws_headers, + ping_timeout=CONSTANTS.WS_PING_INTERVAL, + ) + return ws + + async def _subscribe_channels(self, websocket_assistant: WSAssistant) -> None: + response: Optional[WSResponse] = await websocket_assistant.receive() + message: Dict[str, Any] = response.data if response is not None else {} + if message.get("type") != "connected": + raise IOError("Private websocket connection did not acknowledge the session") + + account_identifiers = { + str(self._auth.user_wallet_public_key), + str(getattr(self._connector, "account_index", "") or ""), + str(getattr(self._connector, "api_key_index", "") or ""), + } + account_identifiers.discard("") + + sent_channels: set = set() + auth_token = "" + try: + auth_token = str(self._connector._get_lighter_auth_token() or "") + except Exception: + auth_token = "" + + # Subscribe SPOT private channels. + # • account_all → full account snapshot (assets, orders, trades) on connect; + # incremental updates as orders change state. + # • account_all_assets → real-time balance updates (auth token required). + # • account_all_orders → full-JSON order history snapshot + incremental updates; + # populates client_order_index → order_id mapping and + # triggers fill-detection when FILLED/CANCELED state arrives. + # • account_all_trades + # Real-time fill channel with ask_client_id/bid_client_id fields. + # + # Legacy channels account_trades/account_order_updates are not subscribed + # for SPOT because they are not consistently supported across environments. + # + # Delimiter format differs by deployment (":" vs "/"). + # Subscribe using both formats and rely on Invalid Channel suppression. + spot_private_channels = ( + CONSTANTS.WS_ACCOUNT_ALL_CHANNEL, + CONSTANTS.WS_ACCOUNT_ALL_ASSETS_CHANNEL, + CONSTANTS.WS_ACCOUNT_ALL_ORDERS_CHANNEL, + CONSTANTS.WS_ACCOUNT_ALL_TRADES_CHANNEL, + ) + for account_identifier in sorted(account_identifiers): # sorted for deterministic test order + for base_channel in spot_private_channels: + # Prefer slash format first (validated on live SPOT channels), keep ':' as fallback. + for channel in (f"{base_channel}/{account_identifier}", f"{base_channel}:{account_identifier}"): + if channel in sent_channels: + continue + payload = {"type": "subscribe", "channel": channel} + if ( + base_channel in { + CONSTANTS.WS_ACCOUNT_ALL_ASSETS_CHANNEL, + CONSTANTS.WS_ACCOUNT_ALL_ORDERS_CHANNEL, + } + and auth_token + ): + payload["auth"] = auth_token + await websocket_assistant.send(WSJSONRequest(payload)) + sent_channels.add(channel) + + log_method = self.logger().debug + log_method( + "Subscribed to spot private channels=%s for %d account identifier(s) (%d subscriptions)", + [c for c in spot_private_channels], + len(account_identifiers), + len(sent_channels), + ) + self._has_logged_subscription_info = True + + async def _process_websocket_messages(self, websocket_assistant: WSAssistant, queue: asyncio.Queue): + async for ws_response in websocket_assistant.iter_messages(): + data = ws_response.data + if data.get("type") == "ping": + await websocket_assistant.send(WSJSONRequest(payload={"type": "pong"})) + continue + await self._process_event_message(event_message=data, queue=queue) + + async def _process_event_message(self, event_message: Dict[str, Any], queue: asyncio.Queue): + if event_message.get("error") is not None: + err_msg = event_message.get("error", {}).get("message", event_message.get("error")) + if "invalid channel" in str(err_msg).lower(): + self.logger().debug("Ignoring late 'Invalid Channel' response from server: %s", err_msg) + return + raise IOError({ + "label": "WSS_ERROR", + "message": f"Error received via websocket - {err_msg}.", + }) + + message_type = str(event_message.get("type", "")) + channel = str(event_message.get("channel", "")) + event_type_name = message_type.split("/", 1)[1] if "/" in message_type else message_type + # Forward events from all SPOT private channels plus optional compatibility + # channels used by some backend deployments. + account_channels = ( + CONSTANTS.WS_ACCOUNT_ALL_CHANNEL, + CONSTANTS.WS_ACCOUNT_ALL_ASSETS_CHANNEL, + CONSTANTS.WS_ACCOUNT_TX_CHANNEL, + CONSTANTS.WS_ACCOUNT_ALL_ORDERS_CHANNEL, + CONSTANTS.WS_ACCOUNT_ALL_TRADES_CHANNEL, + ) + if ( + event_type_name in account_channels + or any(message_type.endswith(f"/{account_channel}") for account_channel in account_channels) + or any(channel.startswith(f"{account_channel}/") for account_channel in account_channels) + or any(channel.startswith(f"{account_channel}:") for account_channel in account_channels) + or channel in account_channels + ): + queue.put_nowait(event_message) diff --git a/hummingbot/connector/exchange/lighter/lighter_auth.py b/hummingbot/connector/exchange/lighter/lighter_auth.py new file mode 100644 index 00000000000..f52b0d08518 --- /dev/null +++ b/hummingbot/connector/exchange/lighter/lighter_auth.py @@ -0,0 +1,21 @@ +from hummingbot.core.web_assistant.auth import AuthBase +from hummingbot.core.web_assistant.connections.data_types import RESTRequest, WSRequest + + +class LighterAuth(AuthBase): + def __init__(self, api_key: str, api_secret: str = "", account_identifier: str = ""): + self.api_key = api_key + self.api_secret = api_secret + self.user_wallet_public_key = account_identifier + + async def rest_authenticate(self, request: RESTRequest) -> RESTRequest: + headers = dict(request.headers or {}) + headers["accept"] = "application/json" + headers["Content-Type"] = "application/json" + if self.api_key: + headers["X-Api-Key"] = self.api_key + request.headers = headers + return request + + async def ws_authenticate(self, request: WSRequest) -> WSRequest: + return request diff --git a/hummingbot/connector/exchange/lighter/lighter_constants.py b/hummingbot/connector/exchange/lighter/lighter_constants.py new file mode 100644 index 00000000000..3cf46a55839 --- /dev/null +++ b/hummingbot/connector/exchange/lighter/lighter_constants.py @@ -0,0 +1,177 @@ +from hummingbot.core.api_throttler.data_types import LinkedLimitWeightPair, RateLimit + +EXCHANGE_NAME = "lighter" +DEFAULT_DOMAIN = "lighter" +HBOT_ORDER_ID_PREFIX = "HBOT" +MAX_ORDER_ID_LEN = 32 + +REST_URL = "https://mainnet.zklighter.elliot.ai/api/v1" +WSS_URL = "wss://mainnet.zklighter.elliot.ai/stream" + +TESTNET_DOMAIN = "lighter_testnet" +TESTNET_REST_URL = "https://testnet.zklighter.elliot.ai/api/v1" +TESTNET_WSS_URL = "wss://testnet.zklighter.elliot.ai/stream" + +PING_PATH_URL = "/orderBooks" +EXCHANGE_INFO_PATH_URL = "/orderBooks" +GET_MARKET_ORDER_BOOK_SNAPSHOT_PATH_URL = "/orderBookOrders" +GET_ORDER_HISTORY_PATH_URL = "/accountInactiveOrders" +GET_ACTIVE_ORDERS_PATH_URL = "/accountActiveOrders" +GET_TRADE_HISTORY_PATH_URL = "/trades" +GET_PRICES_PATH_URL = "/exchangeStats" +GET_ACCOUNT_INFO_PATH_URL = "/account" +GET_ACCOUNT_API_CONFIG_KEYS = "/apikeys" +CREATE_ACCOUNT_API_CONFIG_KEY = "/tokens_create" +CREATE_ORDER_PATH_URL = "/sendTx" +CANCEL_ORDER_PATH_URL = "/sendTx" +GET_TOKENLIST_PATH_URL = "/tokenlist" + +WS_ORDER_BOOK_SNAPSHOT_CHANNEL = "order_book" +WS_TRADES_CHANNEL = "trade" +WS_ACCOUNT_ALL_CHANNEL = "account_all" +WS_ACCOUNT_ALL_ORDERS_CHANNEL = "account_all_orders" +WS_ACCOUNT_ALL_TRADES_CHANNEL = "account_all_trades" +WS_ACCOUNT_ALL_ASSETS_CHANNEL = "account_all_assets" +WS_ACCOUNT_TX_CHANNEL = "account_tx" +WS_ACCOUNT_ORDER_UPDATES_CHANNEL = "account_order_updates" +WS_ACCOUNT_TRADES_CHANNEL = "account_trades" +WS_ACCOUNT_INFO_CHANNEL = "account_info" +WS_ACCOUNT_POSITIONS_CHANNEL = "account_positions" +WS_PING_INTERVAL = 30 + +LIGHTER_LIMIT_ID = "LIGHTER_LIMIT" +LIGHTER_TIER_1_LIMIT = 24000 +LIGHTER_TIER_2_LIMIT = 24000 +LIGHTER_LIMIT_INTERVAL = 60 +STANDARD_REQUEST_COST = 10 +HEAVY_GET_REQUEST_COST_TIER_1 = 120 +HEAVY_GET_REQUEST_COST_TIER_2 = 30 +ORDER_CANCELLATION_COST = 5 + +RATE_LIMITS = [ + RateLimit(limit_id=LIGHTER_LIMIT_ID, limit=LIGHTER_TIER_1_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL), + RateLimit( + limit_id=EXCHANGE_INFO_PATH_URL, + limit=LIGHTER_TIER_1_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_1)], + ), + RateLimit( + limit_id=GET_MARKET_ORDER_BOOK_SNAPSHOT_PATH_URL, + limit=LIGHTER_TIER_1_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_1)], + ), + RateLimit( + limit_id=GET_ACCOUNT_INFO_PATH_URL, + limit=LIGHTER_TIER_1_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_1)], + ), + RateLimit( + limit_id=GET_TRADE_HISTORY_PATH_URL, + limit=LIGHTER_TIER_1_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_1)], + ), + RateLimit( + limit_id=GET_PRICES_PATH_URL, + limit=LIGHTER_TIER_1_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=STANDARD_REQUEST_COST)], + ), + RateLimit( + limit_id=GET_ORDER_HISTORY_PATH_URL, + limit=LIGHTER_TIER_1_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_1)], + ), + RateLimit( + limit_id=GET_ACTIVE_ORDERS_PATH_URL, + limit=LIGHTER_TIER_1_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_1)], + ), + RateLimit( + limit_id=CREATE_ORDER_PATH_URL, + limit=LIGHTER_TIER_1_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=STANDARD_REQUEST_COST)], + ), + RateLimit( + limit_id=CANCEL_ORDER_PATH_URL, + limit=LIGHTER_TIER_1_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=ORDER_CANCELLATION_COST)], + ), + RateLimit( + limit_id=GET_TOKENLIST_PATH_URL, + limit=LIGHTER_TIER_1_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=STANDARD_REQUEST_COST)], + ), +] + +RATE_LIMITS_TIER_2 = [ + RateLimit(limit_id=LIGHTER_LIMIT_ID, limit=LIGHTER_TIER_2_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL), + RateLimit( + limit_id=EXCHANGE_INFO_PATH_URL, + limit=LIGHTER_TIER_2_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_2)], + ), + RateLimit( + limit_id=GET_MARKET_ORDER_BOOK_SNAPSHOT_PATH_URL, + limit=LIGHTER_TIER_2_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_2)], + ), + RateLimit( + limit_id=GET_ACCOUNT_INFO_PATH_URL, + limit=LIGHTER_TIER_2_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_2)], + ), + RateLimit( + limit_id=GET_TRADE_HISTORY_PATH_URL, + limit=LIGHTER_TIER_2_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_2)], + ), + RateLimit( + limit_id=GET_PRICES_PATH_URL, + limit=LIGHTER_TIER_2_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=STANDARD_REQUEST_COST)], + ), + RateLimit( + limit_id=GET_ORDER_HISTORY_PATH_URL, + limit=LIGHTER_TIER_2_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_2)], + ), + RateLimit( + limit_id=GET_ACTIVE_ORDERS_PATH_URL, + limit=LIGHTER_TIER_2_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=HEAVY_GET_REQUEST_COST_TIER_2)], + ), + RateLimit( + limit_id=CREATE_ORDER_PATH_URL, + limit=LIGHTER_TIER_2_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=STANDARD_REQUEST_COST)], + ), + RateLimit( + limit_id=CANCEL_ORDER_PATH_URL, + limit=LIGHTER_TIER_2_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=ORDER_CANCELLATION_COST)], + ), + RateLimit( + limit_id=GET_TOKENLIST_PATH_URL, + limit=LIGHTER_TIER_2_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=STANDARD_REQUEST_COST)], + ), +] diff --git a/hummingbot/connector/exchange/lighter/lighter_exchange.py b/hummingbot/connector/exchange/lighter/lighter_exchange.py new file mode 100644 index 00000000000..32a25d93d31 --- /dev/null +++ b/hummingbot/connector/exchange/lighter/lighter_exchange.py @@ -0,0 +1,3486 @@ +import asyncio +import hashlib +import json +import time +from decimal import Decimal +from typing import Any, AsyncIterable, Callable, Dict, List, Optional, Set, Tuple + +from bidict import bidict + +from hummingbot.connector.constants import s_decimal_NaN +from hummingbot.connector.exchange.lighter import lighter_constants as CONSTANTS, lighter_web_utils as web_utils +from hummingbot.connector.exchange.lighter.lighter_api_order_book_data_source import LighterAPIOrderBookDataSource +from hummingbot.connector.exchange.lighter.lighter_api_user_stream_data_source import LighterAPIUserStreamDataSource +from hummingbot.connector.exchange.lighter.lighter_auth import LighterAuth +from hummingbot.connector.exchange_py_base import ExchangePyBase +from hummingbot.connector.trading_rule import TradingRule +from hummingbot.connector.utils import TradeFillOrderDetails, combine_to_hb_trading_pair, get_new_client_order_id +from hummingbot.core.api_throttler.data_types import RateLimit +from hummingbot.core.data_type.common import OrderType, TradeType +from hummingbot.core.data_type.in_flight_order import InFlightOrder, OrderState, OrderUpdate, TradeUpdate +from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource +from hummingbot.core.data_type.trade_fee import AddedToCostTradeFee, TokenAmount, TradeFeeBase +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.core.event.events import MarketEvent, OrderFilledEvent +from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather +from hummingbot.core.web_assistant.connections.data_types import RESTMethod +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory + + +class LighterExchange(ExchangePyBase): + web_utils = web_utils + # Keep REST reconciliations conservative when private stream is connected. + UPDATE_ORDER_STATUS_MIN_INTERVAL = 30.0 + _HEALTHY_PRIVATE_STREAM_POLL_INTERVAL = 30.0 + TICK_INTERVAL_LIMIT = 180.0 + BALANCE_SYNC_REQUIRED_TIMEOUT = 3.0 + _MARKET_ORDER_MAX_SLIPPAGE = Decimal("5") # 5% + _TRADE_HISTORY_TIME_DRIFT_BUFFER = 10.0 # seconds + # Lighter on-chain cancel TX takes ~29 s to confirm. Any CANCELED WS event for an + # order younger than this threshold is almost certainly a subscription snapshot replay + # (false cancel) rather than a real user-initiated cancellation. 555 ms is the + # observed maximum false-cancel latency; 10 s gives a 18× safety margin well below + # the 29 s minimum real-cancel latency. + _CANCEL_MIN_ORDER_AGE_SECS: float = 10.0 + _ORDER_STATE = { + "in-progress": OrderState.OPEN, + "open": OrderState.OPEN, + "pending": OrderState.PENDING_CREATE, + "partially_filled": OrderState.PARTIALLY_FILLED, + "partial_fill": OrderState.PARTIALLY_FILLED, + "filled": OrderState.FILLED, + "closed": OrderState.CANCELED, + "done": OrderState.CANCELED, + "cancelled": OrderState.CANCELED, + "canceled": OrderState.CANCELED, + "canceled-post-only": OrderState.CANCELED, + "canceled-reduce-only": OrderState.CANCELED, + "canceled-position-not-allowed": OrderState.CANCELED, + "canceled-margin-not-allowed": OrderState.CANCELED, + "canceled-too-much-slippage": OrderState.CANCELED, + "canceled-not-enough-liquidity": OrderState.CANCELED, + "canceled-self-trade": OrderState.CANCELED, + "canceled-expired": OrderState.CANCELED, + "canceled-oco": OrderState.CANCELED, + "canceled-child": OrderState.CANCELED, + "canceled-liquidation": OrderState.CANCELED, + "canceled-invalid-balance": OrderState.CANCELED, + "pending_cancel": OrderState.PENDING_CANCEL, + "rejected": OrderState.FAILED, + "failed": OrderState.FAILED, + "expired": OrderState.FAILED, + } + + @staticmethod + def _is_expected_order_rejection(error_message: str) -> bool: + normalized = (error_message or "").lower() + expected_patterns = ( + "minimum notional", + "minimum lot size", + "invalid order base or quote amount", + "below the minimum", + "order amount", + "order notional", + "insufficient", + "balance refresh", + "stale-balance rejects", + ) + return any(pattern in normalized for pattern in expected_patterns) + + def __init__( + self, + lighter_account_index: str = "", + lighter_api_key_index: str = "", + lighter_api_key_public_key: str = "", + lighter_api_key_private_key: str = "", + balance_asset_limit: Optional[Dict[str, Dict[str, Decimal]]] = None, + rate_limits_share_pct: Decimal = Decimal("100"), + trading_pairs: Optional[List[str]] = None, + trading_required: bool = True, + domain: str = CONSTANTS.DEFAULT_DOMAIN, + ): + self._api_key = lighter_api_key_private_key + self._api_secret = lighter_api_key_index + self._account_index = lighter_account_index + self._api_key_index = lighter_api_key_index + self._api_key_public_key = lighter_api_key_public_key + self._domain = domain + self._trading_required = trading_required + self._trading_pairs = trading_pairs or [] + self._exchange_symbol_map = bidict() + self._market_id_by_symbol: Dict[str, int] = {} + self._size_decimals_by_symbol: Dict[str, int] = {} + self._price_decimals_by_symbol: Dict[str, int] = {} + self._signer_client_lock = asyncio.Lock() + self._lighter_signer_client = None + self._cached_auth_token: Optional[str] = None + self._cached_auth_token_expiry_ts: float = 0.0 + self._order_history_last_poll_timestamp: Dict[str, float] = {} + self._last_trades_poll_timestamp: float = 0.0 + self._last_private_stream_balance_sync_ts: float = 0.0 + self._last_unmatched_private_event_reconcile_ts: float = 0.0 + self._last_balance_update_timestamp: float = 0.0 + self._balance_refresh_required_since: float = 0.0 + self._last_ws_balance_update_ts: float = 0.0 + self._last_signed_tx_ts: float = 0.0 + self._cancel_in_flight_client_order_ids: Set[str] = set() + initial_index = int(time.time() * 1000) * getattr(self, "_CLIENT_ORDER_INDEX_TIME_MULTIPLIER", 140) + self._last_client_order_index: int = min(initial_index, getattr(self, "_CLIENT_ORDER_INDEX_MAX", (1 << 48) - 1) - 1_000_000) + # Bidirectional mapping between Lighter's numeric client_order_index and hummingbot's + # client_order_id (UUID string). Required because WS events may update exchange_order_id + # from the original client_order_index to the server-assigned order_id, breaking fill + # matching in REST rescue polls that use ask_client_id / bid_client_id (= client_order_index). + self._client_order_index_to_client_order_id: Dict[str, str] = {} # str(coi) → hb UUID + self._hb_order_id_to_client_order_index: Dict[str, int] = {} # hb UUID → coi int + # Reverse map: server-assigned order_index ("i" in WS) → client_order_index string. + # Populated when a WS order update carries both "I" (COI) and "i" (server order_index) + # so that compact account_trades fills (which only carry "i") can still be matched. + self._server_order_index_to_client_order_index: Dict[str, str] = {} # str(server_oi) → str(coi) + # One-time startup flag: cancel untracked active orders left from previous sessions. + self._startup_orphan_cleanup_done: bool = False + # Counter for periodic runtime orphan cleanup (runs every 10 status-poll cycles ~2 min). + self._runtime_orphan_poll_counter: int = 0 + # Dedup guard: tracks orders for which a fill fetch is currently in-flight. + # Prevents concurrent /trades calls for the same order from bursting the rate limit. + self._fill_fetch_in_progress: Set[str] = set() + # Buffer for account_trades WS fill entries that arrived before account_all established + # the client_order_index → client_order_id mapping (mirrors PERP's _pending_trade_entries). + self._pending_spot_trade_entries: List[Tuple[float, Dict[str, Any]]] = [] + # Guard against stale account_all_assets WS events overwriting an optimistic balance + # release made by _release_locked_balance_on_cancel. Maps asset symbol → (available, ts) + # where `available` is the value set by the optimistic release and `ts` is wall time. + # _process_balance_message_from_account uses this to avoid reverting a fresh release. + self._optimistic_balance_release: Dict[str, Tuple[Decimal, float]] = {} + super().__init__(balance_asset_limit, rate_limits_share_pct) + + @staticmethod + def _client_order_index_from_order_id(order_id: str) -> int: + digest = hashlib.sha256(order_id.encode()).digest() + # Lighter API enforces client_order_index <= 2^48-1 (281474976710655) + return int.from_bytes(digest[:8], byteorder="big", signed=False) & ((1 << 48) - 1) + + @staticmethod + def _is_int_string(value: str) -> bool: + if value is None: + return False + try: + int(str(value).strip()) + return True + except Exception: + return False + + def _get_signer_private_key(self) -> str: + if self._api_key and not self._is_int_string(self._api_key) and self._is_hex_private_key(self._api_key): + return self._api_key + raise ValueError( + "API private key is required for signed transactions. " + "Enter your signing key via connect lighter." + ) + + @staticmethod + def _is_hex_private_key(value: str) -> bool: + """Return True only if value is a 64-char hex string (valid signer private key).""" + if not value: + return False + key = value[2:] if value.lower().startswith("0x") else value + return len(key) >= 64 and all(c in "0123456789abcdefABCDEF" for c in key) + + def _api_host_for_signer(self) -> str: + rest_url = CONSTANTS.REST_URL if self.domain == CONSTANTS.DEFAULT_DOMAIN else CONSTANTS.TESTNET_REST_URL + return rest_url.split("/api/v1")[0] + + def _sdk_rest_base_url(self) -> str: + return self._api_host_for_signer() + + def _get_lighter_api_client(self): + if getattr(self, "_lighter_api_client", None) is None: + import lighter + + configuration = lighter.Configuration(host=self._sdk_rest_base_url()) + self._lighter_api_client = lighter.ApiClient(configuration=configuration) + + return self._lighter_api_client + + async def _close_lighter_api_client(self): + api_client = getattr(self, "_lighter_api_client", None) + if api_client is not None: + await api_client.close() + self._lighter_api_client = None + + async def _sdk_api_request( + self, + path_url: str, + method: RESTMethod = RESTMethod.GET, + params: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, Any]] = None, + limit_id: Optional[str] = None, + headers: Optional[Dict[str, Any]] = None, + return_err: bool = False, + ) -> Dict[str, Any]: + api_client = self._get_lighter_api_client() + request_headers = dict(headers or {}) + + if data is not None and "Content-Type" not in request_headers: + request_headers["Content-Type"] = "application/json" + + serialized_request = api_client.param_serialize( + method=method.value, + resource_path=f"/api/v1{path_url}", + query_params=params, + header_params=request_headers, + body=data, + _host=self._sdk_rest_base_url(), + ) + + throttler = getattr(self, "_throttler", None) + limit_context = throttler.execute_task(limit_id=limit_id or path_url) if throttler is not None else None + + try: + if limit_context is None: + response = await api_client.call_api(*serialized_request) + else: + async with limit_context: + response = await api_client.call_api(*serialized_request) + await response.read() + raw_body = response.data.decode("utf-8") if response.data else "" + payload: Any = json.loads(raw_body) if raw_body else {} + except Exception as request_exception: + if return_err: + return { + "success": False, + "error": str(request_exception), + "code": getattr(request_exception, "status", None), + } + raise IOError(f"Error executing Lighter SDK request {method.value} {path_url}: {request_exception}") + + if not isinstance(payload, dict): + payload = {"data": payload} + + payload.setdefault("code", getattr(response, "status", None)) + payload.setdefault("success", int(payload.get("code") or 0) < 400) + + if int(payload.get("code") or 0) >= 400 and not return_err: + if int(payload.get("code") or 0) == 23000: + # Server rate limit (Too Many Requests). Sleep before raising so that + # the status-polling loop's immediate retry does not create a cascade of + # back-to-back 429 errors. The base class re-schedules the next poll via + # its normal interval after the exception propagates. + await asyncio.sleep(3.0) + raise IOError(f"Lighter SDK request failed for {method.value} {path_url}: {payload}") + + return payload + + def _get_api_key_index(self) -> int: + api_key_index = getattr(self, "_api_key_index", "") + if self._is_int_string(api_key_index): + return int(api_key_index) + raise ValueError( + "API key index must be an integer. Enter it via connect lighter." + ) + + def _get_account_index(self) -> int: + try: + return int(str(self._account_index).strip()) + except Exception as e: + raise ValueError("Lighter account index must be an integer string") from e + + @staticmethod + def _is_ok_response(response: Dict[str, Any]) -> bool: + if response.get("success") is True: + return True + code = response.get("code") + try: + return int(code) == 200 + except Exception: + return False + + @staticmethod + def _is_rate_limited_response(response: Optional[Dict[str, Any]]) -> bool: + if not isinstance(response, dict): + return False + + code = response.get("code") + if str(code) in {"23000", "429"}: + return True + + message = str(response.get("message") or response.get("error") or "") + return "too many requests" in message.lower() + + @classmethod + def _is_rate_limited_exception(cls, request_error: Exception) -> bool: + return cls._is_rate_limited_response({"message": str(request_error)}) + + def _current_state_order_update(self, tracked_order: InFlightOrder) -> OrderUpdate: + update_ts = getattr(self, "current_timestamp", None) + if update_ts is None: + update_ts = self._time() + + return OrderUpdate( + client_order_id=tracked_order.client_order_id, + exchange_order_id=tracked_order.exchange_order_id, + trading_pair=tracked_order.trading_pair, + update_timestamp=update_ts, + new_state=getattr(tracked_order, "current_state", OrderState.OPEN), + ) + + def _account_query_params(self) -> Dict[str, Any]: + return { + "by": "index", + "value": str(self._get_account_index()), + "active_only": "true", + } + + @staticmethod + def _account_from_response(response: Dict[str, Any]) -> Optional[Dict[str, Any]]: + data = response.get("data") + if isinstance(data, dict): + return data + if isinstance(data, list) and len(data) > 0 and isinstance(data[0], dict): + return data[0] + accounts = response.get("accounts") + if isinstance(accounts, list) and len(accounts) > 0: + return accounts[0] + # Lighter API returns the account object directly (assets at top level) + if "assets" in response or "collateral" in response or "available_balance" in response: + return response + return None + + def _get_lighter_signer_client(self): + if self._lighter_signer_client is None: + import lighter + + self._lighter_signer_client = lighter.signer_client.SignerClient( + url=self._api_host_for_signer(), + account_index=self._get_account_index(), + api_private_keys={self._get_api_key_index(): self._get_signer_private_key()}, + ) + + return self._lighter_signer_client + + def _get_lighter_auth_token(self) -> str: + now = float(getattr(self, "current_timestamp", time.time())) + cached_auth_token = getattr(self, "_cached_auth_token", None) + cached_auth_token_expiry_ts = float(getattr(self, "_cached_auth_token_expiry_ts", 0.0) or 0.0) + if cached_auth_token and now < cached_auth_token_expiry_ts: + return cached_auth_token + + signer_client = self._get_lighter_signer_client() + auth_token, error = signer_client.create_auth_token_with_expiry() + if error is not None or not auth_token: + raise IOError(f"Failed to generate Lighter auth token: {error}") + + self._cached_auth_token = auth_token + # Refresh slightly before default 10-minute expiry. + self._cached_auth_token_expiry_ts = now + 9 * 60 + return auth_token + + async def stop_network(self): + await super().stop_network() + await self._close_lighter_api_client() + + async def _refresh_market_metadata(self): + response = await self._api_get( + path_url=CONSTANTS.EXCHANGE_INFO_PATH_URL, + return_err=True, + ) + + for market in response.get("order_books", []) or response.get("data", []): + market_type = str(market.get("market_type") or "").lower() + if market_type and market_type != "spot": + continue + + symbol = market.get("symbol") + if symbol is None: + continue + + self._market_id_by_symbol[symbol] = int(market["market_id"]) + self._size_decimals_by_symbol[symbol] = int(market.get("supported_size_decimals", 0)) + self._price_decimals_by_symbol[symbol] = int(market.get("supported_price_decimals", 0)) + + async def _get_market_spec(self, trading_pair: str) -> Tuple[int, int, int, str]: + symbol = await self.exchange_symbol_associated_to_pair(trading_pair) + + if symbol not in self._market_id_by_symbol: + await self._refresh_market_metadata() + + if symbol not in self._market_id_by_symbol: + raise ValueError(f"Market metadata not found for symbol {symbol}") + + return ( + self._market_id_by_symbol[symbol], + self._size_decimals_by_symbol.get(symbol, 0), + self._price_decimals_by_symbol.get(symbol, 0), + symbol, + ) + + @property + def name(self) -> str: + return self._domain + + @property + def authenticator(self) -> LighterAuth: + account_identifier = self._api_key_public_key if self._api_key_public_key else self._account_index + return LighterAuth( + api_key=self.rest_api_key, + api_secret=self._api_secret, + account_identifier=account_identifier, + ) + + @property + def rate_limits_rules(self) -> List[RateLimit]: + # Lighter applies lighter-weight costs when requests are authenticated with an API key. + return CONSTANTS.RATE_LIMITS_TIER_2 if self.rest_api_key else CONSTANTS.RATE_LIMITS + + @property + def domain(self) -> str: + return self._domain + + @property + def client_order_id_max_length(self) -> int: + return CONSTANTS.MAX_ORDER_ID_LEN + + @property + def client_order_id_prefix(self) -> str: + return CONSTANTS.HBOT_ORDER_ID_PREFIX + + @property + def trading_rules_request_path(self) -> str: + return CONSTANTS.EXCHANGE_INFO_PATH_URL + + @property + def trading_pairs_request_path(self) -> str: + return CONSTANTS.EXCHANGE_INFO_PATH_URL + + @property + def check_network_request_path(self) -> str: + return CONSTANTS.PING_PATH_URL + + @property + def trading_pairs(self) -> List[str]: + return self._trading_pairs + + async def all_trading_pairs(self) -> List[str]: + """ + Returns all active spot trading pairs available on the Lighter exchange. + Uses the /orderBooks endpoint (same as _initialize_trading_pair_symbols_from_exchange_info) + which is stable on both mainnet and testnet. + """ + try: + result = await self._api_get(path_url=CONSTANTS.EXCHANGE_INFO_PATH_URL) + entries = result.get("data") or result.get("order_books") or [] + pairs = [] + for entry in entries: + market_type = (entry.get("market_type") or "").lower() + if market_type and market_type != "spot": + continue + symbol = entry.get("symbol") + if symbol: + pairs.append(self._hb_pair_from_symbol(symbol)) + return pairs + except Exception: + return [] + + @property + def is_cancel_request_in_exchange_synchronous(self) -> bool: + return True + + @property + def is_trading_required(self) -> bool: + return self._trading_required + + @property + def rest_api_key(self) -> str: + api_key = getattr(self, "_api_key", "") + api_secret = getattr(self, "_api_secret", "") + if self._is_int_string(api_key): + return str(api_key) + if self._is_int_string(api_secret): + return str(api_secret) + return api_key + + def supported_order_types(self) -> List[OrderType]: + return [OrderType.LIMIT, OrderType.LIMIT_MAKER, OrderType.MARKET] + + def _is_request_exception_related_to_time_synchronizer(self, request_exception: Exception) -> bool: + return False + + def _is_order_not_found_during_status_update_error(self, status_update_exception: Exception) -> bool: + return "not found" in str(status_update_exception).lower() + + def _is_order_not_found_during_cancelation_error(self, cancelation_exception: Exception) -> bool: + error_text = str(cancelation_exception).lower() + # Lighter API error code 5 = order not found / already cancelled. + # Error can appear as JSON (double-quotes) or Python dict repr (single-quotes). + if '"code":5' in error_text or "'code': 5" in error_text or '"code": 5' in error_text: + return True + # "order not found" literal embedded in exchange error messages. + if "order not found" in error_text: + return True + return False + + @staticmethod + def _hb_pair_from_symbol(symbol: str) -> str: + if "/" in symbol: + base, quote = symbol.split("/", 1) + return combine_to_hb_trading_pair(base=base, quote=quote) + if "-" in symbol: + base, quote = symbol.split("-", 1) + return combine_to_hb_trading_pair(base=base, quote=quote) + return symbol + + # Lighter on-chain TX confirmation takes ~29 seconds. The HTTP body (signed TX) is + # submitted in <100ms; the long wait is for sequencer inclusion. We apply a short + # asyncio.wait_for timeout so that once the TX is submitted we return True immediately + # and let the base class emit CANCELED. The order moves to cached_orders (fills within + # the TTL are still processed). This prevents 90-second delays from a hung connection. + _CANCEL_TX_OPTIMISTIC_TIMEOUT = 5.0 # seconds; body is sent before this fires + + async def _place_cancel(self, order_id: str, tracked_order: InFlightOrder): + if tracked_order.exchange_order_id is None: + return False + + market_id, _, _, _ = await self._get_market_spec(tracked_order.trading_pair) + + cancel_succeeded = False + async with self._signer_client_lock: + signer_client = self._get_lighter_signer_client() + tx_response = None + error = None + for attempt in range(3): + # Re-check after awaits in case order tracking clears the exchange id concurrently. + exchange_order_id = tracked_order.exchange_order_id + if exchange_order_id is None: + return False + + try: + _, tx_response, error = await asyncio.wait_for( + signer_client.cancel_order( + market_index=market_id, + order_index=int(exchange_order_id), + api_key_index=self._get_api_key_index(), + ), + timeout=self._CANCEL_TX_OPTIMISTIC_TIMEOUT, + ) + except asyncio.TimeoutError: + # Do not mark CANCELED optimistically on timeout. A timed-out HTTP await + # means TX submission may be in-flight, but exchange terminal state is not + # confirmed yet. Let _execute_order_cancel reconcile state first. + raise IOError( + f"Lighter spot cancel_order timed out for order {order_id} before confirmation" + ) + + if error is None and self._response_code(tx_response) == 200: + cancel_succeeded = True + break # release signer lock before REST fill-fetch + + if attempt < 2 and self._is_invalid_nonce_failure(error=error, response=tx_response): + # Nonce refresh may fail during transient DNS/network issues. + # Keep the existing signer client and retry instead of failing fast. + try: + signer_client = await self._refresh_signer_client_async() + except Exception as refresh_error: + self.logger().warning( + f"Failed to refresh signer client after invalid nonce for {order_id}: {refresh_error}. " + f"Retrying cancel with existing signer client." + ) + await self._sleep(0.3) + continue + break + + if cancel_succeeded: + # Fetch fills OUTSIDE the signer lock: REST calls must not hold the signing lock. + # Start with delay=0 — if the fill was already indexed (happened ≥9 s before cancel + # submission), the immediate attempt finds it and emits OrderFilledEvent before + # OrderCancelledEvent so strategy accounting stays correct. The internal retry + # logic of _fetch_and_apply_fills handles the case where REST has not indexed the + # fill yet (retries at +8 s and +16 s). + try: + pre_cancel_fills = await self._all_trade_updates_for_order(tracked_order) + for fill_update in pre_cancel_fills: + self._order_tracker.process_trade_update(fill_update) + if pre_cancel_fills: + self.logger().info( + "[cancel] Found %d fill(s) for %s before marking CANCELED — cancel-fill race handled", + len(pre_cancel_fills), + tracked_order.client_order_id, + ) + else: + # No fills indexed yet. Start a background retry loop: _fetch_and_apply_fills + # will check immediately (delay=0) and then retry every 8 s as needed. + safe_ensure_future(self._fetch_and_apply_fills(tracked_order, delay=0.0)) + except Exception as fill_err: + self.logger().debug( + "[cancel] Pre-cancel fill fetch failed for %s: %s", + tracked_order.client_order_id, + fill_err, + ) + # Still schedule a background retry so fills are not permanently lost. + safe_ensure_future(self._fetch_and_apply_fills(tracked_order, delay=8.0)) + optimistic_cancel_update = OrderUpdate( + client_order_id=tracked_order.client_order_id, + exchange_order_id=tracked_order.exchange_order_id, + trading_pair=tracked_order.trading_pair, + update_timestamp=self._current_timestamp_safely(), + new_state=OrderState.CANCELED, + ) + self._schedule_balance_sync_for_terminal_update( + order_update=optimistic_cancel_update, + tracked_order=tracked_order, + ) + return True + + if error is not None: + raise IOError(f"Lighter spot cancel_order signing/send failed: {error}") + if tx_response is None or getattr(tx_response, "code", None) != 200: + raise IOError(f"Lighter spot cancel_order failed: {tx_response}") + + return True + + async def _place_modify(self, tracked_order: InFlightOrder, amount: Decimal, price: Decimal) -> bool: + """Modify existing order via signer client.""" + if tracked_order.exchange_order_id is None: + return False + + market_id, size_decimals, price_decimals, _ = await self._get_market_spec(tracked_order.trading_pair) + + base_amount_scaled = int((amount * Decimal(f"1e{size_decimals}")).to_integral_value()) + price_scaled = int((price * Decimal(f"1e{price_decimals}")).to_integral_value()) + + async with self._signer_client_lock: + signer_client = self._get_lighter_signer_client() + tx_response = None + error = None + for attempt in range(5): + # Re-check after awaits in case order tracking clears the exchange id concurrently. + exchange_order_id = tracked_order.exchange_order_id + if exchange_order_id is None: + return False + + _, tx_response, error = await signer_client.modify_order( + market_index=market_id, + order_index=int(exchange_order_id), + base_amount=base_amount_scaled, + price=price_scaled, + api_key_index=self._get_api_key_index(), + ) + if error is None and self._response_code(tx_response) == 200: + break + if attempt < 4 and self._is_invalid_nonce_failure(error=error, response=tx_response): + signer_client = await self._refresh_signer_client_async() + await self._sleep(0.3) + continue + break + + if error is not None: + raise IOError(f"Lighter spot modify_order signing/send failed: {error}") + if tx_response is None or getattr(tx_response, "code", None) != 200: + raise IOError(f"Lighter spot modify_order failed: {tx_response}") + + return True + + def buy( + self, + trading_pair: str, + amount: Decimal, + order_type=OrderType.LIMIT, + price: Decimal = s_decimal_NaN, + **kwargs, + ) -> str: + order_id = get_new_client_order_id( + is_buy=True, + trading_pair=trading_pair, + hbot_order_id_prefix=self.client_order_id_prefix, + max_id_len=self.client_order_id_max_length, + ) + if order_type is OrderType.MARKET: + reference_price = self.get_mid_price(trading_pair) if price.is_nan() else price + slippage = self._MARKET_ORDER_MAX_SLIPPAGE / Decimal("100") + price = self.quantize_order_price(trading_pair, reference_price * (Decimal("1") + slippage)) + safe_ensure_future( + self._create_order( + trade_type=TradeType.BUY, + order_id=order_id, + trading_pair=trading_pair, + amount=amount, + order_type=order_type, + price=price, + **kwargs, + ) + ) + return order_id + + def sell( + self, + trading_pair: str, + amount: Decimal, + order_type: OrderType = OrderType.LIMIT, + price: Decimal = s_decimal_NaN, + **kwargs, + ) -> str: + order_id = get_new_client_order_id( + is_buy=False, + trading_pair=trading_pair, + hbot_order_id_prefix=self.client_order_id_prefix, + max_id_len=self.client_order_id_max_length, + ) + if order_type is OrderType.MARKET: + reference_price = self.get_mid_price(trading_pair) if price.is_nan() else price + slippage = self._MARKET_ORDER_MAX_SLIPPAGE / Decimal("100") + price = self.quantize_order_price(trading_pair, reference_price * (Decimal("1") - slippage)) + safe_ensure_future( + self._create_order( + trade_type=TradeType.SELL, + order_id=order_id, + trading_pair=trading_pair, + amount=amount, + order_type=order_type, + price=price, + **kwargs, + ) + ) + return order_id + + async def _ensure_fresh_balance_snapshot_before_order(self, trade_type: TradeType): + # Avoid submitting BUY orders with stale balances right after terminal + # order updates (cancel/fill/failure), when local availability may lag. + if trade_type != TradeType.BUY: + return + + required_since = float(getattr(self, "_balance_refresh_required_since", 0.0) or 0.0) + if required_since <= 0: + return + + # A recent account_all_assets WS push is authoritative — treat it as a fresh REST snapshot. + last_ws_balance_ts = float(getattr(self, "_last_ws_balance_update_ts", 0.0) or 0.0) + if last_ws_balance_ts >= required_since: + self._balance_refresh_required_since = 0.0 + return + + last_balance_ts = float(getattr(self, "_last_balance_update_timestamp", 0.0) or 0.0) + if last_balance_ts >= required_since: + return + + try: + await asyncio.wait_for( + self._update_balances(force_rest=True), + timeout=self.BALANCE_SYNC_REQUIRED_TIMEOUT, + ) + except Exception as balance_error: + raise IOError( + "Balance refresh is pending after a terminal order update. " + "Skipping BUY order submission to avoid stale-balance rejects." + ) from balance_error + + if float(getattr(self, "_last_balance_update_timestamp", 0.0) or 0.0) < required_since: + raise IOError( + "Balance refresh is still pending after a terminal order update. " + "Skipping BUY order submission to avoid stale-balance rejects." + ) + + async def _place_order( + self, + order_id: str, + trading_pair: str, + amount: Decimal, + trade_type: TradeType, + order_type: OrderType, + price: Decimal, + **kwargs, + ) -> Tuple[str, float]: + # Guard moved here from _create_order so that start_tracking_order() in the base class + # _create_order() always runs first. This ensures that if the balance refresh fails + # (e.g., 429 Too Many Requests), the base class _on_order_failure() is called and a + # MarketOrderFailureEvent is emitted — clearing the order from the strategy and preventing + # the "ghost order" cancel loop that occurred when the exception escaped _create_order + # before start_tracking_order() was called. + await self._ensure_fresh_balance_snapshot_before_order(trade_type=trade_type) + if order_type not in self.supported_order_types(): + raise ValueError(f"Order type {order_type} is not supported by {self.name}.") + + market_id, size_decimals, price_decimals, _ = await self._get_market_spec(trading_pair) + + # Resolve effective price; for MARKET orders apply a slippage cap + effective_price = price + if order_type == OrderType.MARKET or effective_price is None or effective_price.is_nan(): + order_book = self.get_order_book(trading_pair) + best_price = ( + Decimal(str(order_book.get_price(True))) + if trade_type == TradeType.BUY + else Decimal(str(order_book.get_price(False))) + ) + if best_price is None or best_price.is_nan() or best_price <= 0: + raise ValueError( + f"Unable to determine a valid execution price for {order_type.name} order on {trading_pair}." + ) + slippage = self._MARKET_ORDER_MAX_SLIPPAGE / Decimal("100") + if trade_type == TradeType.BUY: + effective_price = best_price * (Decimal("1") + slippage) + else: + effective_price = best_price * (Decimal("1") - slippage) + + # Validate sufficient balance (skip for MARKET — price is already capped) + if trade_type == TradeType.BUY and order_type != OrderType.MARKET: + quote_asset = trading_pair.split("-")[-1] + required_balance = amount * effective_price + available_balances = getattr(self, "_account_available_balances", None) + if available_balances is not None: + available_balance = available_balances.get(quote_asset, Decimal("0")) + if available_balance < required_balance: + raise IOError( + f"Insufficient {quote_asset} balance for {amount} {trading_pair.split('-')[0]} buy order. " + f"Required: {required_balance}, Available: {available_balance}" + ) + + base_amount_scaled = int((amount * Decimal(f"1e{size_decimals}")).to_integral_value()) + price_scaled = int((effective_price * Decimal(f"1e{price_decimals}")).to_integral_value()) + + signer_order_type = self._get_lighter_signer_client().ORDER_TYPE_LIMIT + signer_tif = self._get_lighter_signer_client().ORDER_TIME_IN_FORCE_GOOD_TILL_TIME + order_expiry = self._get_lighter_signer_client().DEFAULT_28_DAY_ORDER_EXPIRY + if order_type == OrderType.MARKET: + signer_order_type = self._get_lighter_signer_client().ORDER_TYPE_MARKET + signer_tif = self._get_lighter_signer_client().ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL + order_expiry = self._get_lighter_signer_client().DEFAULT_IOC_EXPIRY + elif order_type == OrderType.LIMIT_MAKER: + signer_tif = self._get_lighter_signer_client().ORDER_TIME_IN_FORCE_POST_ONLY + + async with self._signer_client_lock: + signer_client = self._get_lighter_signer_client() + client_order_index = self._allocate_client_order_index() + tx_response = None + error = None + for attempt in range(5): + _, tx_response, error = await signer_client.create_order( + market_index=market_id, + client_order_index=client_order_index, + base_amount=base_amount_scaled, + price=price_scaled, + is_ask=(trade_type == TradeType.SELL), + order_type=signer_order_type, + time_in_force=signer_tif, + reduce_only=False, + order_expiry=order_expiry, + api_key_index=self._get_api_key_index(), + ) + if error is None and self._response_code(tx_response) == 200: + break + if attempt < 4 and self._is_invalid_nonce_failure(error=error, response=tx_response): + signer_client = await self._refresh_signer_client_async() + client_order_index = self._allocate_client_order_index() + await self._sleep(0.3) + continue + break + + if error is not None: + raise IOError(f"Lighter spot create_order signing/send failed: {error}") + if tx_response is None or self._response_code(tx_response) != 200: + raise IOError(f"Lighter spot create_order failed: {tx_response}") + + # Record the bidirectional mapping so fill-matching works even after WS events update + # exchange_order_id from this client_order_index to the server-assigned order_id. + coi_str = str(client_order_index) + if hasattr(self, "_client_order_index_to_client_order_id"): + self._client_order_index_to_client_order_id[coi_str] = order_id + if hasattr(self, "_hb_order_id_to_client_order_index"): + self._hb_order_id_to_client_order_index[order_id] = client_order_index + + # Optimistically deduct the allocated balance so the strategy immediately sees the + # correct available capital before the async REST/WS balance sync completes. + # Mirrors _release_locked_balance_on_cancel in reverse (lock instead of unlock). + self._lock_balance_on_order_creation(trading_pair, amount, effective_price, trade_type) + + # Refresh balance so locked/available display updates immediately after order placement. + self._schedule_fast_balance_sync() + + return str(client_order_index), self.current_timestamp + + def _on_order_failure( + self, + order_id: str, + trading_pair: str, + amount: Decimal, + trade_type: TradeType, + order_type: OrderType, + price: Optional[Decimal], + exception: Exception, + **kwargs, + ): + error_message = str(exception) + if self._is_expected_order_rejection(error_message=error_message): + self.logger().debug( + "Order rejected by exchange (expected validation) for %s %s %s @ %s: %s", + trade_type.name, + amount, + trading_pair, + price, + error_message, + ) + self._update_order_after_failure(order_id=order_id, trading_pair=trading_pair, exception=exception) + return + + super()._on_order_failure( + order_id=order_id, + trading_pair=trading_pair, + amount=amount, + trade_type=trade_type, + order_type=order_type, + price=price, + exception=exception, + **kwargs, + ) + + def _get_fee( + self, + base_currency: str, + quote_currency: str, + order_type: OrderType, + order_side: TradeType, + amount: Decimal, + price: Decimal = s_decimal_NaN, + is_maker: Optional[bool] = None, + ) -> AddedToCostTradeFee: + fee = TradeFeeBase.new_spot_fee( + fee_schema=self.trade_fee_schema(), + trade_type=order_side, + percent_token=quote_currency, + percent=Decimal("0"), + flat_fees=[TokenAmount(token=quote_currency, amount=Decimal("0"))], + ) + return fee + + async def _update_trading_fees(self): + return + + def _get_poll_interval(self, timestamp: float) -> float: + # Use a slower cadence while the private stream is healthy to avoid + # redundant REST polling between strategy refresh cycles. + if len(self.in_flight_orders) > 0: + return ( + self._HEALTHY_PRIVATE_STREAM_POLL_INTERVAL + if self._is_private_user_stream_healthy() + else self.SHORT_POLL_INTERVAL + ) + return super()._get_poll_interval(timestamp) + + async def _user_stream_event_listener(self): + + def _looks_like_fill(t: Dict[str, Any]) -> bool: + """Return True if t appears to be a fill event worth buffering for replay.""" + return bool( + (t.get("i") or t.get("h")) # compact: server_order_index or trade hash + and (t.get("p") or t.get("price")) # price present + and (t.get("a") or t.get("size") or t.get("amount")) # amount present + ) + + async for event_message in self._iter_user_event_queue(): + try: + if not isinstance(event_message, dict): + continue + + # Determine whether this event came from the standalone account_trades channel. + # Fills from account_trades that arrive before account_all establishes the + # client_order_index → client_order_id mapping are buffered and replayed after + # the order updates in the same or subsequent event processing cycle. + # Mirrors the PERP connector's _pending_trade_entries pattern. + _ev_channel = str(event_message.get("channel", "")) + _ev_type = str(event_message.get("type", "")) + _ev_type_name = _ev_type.split("/", 1)[1] if "/" in _ev_type else _ev_type + _is_account_trades_event = ( + _ev_channel.startswith(f"{CONSTANTS.WS_ACCOUNT_TRADES_CHANNEL}:") + or _ev_channel.startswith(f"{CONSTANTS.WS_ACCOUNT_TRADES_CHANNEL}/") + or _ev_channel == CONSTANTS.WS_ACCOUNT_TRADES_CHANNEL + or _ev_type_name == CONSTANTS.WS_ACCOUNT_TRADES_CHANNEL + ) + # account_all_trades: SPOT real-time fill channel with full Trade JSON + # (ask_client_id/bid_client_id = COI). No auth required. Enables O(1) + # instant fill matching via _client_order_index_to_client_order_id map. + _is_account_all_trades_event = ( + _ev_channel.startswith(f"{CONSTANTS.WS_ACCOUNT_ALL_TRADES_CHANNEL}:") + or _ev_channel.startswith(f"{CONSTANTS.WS_ACCOUNT_ALL_TRADES_CHANNEL}/") + or _ev_channel == CONSTANTS.WS_ACCOUNT_ALL_TRADES_CHANNEL + or _ev_type_name == CONSTANTS.WS_ACCOUNT_ALL_TRADES_CHANNEL + ) + _is_any_trades_channel_event = _is_account_trades_event or _is_account_all_trades_event + # account_all_orders sends a history snapshot on subscribe (false-cancel source). + # account_order_updates sends ONLY real-time events — no snapshot, no false cancels. + _is_account_all_orders_event = ( + _ev_channel.startswith(f"{CONSTANTS.WS_ACCOUNT_ALL_ORDERS_CHANNEL}:") + or _ev_channel.startswith(f"{CONSTANTS.WS_ACCOUNT_ALL_ORDERS_CHANNEL}/") + or _ev_channel == CONSTANTS.WS_ACCOUNT_ALL_ORDERS_CHANNEL + or _ev_type_name == CONSTANTS.WS_ACCOUNT_ALL_ORDERS_CHANNEL + ) + + account_data, trades, orders = self._extract_private_stream_payloads(event_message=event_message) + has_assets_payload = self._account_payload_has_assets(account_data) + + if isinstance(account_data, dict): + self._process_balance_message_from_account(account_data) + if has_assets_payload: + # WS pushed a confirmed balance snapshot — treat as equivalent to a REST + # balance refresh so BUY orders are not blocked waiting for a poll. + now = self._current_timestamp_safely() + self._last_ws_balance_update_ts = now + self._last_balance_update_timestamp = now + if self._balance_refresh_required_since > 0 and now >= self._balance_refresh_required_since: + self._balance_refresh_required_since = 0.0 + + unmatched_private_event = False + # ── Orders FIRST (mirrors PERP connector pattern) ────────────────────────── + # Processing orders before trades ensures that the COI→UUID and SOI→COI maps + # are fully populated before fill matching is attempted. Without this ordering, + # account_all events that bundle a FILLED order state AND the fill details in + # the same message would lose the fill: the trade loop would try to match via + # server_order_index ("i") but the SOI→COI map wasn't populated yet, so the + # fill would hit the `unmatched_private_event` path and be permanently lost. + # PERP's _process_account_all_ws_event_message processes orders then trades; + # this aligns SPOT with that architecture. + for order_data in orders: + order_update = self._order_update_from_raw_message(order_data) + if order_update is not None: + # ── False-cancel guard ──────────────────────────────────────────────── + # Lighter's account_all_orders channel dumps ALL historical orders on + # subscription (snapshot replay). On WS reconnect this can deliver an + # old CANCELED order whose client_order_index coincidentally matches a + # newly placed order — firing a false CANCELED event within milliseconds + # of creation. A real cancel TX takes ~29 s on-chain, so any CANCELED + # event for an order younger than _CANCEL_MIN_ORDER_AGE_SECS is almost + # certainly a snapshot replay. Suppress it and verify via REST instead. + # NOTE: account_order_updates does NOT send historical snapshots — only + # real-time events — so the guard must NOT apply to those events. + if order_update.new_state == OrderState.CANCELED and _is_account_all_orders_event: + _cancel_order_snap = ( + self._order_tracker.all_updatable_orders.get(order_update.client_order_id) + or self._order_tracker.all_fillable_orders.get(order_update.client_order_id) + ) + if _cancel_order_snap is not None: + _order_age = time.time() - float(_cancel_order_snap.creation_timestamp or 0) + if _order_age < self._CANCEL_MIN_ORDER_AGE_SECS: + self.logger().debug( + "[ws-cancel guard] Suppressing CANCELED WS event for %s " + "(age=%.2fs < %.0fs — likely subscription snapshot replay). " + "Scheduling REST verification.", + order_update.client_order_id, + _order_age, + self._CANCEL_MIN_ORDER_AGE_SECS, + ) + safe_ensure_future(self._verify_cancel_not_false(_cancel_order_snap)) + continue # Do NOT pass this CANCELED event to process_order_update + # ── End false-cancel guard ──────────────────────────────────────────── + # When FILLED arrives via WS, eagerly fetch fills in the background so + # they arrive before wait_until_completely_filled() times out (5 s). + # This eliminates the 23-second fill delay and also covers orders that + # are already in cached_orders (e.g. CANCELED before FILLED). + if order_update.new_state in (OrderState.FILLED, OrderState.CANCELED): + _ws_fill_order = ( + self._order_tracker.all_fillable_orders.get(order_update.client_order_id) + or self._order_tracker.all_fillable_orders_by_exchange_order_id.get( + order_update.exchange_order_id or "" + ) + ) + if _ws_fill_order is not None: + safe_ensure_future(self._fetch_and_apply_fills(_ws_fill_order)) + # Optimistically free the locked balance immediately on cancel confirmation. + # The account_all_assets WS balance push may arrive 30+ seconds after the + # cancel WS event. During that window the strategy would compute a stale + # (too small) order size from the still-locked balance. By releasing the + # locked portion now the strategy can immediately place orders at the + # correct size. The WS push will overwrite with the authoritative value. + if order_update.new_state == OrderState.CANCELED: + _cancel_snap = ( + self._order_tracker.all_updatable_orders.get(order_update.client_order_id) + or self._order_tracker.all_fillable_orders.get(order_update.client_order_id) + ) + if _cancel_snap is not None: + self._release_locked_balance_on_cancel(_cancel_snap) + # Optimistically update balance when a fill is confirmed. The received + # quote (SELL) or base (BUY) may not arrive via WS/REST for seconds due + # to rate-limiting. Using order.price as the approximation is correct + # for maker orders (exact fill price) and a safe estimate for takers. + elif order_update.new_state == OrderState.FILLED: + _fill_snap = ( + self._order_tracker.all_fillable_orders.get(order_update.client_order_id) + or self._order_tracker.all_fillable_orders_by_exchange_order_id.get( + order_update.exchange_order_id or "" + ) + ) + if _fill_snap is not None: + self._release_locked_balance_on_fill(_fill_snap) + self._order_tracker.process_order_update(order_update) + self._schedule_balance_sync_for_terminal_update(order_update=order_update) + else: + unmatched_private_event = True + + # After processing orders, replay any buffered account_trades fills that + # previously couldn't be matched (COI/SOI maps are now current). + # Mirrors PERP's _replay_pending_trade_entries() called from + # _process_account_all_ws_event_message. + if orders and getattr(self, "_pending_spot_trade_entries", []): + await self._replay_pending_spot_trade_entries() + + # ── Trades (after orders so COI/SOI maps are current) ────────────────────── + for trade in trades: + trade_update = self._trade_update_from_raw_message(trade) + if trade_update is not None: + # Snapshot order state BEFORE applying fill to detect post-fill state. + _fill_order = self._order_tracker.all_fillable_orders.get(trade_update.client_order_id) + self._order_tracker.process_trade_update(trade_update) + if _fill_order is not None: + _order_state = getattr(_fill_order, "current_state", None) + if _order_state == OrderState.CANCELED: + # Cancel-fill race: order was CANCELED but fill arrived later + # via account_trades WS (10-40s lag). The CANCELED event + # already freed locked collateral but didn't credit the received + # quote (USDC for SELL). Apply the fill balance credit now — + # mirrors the _fetch_and_apply_fills REST retry path fix. + self._release_locked_balance_on_fill(_fill_order) + self.logger().info( + "[cancel-fill race WS] Order %s was canceled but fill " + "arrived via account_trades WS — applied fill balance " + "credit immediately.", + _fill_order.client_order_id, + ) + elif not _fill_order.is_done: + # Mirror PERP connector: if fills now make the order fully + # filled, fire FILLED state immediately so + # BuyOrderCompletedEvent fires WITH correct fill amounts. + # Handles the case where account_trades arrives before the + # account_all FILLED order update. + try: + _exec = _fill_order.executed_amount_base + _total = Decimal(str(_fill_order.amount)) + if not _total.is_nan() and _total > 0 and _exec >= _total: + self._order_tracker.process_order_update(OrderUpdate( + trading_pair=_fill_order.trading_pair, + update_timestamp=trade_update.fill_timestamp, + new_state=OrderState.FILLED, + client_order_id=_fill_order.client_order_id, + exchange_order_id=_fill_order.exchange_order_id, + )) + except Exception: + pass + elif _is_any_trades_channel_event: + # Fill arrived from a dedicated trade channel (account_trades PERP or + # account_all_trades SPOT) but we can't match the order yet. + # Buffer for replay after the next order-update batch, mirrors PERP. + _spot_buf = getattr(self, "_pending_spot_trade_entries", None) + if _spot_buf is not None: + _spot_buf.append((time.time(), trade)) + elif _looks_like_fill(trade): + # Unmatched fill from a non-account_trades channel (e.g. account_all + # bundled trade that arrived before the SOI→COI map was populated). + # Buffer for replay the same way as account_trades fills so the next + # order-update batch can resolve the match without falling back to REST. + _spot_buf = getattr(self, "_pending_spot_trade_entries", None) + if _spot_buf is not None: + _spot_buf.append((time.time(), trade)) + else: + unmatched_private_event = True + + if unmatched_private_event: + self._schedule_unmatched_private_event_reconcile(min_interval_seconds=1.0) + + # Some private event payloads include order/trade changes but omit account assets. + # Trigger a throttled balance refresh so locked/available values in status --live + # reflect open/canceled orders without waiting for the next periodic poll. + # Skip if account_all_assets already delivered a fresh WS balance snapshot recently + # (within 2 s) — the WS push is authoritative and a REST call is redundant. + if ( + (not has_assets_payload) + and (len(trades) > 0 or len(orders) > 0) + and (self._current_timestamp_safely() - getattr(self, "_last_private_stream_balance_sync_ts", 0.0)) >= 1.0 + and (self._current_timestamp_safely() - getattr(self, "_last_ws_balance_update_ts", 0.0)) >= 2.0 + ): + self._last_private_stream_balance_sync_ts = self._current_timestamp_safely() + safe_ensure_future(self._safe_update_balances_from_private_stream()) + except asyncio.CancelledError: + raise + except Exception: + self.logger().error("Unexpected error in user stream listener loop.", exc_info=True) + await self._sleep(5.0) + + async def _replay_pending_spot_trade_entries(self) -> None: + """Replay account_trades fill entries buffered when the COI→UUID mapping wasn't yet established. + + Called after processing an account_all order-update batch that may have populated + _server_order_index_to_client_order_index and _client_order_index_to_client_order_id. + Mirrors PERP's _replay_pending_trade_entries pattern. + + Entries still unmatched after 5 seconds are escalated to reconciliation (REST status poll). + """ + now = time.time() + still_pending: List[Tuple[float, Dict[str, Any]]] = [] + for buffered_ts, trade_data in self._pending_spot_trade_entries: + trade_update = self._trade_update_from_raw_message(trade_data) + if trade_update is not None: + _fill_order = self._order_tracker.all_fillable_orders.get(trade_update.client_order_id) + self._order_tracker.process_trade_update(trade_update) + if _fill_order is not None and not _fill_order.is_done: + try: + _total = Decimal(str(_fill_order.amount)) + if not _total.is_nan() and _total > 0 and _fill_order.executed_amount_base >= _total: + self._order_tracker.process_order_update(OrderUpdate( + trading_pair=_fill_order.trading_pair, + update_timestamp=trade_update.fill_timestamp, + new_state=OrderState.FILLED, + client_order_id=_fill_order.client_order_id, + exchange_order_id=_fill_order.exchange_order_id, + )) + except Exception: + pass + self.logger().debug( + "[replay-fill] Replayed buffered account_trades fill for %s (buffered for %.1fs)", + trade_update.client_order_id, + now - buffered_ts, + ) + else: + age = now - buffered_ts + if age < 5.0: + still_pending.append((buffered_ts, trade_data)) + else: + # Stale unmatched fill — escalate to REST reconciliation + self.logger().debug( + "[replay-fill] Discarding stale buffered fill after %.1fs — scheduling reconciliation.", + age, + ) + self._schedule_unmatched_private_event_reconcile(min_interval_seconds=0.0) + self._pending_spot_trade_entries = still_pending + + async def _fetch_and_apply_fills(self, order: InFlightOrder, delay: float = 0.0, _retries_left: int = 7): + """Fetch fills for *order* and apply them via process_trade_update. + + Called in the background when a FILLED state arrives via WebSocket so that + fill details reach the tracker before wait_until_completely_filled() times out. + Also handles orders already in cached_orders (e.g. cancelled then filled race). + + A dedup guard (_fill_fetch_in_progress) prevents multiple concurrent /trades + requests for the same order from bursting the exchange rate limit. If a fetch + is already running when this coroutine is entered, it returns immediately. + Pass *delay* > 0 to back off before retrying after a rate-limit failure. + *_retries_left* controls how many additional 8-second retries are allowed when + 0 fills are found; the default of 7 gives a 56-second window (0+8*7 s). + Lighter REST /trades indexing can lag up to ~40 s after on-chain match for + simultaneous cancel-fill races, so multiple retries are needed. + """ + order_id = order.client_order_id + if order_id in self._fill_fetch_in_progress: + return # Another fetch is already running for this order + self._fill_fetch_in_progress.add(order_id) + try: + if delay > 0: + await asyncio.sleep(delay) + fills = await self._all_trade_updates_for_order(order) + for fill in fills: + self._order_tracker.process_trade_update(fill) + if fills: + # Cancel-fill race recovery: if the order is in CANCELED state but fills were + # found, the cancel TX was processed AFTER the fill had already matched on-chain. + # The exchange emitted a CANCELED WS event (from account_all_orders) BEFORE the + # fill arrived via account_trades, so _release_locked_balance_on_cancel ran and + # freed the locked collateral but didn't credit the received quote (e.g. USDC + # for a SELL fill). Apply the fill balance credit now so the strategy's next + # tick sees the correct available USDC without waiting for the next WS + # account_all_assets push (which may take 30+ seconds). + _order_current_state = getattr(order, "current_state", None) + if _order_current_state == OrderState.CANCELED: + self._release_locked_balance_on_fill(order) + self.logger().info( + "[cancel-fill race] Order %s was canceled but fills found — " + "applied fill balance credit immediately (USDC credited, locked base corrected).", + order.client_order_id, + ) + self.logger().debug( + "[ws-fill] Applied %d fill(s) for order %s from eager REST fetch", + len(fills), + order.client_order_id, + ) + elif _retries_left > 0: + # No fills found — the REST /trades endpoint may not have indexed the fill yet. + # Lighter REST indexing lags 2–40 s after on-chain match for cancel-fill races. + # Schedule another retry (up to _retries_left more attempts, each 8 s apart). + self.logger().debug( + "[ws-fill] No fills found for %s (retries_left=%d) — scheduling retry in 8s", + order.client_order_id, + _retries_left, + ) + safe_ensure_future(self._fetch_and_apply_fills(order, delay=8.0, _retries_left=_retries_left - 1)) + else: + self.logger().debug( + "[ws-fill] No fills found for %s after all retries — giving up", + order.client_order_id, + ) + except asyncio.CancelledError: + raise + except Exception as err: + self.logger().debug( + "[ws-fill] Eager fill fetch failed for %s: %s", + order.client_order_id, + err, + ) + finally: + self._fill_fetch_in_progress.discard(order_id) + + async def _verify_cancel_not_false(self, order: InFlightOrder, delay: float = 2.0) -> None: + """REST-verify an order whose WS CANCELED event was suppressed as a likely false cancel. + + Waits *delay* seconds (so the REST API reflects the latest sequencer state), then polls + the order status once. Three outcomes: + + * Order is truly CANCELED on the exchange → apply the CANCELED update now (late but correct). + * Order is still OPEN → false cancel confirmed; do nothing. The order continues to be + tracked normally and will be cancelled/filled through the regular refresh cycle. + * Request fails (e.g., 429 rate-limit) → log and do nothing; the periodic status poll + will catch the real state within UPDATE_ORDER_STATUS_MIN_INTERVAL seconds. + """ + try: + await asyncio.sleep(delay) + order_update = await self._request_order_status(order) + if order_update.new_state == OrderState.CANCELED: + self.logger().debug( + "[ws-cancel guard] REST confirmed CANCELED for %s — applying state.", + order.client_order_id, + ) + _cancel_snap = self._order_tracker.all_fillable_orders.get(order.client_order_id) + if _cancel_snap is not None: + self._release_locked_balance_on_cancel(_cancel_snap) + self._order_tracker.process_order_update(order_update) + self._schedule_balance_sync_for_terminal_update(order_update=order_update) + else: + self.logger().debug( + "[ws-cancel guard] REST confirmed %s is still %s — WS CANCELED was a false cancel " + "(subscription snapshot replay). Order tracking preserved.", + order.client_order_id, + order_update.new_state.name, + ) + except asyncio.CancelledError: + raise + except Exception as ex: + self.logger().debug( + "[ws-cancel guard] REST verification failed for %s: %s — " + "the next explicit reconcile or stale-stream fallback will reconcile.", + order.client_order_id, + ex, + ) + + def _current_timestamp_safely(self) -> float: + try: + return self.current_timestamp + except Exception: + return time.time() + + def _private_user_stream_last_recv_time(self) -> float: + try: + tracker = getattr(self, "_user_stream_tracker", None) + if tracker is None: + return 0.0 + + last_recv_time = getattr(tracker, "last_recv_time", None) + if last_recv_time is None: + data_source = getattr(tracker, "data_source", None) + last_recv_time = getattr(data_source, "last_recv_time", 0.0) + + return float(last_recv_time or 0.0) + except Exception: + return 0.0 + + def _is_private_user_stream_healthy(self) -> bool: + last_recv_time = self._private_user_stream_last_recv_time() + if last_recv_time <= 0: + return False + return (self._current_timestamp_safely() - last_recv_time) < self.TICK_INTERVAL_LIMIT + + def _should_poll_balances_via_rest(self, force_rest: bool = False) -> bool: + if force_rest: + return True + + last_balance_ts = float(getattr(self, "_last_balance_update_timestamp", 0.0) or 0.0) + last_ws_balance_ts = float(getattr(self, "_last_ws_balance_update_ts", 0.0) or 0.0) + required_since = float(getattr(self, "_balance_refresh_required_since", 0.0) or 0.0) + + if required_since > max(last_balance_ts, last_ws_balance_ts): + return True + + if last_balance_ts <= 0 and last_ws_balance_ts <= 0: + return True + + return not self._is_private_user_stream_healthy() + + def _should_reconcile_orders_via_rest(self, force_rest_reconcile: bool = False) -> bool: + if force_rest_reconcile: + return True + return not self._is_private_user_stream_healthy() + + async def _safe_update_balances_from_private_stream(self): + try: + await self._update_balances(force_rest=True) + except asyncio.CancelledError: + raise + except Exception as balance_error: + self.logger().debug( + "Private-stream-triggered balance refresh failed: %s", + balance_error, + ) + + def _schedule_unmatched_private_event_reconcile(self, min_interval_seconds: float = 1.0): + now = self._current_timestamp_safely() + if (now - getattr(self, "_last_unmatched_private_event_reconcile_ts", 0.0)) < min_interval_seconds: + return + self._last_unmatched_private_event_reconcile_ts = now + safe_ensure_future(self._safe_reconcile_unmatched_private_event()) + + async def _safe_reconcile_unmatched_private_event(self): + try: + await self._update_order_status(force_rest_reconcile=True) + except asyncio.CancelledError: + raise + except Exception as reconcile_error: + self.logger().debug( + "Unmatched private-event reconcile failed: %s", + reconcile_error, + ) + + def _lock_balance_on_order_creation( + self, + trading_pair: str, + amount: Decimal, + price: Decimal, + trade_type: TradeType, + ) -> None: + """Optimistically deduct the allocated balance immediately when an order is submitted. + + The account_all_assets WS push and the fast REST sync are asynchronous — during + the window before they complete, _account_available_balances still shows the + pre-order value. If the strategy evaluates balance in that window it may place a + second order thinking it has more capital available than is actually locked. + + For a BUY order: deduct amount × price from the quote asset (e.g. USDC). + For a SELL order: deduct amount from the base asset. + + This is the inverse of _release_locked_balance_on_cancel; both are best-effort + and corrected by the authoritative REST/WS balance sync when it arrives. + """ + try: + base, quote = trading_pair.split("-", 1) + if trade_type == TradeType.BUY: + locked = amount * price + if locked > 0 and quote in self._account_available_balances: + new_avail = max(Decimal("0"), self._account_available_balances[quote] - locked) + self._account_available_balances[quote] = new_avail + _opt_locks = getattr(self, "_optimistic_balance_lock", None) + if _opt_locks is None: + self._optimistic_balance_lock: Dict[str, Any] = {} + _opt_locks = self._optimistic_balance_lock + _opt_locks[quote] = (new_avail, time.time()) + elif trade_type == TradeType.SELL: + if amount > 0 and base in self._account_available_balances: + new_avail = max(Decimal("0"), self._account_available_balances[base] - amount) + self._account_available_balances[base] = new_avail + _opt_locks = getattr(self, "_optimistic_balance_lock", None) + if _opt_locks is None: + self._optimistic_balance_lock: Dict[str, Any] = {} + _opt_locks = self._optimistic_balance_lock + _opt_locks[base] = (new_avail, time.time()) + except Exception: + pass # Best-effort; REST/WS sync will correct any imprecision + + def _release_locked_balance_on_cancel(self, order: "InFlightOrder") -> None: + """Optimistically free the locked balance immediately when a cancel is confirmed. + + The account_all_assets WS balance push can arrive 30+ seconds after the cancel + WS event. During that window the strategy computes order sizes from the stale + (still-locked) balance and ends up placing orders that are too small to meet + minimum notional. By releasing the unfilled locked portion now, the strategy + immediately sees the correct available balance. + + The WS/REST balance push will overwrite _account_available_balances with the + authoritative exchange value when it arrives, so any imprecision here is transient. + """ + try: + trading_pair = order.trading_pair + base, quote = trading_pair.split("-", 1) + amount = Decimal(str(order.amount)) + price = Decimal(str(order.price)) + executed = Decimal(str(getattr(order, "executed_amount_base", Decimal("0")) or "0")) + remaining = max(Decimal("0"), amount - executed) + + if order.trade_type == TradeType.BUY: + # Free the quote asset (e.g. USDC) locked for the unfilled portion. + freed = remaining * price + if freed > 0 and quote in self._account_available_balances: + new_avail = self._account_available_balances[quote] + freed + # Cap at total balance so we never report more than we have. + total = self._account_balances.get(quote, new_avail) + new_avail_capped = min(new_avail, total) + self._account_available_balances[quote] = new_avail_capped + # Record the optimistic release so _process_balance_message_from_account + # can guard against stale account_all_assets WS events (which still show + # the locked balance) arriving after the cancel was processed and + # overwriting the correctly-released available balance. + _opt = getattr(self, "_optimistic_balance_release", {}) + _opt[quote] = (new_avail_capped, time.time()) + self._optimistic_balance_release = _opt + elif order.trade_type == TradeType.SELL: + # Free the base asset (e.g. UNI) locked for the unfilled portion. + if remaining > 0 and base in self._account_available_balances: + new_avail = self._account_available_balances[base] + remaining + total = self._account_balances.get(base, new_avail) + new_avail_capped = min(new_avail, total) + self._account_available_balances[base] = new_avail_capped + _opt = getattr(self, "_optimistic_balance_release", {}) + _opt[base] = (new_avail_capped, time.time()) + self._optimistic_balance_release = _opt + except Exception: + pass # Best-effort; WS push will correct any imprecision + + def _release_locked_balance_on_fill(self, order: "InFlightOrder") -> None: + """Optimistically update available balance immediately when a fill is confirmed. + + When a FILLED WS event arrives the exchange-side balance has already changed, but + the account_all_assets WS push and the REST balance poll may be delayed (or + rate-limited). During that window the strategy computes order sizes from the + pre-fill balance and places orders that are too small to meet minimum notional. + + For a SELL fill: quote (USDC) received = order.amount × order.price. + For a BUY fill: base received = order.amount. + + This is best-effort; the authoritative WS/REST balance will overwrite these values. + """ + try: + trading_pair = order.trading_pair + base, quote = trading_pair.split("-", 1) + amount = Decimal(str(order.amount)) + price = Decimal(str(order.price)) + + if order.trade_type == TradeType.SELL: + # We sold base → received quote. + received_quote = amount * price + if received_quote > 0 and quote in self._account_available_balances: + self._account_available_balances[quote] = ( + self._account_available_balances[quote] + received_quote + ) + self._account_balances[quote] = self._account_balances.get(quote, Decimal("0")) + received_quote + # Base was locked for the order; it's now spent. + if base in self._account_balances: + self._account_balances[base] = max(Decimal("0"), self._account_balances[base] - amount) + # available was locked so net effect on available ≈ 0, just cap at new total + self._account_available_balances[base] = min( + self._account_available_balances.get(base, Decimal("0")), + self._account_balances[base], + ) + elif order.trade_type == TradeType.BUY: + # We bought base → received base, spent quote (already locked). + if base in self._account_available_balances: + self._account_available_balances[base] = ( + self._account_available_balances[base] + amount + ) + self._account_balances[base] = self._account_balances.get(base, Decimal("0")) + amount + # Quote was locked; it's spent. + spent_quote = amount * price + if quote in self._account_balances: + self._account_balances[quote] = max(Decimal("0"), self._account_balances[quote] - spent_quote) + self._account_available_balances[quote] = min( + self._account_available_balances.get(quote, Decimal("0")), + self._account_balances[quote], + ) + except Exception: + pass # Best-effort; WS push will correct any imprecision + + def _schedule_fast_balance_sync(self, min_interval_seconds: float = 0.2): + now = self._current_timestamp_safely() + if (now - getattr(self, "_last_private_stream_balance_sync_ts", 0.0)) < min_interval_seconds: + return + self._last_private_stream_balance_sync_ts = now + safe_ensure_future(self._safe_update_balances_from_private_stream()) + + def _schedule_balance_sync_for_terminal_update( + self, + order_update: OrderUpdate, + tracked_order: Optional[InFlightOrder] = None, + ): + # CANCELED and FILLED orders change exchange locked_balance → require fresh REST data. + # FAILED orders that were rejected before submission don't change exchange state, + # so don't block future BUY orders on a mandatory balance refresh. + if order_update.new_state in {OrderState.CANCELED, OrderState.FILLED}: + _ = tracked_order + self._balance_refresh_required_since = max( + self._balance_refresh_required_since, + self._current_timestamp_safely(), + ) + # Only trigger a REST balance poll when account_all_assets WS push has not + # delivered a fresh snapshot recently (≤1 s). The WS push is the primary source. + if (self._current_timestamp_safely() - getattr(self, "_last_ws_balance_update_ts", 0.0)) >= 1.0: + self._schedule_fast_balance_sync(min_interval_seconds=0.2) + elif order_update.new_state == OrderState.FAILED: + # Still do a fast sync (balance might have been reserved briefly) but + # don't mark it as required so BUY orders aren't blocked if it fails. + if (self._current_timestamp_safely() - getattr(self, "_last_ws_balance_update_ts", 0.0)) >= 2.0: + self._schedule_fast_balance_sync(min_interval_seconds=1.0) + + @staticmethod + def _account_payload_has_assets(account_data: Optional[Dict[str, Any]]) -> bool: + if not isinstance(account_data, dict): + return False + + assets = account_data.get("assets") + if isinstance(assets, list): + return any(isinstance(asset, dict) for asset in assets) + if isinstance(assets, dict): + return any(isinstance(asset, dict) for asset in assets.values()) + return False + + @staticmethod + def _extract_private_stream_payloads(event_message: Dict[str, Any]) -> Tuple[Optional[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]: + account_data: Optional[Dict[str, Any]] = None + trades: List[Dict[str, Any]] = [] + orders: List[Dict[str, Any]] = [] + + message_type = str(event_message.get("type", "")) + event_type_name = message_type.split("/", 1)[1] if "/" in message_type else message_type + channel = str(event_message.get("channel", "")) + payload = event_message.get("data") + + if isinstance(event_message.get("account"), dict): + account_data = event_message.get("account") + elif isinstance(payload, dict) and isinstance(payload.get("account"), dict): + account_data = payload.get("account") + elif event_type_name in { + CONSTANTS.WS_ACCOUNT_ALL_CHANNEL, + CONSTANTS.WS_ACCOUNT_INFO_CHANNEL, + }: + if isinstance(payload, dict): + # Some environments wrap the payload in a "data" key. + account_data = payload + elif isinstance(event_message.get("assets"), (dict, list)): + # Live account_all WS events put assets at the top level (no "data" wrapper). + # Normalise into the same shape as the account_all_assets handler below. + _assets_raw = event_message["assets"] + if isinstance(_assets_raw, dict): + _assets_list = [a for a in _assets_raw.values() if isinstance(a, dict)] + else: + _assets_list = [a for a in _assets_raw if isinstance(a, dict)] + account_data = {"assets": _assets_list} + elif event_type_name == CONSTANTS.WS_ACCOUNT_ALL_ASSETS_CHANNEL: + assets_payload = None + if isinstance(event_message.get("assets"), (dict, list)): + assets_payload = event_message.get("assets") + elif isinstance(payload, dict) and isinstance(payload.get("assets"), (dict, list)): + assets_payload = payload.get("assets") + + if assets_payload is not None: + if isinstance(assets_payload, dict): + assets_list = [asset for asset in assets_payload.values() if isinstance(asset, dict)] + else: + assets_list = [asset for asset in assets_payload if isinstance(asset, dict)] + account_data = {"assets": assets_list} + + # trades may be a flat list OR a dict keyed by market_id (e.g. account_all sends + # {"market_id": Trade} or {"market_id": [Trade, ...]}). Handle all variants. + for _trades_src in (event_message, payload if isinstance(payload, dict) else {}): + _tf = _trades_src.get("trades") + if isinstance(_tf, list): + trades.extend([t for t in _tf if isinstance(t, dict)]) + elif isinstance(_tf, dict): + for _mkt_val in _tf.values(): + if isinstance(_mkt_val, list): + trades.extend([t for t in _mkt_val if isinstance(t, dict)]) + elif isinstance(_mkt_val, dict): + trades.append(_mkt_val) + if isinstance(event_message.get("trade"), dict): + trades.append(event_message.get("trade")) + if isinstance(payload, dict) and isinstance(payload.get("trade"), dict): + trades.append(payload.get("trade")) + + if event_type_name == CONSTANTS.WS_ACCOUNT_TRADES_CHANNEL: + if isinstance(payload, list): + trades.extend([trade for trade in payload if isinstance(trade, dict)]) + elif isinstance(payload, dict) and "trades" not in payload: + trades.append(payload) + + # orders may be a flat list OR a dict keyed by market_id (e.g. account_all_orders sends + # {"market_id": [Order, ...]}). Handle all variants. + for _orders_src in (event_message, payload if isinstance(payload, dict) else {}): + _of = _orders_src.get("orders") + if isinstance(_of, list): + orders.extend([o for o in _of if isinstance(o, dict)]) + elif isinstance(_of, dict): + for _mkt_val in _of.values(): + if isinstance(_mkt_val, list): + orders.extend([o for o in _mkt_val if isinstance(o, dict)]) + elif isinstance(_mkt_val, dict): + orders.append(_mkt_val) + if isinstance(event_message.get("order"), dict): + orders.append(event_message.get("order")) + if isinstance(payload, dict) and isinstance(payload.get("order"), dict): + orders.append(payload.get("order")) + + if ( + event_type_name == CONSTANTS.WS_ACCOUNT_ORDER_UPDATES_CHANNEL + or channel.startswith(f"{CONSTANTS.WS_ACCOUNT_ORDER_UPDATES_CHANNEL}:") + or channel.startswith(f"{CONSTANTS.WS_ACCOUNT_ORDER_UPDATES_CHANNEL}/") + ): + if isinstance(payload, list): + orders.extend([order for order in payload if isinstance(order, dict)]) + elif isinstance(payload, dict) and "orders" not in payload: + orders.append(payload) + + if ( + event_type_name == CONSTANTS.WS_ACCOUNT_ALL_ORDERS_CHANNEL + or channel.startswith(f"{CONSTANTS.WS_ACCOUNT_ALL_ORDERS_CHANNEL}:") + or channel.startswith(f"{CONSTANTS.WS_ACCOUNT_ALL_ORDERS_CHANNEL}/") + ): + # Some environments send dedicated account_all_orders updates under a flat + # `data` payload instead of the documented top-level `orders` map. + if isinstance(payload, list): + orders.extend([order for order in payload if isinstance(order, dict)]) + elif isinstance(payload, dict) and "orders" not in payload: + orders.append(payload) + + # account_tx: txs is a list of Account_tx objects (each is an Order JSON, possibly + # wrapped under an "order" key). Extract as order updates for status + fill tracking. + if ( + event_type_name == CONSTANTS.WS_ACCOUNT_TX_CHANNEL + or channel.startswith(f"{CONSTANTS.WS_ACCOUNT_TX_CHANNEL}:") + or channel.startswith(f"{CONSTANTS.WS_ACCOUNT_TX_CHANNEL}/") + ): + txs = event_message.get("txs", []) + if not isinstance(txs, list) and isinstance(payload, dict): + txs = payload.get("txs", []) + if isinstance(txs, list): + for tx in txs: + if isinstance(tx, dict): + order_obj = tx.get("order") if isinstance(tx.get("order"), dict) else tx + if isinstance(order_obj, dict): + orders.append(order_obj) + + return account_data, trades, orders + + def _state_from_raw_order_status(self, raw_status: str) -> OrderState: + return self._ORDER_STATE.get(raw_status.lower(), OrderState.OPEN) + + def _process_balance_message_from_account(self, account_data: Dict[str, Any]): + assets_payload = account_data.get("assets", []) + if isinstance(assets_payload, dict): + assets_iterable = [asset for asset in assets_payload.values() if isinstance(asset, dict)] + else: + assets_iterable = assets_payload + + ws_asset_names = set() + for asset_entry in assets_iterable: + asset_symbol = asset_entry.get("symbol") + if asset_symbol is None: + continue + + ws_asset_names.add(asset_symbol) + total_balance = Decimal(str(asset_entry.get("balance") or "0")) + locked_balance = Decimal(str(asset_entry.get("locked_balance") or "0")) + available_balance = total_balance - locked_balance + + self._account_balances[asset_symbol] = total_balance + + # Guard against a stale account_all_assets WS event (which still carries the + # locked balance from when the order was placed) arriving AFTER + # _release_locked_balance_on_cancel has already optimistically freed that lock. + # Such stale events would reduce available_balance back to the locked value, + # causing the strategy to compute an undersized order (e.g. 1.21 UNI instead + # of 3.8 UNI) and fail the minimum-notional check. + # The guard allows the optimistic release to stand for up to 3 seconds. Once a + # WS event arrives showing locked_balance == 0 (confirming the cancel on-chain) + # or 3 seconds elapse, the guard clears automatically. + _opt_releases = getattr(self, "_optimistic_balance_release", {}) + _opt_entry = _opt_releases.get(asset_symbol) + if _opt_entry is not None: + _opt_avail, _opt_ts = _opt_entry + if locked_balance > Decimal("0") and available_balance < _opt_avail and (time.time() - _opt_ts) < 3.0: + # WS event still shows a lock that we already released — skip the + # available-balance update but keep the total-balance update above. + continue + else: + # Guard condition no longer applies — clear it. + _opt_releases.pop(asset_symbol, None) + + # Guard against a stale WS event undoing an optimistic order-placement lock. + # After an order is placed, the exchange may not yet have added it to + # locked_balance (ZK batch propagation lag, up to a few seconds). A WS event + # arriving in that window shows locked_balance == 0 and a too-high available — + # skip overwriting the lock until the exchange catches up or 3 s elapse. + _opt_locks = getattr(self, "_optimistic_balance_lock", {}) + _opt_lock_entry = _opt_locks.get(asset_symbol) + if _opt_lock_entry is not None: + _opt_locked_avail, _opt_lock_ts = _opt_lock_entry + if locked_balance == Decimal("0") and available_balance > _opt_locked_avail and (time.time() - _opt_lock_ts) < 3.0: + # Exchange hasn't registered the new order yet — keep optimistic lock. + continue + else: + _opt_locks.pop(asset_symbol, None) + + self._account_available_balances[asset_symbol] = available_balance + + # Remove assets that have gone to zero and are no longer in the WS payload. + # Mirrors the cleanup done by the REST _update_balances to prevent ghost entries. + # Only do this when the payload is a full snapshot (has more than one asset or + # when the total local asset count is small) to avoid removing assets on partial updates. + if len(ws_asset_names) > 0: + for local_asset in list(self._account_balances.keys()): + if local_asset not in ws_asset_names and self._account_balances.get(local_asset, Decimal("1")) == Decimal("0"): + self._account_balances.pop(local_asset, None) + self._account_available_balances.pop(local_asset, None) + + # For the spot connector, available balance is derived from per-asset wallet balances + # (`balance - locked_balance`) rather than the account-level `available_balance` field. + # This ensures allocated percentage calculations reflect the true per-asset balance. + + def _order_update_from_raw_message(self, order_data: Dict[str, Any]) -> Optional[OrderUpdate]: + # exchange_order_id == str(client_order_index) in this connector. + # Prefer client_order_id / client_order_index so order lookup succeeds. + # Also handle compact WS format keys: "I" = client_order_index, "i" = server order_index. + # From lighter-go cancel_order.go: cancel/modify Index field accepts either value, + # so we keep exchange_order_id = client_order_index throughout the lifecycle. + exchange_order_id = str( + order_data.get("client_order_id") + or order_data.get("client_order_index") + or order_data.get("I") # compact WS: client_order_index + or order_data.get("order_id") + or order_data.get("orderId") + or order_data.get("order_index") + or order_data.get("orderIndex") + or order_data.get("i") # compact WS: server order_index (lowest priority) + or "" + ) + # Populate server_order_index → client_order_index reverse map when both are available. + # This allows compact account_trades fills (which carry only "i") to be matched even + # when the WS trade event arrives before the order update updates exchange_order_id. + _coi_raw = str(order_data.get("I") or order_data.get("client_order_index") or "") + _soi_raw = str(order_data.get("i") or order_data.get("order_index") or order_data.get("orderIndex") or "") + # Full-format events (account_all) use "order_id" (= SOI as string) together with + # "client_order_index" or "client_order_id" (= COI). Extract the SOI from "order_id" + # when a COI field is also present so we can build the SOI→COI map even in non-compact + # format where "i"/"order_index" may be absent. + if not _soi_raw and _coi_raw: + _soi_raw = str(order_data.get("order_id") or order_data.get("orderId") or "") + if _coi_raw and _soi_raw and _coi_raw != _soi_raw: + self._server_order_index_to_client_order_index[_soi_raw] = _coi_raw + client_order_id = str(order_data.get("client_order_id") or order_data.get("clientOrderId") or "") + tracked_order = self._order_tracker.all_updatable_orders.get(client_order_id) + + if tracked_order is None and exchange_order_id: + tracked_order = self._order_tracker.all_fillable_orders_by_exchange_order_id.get(exchange_order_id) + + # FALLBACK — compact format with I=null (SOI-only update). + # The exchange sends account_all_orders compact events where "I" (client_order_index) + # is null but "i" (server order_index), "s" (symbol), and "d" (direction) are present. + # When the standard lookups fail because the order's exchange_order_id is the COI + # (not the SOI), use the symbol+direction from the event to find the one active order + # in that market/direction — the same heuristic the PERP connector uses via its + # multi-path order matching. Only safe when exactly ONE candidate remains after + # filtering; with multiple open orders in the same direction, fall through to reconcile. + if tracked_order is None and _soi_raw: + _ev_symbol = str(order_data.get("s") or "") + _ev_direction = str(order_data.get("d") or "") + _ev_price_str = str(order_data.get("p") or order_data.get("ip") or "") + _soi_map = getattr(self, "_server_order_index_to_client_order_index", {}) + candidates = [ + o for o in self._order_tracker.all_updatable_orders.values() + if not o.is_done + ] + if _ev_symbol: + candidates = [o for o in candidates if _ev_symbol in (o.trading_pair or "")] + if _ev_direction: + _expected_type = TradeType.BUY if _ev_direction == "bid" else TradeType.SELL + candidates = [o for o in candidates if o.trade_type == _expected_type] + if _ev_price_str: + try: + _ev_price = Decimal(_ev_price_str) + candidates = [o for o in candidates if abs((o.price or Decimal("0")) - _ev_price) < Decimal("0.01")] + except Exception: + pass + if len(candidates) == 1: + tracked_order = candidates[0] + _effective_coi = str(tracked_order.exchange_order_id or "") + if _effective_coi and _effective_coi != _soi_raw: + _soi_map[_soi_raw] = _effective_coi + self.logger().debug( + "[order-soi-fallback] SOI=%s → COI=%s for order %s " + "(matched via %s %s scan)", + _soi_raw, + _effective_coi, + tracked_order.client_order_id, + _ev_direction, + _ev_symbol, + ) + + if tracked_order is None: + return None + + # Whenever we found the tracked order, ensure the SOI→COI map is populated even + # when "I" (client_order_index) was absent from the event. This covers full-format + # account_all events where order_id=SOI and client_order_index=COI are both present + # (the earlier block handles it) as well as cases resolved by the fallback scan above. + # Having the map populated ensures subsequent compact account_trades fills (I=null, + # i=SOI) can be replayed and matched instantly once the order state is known. + if _soi_raw and not _coi_raw: + _effective_coi2 = str(tracked_order.exchange_order_id or "") + if _effective_coi2 and _effective_coi2 != _soi_raw: + getattr(self, "_server_order_index_to_client_order_index", {}).setdefault( + _soi_raw, _effective_coi2 + ) + + # "os" is the compact field used by account_order_updates; "order_status"/"status" are + # used by account_all_orders full-JSON format. Check compact format last as fallback. + raw_status = str(order_data.get("order_status") or order_data.get("status") or order_data.get("os") or "open") + # "ut" (updated_at ms) is the compact timestamp in account_order_updates format. + update_ts = float(order_data.get("updated_at") or order_data.get("created_at") or order_data.get("ut") or self.current_timestamp) + if update_ts > 1e12: + update_ts *= 1e-3 + if update_ts > 1e12: + update_ts *= 1e-3 + + return OrderUpdate( + client_order_id=tracked_order.client_order_id, + exchange_order_id=exchange_order_id or tracked_order.exchange_order_id, + trading_pair=tracked_order.trading_pair, + update_timestamp=update_ts, + new_state=self._state_from_raw_order_status(raw_status), + ) + + def _trade_update_from_raw_message(self, trade_data: Dict[str, Any]) -> Optional[TradeUpdate]: + if not isinstance(trade_data, dict): + return None + + # Try to find the tracked order via ask_client_id / bid_client_id (REST API field names). + # Also check _str suffixed variants used by the live account_all WS event format. + # Fall back to legacy order_id fields used by older WS formats. + # NilClientOrderIndex = 0 means "not set" — skip those to avoid spurious dict lookups. + tracked_order = None + for cid_field in ("ask_client_id_str", "bid_client_id_str", "ask_client_id", "bid_client_id", "ask_clientId", "bid_clientId"): + candidate_id = str(trade_data.get(cid_field) or "") + if candidate_id and candidate_id != "0": # 0 = NilClientOrderIndex (unset) + tracked_order = self._order_tracker.all_fillable_orders_by_exchange_order_id.get(candidate_id) + if tracked_order is None: + # Fallback: WS may have updated exchange_order_id to server order_id; look up + # via the client_order_index → client_order_id map recorded at placement time. + _coi_map = getattr(self, "_client_order_index_to_client_order_id", {}) + hb_coid = _coi_map.get(candidate_id) + if hb_coid: + tracked_order = self._order_tracker.all_fillable_orders.get(hb_coid) + if tracked_order is not None: + break + + if tracked_order is None: + # Legacy / compact WS format fallback. + # Prefer the client_order_id string (HB UUID) first, then try REST-format order_id. + # Also try the compact WS "i" field (server-assigned order_index): this is the + # primary field in account_trades compact messages when ask/bid_client_id are absent. + client_order_id = str(trade_data.get("client_order_id") or trade_data.get("clientOrderId") or "") + tracked_order = self._order_tracker.all_fillable_orders.get(client_order_id) + + if tracked_order is None: + exchange_order_id = str(trade_data.get("order_id") or trade_data.get("orderId") or "") + if exchange_order_id: + tracked_order = self._order_tracker.all_fillable_orders_by_exchange_order_id.get(exchange_order_id) + + if tracked_order is None: + # Compact account_trades format: "I" (capital I) = client_order_index (COI). + # This field is populated by the exchange in account_trades WS messages and + # allows matching BEFORE the SOI mapping is established by account_all updates. + # This is the PERP connector's Path 0 equivalent — O(1) lookup via COI map + # populated at order placement time, eliminating the race condition. + coi_from_I = str(trade_data.get("I") or "") + if coi_from_I and coi_from_I != "0": + _coi_map = getattr(self, "_client_order_index_to_client_order_id", {}) + hb_coid = _coi_map.get(coi_from_I) + if hb_coid: + tracked_order = self._order_tracker.all_fillable_orders.get(hb_coid) + + if tracked_order is None: + # Compact account_trades format: "i" = server order_index. + # Try direct lookup (works if the order update already updated exchange_order_id + # from client_order_index to server_order_index via the order-update path). + # Then try via the reverse map populated by _order_update_from_raw_message. + server_oi = str(trade_data.get("i") or "") + if server_oi: + tracked_order = self._order_tracker.all_fillable_orders_by_exchange_order_id.get(server_oi) + if tracked_order is None: + _soi_map = getattr(self, "_server_order_index_to_client_order_index", {}) + coi_str = _soi_map.get(server_oi) + if coi_str: + _coi_map = getattr(self, "_client_order_index_to_client_order_id", {}) + hb_coid = _coi_map.get(coi_str) + if hb_coid: + tracked_order = self._order_tracker.all_fillable_orders.get(hb_coid) + + if tracked_order is None: + # Log key fields to help diagnose which matching path failed. This is especially + # useful for understanding why account_all bundled trades or account_trades compact + # fills (I=null, i=SOI) can't be attributed to a tracked order. + self.logger().debug( + "[fill-unmatch] Could not match fill — " + "bid_client_id=%s ask_client_id=%s I=%s i=%s trade_id=%s " + "active_orders=%d soi_map_size=%d", + trade_data.get("bid_client_id") or trade_data.get("bid_client_id_str"), + trade_data.get("ask_client_id") or trade_data.get("ask_client_id_str"), + trade_data.get("I"), + trade_data.get("i"), + trade_data.get("trade_id") or trade_data.get("h"), + len(self._order_tracker.all_updatable_orders), + len(getattr(self, "_server_order_index_to_client_order_index", {})), + ) + return None + + fill_price = Decimal(str(trade_data.get("price") or trade_data.get("p") or "0")) + # API field is 'size'; 'amount'/'a' kept as fallbacks for compact WS formats. + fill_base_amount = Decimal(str(trade_data.get("size") or trade_data.get("amount") or trade_data.get("a") or "0")) + + # Determine taker/maker using is_maker_ask and order direction. + is_ask = (tracked_order.trade_type == TradeType.SELL) + is_maker_ask = trade_data.get("is_maker_ask", None) + if is_maker_ask is not None: + is_taker = (is_ask and not is_maker_ask) or (not is_ask and is_maker_ask) + else: + # Fallback for compact WS format. + is_taker = trade_data.get("event_type") == "fulfill_taker" + + fill_timestamp = float(trade_data.get("timestamp") or trade_data.get("created_at") or trade_data.get("t") or self.current_timestamp) + # REST API 'timestamp' is in seconds; milliseconds if > 1e12. + if fill_timestamp > 1e12: + fill_timestamp *= 1e-3 + + # Use actual fee amount from WS message when available (mirrors perp connector approach). + # Exchange WS messages carry taker_fee/maker_fee (rate in PPM) + usd_amount (USDC). + # Actual fee = usd_amount * fee_rate_ppm / 1_000_000. Falls back to schema estimate. + _fee_schema = self.trade_fee_schema() + _fee_percent = ( + _fee_schema.taker_percent_fee_decimal if is_taker else _fee_schema.maker_percent_fee_decimal + ) + _fee_raw = trade_data.get("taker_fee") if is_taker else trade_data.get("maker_fee") + _usd_amount_raw = trade_data.get("usd_amount") or trade_data.get("quote_amount") + if _fee_raw is not None and _usd_amount_raw is not None: + try: + _actual_fee = Decimal(str(_usd_amount_raw)) * Decimal(str(_fee_raw)) / Decimal("1000000") + fee = TradeFeeBase.new_spot_fee( + fee_schema=_fee_schema, + trade_type=tracked_order.trade_type, + percent_token=tracked_order.quote_asset, + flat_fees=[TokenAmount(amount=_actual_fee, token=tracked_order.quote_asset)], + ) + except Exception: + fee = TradeFeeBase.new_spot_fee( + fee_schema=_fee_schema, + trade_type=tracked_order.trade_type, + percent=_fee_percent, + percent_token=tracked_order.quote_asset, + flat_fees=[], + ) + else: + fee = TradeFeeBase.new_spot_fee( + fee_schema=_fee_schema, + trade_type=tracked_order.trade_type, + percent=_fee_percent, + percent_token=tracked_order.quote_asset, + flat_fees=[], + ) + + return TradeUpdate( + trade_id=str(trade_data.get("trade_id_str") or trade_data.get("trade_id") or trade_data.get("history_id") or trade_data.get("id") or trade_data.get("h") or ""), + client_order_id=tracked_order.client_order_id, + exchange_order_id=tracked_order.exchange_order_id, + trading_pair=tracked_order.trading_pair, + fill_timestamp=fill_timestamp, + fill_price=fill_price, + fill_base_amount=fill_base_amount, + fill_quote_amount=fill_price * fill_base_amount, + fee=fee, + is_taker=is_taker, + ) + + async def _format_trading_rules(self, exchange_info_dict: Dict[str, Any]) -> List[TradingRule]: + rules = [] + entries = exchange_info_dict.get("order_books") or exchange_info_dict.get("data") or [] + for entry in entries: + market_type = (entry.get("market_type") or "").lower() + if market_type and market_type != "spot": + continue + + symbol = entry.get("symbol") + if symbol is None: + continue + + hb_pair = self._hb_pair_from_symbol(symbol) + amount_decimals = int(entry.get("supported_size_decimals", 4)) + price_decimals = int(entry.get("supported_price_decimals", 2)) + min_amount_increment = Decimal("1") / (Decimal("10") ** amount_decimals) + min_price = Decimal("1") / (Decimal("10") ** price_decimals) + min_order_size = Decimal(str(entry.get("min_base_amount") or "0")) or min_amount_increment + min_notional = Decimal(str(entry.get("min_quote_amount") or "0")) + + rules.append( + TradingRule( + trading_pair=hb_pair, + min_order_size=min_order_size, + min_price_increment=min_price, + min_base_amount_increment=min_amount_increment, + min_notional_size=min_notional, + ) + ) + return rules + + async def start_network(self): + # Fetch balances immediately so check_budget_available() in the strategy tick + # sees real balances instead of zeros before the first polling interval fires. + # Wrap in try-except so a transient network error on startup doesn't prevent + # the WS and polling loop from starting; balance will be loaded on first poll. + try: + await self._update_balances(force_rest=True) + except Exception as e: + self.logger().warning( + "Initial balance fetch failed; will retry in next polling cycle: %s", e + ) + await super().start_network() + + async def _update_balances(self, force_rest: bool = False): + if not self._should_poll_balances_via_rest(force_rest=force_rest): + return + + response = await self._api_get( + path_url=CONSTANTS.GET_ACCOUNT_INFO_PATH_URL, + params=self._account_query_params(), + is_auth_required=True, + return_err=True, + limit_id=CONSTANTS.GET_ACCOUNT_INFO_PATH_URL, + ) + + if not self._is_ok_response(response): + if self._is_rate_limited_response(response): + return + code = response.get("code") if isinstance(response, dict) else "" + msg = response.get("message") or response.get("error") or "" if isinstance(response, dict) else str(response) + raise IOError( + f"Cannot connect to Lighter: server returned code {code}. " + f"{msg} — check your account index and API key index." + ) + + account_data = self._account_from_response(response) + if not account_data: + raise IOError( + f"Cannot connect to Lighter: no account data returned. " + f"Verify your account index is correct. Response: {response}" + ) + + remote_asset_names = set() + for asset_entry in account_data.get("assets", []): + asset_symbol = asset_entry.get("symbol") + if asset_symbol is None: + continue + remote_asset_names.add(asset_symbol) + + total_balance = Decimal(str(asset_entry.get("balance") or "0")) + locked_balance = Decimal(str(asset_entry.get("locked_balance") or "0")) + available_balance = total_balance - locked_balance + + self._account_balances[asset_symbol] = total_balance + + # Guard against stale REST data overwriting an optimistic order-placement lock. + # After an order is placed, the exchange may not yet have added it to + # locked_balance (ZK batch propagation lag, up to a few seconds). If so, + # the REST response shows locked_balance == 0 and an inflated available — + # skip overwriting the lock until the exchange catches up or 3 s elapse. + _opt_locks = getattr(self, "_optimistic_balance_lock", {}) + _opt_lock_entry = _opt_locks.get(asset_symbol) + if _opt_lock_entry is not None: + _opt_locked_avail, _opt_lock_ts = _opt_lock_entry + if locked_balance == Decimal("0") and available_balance > _opt_locked_avail and (time.time() - _opt_lock_ts) < 3.0: + # Exchange hasn't registered the new order yet — keep optimistic lock. + continue + else: + _opt_locks.pop(asset_symbol, None) + + self._account_available_balances[asset_symbol] = available_balance + + # For the spot connector, keep the original per-asset available balance calculation + # (`balance - locked_balance`) and ignore the top-level `available_balance` field. + + for local_asset in list(self._account_balances.keys()): + if local_asset not in remote_asset_names: + self._account_balances.pop(local_asset, None) + self._account_available_balances.pop(local_asset, None) + + self._last_balance_update_timestamp = self._current_timestamp_safely() + if self._balance_refresh_required_since > 0 and self._last_balance_update_timestamp >= self._balance_refresh_required_since: + self._balance_refresh_required_since = 0.0 + + async def _all_trade_updates_for_order(self, order: InFlightOrder) -> List[TradeUpdate]: + trade_updates = [] + current_time = self.current_timestamp + + is_ask = (order.trade_type == TradeType.SELL) + # Get the original lighter client_order_index. WS events may have updated + # exchange_order_id from the client_order_index to the server order_id, so always + # prefer the recorded mapping and fall back to exchange_order_id only as a last resort. + _hb_to_coi = getattr(self, "_hb_order_id_to_client_order_index", {}) + client_order_idx = _hb_to_coi.get(order.client_order_id) + if client_order_idx is None: + client_order_idx = int(str(order.exchange_order_id)) if self._is_int_string(str(order.exchange_order_id)) else None + + # If we cannot map the order to a valid client_order_index, do not scan global + # account trades. Otherwise unrelated market fills can be attributed to this order. + if client_order_idx is None: + self._order_history_last_poll_timestamp[str(order.exchange_order_id)] = current_time + return trade_updates + + client_order_idx_str = str(client_order_idx) + candidate_order_indices: Set[int] = {client_order_idx} + exchange_order_id_str = str(order.exchange_order_id or "") + if self._is_int_string(exchange_order_id_str): + candidate_order_indices.add(int(exchange_order_id_str)) + + # Some /trades payloads only expose server order_index (order_id/order_index) and + # omit ask_client_id/bid_client_id. Bridge server order_index -> client_order_index + # from WS-derived mapping to keep fill recovery working after FILLED timeout paths. + _soi_to_coi = getattr(self, "_server_order_index_to_client_order_index", {}) + for server_order_idx, mapped_coi in _soi_to_coi.items(): + if str(mapped_coi) == client_order_idx_str and self._is_int_string(str(server_order_idx)): + candidate_order_indices.add(int(str(server_order_idx))) + + # exchange_order_id == str(client_order_index). The /trades filter 'order_index' refers to + # the exchange-assigned sequential order_index, which differs from client_order_index. + # Filter client-side using ask_client_id / bid_client_id instead. + last_poll_ts = self._order_history_last_poll_timestamp.get(order.exchange_order_id, order.creation_timestamp) + from_ts = max(0, int(last_poll_ts - self._TRADE_HISTORY_TIME_DRIFT_BUFFER)) + latest_matched_fill_ts: Optional[float] = None + + params = { + "account_index": self._get_account_index(), + "limit": 100, + "sort_by": "timestamp", + "from": from_ts, + } + + while True: + response = await self._api_get( + path_url=CONSTANTS.GET_TRADE_HISTORY_PATH_URL, + params=params, + is_auth_required=True, + limit_id=CONSTANTS.GET_TRADE_HISTORY_PATH_URL, + ) + + if not response.get("success") or not (response.get("trades") or response.get("data")): + break + + for trade_message in response.get("trades") or response.get("data") or []: + # Prefer explicit client order index fields when present. + side_client_ids = [trade_message.get("ask_client_id"), trade_message.get("bid_client_id")] + present_side_client_ids = [cid for cid in side_client_ids if cid is not None] + if present_side_client_ids: + matched_side_client_id = False + for side_client_id in present_side_client_ids: + if self._is_int_string(str(side_client_id)) and int(str(side_client_id)) in candidate_order_indices: + matched_side_client_id = True + break + if not matched_side_client_id: + continue + else: + # Fallback for payload variants that only include order_id/order_index. + raw_order_idx = trade_message.get("order_id") or trade_message.get("orderId") or trade_message.get("order_index") + if ( + raw_order_idx is None + or not self._is_int_string(str(raw_order_idx)) + or int(str(raw_order_idx)) not in candidate_order_indices + ): + continue + + # 'timestamp' is in seconds per the API spec. + fill_timestamp = float( + trade_message.get("timestamp") + or trade_message.get("created_at") + or trade_message.get("t") + or 0 + ) + if fill_timestamp > 1e12: + fill_timestamp *= 1e-3 + + fill_price = Decimal(str(trade_message.get("price") or "0")) + # API field is 'size' (not 'amount'). + fill_base_amount = Decimal(str(trade_message.get("size") or "0")) + + # Determine taker side from is_maker_ask. + is_maker_ask = trade_message.get("is_maker_ask", None) + if is_maker_ask is not None: + is_taker = (is_ask and not is_maker_ask) or (not is_ask and is_maker_ask) + else: + is_taker = trade_message.get("event_type") == "fulfill_taker" + + _fee_schema_h = self.trade_fee_schema() + _fee_percent_h = ( + _fee_schema_h.taker_percent_fee_decimal if is_taker else _fee_schema_h.maker_percent_fee_decimal + ) + fee = TradeFeeBase.new_spot_fee( + fee_schema=_fee_schema_h, + trade_type=order.trade_type, + percent=_fee_percent_h, + percent_token=order.quote_asset, + flat_fees=[], + ) + + trade_updates.append( + TradeUpdate( + trade_id=str(trade_message.get("trade_id_str") or trade_message.get("trade_id") or trade_message.get("history_id") or trade_message.get("id") or ""), + client_order_id=order.client_order_id, + exchange_order_id=order.exchange_order_id, + trading_pair=order.trading_pair, + fill_timestamp=fill_timestamp, + fill_price=fill_price, + fill_base_amount=fill_base_amount, + fill_quote_amount=fill_price * fill_base_amount, + fee=fee, + is_taker=is_taker, + ) + ) + if latest_matched_fill_ts is None or fill_timestamp > latest_matched_fill_ts: + latest_matched_fill_ts = fill_timestamp + + # Pagination: API returns next_cursor (null when no more pages). + if response.get("next_cursor"): + params["cursor"] = response["next_cursor"] + else: + break + + # Only advance this order-specific cursor once at least one matching fill is seen. + # Advancing on empty results can permanently skip late-indexed fills whose exchange + # timestamp is older than the next request's `from` window. + if latest_matched_fill_ts is not None: + self._order_history_last_poll_timestamp[order.exchange_order_id] = max( + current_time, + latest_matched_fill_ts, + ) + else: + self._order_history_last_poll_timestamp[order.exchange_order_id] = last_poll_ts + return trade_updates + + async def _request_order_status(self, tracked_order: InFlightOrder) -> OrderUpdate: + params: Dict[str, Any] = { + "limit": 100, + } + try: + params["account_index"] = self._get_account_index() + except Exception: + # Unit tests may instantiate this class via __new__ without credentials configured. + pass + + active_response = await self._api_get( + path_url=CONSTANTS.GET_ACTIVE_ORDERS_PATH_URL, + params=params, + is_auth_required=True, + limit_id=CONSTANTS.GET_ACTIVE_ORDERS_PATH_URL, + return_err=True, + ) + + response = active_response + + if not response.get("success"): + if self._is_rate_limited_response(response): + return self._current_state_order_update(tracked_order) + raise IOError(f"Failed to fetch order status for {tracked_order.client_order_id}: {response}") + + rows = response.get("orders") or response.get("data") or [] + target_exchange_order_id = str(tracked_order.exchange_order_id or "") + expected_symbol = tracked_order.trading_pair.replace("-", "/") + + has_order_id_fields = any( + any(key in item for key in ("client_order_id", "client_order_index", "order_id", "order_index")) + for item in rows + ) + if target_exchange_order_id and has_order_id_fields: + # The connector may track either client_order_index (initially) or the real exchange order_id + # depending on where in the lifecycle the order is. + rows = [ + item for item in rows + if ( + str(item.get("client_order_id") or item.get("client_order_index") or "") == target_exchange_order_id + or str(item.get("order_id") or item.get("order_index") or "") == target_exchange_order_id + ) + ] + + if expected_symbol: + # Guard against stale status contamination from different markets. + rows = [ + item for item in rows + if str(item.get("symbol") or "") in {"", expected_symbol, tracked_order.trading_pair} + ] + + if len(rows) == 0: + inactive_response = await self._api_get( + path_url=CONSTANTS.GET_ORDER_HISTORY_PATH_URL, + params=params, + is_auth_required=True, + limit_id=CONSTANTS.GET_ORDER_HISTORY_PATH_URL, + return_err=True, + ) + if not inactive_response.get("success"): + if self._is_rate_limited_response(inactive_response): + return self._current_state_order_update(tracked_order) + raise IOError(f"Failed to fetch order status for {tracked_order.client_order_id}: {inactive_response}") + + inactive_rows = inactive_response.get("orders") or inactive_response.get("data") or [] + has_inactive_id_fields = any( + any(key in item for key in ("client_order_id", "client_order_index", "order_id", "order_index")) + for item in inactive_rows + ) + if target_exchange_order_id and has_inactive_id_fields: + inactive_rows = [ + item for item in inactive_rows + if str(item.get("client_order_id") or item.get("client_order_index") or "") == target_exchange_order_id + or str(item.get("order_id") or item.get("order_index") or "") == target_exchange_order_id + ] + + if expected_symbol: + inactive_rows = [ + item for item in inactive_rows + if str(item.get("symbol") or "") in {"", expected_symbol, tracked_order.trading_pair} + ] + + if len(inactive_rows) > 0: + newest_inactive = max(inactive_rows, key=lambda item: item.get("updated_at") or item.get("created_at") or 0) + raw_inactive_status = str(newest_inactive.get("status") or newest_inactive.get("order_status") or "closed").lower() + new_state = self._state_from_raw_order_status(raw_inactive_status) + else: + new_state = getattr(tracked_order, "current_state", OrderState.OPEN) + update_ts = getattr(self, "current_timestamp", None) + if update_ts is None: + update_ts = self._time() + return OrderUpdate( + client_order_id=tracked_order.client_order_id, + exchange_order_id=tracked_order.exchange_order_id, + trading_pair=tracked_order.trading_pair, + update_timestamp=update_ts, + new_state=new_state, + ) + + newest = max(rows, key=lambda item: item.get("updated_at") or item.get("created_at") or 0) + raw_status = str(newest.get("status") or newest.get("order_status") or "open").lower() + state = self._state_from_raw_order_status(raw_status) + # updated_at / created_at may be in ms; divide if > 1e12. + update_ts_raw = float(newest.get("updated_at") or newest.get("created_at") or self.current_timestamp) + update_ts = update_ts_raw * 1e-3 if update_ts_raw > 1e12 else update_ts_raw + + return OrderUpdate( + client_order_id=tracked_order.client_order_id, + exchange_order_id=tracked_order.exchange_order_id, + trading_pair=tracked_order.trading_pair, + update_timestamp=update_ts, + new_state=state, + ) + + def _create_web_assistants_factory(self) -> WebAssistantsFactory: + return web_utils.build_api_factory(throttler=self._throttler, auth=self._auth) + + def _create_order_book_data_source(self) -> OrderBookTrackerDataSource: + return LighterAPIOrderBookDataSource( + trading_pairs=self.trading_pairs, + connector=self, + api_factory=self._web_assistants_factory, + domain=self._domain, + ) + + def _create_user_stream_data_source(self) -> UserStreamTrackerDataSource: + return LighterAPIUserStreamDataSource( + connector=self, + api_factory=self._web_assistants_factory, + auth=self._auth, + domain=self._domain, + ) + + def _initialize_trading_pair_symbols_from_exchange_info(self, exchange_info: Dict[str, Any]): + entries = exchange_info.get("order_books") or exchange_info.get("data") or [] + mapping = bidict() + for entry in entries: + market_type = (entry.get("market_type") or "").lower() + if market_type and market_type != "spot": + continue + symbol = entry.get("symbol") + if symbol is None: + continue + hb_pair = self._hb_pair_from_symbol(symbol) + mapping[symbol] = hb_pair + self._set_trading_pair_symbol_map(mapping) + + async def _api_request( + self, + path_url: str, + overwrite_url: Optional[str] = None, + method: RESTMethod = RESTMethod.GET, + params: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, Any]] = None, + is_auth_required: bool = False, + return_err: bool = False, + limit_id: Optional[str] = None, + headers: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> Dict[str, Any]: + headers = dict(headers or {}) + params = dict(params or {}) + if is_auth_required: + auth_token = "" + try: + auth_token = self._get_lighter_auth_token() + except Exception: + # Some unit tests instantiate the connector with __new__ and without signer setup. + auth_token = "" + + if auth_token: + headers.setdefault("Authorization", auth_token) + params.setdefault("auth", auth_token) + params.setdefault("authorization", auth_token) + + if is_auth_required: + return await self._sdk_api_request( + path_url=path_url, + method=method, + params=params, + data=data, + limit_id=limit_id, + headers=headers, + return_err=return_err, + ) + + rest_assistant = await self._web_assistants_factory.get_rest_assistant() + url = overwrite_url or await self._api_request_url(path_url=path_url, is_auth_required=is_auth_required) + + return await rest_assistant.execute_request( + url=url, + params=params, + data=data, + method=method, + is_auth_required=is_auth_required, + return_err=return_err, + throttler_limit_id=limit_id or path_url, + headers=headers, + ) + + async def get_last_traded_prices(self, trading_pairs: List[str]) -> Dict[str, float]: + prices: Dict[str, float] = {} + stats = await self._api_request(path_url=CONSTANTS.GET_PRICES_PATH_URL, method=RESTMethod.GET) + entries = stats.get("order_book_stats") or stats.get("data") or [] + + for entry in entries: + symbol = entry.get("symbol") + if symbol is None: + continue + try: + trading_pair = await self.trading_pair_associated_to_exchange_symbol(symbol) + except KeyError: + continue + if trading_pair in trading_pairs: + last_price = entry.get("last_trade_price") + if last_price is not None: + prices[trading_pair] = float(last_price) + return prices + + async def _status_polling_loop_fetch_updates(self): + await safe_gather( + self._update_balances(), + self._update_order_status(), + self._update_lost_orders_status(), + ) + await self._cleanup_startup_orphan_orders() + # Periodic runtime orphan check: runs every 10 status-poll cycles (~2 min). + # Catches orders false-cancelled locally mid-session that remain open on the exchange, + # locking USDC balance and preventing correctly-sized orders from being placed. + self._runtime_orphan_poll_counter += 1 + if self._runtime_orphan_poll_counter >= 10: + self._runtime_orphan_poll_counter = 0 + await self._cleanup_runtime_orphan_orders() + + async def _update_order_fills_from_trades(self, force_rest_reconcile: bool = False): + # Skip the bulk REST trade-history scan when the WS user stream is healthy. + # The account_trades WS channel delivers fills in real-time; this REST endpoint + # is redundant during normal operation and wastes rate-limit quota. + # Fall back to REST polling only when WS has been silent for > TICK_INTERVAL_LIMIT, + # unless a caller explicitly forces reconciliation. + if self._is_private_user_stream_healthy() and not force_rest_reconcile: + return + + small_interval_last_tick = self._last_poll_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL + small_interval_current_tick = self.current_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL + long_interval_last_tick = self._last_poll_timestamp / self.LONG_POLL_INTERVAL + long_interval_current_tick = self.current_timestamp / self.LONG_POLL_INTERVAL + + if long_interval_current_tick > long_interval_last_tick or ( + self.in_flight_orders and small_interval_current_tick > small_interval_last_tick + ): + # Build lookup: exchange_order_id (str) -> tracked order. + order_by_exchange_id_map = {order.exchange_order_id: order for order in self._order_tracker.all_fillable_orders.values()} + # Also build per-direction maps for ask/bid_client_id matching. + ask_order_map = {eid: o for eid, o in order_by_exchange_id_map.items() if o.trade_type == TradeType.SELL} + bid_order_map = {eid: o for eid, o in order_by_exchange_id_map.items() if o.trade_type == TradeType.BUY} + # Augment maps with client_order_index entries so that ask/bid_client_id matching + # works even after WS events have updated exchange_order_id to the server order_id. + _coi_map = getattr(self, "_client_order_index_to_client_order_id", {}) + for coi_str, hb_coid in _coi_map.items(): + o = self._order_tracker.all_fillable_orders.get(hb_coid) + if o is None: + continue + if o.trade_type == TradeType.SELL: + ask_order_map.setdefault(coi_str, o) + elif o.trade_type == TradeType.BUY: + bid_order_map.setdefault(coi_str, o) + + # Record the timestamp before the request so concurrent fills that arrive during + # the request window are captured in the next polling cycle. + query_from_ts = ( + max(0, int(self._last_trades_poll_timestamp - self._TRADE_HISTORY_TIME_DRIFT_BUFFER)) + if self._last_trades_poll_timestamp > 0 + else 0 + ) + self._last_trades_poll_timestamp = self.current_timestamp + + poll_params: Dict[str, Any] = { + "account_index": self._get_account_index(), + "limit": 100, + "sort_by": "timestamp", + } + if query_from_ts > 0: + poll_params["from"] = query_from_ts + + response = await self._api_get( + path_url=CONSTANTS.GET_TRADE_HISTORY_PATH_URL, + params=poll_params, + is_auth_required=True, + return_err=True, + limit_id=CONSTANTS.GET_TRADE_HISTORY_PATH_URL, + ) + response_failed = response.get("success") is False or ( + "code" in response and not self._is_ok_response(response) + ) + if response_failed: + if self._is_rate_limited_response(response): + return + raise IOError(f"Failed to fetch trade history updates: {response}") + # API returns 'trades' key; fall back to 'data' for test mocks. + for trade in response.get("trades") or response.get("data") or []: + # Match via ask_client_id / bid_client_id (= client_order_index = exchange_order_id). + ask_cid = str(trade.get("ask_client_id") or "") + bid_cid = str(trade.get("bid_client_id") or "") + tracked_order = ask_order_map.get(ask_cid) or bid_order_map.get(bid_cid) + if tracked_order is None: + # Order is no longer in the tracker (e.g., completed in a previous session or + # lost before fill was recorded). Apply the same "untracked fill" path that + # Hyperliquid uses: fire OrderFilledEvent directly after dedup check so the + # fill is persisted to the DB (TradeFill table) and shown in `history --status`. + exchange_trade_id = str(trade.get("trade_id_str") or trade.get("trade_id") or trade.get("history_id") or trade.get("id") or "") + if not exchange_trade_id: + continue + symbol = str(trade.get("symbol") or "") + if not symbol: + continue + try: + trading_pair = self._hb_pair_from_symbol(symbol) + except Exception: + continue + if trading_pair not in self.trading_pairs: + continue + # Determine which side belongs to this account. bid_cid non-zero → BUY. + is_bid_side = bool(bid_cid and bid_cid != "0") + trade_type = TradeType.BUY if is_bid_side else TradeType.SELL + exchange_order_id = bid_cid if is_bid_side else ask_cid + if self.is_confirmed_new_order_filled_event(exchange_trade_id, exchange_order_id, trading_pair): + fill_timestamp_u = float( + trade.get("timestamp") or trade.get("created_at") or trade.get("t") or self.current_timestamp + ) + if fill_timestamp_u > 1e12: + fill_timestamp_u *= 1e-3 + fill_price_u = Decimal(str(trade.get("price") or "0")) + fill_base_u = Decimal(str(trade.get("size") or trade.get("amount") or "0")) + is_maker_ask_u = trade.get("is_maker_ask", None) + is_taker_u = (is_maker_ask_u is not None) and ( + (trade_type == TradeType.SELL and not is_maker_ask_u) + or (trade_type == TradeType.BUY and is_maker_ask_u) + ) + _fee_schema_u = self.trade_fee_schema() + _fee_percent_u = ( + _fee_schema_u.taker_percent_fee_decimal if is_taker_u else _fee_schema_u.maker_percent_fee_decimal + ) + quote_asset = trading_pair.split("-")[1] if "-" in trading_pair else "" + fee_u = TradeFeeBase.new_spot_fee( + fee_schema=_fee_schema_u, + trade_type=trade_type, + percent=_fee_percent_u, + percent_token=quote_asset, + flat_fees=[], + ) + self._current_trade_fills.add(TradeFillOrderDetails( + market=self.display_name, + exchange_trade_id=exchange_trade_id, + symbol=trading_pair, + )) + self.logger().info( + "[untracked-fill] Recovered fill for untracked order %s " + "(exchange_order_id=%s, trade_id=%s): %s %s %s @ %s", + self._exchange_order_ids.get(exchange_order_id, "unknown"), + exchange_order_id, + exchange_trade_id, + trade_type.name, + fill_base_u, + trading_pair, + fill_price_u, + ) + self.trigger_event( + MarketEvent.OrderFilled, + OrderFilledEvent( + timestamp=fill_timestamp_u, + order_id=self._exchange_order_ids.get(exchange_order_id, None), + trading_pair=trading_pair, + trade_type=trade_type, + order_type=OrderType.LIMIT, + price=fill_price_u, + amount=fill_base_u, + trade_fee=fee_u, + exchange_trade_id=exchange_trade_id, + ), + ) + continue + + is_ask = (tracked_order.trade_type == TradeType.SELL) + is_maker_ask = trade.get("is_maker_ask", None) + is_taker = (is_maker_ask is not None) and ((is_ask and not is_maker_ask) or (not is_ask and is_maker_ask)) + + fill_timestamp = float( + trade.get("timestamp") + or trade.get("created_at") + or trade.get("t") + or self.current_timestamp + ) + if fill_timestamp > 1e12: + fill_timestamp *= 1e-3 + + # Use actual fee amount from WS message when available (mirrors perp connector). + _fee_schema_at = self.trade_fee_schema() + _fee_percent_at = ( + _fee_schema_at.taker_percent_fee_decimal if is_taker else _fee_schema_at.maker_percent_fee_decimal + ) + _fee_raw_at = trade.get("taker_fee") if is_taker else trade.get("maker_fee") + _usd_amount_at = trade.get("usd_amount") or trade.get("quote_amount") + if _fee_raw_at is not None and _usd_amount_at is not None: + try: + _actual_fee_at = Decimal(str(_usd_amount_at)) * Decimal(str(_fee_raw_at)) / Decimal("1000000") + fee = TradeFeeBase.new_spot_fee( + fee_schema=_fee_schema_at, + trade_type=tracked_order.trade_type, + percent_token=tracked_order.quote_asset, + flat_fees=[TokenAmount(amount=_actual_fee_at, token=tracked_order.quote_asset)], + ) + except Exception: + fee = TradeFeeBase.new_spot_fee( + fee_schema=_fee_schema_at, + trade_type=tracked_order.trade_type, + percent=_fee_percent_at, + percent_token=tracked_order.quote_asset, + flat_fees=[], + ) + else: + fee = TradeFeeBase.new_spot_fee( + fee_schema=_fee_schema_at, + trade_type=tracked_order.trade_type, + percent=_fee_percent_at, + percent_token=tracked_order.quote_asset, + flat_fees=[], + ) + fill_price = Decimal(str(trade.get("price") or "0")) + fill_base = Decimal(str(trade.get("size") or trade.get("amount") or "0")) + trade_update = TradeUpdate( + trade_id=str(trade.get("trade_id_str") or trade.get("trade_id") or trade.get("history_id") or trade.get("id") or ""), + client_order_id=tracked_order.client_order_id, + exchange_order_id=tracked_order.exchange_order_id, + trading_pair=tracked_order.trading_pair, + fee=fee, + fill_base_amount=fill_base, + fill_quote_amount=fill_price * fill_base, + fill_price=fill_price, + fill_timestamp=fill_timestamp, + is_taker=is_taker, + ) + self._order_tracker.process_trade_update(trade_update) + + async def _cleanup_startup_orphan_orders(self) -> None: + """One-time startup cleanup: cancel ALL untracked active SPOT orders from previous sessions. + + Untracked orders lock USDC/base-asset funds, causing the strategy to underestimate + available balance and place smaller-than-minimum-notional orders. Cancelling them + on first startup frees the locked funds and restores correct available balance. + + Runs ONCE after the first successful status poll cycle. + """ + if self._startup_orphan_cleanup_done: + return + self._startup_orphan_cleanup_done = True + + known_exchange_ids: Set[str] = { + str(o.exchange_order_id) for o in self.in_flight_orders.values() + } + known_client_order_indices: Set[str] = set(self._client_order_index_to_client_order_id.keys()) + + try: + params: Dict[str, Any] = {"limit": 200} + try: + params["account_index"] = self._get_account_index() + except Exception: + return + + response = await self._api_get( + path_url=CONSTANTS.GET_ACTIVE_ORDERS_PATH_URL, + params=params, + is_auth_required=True, + return_err=True, + ) + if not (isinstance(response, dict) and response.get("success")): + return + + rows = response.get("orders") or response.get("data") or [] + for row in rows: + row_coi = str(row.get("client_order_id") or row.get("client_order_index") or "") + row_oid = str(row.get("order_id") or row.get("order_index") or "") + is_tracked = ( + (row_coi and row_coi in known_exchange_ids) + or (row_coi and row_coi in known_client_order_indices) + or (row_oid and row_oid in known_exchange_ids) + ) + if is_tracked: + continue + + cancel_index = row_coi or row_oid + if not cancel_index or not self._is_int_string(cancel_index): + continue + + symbol = str(row.get("symbol") or row.get("market") or "") + self.logger().info( + "[startup cleanup] Cancelling orphan SPOT order index=%s symbol=%s from a previous session.", + cancel_index, + symbol, + ) + try: + hb_pair = self._hb_pair_from_symbol(symbol) if symbol else None + if hb_pair is None: + # Try to infer market_id from the symbol field + self.logger().warning( + "[startup cleanup] Cannot determine trading pair for orphan order %s; skipping.", + cancel_index, + ) + continue + market_id, _, _, _ = await self._get_market_spec(hb_pair) + async with self._signer_client_lock: + signer_client = self._get_lighter_signer_client() + _, tx_response, error = await signer_client.cancel_order( + market_index=market_id, + order_index=int(cancel_index), + api_key_index=self._get_api_key_index(), + ) + if error is not None: + self.logger().warning( + "[startup cleanup] Failed to cancel orphan SPOT order %s: %s", cancel_index, error + ) + else: + self.logger().info( + "[startup cleanup] Cancelled orphan SPOT order %s for %s.", + cancel_index, symbol, + ) + except Exception as cancel_err: + self.logger().warning( + "[startup cleanup] Exception cancelling orphan SPOT order %s: %s", + cancel_index, cancel_err, + ) + + # Schedule a balance refresh after cancelling orphan orders. + self._schedule_fast_balance_sync(min_interval_seconds=0.0) + except Exception as ex: + self.logger().warning("[startup cleanup] SPOT orphan cleanup failed: %s", ex) + + async def _cleanup_runtime_orphan_orders(self) -> None: + """Periodic runtime cleanup: cancel any active SPOT exchange order not tracked by this bot. + + Unlike ``_cleanup_startup_orphan_orders`` (which runs once at startup), this method + runs every 10 status-poll cycles (~2 minutes) to catch orders that became orphaned + *during* the session — for example when a WS snapshot replay fires a false CANCELED + event locally while the exchange still holds the order open, locking the USDC balance + and preventing the strategy from placing correctly-sized orders. + """ + known_exchange_ids: Set[str] = { + str(o.exchange_order_id) for o in self.in_flight_orders.values() + } + known_client_order_indices: Set[str] = set(self._client_order_index_to_client_order_id.keys()) + + try: + params: Dict[str, Any] = {"limit": 200} + try: + params["account_index"] = self._get_account_index() + except Exception: + return + + response = await self._api_get( + path_url=CONSTANTS.GET_ACTIVE_ORDERS_PATH_URL, + params=params, + is_auth_required=True, + return_err=True, + ) + if not (isinstance(response, dict) and response.get("success")): + return + + rows = response.get("orders") or response.get("data") or [] + orphans_found = 0 + for row in rows: + row_coi = str(row.get("client_order_id") or row.get("client_order_index") or "") + row_oid = str(row.get("order_id") or row.get("order_index") or "") + is_tracked = ( + (row_coi and row_coi in known_exchange_ids) + or (row_coi and row_coi in known_client_order_indices) + or (row_oid and row_oid in known_exchange_ids) + ) + if is_tracked: + continue + + cancel_index = row_coi or row_oid + if not cancel_index or not self._is_int_string(cancel_index): + continue + + orphans_found += 1 + symbol = str(row.get("symbol") or row.get("market") or "") + self.logger().warning( + "[runtime orphan cleanup] Cancelling untracked SPOT order index=%s symbol=%s " + "(runtime orphan — likely from a WS false-cancel that dropped local tracking " + "while the exchange order remained open, locking balance).", + cancel_index, + symbol, + ) + try: + hb_pair = self._hb_pair_from_symbol(symbol) if symbol else None + if hb_pair is None: + self.logger().warning( + "[runtime orphan cleanup] Cannot determine trading pair for orphan order %s; skipping.", + cancel_index, + ) + continue + market_id, _, _, _ = await self._get_market_spec(hb_pair) + async with self._signer_client_lock: + signer_client = self._get_lighter_signer_client() + _, tx_response, error = await signer_client.cancel_order( + market_index=market_id, + order_index=int(cancel_index), + api_key_index=self._get_api_key_index(), + ) + if error is not None: + self.logger().warning( + "[runtime orphan cleanup] Failed to cancel orphan SPOT order %s: %s", + cancel_index, + error, + ) + else: + self.logger().info( + "[runtime orphan cleanup] Cancelled orphan SPOT order %s for %s.", + cancel_index, + symbol, + ) + except Exception as cancel_err: + self.logger().warning( + "[runtime orphan cleanup] Exception cancelling orphan SPOT order %s: %s", + cancel_index, + cancel_err, + ) + + if orphans_found > 0: + # Refresh balance so locked funds are freed in the strategy's next cycle. + self._schedule_fast_balance_sync(min_interval_seconds=0.0) + except Exception as ex: + self.logger().warning("[runtime orphan cleanup] SPOT runtime orphan cleanup failed: %s", ex) + + async def _update_order_status(self, force_rest_reconcile: bool = False): + await self._update_order_fills_from_trades(force_rest_reconcile=force_rest_reconcile) + + # Always scan cached orders for missed fills — catches cancel-fill races regardless + # of whether the WS is healthy. When the WS is healthy _update_order_fills_from_trades + # is skipped above, so without this unconditional call, fills for orders that were + # cancelled via a cancel-fill race would never be recovered during normal operation. + await self._rescue_cached_order_fills() + + if not self._should_reconcile_orders_via_rest(force_rest_reconcile=force_rest_reconcile): + return + + await self._update_orders() + + async def _update_orders_fills(self, orders: List[InFlightOrder]): + for order in orders: + try: + trade_updates = await self._all_trade_updates_for_order(order) + for trade_update in trade_updates: + self._order_tracker.process_trade_update(trade_update) + except Exception as request_error: + # Do not block status updates when trade history endpoint rejects optional params. + self.logger().warning(f"Error updating fills for active order {order.client_order_id}: {request_error}") + + async def _update_orders(self): + for tracked_order in list(self.in_flight_orders.values()): + order_update = await self._request_order_status(tracked_order=tracked_order) + if ( + isinstance(order_update, OrderUpdate) + and order_update.new_state in (OrderState.FILLED, OrderState.CANCELED) + and not tracked_order.is_done + and tracked_order.executed_amount_base < tracked_order.amount + ): + # Rescue fill fetch: the bulk trade-history poll ran before the fill appeared on + # the exchange REST API. Fetch fills specifically for this order now so the + # tracker has the fill data before wait_until_completely_filled() times out. + # Also covers CANCELED: an order can be cancelled after a partial or full fill + # (cancel-fill race) — we must recover those fills before the order is evicted. + try: + # Apply the resolved server order_index BEFORE fetching fills so that + # _all_trade_updates_for_order uses the correct ask_id/bid_id for matching + # — not the stale client_order_index when I=null in WS events prevented + # the normal mapping update (mirrors the perp connector's approach). + resolved_eid = order_update.exchange_order_id + if ( + resolved_eid + and resolved_eid != "None" + and resolved_eid != str(tracked_order.exchange_order_id) + ): + tracked_order.update_exchange_order_id(resolved_eid) + fill_updates = await self._all_trade_updates_for_order(tracked_order) + for fill_update in fill_updates: + self._order_tracker.process_trade_update(fill_update) + if fill_updates: + self.logger().debug( + "[_update_orders] Rescue fill fetch found %d fill(s) for %s (state=%s)", + len(fill_updates), + tracked_order.client_order_id, + order_update.new_state.name, + ) + except Exception as ex: + is_rate_limited = self._is_rate_limited_exception(ex) + if is_rate_limited: + self.logger().debug( + "[_update_orders] Rescue fill fetch deferred for %s due to rate limit: %s", + tracked_order.client_order_id, + ex, + ) + else: + self.logger().warning( + "[_update_orders] Rescue fill fetch failed for %s: %s", + tracked_order.client_order_id, + ex, + ) + # Schedule a delayed retry so fills are recorded once the rate limit clears, + # even though process_order_update below marks the order as done now. + # _fetch_and_apply_fills uses the dedup guard so only one retry runs at a time. + safe_ensure_future(self._fetch_and_apply_fills(tracked_order, delay=10.0 if is_rate_limited else 5.0)) + self._order_tracker.process_order_update(order_update) + if isinstance(order_update, OrderUpdate): + self._schedule_balance_sync_for_terminal_update(order_update=order_update, tracked_order=tracked_order) + + async def _rescue_cached_order_fills(self): + """Scan recently-cached (recently-cancelled) orders that have no fills yet. + + Acts as a safety net for the cancel-fill race: if the exchange filled an order + shortly after the bot marked it CANCELED (e.g. via a WS cancel event or REST + cancel TX), this scan ensures fill events are emitted once the REST trade history + catches up. Only scans orders cancelled within the last 5 minutes with zero fills. + """ + _RESCUE_WINDOW_SECS = 300.0 # 5 minutes + _MAX_RESCUES_PER_CYCLE = 4 # cap burst — remaining orders rescued on next poll + now = self.current_timestamp + cached = self._order_tracker.cached_orders + rescued_count = 0 + for order in list(cached.values()): + if rescued_count >= _MAX_RESCUES_PER_CYCLE: + break + # Only bother with orders that have no registered fills yet. + if order.executed_amount_base > 0: + continue + # Only scan recently-cancelled orders to limit API calls. + age = now - order.last_update_timestamp + if age > _RESCUE_WINDOW_SECS: + continue + # Skip if a fetch is already running for this order (dedup guard). + if order.client_order_id in self._fill_fetch_in_progress: + continue + rescued_count += 1 + try: + fills = await self._all_trade_updates_for_order(order) + for fill in fills: + self._order_tracker.process_trade_update(fill) + if fills: + # Apply fill balance credit for the cancel-fill race (cached CANCELED orders + # that were actually filled before the cancel TX was processed). + _order_state = getattr(order, "current_state", None) + if _order_state == OrderState.CANCELED: + self._release_locked_balance_on_fill(order) + self.logger().info( + "[rescue-cached] Found %d fill(s) for recently-cached order %s", + len(fills), + order.client_order_id, + ) + except asyncio.CancelledError: + raise + except Exception as err: + self.logger().debug( + "[rescue-cached] Fill fetch failed for cached order %s: %s", + order.client_order_id, + err, + ) + + async def _iter_user_event_queue(self) -> AsyncIterable[Dict[str, any]]: + while True: + try: + yield await self._user_stream_tracker.user_stream.get() + except asyncio.CancelledError: + raise + + def _create_trade_fill_updates(self, inflight_order: InFlightOrder, fills_data: List[Dict[str, Any]]) -> List[TradeUpdate]: + trade_updates: List[TradeUpdate] = [] + for fill_data in fills_data: + fill_timestamp = float( + fill_data.get("timestamp") + or fill_data.get("created_at") + or fill_data.get("t") + or self.current_timestamp + ) + if fill_timestamp > 1e12: + fill_timestamp *= 1e-3 + trade_update = TradeUpdate( + trade_id=str(fill_data.get("trade_id") or fill_data.get("id") or fill_data.get("h")), + client_order_id=inflight_order.client_order_id, + exchange_order_id=inflight_order.exchange_order_id, + trading_pair=inflight_order.trading_pair, + fill_timestamp=fill_timestamp, + fill_price=Decimal(str(fill_data.get("price") or fill_data.get("p") or "0")), + fill_base_amount=Decimal(str(fill_data.get("size") or fill_data.get("amount") or fill_data.get("a") or "0")), + fill_quote_amount=Decimal(str(fill_data.get("quote_amount") or fill_data.get("q") or "0")), + fee=self._get_fee( + base_currency=inflight_order.base_asset, + quote_currency=inflight_order.quote_asset, + order_type=inflight_order.order_type, + order_side=inflight_order.trade_type, + amount=inflight_order.amount, + price=inflight_order.price, + ), + ) + trade_updates.append(trade_update) + return trade_updates + + async def _update_orders_with_error_handler( + self, + orders: List[InFlightOrder], + fetch_updates: Callable, + error_handler: Callable, + ): + for order in orders: + try: + updates = await fetch_updates(order) + if isinstance(updates, OrderUpdate): + self._order_tracker.process_order_update(updates) + elif isinstance(updates, list): + for update in updates: + if isinstance(update, TradeUpdate): + self._order_tracker.process_trade_update(update) + except asyncio.CancelledError: + raise + except Exception as request_error: + await error_handler(order, request_error) + + async def _handle_update_error_for_active_order(self, order: InFlightOrder, request_error: Exception): + self.logger().warning(f"Error updating active order {order.client_order_id}: {request_error}") + + async def _handle_update_error_for_lost_order(self, order: InFlightOrder, request_error: Exception): + self.logger().warning(f"Error updating lost order {order.client_order_id}: {request_error}") + + async def _update_lost_orders(self): + await self._update_orders_with_error_handler( + orders=list(self._order_tracker.lost_orders.values()), + fetch_updates=self._request_order_status, + error_handler=self._handle_update_error_for_lost_order, + ) + + async def _cancel_lost_orders(self): + for _, lost_order in self._order_tracker.lost_orders.items(): + await self._execute_order_cancel(order=lost_order) + + async def _execute_order_cancel(self, order: InFlightOrder) -> Optional[str]: + """Reconcile order state on cancel errors to avoid stale active orders in TUI.""" + if order.client_order_id in self._cancel_in_flight_client_order_ids: + self.logger().debug( + "Skipping duplicate cancel attempt for %s because a previous cancel is still in-flight.", + order.client_order_id, + ) + return None + + self._cancel_in_flight_client_order_ids.add(order.client_order_id) + try: + # Use the base cancel/update flow so OrderState.CANCELED is emitted immediately + # when cancel submission succeeds (`is_cancel_request_in_exchange_synchronous = True`). + # Unit tests may provide lightweight order stubs without full fields. + if hasattr(order, "trading_pair"): + cancelled = await self._execute_order_cancel_and_process_update(order=order) + else: + cancelled = await self._place_cancel(order_id=order.client_order_id, tracked_order=order) + if cancelled: + return order.client_order_id + except asyncio.CancelledError: + raise + except asyncio.TimeoutError: + self.logger().warning( + f"Failed to cancel the order {order.client_order_id} because it does not have an exchange order id yet" + ) + self.logger().warning( + "Keeping order %s tracked after cancel timeout; scheduling reconciliation instead of not-found escalation.", + order.client_order_id, + ) + self._schedule_unmatched_private_event_reconcile() + except IOError as ex: + reconciled_state = await self._reconcile_order_state_after_cancel_error( + order=order, + error_message=str(ex), + ) + if reconciled_state in {OrderState.CANCELED, OrderState.FILLED, OrderState.FAILED}: + return order.client_order_id + if self._is_order_not_found_during_cancelation_error(cancelation_exception=ex): + self.logger().warning( + "Cancel returned not-found for %s but reconciliation was non-terminal. " + "Keeping order tracked until explicit terminal update arrives.", + order.client_order_id, + ) + self._schedule_unmatched_private_event_reconcile() + else: + self.logger().error(f"Failed to cancel order {order.client_order_id}", exc_info=True) + except Exception as ex: + if self._is_order_not_found_during_cancelation_error(cancelation_exception=ex): + reconciled_state = await self._reconcile_order_state_after_cancel_error( + order=order, + error_message=str(ex), + ) + if reconciled_state in {OrderState.CANCELED, OrderState.FILLED, OrderState.FAILED}: + return order.client_order_id + else: + self.logger().error(f"Failed to cancel order {order.client_order_id}", exc_info=True) + finally: + self._cancel_in_flight_client_order_ids.discard(order.client_order_id) + return None + + async def _reconcile_order_state_after_cancel_error( + self, + order: InFlightOrder, + error_message: str, + ) -> Optional[OrderState]: + """Reconcile exchange state before deciding whether to keep tracking an order after cancel errors.""" + try: + order_update = await self._request_order_status(order) + self._order_tracker.process_order_update(order_update) + self.logger().debug( + "Cancel reconciliation for %s after error '%s' -> exchange state %s", + order.client_order_id, + error_message, + order_update.new_state, + ) + return order_update.new_state + except Exception as status_error: + self.logger().warning( + "Cancel reconciliation for %s failed after error '%s': %s. " + "Order remains tracked until a later WS/REST update confirms terminal state.", + order.client_order_id, + error_message, + status_error, + ) + return None + + async def _execute_orders_cancel(self, orders: List[InFlightOrder]) -> List[OrderUpdate]: + results = [] + for order in orders: + cancelled_order_id = await self._execute_order_cancel(order) + if cancelled_order_id: + results.append( + OrderUpdate( + client_order_id=cancelled_order_id, + trading_pair=order.trading_pair, + update_timestamp=self.current_timestamp, + new_state=OrderState.CANCELED, + ) + ) + return results + + async def _get_last_traded_price(self, trading_pair: str) -> float: + prices = await self.get_last_traded_prices(trading_pairs=[trading_pair]) + return prices[trading_pair] + + async def _create_order_fill_updates(self, order: InFlightOrder, exchange_order_id: str, fee: TradeFeeBase) -> List[TradeUpdate]: + _ = exchange_order_id + _ = fee + return await self._all_trade_updates_for_order(order) + + async def _fetch_last_fee_payment(self, trading_pair: str) -> Tuple[int, Decimal, Decimal]: + return 0, Decimal("0"), Decimal("0") + + async def _get_last_trade_price(self, trading_pair: str) -> float: + return await self._get_last_traded_price(trading_pair) + + async def _get_all_pairs_prices(self) -> List[Dict[str, str]]: + pairs = await self._api_get(path_url=CONSTANTS.EXCHANGE_INFO_PATH_URL) + return pairs.get("data", []) + + async def _request_order_fills(self, order: InFlightOrder) -> List[Dict[str, Any]]: + if order.exchange_order_id is None: + return [] + return await self._request_order_fills_by_exchange_order_id(order) + + async def _request_order_fills_from_trades_api(self, order: InFlightOrder) -> List[Dict[str, Any]]: + if order.exchange_order_id is None or not self._is_int_string(str(order.exchange_order_id)): + return [] + + params = { + "account_index": self._get_account_index(), + "limit": 100, + "sort_by": "timestamp", + } + params["order_index"] = int(str(order.exchange_order_id)) + + response = await self._api_get( + path_url=CONSTANTS.GET_TRADE_HISTORY_PATH_URL, + params=params, + is_auth_required=True, + limit_id=CONSTANTS.GET_TRADE_HISTORY_PATH_URL, + ) + + if not response.get("success"): + return [] + return response.get("data") or [] + + async def _request_order_fills_from_fills_api(self, order: InFlightOrder) -> List[Dict[str, Any]]: + return await self._request_order_fills_from_trades_api(order) + + async def _request_order_fills_by_exchange_order_id(self, order: InFlightOrder) -> List[Dict[str, Any]]: + fills = await self._request_order_fills_from_trades_api(order) + target_exchange_order_id = str(order.exchange_order_id) + filtered_fills = [] + for fill in fills: + fill_exchange_order_id = str(fill.get("order_id") or fill.get("orderId") or "") + if fill_exchange_order_id == target_exchange_order_id: + filtered_fills.append(fill) + return filtered_fills + + async def _request_order_fills_by_client_order_id(self, order: InFlightOrder) -> List[Dict[str, Any]]: + fills = await self._request_order_fills_from_trades_api(order) + filtered_fills = [] + for fill in fills: + fill_client_order_id = str(fill.get("client_order_id") or fill.get("clientOrderId") or "") + if fill_client_order_id == order.client_order_id: + filtered_fills.append(fill) + return filtered_fills + + async def _request_trade_updates(self, orders: List[InFlightOrder]) -> List[TradeUpdate]: + trade_updates: List[TradeUpdate] = [] + for order in orders: + trade_updates.extend(await self._all_trade_updates_for_order(order)) + return trade_updates + + async def _request_order_update(self, order: InFlightOrder) -> OrderUpdate: + return await self._request_order_status(order) + + async def _request_trade_fills(self) -> List[TradeFillOrderDetails]: + response = await self._api_get( + path_url=CONSTANTS.GET_TRADE_HISTORY_PATH_URL, + params={ + "account_index": self._get_account_index(), + "limit": 100, + "sort_by": "timestamp", + }, + is_auth_required=True, + limit_id=CONSTANTS.GET_TRADE_HISTORY_PATH_URL, + ) + fills = response.get("data") or [] + return [ + TradeFillOrderDetails( + market=self.name, + exchange_trade_id=str(fill.get("trade_id_str") or fill.get("history_id") or fill.get("trade_id") or fill.get("id") or fill.get("h") or ""), + symbol=str(fill.get("symbol") or ""), + ) + for fill in fills + ] + + def __getattr__(self, name: str): + if name == "_signer_client_lock": + lock = asyncio.Lock() + setattr(self, name, lock) + return lock + if name == "_cancel_in_flight_client_order_ids": + in_flight_set: Set[str] = set() + setattr(self, name, in_flight_set) + return in_flight_set + if name == "_balance_refresh_required_since": + setattr(self, name, 0.0) + return 0.0 + if name == "_last_ws_balance_update_ts": + setattr(self, name, 0.0) + return 0.0 + if name == "_api_key_public_key": + setattr(self, name, "") + return "" + if name == "_startup_orphan_cleanup_done": + setattr(self, name, False) + return False + if name == "_runtime_orphan_poll_counter": + setattr(self, name, 0) + return 0 + raise AttributeError(f"{self.__class__.__name__!s} object has no attribute {name!r}") + + def _allocate_client_order_index(self) -> int: + last_idx = getattr(self, "_last_client_order_index", 0) + candidate = int(time.time() * 1000) * getattr(self, "_CLIENT_ORDER_INDEX_TIME_MULTIPLIER", 140) + if candidate <= last_idx: + candidate = last_idx + 1 + if candidate > getattr(self, "_CLIENT_ORDER_INDEX_MAX", (1 << 48) - 1): + candidate = getattr(self, "_CLIENT_ORDER_INDEX_MAX", (1 << 48) - 1) + self._last_client_order_index = candidate + return candidate + + @staticmethod + def _response_code(response: Any) -> Optional[int]: + if response is None: + return None + if isinstance(response, dict): + code = response.get("code") + else: + code = getattr(response, "code", None) + try: + return int(code) + except Exception: + return None + + def _is_invalid_nonce_failure(self, error: Optional[Any] = None, response: Optional[Any] = None) -> bool: + if self._response_code(response) == 21104: + return True + if error is not None and "invalid nonce" in str(error).lower(): + return True + if response is not None and "invalid nonce" in str(response).lower(): + return True + return False + + def _refresh_signer_client(self): + previous_signer_client = self._lighter_signer_client + self._lighter_signer_client = None + try: + return self._get_lighter_signer_client() + except Exception: + # Preserve previous signer client to keep local nonce flow usable. + self._lighter_signer_client = previous_signer_client + raise + + async def _refresh_signer_client_async(self): + """Reset and recreate the signer client in a thread executor to avoid blocking the event loop. + + SignerClient.__init__ calls get_nonce_from_api() synchronously — a blocking HTTP call to + the Lighter network node. If the node is slow or temporarily unreachable, calling this + synchronously freezes the entire asyncio event loop (and therefore the strategy) for the + duration of the TCP timeout (potentially minutes). + + Must be called after a nonce failure so the next signing attempt uses a fresh nonce. + """ + previous_signer_client = self._lighter_signer_client + self._lighter_signer_client = None + loop = asyncio.get_event_loop() + try: + new_client = await loop.run_in_executor(None, self._get_lighter_signer_client) + return new_client + except Exception: + self._lighter_signer_client = previous_signer_client + raise diff --git a/hummingbot/connector/exchange/lighter/lighter_order_book.py b/hummingbot/connector/exchange/lighter/lighter_order_book.py new file mode 100644 index 00000000000..dc6f5f955e4 --- /dev/null +++ b/hummingbot/connector/exchange/lighter/lighter_order_book.py @@ -0,0 +1,77 @@ +from typing import Any, Dict, Optional + +from hummingbot.core.data_type.common import TradeType +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.core.data_type.order_book_message import OrderBookMessage, OrderBookMessageType + + +class LighterOrderBook(OrderBook): + + @classmethod + def snapshot_message_from_exchange( + cls, + msg: Dict[str, Any], + timestamp: float, + metadata: Optional[Dict[str, Any]] = None, + ) -> OrderBookMessage: + if metadata: + msg.update(metadata) + + return OrderBookMessage( + OrderBookMessageType.SNAPSHOT, + { + "trading_pair": msg["trading_pair"], + "update_id": int(msg["update_id"]), + "bids": msg.get("bids", []), + "asks": msg.get("asks", []), + }, + timestamp=timestamp, + ) + + @classmethod + def diff_message_from_exchange( + cls, + msg: Dict[str, Any], + timestamp: float, + metadata: Optional[Dict[str, Any]] = None, + ) -> OrderBookMessage: + if metadata: + msg.update(metadata) + + return OrderBookMessage( + OrderBookMessageType.DIFF, + { + "trading_pair": msg["trading_pair"], + "first_update_id": int(msg["first_update_id"]), + "update_id": int(msg["update_id"]), + "bids": msg.get("bids", []), + "asks": msg.get("asks", []), + }, + timestamp=timestamp, + ) + + @classmethod + def trade_message_from_exchange( + cls, + msg: Dict[str, Any], + timestamp: float, + metadata: Optional[Dict[str, Any]] = None, + ) -> OrderBookMessage: + if metadata: + msg.update(metadata) + + is_maker_ask = bool(msg.get("is_maker_ask")) + trade_type = float(TradeType.BUY.value) if is_maker_ask else float(TradeType.SELL.value) + + return OrderBookMessage( + OrderBookMessageType.TRADE, + { + "trading_pair": msg["trading_pair"], + "trade_type": trade_type, + "trade_id": msg.get("trade_id") or msg.get("trade_id_str"), + "update_id": msg.get("nonce") or msg.get("trade_id") or msg.get("trade_id_str") or 0, + "price": msg.get("price", "0"), + "amount": msg.get("size", "0"), + }, + timestamp=timestamp, + ) diff --git a/hummingbot/connector/exchange/lighter/lighter_utils.py b/hummingbot/connector/exchange/lighter/lighter_utils.py new file mode 100644 index 00000000000..003ce4b20f7 --- /dev/null +++ b/hummingbot/connector/exchange/lighter/lighter_utils.py @@ -0,0 +1,285 @@ +import json +from decimal import Decimal +from typing import Optional + +from pydantic import AliasChoices, ConfigDict, Field, SecretStr, field_validator, model_validator + +from hummingbot.client.config.config_data_types import BaseConnectorConfigMap +from hummingbot.core.data_type.trade_fee import TradeFeeSchema + +CENTRALIZED = True +EXAMPLE_PAIR = "ETH-USDC" + +DEFAULT_FEES = TradeFeeSchema( + maker_percent_fee_decimal=Decimal("0.00015"), + taker_percent_fee_decimal=Decimal("0.0004"), + buy_percent_fee_deducted_from_returns=True, +) + + +def _is_encrypted_secret_payload(value: str) -> bool: + candidate = value.strip() + if len(candidate) < 20 or len(candidate) % 2 != 0: + return False + + try: + decoded_json = bytes.fromhex(candidate).decode("utf-8") + payload = json.loads(decoded_json) + except (ValueError, UnicodeDecodeError, json.JSONDecodeError): + return False + + return isinstance(payload, dict) and "crypto" in payload and "version" in payload + + +def _is_hex_key(value: str) -> bool: + candidate = value.strip() + if candidate.lower().startswith("0x"): + candidate = candidate[2:] + return len(candidate) >= 64 and len(candidate) % 2 == 0 and all(c in "0123456789abcdefABCDEF" for c in candidate) + + +_API_KEY_FORMAT_HINT = ( + "Lighter API key must be an even-length hex string of at least 64 characters " + "(e.g. 3d6e9253dc51...4357). Copy it from the Lighter exchange API keys page." +) + + +class LighterConfigMap(BaseConnectorConfigMap): + connector: str = "lighter" + + lighter_api_key_index: SecretStr = Field( + default=..., + validation_alias=AliasChoices("lighter_api_key_index", "lighter_api_secret"), + json_schema_extra={ + "prompt": "Enter your API Key Index", + "is_secure": True, + "is_connect_key": True, + "prompt_on_new": True, + }, + ) + + lighter_account_index: SecretStr = Field( + default=..., + validation_alias=AliasChoices("lighter_account_index"), + json_schema_extra={ + "prompt": "Enter your Account Index", + "is_secure": True, + "is_connect_key": True, + "prompt_on_new": True, + }, + ) + + lighter_api_key_private_key: SecretStr = Field( + default=..., + validation_alias=AliasChoices("lighter_api_key_private_key", "lighter_api_key", "lighter_private_key"), + json_schema_extra={ + "prompt": "Enter your Private Key", + "is_secure": True, + "is_connect_key": True, + "prompt_on_new": True, + }, + ) + + model_config = ConfigDict(title="lighter") + + @model_validator(mode="before") + @classmethod + def migrate_legacy_fields(cls, data): + """Map old field names from saved YAML configs to the current names.""" + if not isinstance(data, dict): + return data + # lighter_api_secret → lighter_api_key_index + if "lighter_api_secret" in data and "lighter_api_key_index" not in data: + data["lighter_api_key_index"] = data.pop("lighter_api_secret") + else: + data.pop("lighter_api_secret", None) + # lighter_api_key → lighter_api_key_private_key + if "lighter_api_key" in data and "lighter_api_key_private_key" not in data: + data["lighter_api_key_private_key"] = data.pop("lighter_api_key") + else: + data.pop("lighter_api_key", None) + # lighter_private_key was a separate L1 key; discard (encrypted value is stale after rename) + data.pop("lighter_private_key", None) + # lighter_api_key_public_key was removed; discard from saved configs + data.pop("lighter_api_key_public_key", None) + return data + + @field_validator("lighter_api_key_index", mode="before") + @classmethod + def validate_api_key_index(cls, value): + raw_value = value.get_secret_value() if isinstance(value, SecretStr) else str(value) + sanitized = raw_value.strip() + if sanitized == "": + return SecretStr("") + if _is_encrypted_secret_payload(sanitized): + return SecretStr(sanitized) + if not sanitized.isdigit(): + raise ValueError("Lighter API key index must be an integer string") + return SecretStr(sanitized) + + @field_validator("lighter_account_index", mode="before") + @classmethod + def validate_account_index(cls, value): + raw_value = value.get_secret_value() if isinstance(value, SecretStr) else str(value) + sanitized = raw_value.strip() + if sanitized == "": + return SecretStr("") + if _is_encrypted_secret_payload(sanitized): + return SecretStr(sanitized) + if not sanitized.isdigit(): + raise ValueError("Lighter account index must be an integer string") + return SecretStr(sanitized) + + @field_validator("lighter_api_key_private_key", mode="before") + @classmethod + def validate_api_key(cls, value): + raw_value = value.get_secret_value() if isinstance(value, SecretStr) else str(value) + sanitized = raw_value.strip() + if sanitized == "": + raise ValueError(_API_KEY_FORMAT_HINT) + if _is_encrypted_secret_payload(sanitized): + return SecretStr(sanitized) + if not _is_hex_key(sanitized): + raise ValueError(_API_KEY_FORMAT_HINT) + return SecretStr(sanitized) + + +KEYS = LighterConfigMap.model_construct() + +OTHER_DOMAINS = ["lighter_testnet"] +OTHER_DOMAINS_PARAMETER = {"lighter_testnet": "lighter_testnet"} +OTHER_DOMAINS_EXAMPLE_PAIR = {"lighter_testnet": "ETH-USDC"} +OTHER_DOMAINS_DEFAULT_FEES = {"lighter_testnet": [0.00015, 0.0004]} + + +class LighterTestnetConfigMap(BaseConnectorConfigMap): + connector: str = "lighter_testnet" + + lighter_testnet_api_key_index: SecretStr = Field( + default=..., + validation_alias=AliasChoices("lighter_testnet_api_key_index", "lighter_testnet_api_secret"), + json_schema_extra={ + "prompt": "Enter your API Key Index", + "is_secure": True, + "is_connect_key": True, + "prompt_on_new": True, + }, + ) + + lighter_testnet_account_index: SecretStr = Field( + default=..., + validation_alias=AliasChoices("lighter_testnet_account_index"), + json_schema_extra={ + "prompt": "Enter your Account Index", + "is_secure": True, + "is_connect_key": True, + "prompt_on_new": True, + }, + ) + + lighter_testnet_api_key_private_key: SecretStr = Field( + default=..., + validation_alias=AliasChoices( + "lighter_testnet_api_key_private_key", + "lighter_testnet_api_key", + "lighter_testnet_private_key", + ), + json_schema_extra={ + "prompt": "Enter your Private Key", + "is_secure": True, + "is_connect_key": True, + "prompt_on_new": True, + }, + ) + + model_config = ConfigDict(title="lighter_testnet") + + @model_validator(mode="before") + @classmethod + def migrate_legacy_fields(cls, data): + """Map old field names from saved YAML configs to the current names.""" + if not isinstance(data, dict): + return data + # lighter_testnet_api_secret → lighter_testnet_api_key_index + if "lighter_testnet_api_secret" in data and "lighter_testnet_api_key_index" not in data: + data["lighter_testnet_api_key_index"] = data.pop("lighter_testnet_api_secret") + else: + data.pop("lighter_testnet_api_secret", None) + # lighter_testnet_api_key → lighter_testnet_api_key_private_key + if "lighter_testnet_api_key" in data and "lighter_testnet_api_key_private_key" not in data: + data["lighter_testnet_api_key_private_key"] = data.pop("lighter_testnet_api_key") + else: + data.pop("lighter_testnet_api_key", None) + # lighter_testnet_private_key was a separate L1 key; discard + data.pop("lighter_testnet_private_key", None) + # lighter_testnet_api_key_public_key was removed; discard from saved configs + data.pop("lighter_testnet_api_key_public_key", None) + return data + + @field_validator("lighter_testnet_api_key_index", mode="before") + @classmethod + def validate_testnet_api_key_index(cls, value): + raw_value = value.get_secret_value() if isinstance(value, SecretStr) else str(value) + sanitized = raw_value.strip() + if sanitized == "": + return SecretStr("") + if _is_encrypted_secret_payload(sanitized): + return SecretStr(sanitized) + if not sanitized.isdigit(): + raise ValueError("Lighter API key index must be an integer string") + return SecretStr(sanitized) + + @field_validator("lighter_testnet_account_index", mode="before") + @classmethod + def validate_testnet_account_index(cls, value): + raw_value = value.get_secret_value() if isinstance(value, SecretStr) else str(value) + sanitized = raw_value.strip() + if sanitized == "": + return SecretStr("") + if _is_encrypted_secret_payload(sanitized): + return SecretStr(sanitized) + if not sanitized.isdigit(): + raise ValueError("Lighter account index must be an integer string") + return SecretStr(sanitized) + + @field_validator("lighter_testnet_api_key_private_key", mode="before") + @classmethod + def validate_testnet_api_key(cls, value): + raw_value = value.get_secret_value() if isinstance(value, SecretStr) else str(value) + sanitized = raw_value.strip() + if sanitized == "": + raise ValueError(_API_KEY_FORMAT_HINT) + if _is_encrypted_secret_payload(sanitized): + return SecretStr(sanitized) + if not _is_hex_key(sanitized): + raise ValueError(_API_KEY_FORMAT_HINT) + return SecretStr(sanitized) + + +OTHER_DOMAINS_KEYS = { + "lighter_testnet": LighterTestnetConfigMap.model_construct(), +} + + +def is_exchange_information_valid(exchange_info: dict) -> bool: + market_type = str(exchange_info.get("market_type", "")).lower() + if market_type and market_type != "spot": + return False + + status = str(exchange_info.get("status", "")).lower() + if status in {"inactive", "disabled", "halted", "suspended", "delisted"}: + return False + + return bool(exchange_info.get("symbol")) + + +async def fetch_lighter_public_key(connector_name: str, account_index: str, api_key_index: str) -> Optional[str]: + from hummingbot.connector.lighter_common.lighter_key_utils import fetch_lighter_public_key as _fetch + + return await _fetch(connector_name, account_index, api_key_index) + + +async def validate_lighter_api_key_index(connector_name: str, account_index: str, api_key_index: str) -> Optional[str]: + from hummingbot.connector.lighter_common.lighter_key_utils import validate_lighter_api_key_index as _validate + + return await _validate(connector_name, account_index, api_key_index) diff --git a/hummingbot/connector/exchange/lighter/lighter_web_utils.py b/hummingbot/connector/exchange/lighter/lighter_web_utils.py new file mode 100644 index 00000000000..7e5620c79f1 --- /dev/null +++ b/hummingbot/connector/exchange/lighter/lighter_web_utils.py @@ -0,0 +1,39 @@ +import time +from typing import Optional + +from hummingbot.connector.exchange.lighter import lighter_constants as CONSTANTS +from hummingbot.core.api_throttler.async_throttler import AsyncThrottler +from hummingbot.core.web_assistant.auth import AuthBase +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory + + +def public_rest_url(path_url: str, domain: str = CONSTANTS.DEFAULT_DOMAIN) -> str: + base_url = CONSTANTS.REST_URL if domain == CONSTANTS.DEFAULT_DOMAIN else CONSTANTS.TESTNET_REST_URL + return base_url + path_url + + +def private_rest_url(path_url: str, domain: str = CONSTANTS.DEFAULT_DOMAIN) -> str: + return public_rest_url(path_url, domain) + + +def wss_url(domain: str = CONSTANTS.DEFAULT_DOMAIN) -> str: + return CONSTANTS.WSS_URL if domain == CONSTANTS.DEFAULT_DOMAIN else CONSTANTS.TESTNET_WSS_URL + + +def build_api_factory( + throttler: Optional[AsyncThrottler] = None, + auth: Optional[AuthBase] = None, +) -> WebAssistantsFactory: + throttler = throttler or AsyncThrottler(CONSTANTS.RATE_LIMITS) + api_factory = WebAssistantsFactory( + throttler=throttler, + auth=auth, + ) + return api_factory + + +async def get_current_server_time( + throttler: Optional[AsyncThrottler] = None, + domain: str = CONSTANTS.DEFAULT_DOMAIN, +) -> float: + return time.time() diff --git a/hummingbot/connector/lighter_common/__init__.py b/hummingbot/connector/lighter_common/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/hummingbot/connector/lighter_common/lighter_key_utils.py b/hummingbot/connector/lighter_common/lighter_key_utils.py new file mode 100644 index 00000000000..5474ad76e35 --- /dev/null +++ b/hummingbot/connector/lighter_common/lighter_key_utils.py @@ -0,0 +1,76 @@ +import logging +from typing import Optional + +import aiohttp + +from hummingbot.connector.derivative.lighter_perpetual import lighter_perpetual_constants as perp_constants +from hummingbot.connector.exchange.lighter import lighter_constants as spot_constants + + +def _get_base_url(connector_name: str) -> str: + connector_name = connector_name or "" + is_perpetual = connector_name in {"lighter_perpetual", "lighter_perpetual_testnet"} + is_testnet = connector_name in {"lighter_testnet", "lighter_perpetual_testnet"} + + if is_perpetual: + return perp_constants.TESTNET_REST_URL if is_testnet else perp_constants.REST_URL + return spot_constants.TESTNET_REST_URL if is_testnet else spot_constants.REST_URL + + +async def fetch_lighter_public_key(connector_name: str, account_index: str, api_key_index: str) -> Optional[str]: + """Fetch the public key for a lighter API key from the exchange REST API. + + Returns the public key hex string, or None if the lookup fails. + """ + logger = logging.getLogger(__name__) + url = f"{_get_base_url(connector_name)}/apikeys" + params = {"account_index": account_index, "api_key_index": api_key_index} + + try: + timeout = aiohttp.ClientTimeout(total=5) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url, params=params) as resp: + if resp.status == 200: + data = await resp.json() + api_keys = data.get("api_keys", []) + if api_keys: + return api_keys[0].get("public_key") + logger.warning( + "fetch_lighter_public_key: no api_keys in response " + f"(account={account_index}, key_index={api_key_index}): {data}" + ) + else: + logger.warning( + f"fetch_lighter_public_key: HTTP {resp.status} " + f"(account={account_index}, key_index={api_key_index})" + ) + except Exception as e: + logger.warning(f"fetch_lighter_public_key failed: {e}") + + return None + + +async def validate_lighter_api_key_index(connector_name: str, account_index: str, api_key_index: str) -> Optional[str]: + """Validate that api_key_index exists within the given account. + + Returns None if the key is valid (or if the check cannot be performed due to a network error). + Returns an error message string if the key index is not found in the account. + """ + url = f"{_get_base_url(connector_name)}/apikeys" + params = {"account_index": account_index, "api_key_index": api_key_index} + + try: + timeout = aiohttp.ClientTimeout(total=5) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url, params=params) as resp: + if resp.status == 200: + data = await resp.json() + if not data.get("api_keys"): + return ( + f"No API key found at index {api_key_index} for account {account_index}. " + "Please verify your API key index." + ) + except Exception: + pass + + return None diff --git a/hummingbot/data_feed/candles_feed/candles_factory.py b/hummingbot/data_feed/candles_feed/candles_factory.py index 1ba9ec8866d..360ac1bf7b4 100644 --- a/hummingbot/data_feed/candles_feed/candles_factory.py +++ b/hummingbot/data_feed/candles_feed/candles_factory.py @@ -1,95 +1,99 @@ -from typing import Dict, Type - -from hummingbot.data_feed.candles_feed.aevo_perpetual_candles import AevoPerpetualCandles -from hummingbot.data_feed.candles_feed.ascend_ex_spot_candles.ascend_ex_spot_candles import AscendExSpotCandles -from hummingbot.data_feed.candles_feed.binance_perpetual_candles import BinancePerpetualCandles -from hummingbot.data_feed.candles_feed.binance_spot_candles import BinanceSpotCandles -from hummingbot.data_feed.candles_feed.bitget_perpetual_candles import BitgetPerpetualCandles -from hummingbot.data_feed.candles_feed.bitget_spot_candles import BitgetSpotCandles -from hummingbot.data_feed.candles_feed.bitmart_perpetual_candles.bitmart_perpetual_candles import ( - BitmartPerpetualCandles, -) -from hummingbot.data_feed.candles_feed.btc_markets_spot_candles.btc_markets_spot_candles import BtcMarketsSpotCandles -from hummingbot.data_feed.candles_feed.bybit_perpetual_candles.bybit_perpetual_candles import BybitPerpetualCandles -from hummingbot.data_feed.candles_feed.bybit_spot_candles.bybit_spot_candles import BybitSpotCandles -from hummingbot.data_feed.candles_feed.candles_base import CandlesBase -from hummingbot.data_feed.candles_feed.data_types import CandlesConfig -from hummingbot.data_feed.candles_feed.decibel_perpetual_candles import DecibelPerpetualCandles -from hummingbot.data_feed.candles_feed.dexalot_spot_candles.dexalot_spot_candles import DexalotSpotCandles -from hummingbot.data_feed.candles_feed.evedex_perpetual_candles import EvedexPerpetualCandles -from hummingbot.data_feed.candles_feed.gate_io_perpetual_candles import GateioPerpetualCandles -from hummingbot.data_feed.candles_feed.gate_io_spot_candles import GateioSpotCandles -from hummingbot.data_feed.candles_feed.grvt_perpetual_candles import GrvtPerpetualCandles -from hummingbot.data_feed.candles_feed.hyperliquid_perpetual_candles.hyperliquid_perpetual_candles import ( - HyperliquidPerpetualCandles, -) -from hummingbot.data_feed.candles_feed.hyperliquid_spot_candles.hyperliquid_spot_candles import HyperliquidSpotCandles -from hummingbot.data_feed.candles_feed.kraken_spot_candles.kraken_spot_candles import KrakenSpotCandles -from hummingbot.data_feed.candles_feed.kucoin_perpetual_candles.kucoin_perpetual_candles import KucoinPerpetualCandles -from hummingbot.data_feed.candles_feed.kucoin_spot_candles.kucoin_spot_candles import KucoinSpotCandles -from hummingbot.data_feed.candles_feed.mexc_perpetual_candles.mexc_perpetual_candles import MexcPerpetualCandles -from hummingbot.data_feed.candles_feed.mexc_spot_candles.mexc_spot_candles import MexcSpotCandles -from hummingbot.data_feed.candles_feed.okx_perpetual_candles.okx_perpetual_candles import OKXPerpetualCandles -from hummingbot.data_feed.candles_feed.okx_spot_candles.okx_spot_candles import OKXSpotCandles -from hummingbot.data_feed.candles_feed.pacifica_perpetual_candles import PacificaPerpetualCandles - - -class UnsupportedConnectorException(Exception): - """ - Exception raised when an unsupported connector is requested. - """ - - def __init__(self, connector: str): - message = f"The connector {connector} is not available. Please select another one." - super().__init__(message) - - -class CandlesFactory: - """ - The CandlesFactory class creates and returns a Candle object based on the specified configuration. - It uses a mapping of connector names to their respective candle classes. - """ - - _candles_map: Dict[str, Type[CandlesBase]] = { - "aevo_perpetual": AevoPerpetualCandles, - "binance_perpetual": BinancePerpetualCandles, - "binance": BinanceSpotCandles, - "bitget": BitgetSpotCandles, - "bitget_perpetual": BitgetPerpetualCandles, - "gate_io": GateioSpotCandles, - "gate_io_perpetual": GateioPerpetualCandles, - "grvt_perpetual": GrvtPerpetualCandles, - "kucoin": KucoinSpotCandles, - "kucoin_perpetual": KucoinPerpetualCandles, - "ascend_ex": AscendExSpotCandles, - "okx_perpetual": OKXPerpetualCandles, - "okx": OKXSpotCandles, - "kraken": KrakenSpotCandles, - "mexc": MexcSpotCandles, - "mexc_perpetual": MexcPerpetualCandles, - "bybit": BybitSpotCandles, - "bybit_perpetual": BybitPerpetualCandles, - "hyperliquid": HyperliquidSpotCandles, - "hyperliquid_perpetual": HyperliquidPerpetualCandles, - "dexalot": DexalotSpotCandles, - "evedex_perpetual": EvedexPerpetualCandles, - "bitmart_perpetual": BitmartPerpetualCandles, - "btc_markets": BtcMarketsSpotCandles, - "pacifica_perpetual": PacificaPerpetualCandles, - "decibel_perpetual": DecibelPerpetualCandles, - } - - @classmethod - def get_candle(cls, candles_config: CandlesConfig) -> CandlesBase: - """ - Returns a Candle object based on the specified configuration. - - :param candles_config: CandlesConfig - :return: Instance of CandleBase or its subclass. - :raises UnsupportedConnectorException: If the connector is not supported. - """ - connector_class = cls._candles_map.get(candles_config.connector) - if connector_class: - return connector_class(candles_config.trading_pair, candles_config.interval, candles_config.max_records) - else: - raise UnsupportedConnectorException(candles_config.connector) +from typing import Dict, Type + +from hummingbot.data_feed.candles_feed.aevo_perpetual_candles import AevoPerpetualCandles +from hummingbot.data_feed.candles_feed.ascend_ex_spot_candles.ascend_ex_spot_candles import AscendExSpotCandles +from hummingbot.data_feed.candles_feed.binance_perpetual_candles import BinancePerpetualCandles +from hummingbot.data_feed.candles_feed.binance_spot_candles import BinanceSpotCandles +from hummingbot.data_feed.candles_feed.bitget_perpetual_candles import BitgetPerpetualCandles +from hummingbot.data_feed.candles_feed.bitget_spot_candles import BitgetSpotCandles +from hummingbot.data_feed.candles_feed.bitmart_perpetual_candles.bitmart_perpetual_candles import ( + BitmartPerpetualCandles, +) +from hummingbot.data_feed.candles_feed.btc_markets_spot_candles.btc_markets_spot_candles import BtcMarketsSpotCandles +from hummingbot.data_feed.candles_feed.bybit_perpetual_candles.bybit_perpetual_candles import BybitPerpetualCandles +from hummingbot.data_feed.candles_feed.bybit_spot_candles.bybit_spot_candles import BybitSpotCandles +from hummingbot.data_feed.candles_feed.candles_base import CandlesBase +from hummingbot.data_feed.candles_feed.data_types import CandlesConfig +from hummingbot.data_feed.candles_feed.decibel_perpetual_candles import DecibelPerpetualCandles +from hummingbot.data_feed.candles_feed.dexalot_spot_candles.dexalot_spot_candles import DexalotSpotCandles +from hummingbot.data_feed.candles_feed.evedex_perpetual_candles import EvedexPerpetualCandles +from hummingbot.data_feed.candles_feed.gate_io_perpetual_candles import GateioPerpetualCandles +from hummingbot.data_feed.candles_feed.gate_io_spot_candles import GateioSpotCandles +from hummingbot.data_feed.candles_feed.grvt_perpetual_candles import GrvtPerpetualCandles +from hummingbot.data_feed.candles_feed.hyperliquid_perpetual_candles.hyperliquid_perpetual_candles import ( + HyperliquidPerpetualCandles, +) +from hummingbot.data_feed.candles_feed.hyperliquid_spot_candles.hyperliquid_spot_candles import HyperliquidSpotCandles +from hummingbot.data_feed.candles_feed.kraken_spot_candles.kraken_spot_candles import KrakenSpotCandles +from hummingbot.data_feed.candles_feed.kucoin_perpetual_candles.kucoin_perpetual_candles import KucoinPerpetualCandles +from hummingbot.data_feed.candles_feed.kucoin_spot_candles.kucoin_spot_candles import KucoinSpotCandles +from hummingbot.data_feed.candles_feed.lighter_perpetual_candles import LighterPerpetualCandles +from hummingbot.data_feed.candles_feed.lighter_spot_candles import LighterSpotCandles +from hummingbot.data_feed.candles_feed.mexc_perpetual_candles.mexc_perpetual_candles import MexcPerpetualCandles +from hummingbot.data_feed.candles_feed.mexc_spot_candles.mexc_spot_candles import MexcSpotCandles +from hummingbot.data_feed.candles_feed.okx_perpetual_candles.okx_perpetual_candles import OKXPerpetualCandles +from hummingbot.data_feed.candles_feed.okx_spot_candles.okx_spot_candles import OKXSpotCandles +from hummingbot.data_feed.candles_feed.pacifica_perpetual_candles import PacificaPerpetualCandles + + +class UnsupportedConnectorException(Exception): + """ + Exception raised when an unsupported connector is requested. + """ + + def __init__(self, connector: str): + message = f"The connector {connector} is not available. Please select another one." + super().__init__(message) + + +class CandlesFactory: + """ + The CandlesFactory class creates and returns a Candle object based on the specified configuration. + It uses a mapping of connector names to their respective candle classes. + """ + + _candles_map: Dict[str, Type[CandlesBase]] = { + "aevo_perpetual": AevoPerpetualCandles, + "binance_perpetual": BinancePerpetualCandles, + "binance": BinanceSpotCandles, + "bitget": BitgetSpotCandles, + "bitget_perpetual": BitgetPerpetualCandles, + "gate_io": GateioSpotCandles, + "gate_io_perpetual": GateioPerpetualCandles, + "grvt_perpetual": GrvtPerpetualCandles, + "kucoin": KucoinSpotCandles, + "kucoin_perpetual": KucoinPerpetualCandles, + "ascend_ex": AscendExSpotCandles, + "okx_perpetual": OKXPerpetualCandles, + "okx": OKXSpotCandles, + "kraken": KrakenSpotCandles, + "mexc": MexcSpotCandles, + "mexc_perpetual": MexcPerpetualCandles, + "bybit": BybitSpotCandles, + "bybit_perpetual": BybitPerpetualCandles, + "hyperliquid": HyperliquidSpotCandles, + "hyperliquid_perpetual": HyperliquidPerpetualCandles, + "lighter": LighterSpotCandles, + "lighter_perpetual": LighterPerpetualCandles, + "dexalot": DexalotSpotCandles, + "evedex_perpetual": EvedexPerpetualCandles, + "bitmart_perpetual": BitmartPerpetualCandles, + "btc_markets": BtcMarketsSpotCandles, + "pacifica_perpetual": PacificaPerpetualCandles, + "decibel_perpetual": DecibelPerpetualCandles, + } + + @classmethod + def get_candle(cls, candles_config: CandlesConfig) -> CandlesBase: + """ + Returns a Candle object based on the specified configuration. + + :param candles_config: CandlesConfig + :return: Instance of CandleBase or its subclass. + :raises UnsupportedConnectorException: If the connector is not supported. + """ + connector_class = cls._candles_map.get(candles_config.connector) + if connector_class: + return connector_class(candles_config.trading_pair, candles_config.interval, candles_config.max_records) + else: + raise UnsupportedConnectorException(candles_config.connector) diff --git a/hummingbot/data_feed/candles_feed/lighter_perpetual_candles/__init__.py b/hummingbot/data_feed/candles_feed/lighter_perpetual_candles/__init__.py new file mode 100644 index 00000000000..c3bb925b21a --- /dev/null +++ b/hummingbot/data_feed/candles_feed/lighter_perpetual_candles/__init__.py @@ -0,0 +1,5 @@ +from hummingbot.data_feed.candles_feed.lighter_perpetual_candles.lighter_perpetual_candles import ( + LighterPerpetualCandles, +) + +__all__ = ["LighterPerpetualCandles"] diff --git a/hummingbot/data_feed/candles_feed/lighter_perpetual_candles/lighter_perpetual_candles.py b/hummingbot/data_feed/candles_feed/lighter_perpetual_candles/lighter_perpetual_candles.py new file mode 100644 index 00000000000..433e4d03589 --- /dev/null +++ b/hummingbot/data_feed/candles_feed/lighter_perpetual_candles/lighter_perpetual_candles.py @@ -0,0 +1,231 @@ +import asyncio +import logging +from typing import List, Optional + +from hummingbot.core.api_throttler.data_types import LinkedLimitWeightPair, RateLimit +from hummingbot.core.network_iterator import NetworkStatus +from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.core.web_assistant.connections.data_types import RESTMethod +from hummingbot.core.web_assistant.ws_assistant import WSAssistant +from hummingbot.data_feed.candles_feed.candles_base import CandlesBase +from hummingbot.logger import HummingbotLogger + +REST_URL = "https://mainnet.zklighter.elliot.ai/api/v1" +WSS_URL = "wss://mainnet.zklighter.elliot.ai/stream" +CANDLES_ENDPOINT = "/candles" +ORDER_BOOKS_ENDPOINT = "/orderBooks" +LIGHTER_LIMIT_ID = "LIGHTER_PERP_CANDLES_LIMIT" +LIGHTER_LIMIT = 24000 +LIGHTER_LIMIT_INTERVAL = 60 +MAX_RESULTS_PER_REQUEST = 500 + +INTERVALS = { + "1m": 60, + "3m": 180, + "5m": 300, + "15m": 900, + "30m": 1800, + "1h": 3600, + "2h": 7200, + "4h": 14400, + "6h": 21600, + "12h": 43200, + "1d": 86400, + "1w": 604800, +} + +RATE_LIMITS = [ + RateLimit(limit_id=LIGHTER_LIMIT_ID, limit=LIGHTER_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL), + RateLimit( + limit_id=CANDLES_ENDPOINT, + limit=LIGHTER_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=10)], + ), + RateLimit( + limit_id=ORDER_BOOKS_ENDPOINT, + limit=LIGHTER_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=10)], + ), +] + + +class LighterPerpetualCandles(CandlesBase): + _logger: Optional[HummingbotLogger] = None + + @classmethod + def logger(cls) -> HummingbotLogger: + if cls._logger is None: + cls._logger = logging.getLogger(__name__) + return cls._logger + + def __init__(self, trading_pair: str, interval: str = "1m", max_records: int = 150): + self._market_id: Optional[int] = None + super().__init__(trading_pair, interval, max_records) + + @property + def name(self): + return f"lighter_perpetual_{self._trading_pair}" + + @property + def rest_url(self): + return REST_URL + + @property + def wss_url(self): + return WSS_URL + + @property + def health_check_url(self): + return REST_URL + ORDER_BOOKS_ENDPOINT + + @property + def candles_url(self): + return REST_URL + CANDLES_ENDPOINT + + @property + def candles_endpoint(self): + return CANDLES_ENDPOINT + + @property + def candles_max_result_per_rest_request(self): + return MAX_RESULTS_PER_REQUEST + + @property + def rate_limits(self): + return RATE_LIMITS + + @property + def intervals(self): + return INTERVALS + + def get_exchange_trading_pair(self, trading_pair: str) -> str: + return trading_pair + + async def check_network(self) -> NetworkStatus: + rest_assistant = await self._api_factory.get_rest_assistant() + await rest_assistant.execute_request( + url=self.health_check_url, + throttler_limit_id=ORDER_BOOKS_ENDPOINT, + method=RESTMethod.GET, + ) + return NetworkStatus.CONNECTED + + async def initialize_exchange_data(self): + rest_assistant = await self._api_factory.get_rest_assistant() + response = await rest_assistant.execute_request( + url=self.health_check_url, + throttler_limit_id=ORDER_BOOKS_ENDPOINT, + method=RESTMethod.GET, + ) + base_asset = self._trading_pair.split("-")[0] + for ob in response.get("order_books", []): + if ob.get("market_type") == "perp" and ob.get("symbol") == base_asset: + self._market_id = ob["market_id"] + return + raise ValueError( + f"Perpetual market '{base_asset}' not found in Lighter order books." + ) + + def _get_rest_candles_params( + self, + start_time: Optional[int] = None, + end_time: Optional[int] = None, + limit: Optional[int] = None, + ) -> dict: + params: dict = { + "market_id": self._market_id, + "resolution": self.interval, + "set_timestamp_to_end": "true", + } + if start_time is not None: + params["start_timestamp"] = start_time + if end_time is not None: + params["end_timestamp"] = end_time + params["count_back"] = limit if limit is not None else self.candles_max_result_per_rest_request + return params + + def _parse_rest_candles(self, data: dict, end_time: Optional[int] = None) -> List[List[float]]: + rows = [] + for candle in data.get("c", []): + ts = float(candle["t"]) / 1000 + if end_time is not None and ts > end_time: + continue + rows.append([ + ts, + float(candle["o"]), + float(candle["h"]), + float(candle["l"]), + float(candle["c"]), + float(candle["v"]), + float(candle["V"]), + float(candle["i"]), + 0.0, + 0.0, + ]) + return rows + + def ws_subscription_payload(self) -> dict: + return { + "type": "subscribe", + "channel": f"trade/{self._market_id}", + } + + def _parse_websocket_message(self, data: dict): + return None + + async def listen_for_subscriptions(self): + ws: Optional[WSAssistant] = None + while True: + try: + ws = await self._connected_websocket_assistant() + await self._subscribe_channels(ws) + seed = await self.fetch_candles(end_time=int(self._time())) + if len(seed) > 0: + for row in seed: + self._candles.append(row) + self._ws_candle_available.set() + await self._process_websocket_messages(websocket_assistant=ws) + except asyncio.CancelledError: + raise + except ConnectionError as e: + self.logger().warning(f"The websocket connection was closed ({e})") + except Exception: + self.logger().exception( + "Unexpected error occurred when listening to public klines. Retrying in 1 seconds..." + ) + await self._sleep(1.0) + finally: + await self._on_order_stream_interruption(websocket_assistant=ws) + + async def _process_websocket_messages_task(self, websocket_assistant: WSAssistant): + expected_channel = f"trade:{self._market_id}" + async for ws_response in websocket_assistant.iter_messages(): + data = ws_response.data + if not isinstance(data, dict): + continue + if data.get("channel") != expected_channel: + continue + trade_data = data.get("data", {}) + trade_ts = int(trade_data.get("created_at", 0)) + bucket_ts = trade_ts - (trade_ts % self.interval_in_seconds) + candles = await self.fetch_candles( + start_time=bucket_ts, + end_time=bucket_ts + self.interval_in_seconds, + limit=1, + ) + if len(candles) == 0: + continue + candle_row = candles[0] + if len(self._candles) == 0: + self._candles.append(candle_row) + self._ws_candle_available.set() + safe_ensure_future(self.fill_historical_candles()) + else: + latest_ts = int(self._candles[-1][0]) + current_ts = int(candle_row[0]) + if current_ts > latest_ts: + self._candles.append(candle_row) + elif current_ts == latest_ts: + self._candles[-1] = candle_row diff --git a/hummingbot/data_feed/candles_feed/lighter_spot_candles/__init__.py b/hummingbot/data_feed/candles_feed/lighter_spot_candles/__init__.py new file mode 100644 index 00000000000..eb771f9db14 --- /dev/null +++ b/hummingbot/data_feed/candles_feed/lighter_spot_candles/__init__.py @@ -0,0 +1,3 @@ +from hummingbot.data_feed.candles_feed.lighter_spot_candles.lighter_spot_candles import LighterSpotCandles + +__all__ = ["LighterSpotCandles"] diff --git a/hummingbot/data_feed/candles_feed/lighter_spot_candles/lighter_spot_candles.py b/hummingbot/data_feed/candles_feed/lighter_spot_candles/lighter_spot_candles.py new file mode 100644 index 00000000000..0761d225edb --- /dev/null +++ b/hummingbot/data_feed/candles_feed/lighter_spot_candles/lighter_spot_candles.py @@ -0,0 +1,233 @@ +import asyncio +import logging +from typing import List, Optional + +from hummingbot.core.api_throttler.data_types import LinkedLimitWeightPair, RateLimit +from hummingbot.core.network_iterator import NetworkStatus +from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.core.web_assistant.connections.data_types import RESTMethod +from hummingbot.core.web_assistant.ws_assistant import WSAssistant +from hummingbot.data_feed.candles_feed.candles_base import CandlesBase +from hummingbot.logger import HummingbotLogger + +REST_URL = "https://mainnet.zklighter.elliot.ai/api/v1" +WSS_URL = "wss://mainnet.zklighter.elliot.ai/stream" +CANDLES_ENDPOINT = "/candles" +ORDER_BOOKS_ENDPOINT = "/orderBooks" +LIGHTER_LIMIT_ID = "LIGHTER_CANDLES_LIMIT" +LIGHTER_LIMIT = 24000 +LIGHTER_LIMIT_INTERVAL = 60 +MAX_RESULTS_PER_REQUEST = 500 + +INTERVALS = { + "1m": 60, + "3m": 180, + "5m": 300, + "15m": 900, + "30m": 1800, + "1h": 3600, + "2h": 7200, + "4h": 14400, + "6h": 21600, + "12h": 43200, + "1d": 86400, + "1w": 604800, +} + +RATE_LIMITS = [ + RateLimit(limit_id=LIGHTER_LIMIT_ID, limit=LIGHTER_LIMIT, time_interval=LIGHTER_LIMIT_INTERVAL), + RateLimit( + limit_id=CANDLES_ENDPOINT, + limit=LIGHTER_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=10)], + ), + RateLimit( + limit_id=ORDER_BOOKS_ENDPOINT, + limit=LIGHTER_LIMIT, + time_interval=LIGHTER_LIMIT_INTERVAL, + linked_limits=[LinkedLimitWeightPair(limit_id=LIGHTER_LIMIT_ID, weight=10)], + ), +] + + +class LighterSpotCandles(CandlesBase): + _logger: Optional[HummingbotLogger] = None + + @classmethod + def logger(cls) -> HummingbotLogger: + if cls._logger is None: + cls._logger = logging.getLogger(__name__) + return cls._logger + + def __init__(self, trading_pair: str, interval: str = "1m", max_records: int = 150): + self._market_id: Optional[int] = None + super().__init__(trading_pair, interval, max_records) + + @property + def name(self): + return f"lighter_{self._trading_pair}" + + @property + def rest_url(self): + return REST_URL + + @property + def wss_url(self): + return WSS_URL + + @property + def health_check_url(self): + return REST_URL + ORDER_BOOKS_ENDPOINT + + @property + def candles_url(self): + return REST_URL + CANDLES_ENDPOINT + + @property + def candles_endpoint(self): + return CANDLES_ENDPOINT + + @property + def candles_max_result_per_rest_request(self): + return MAX_RESULTS_PER_REQUEST + + @property + def rate_limits(self): + return RATE_LIMITS + + @property + def intervals(self): + return INTERVALS + + def get_exchange_trading_pair(self, trading_pair: str) -> str: + return trading_pair + + async def check_network(self) -> NetworkStatus: + rest_assistant = await self._api_factory.get_rest_assistant() + await rest_assistant.execute_request( + url=self.health_check_url, + throttler_limit_id=ORDER_BOOKS_ENDPOINT, + method=RESTMethod.GET, + ) + return NetworkStatus.CONNECTED + + async def initialize_exchange_data(self): + rest_assistant = await self._api_factory.get_rest_assistant() + response = await rest_assistant.execute_request( + url=self.health_check_url, + throttler_limit_id=ORDER_BOOKS_ENDPOINT, + method=RESTMethod.GET, + ) + base_asset = self._trading_pair.split("-")[0] + quote_asset = self._trading_pair.split("-")[1] + expected_symbol = f"{base_asset}/{quote_asset}" + for ob in response.get("order_books", []): + if ob.get("market_type") == "spot" and ob.get("symbol") == expected_symbol: + self._market_id = ob["market_id"] + return + raise ValueError( + f"Spot market '{expected_symbol}' not found in Lighter order books." + ) + + def _get_rest_candles_params( + self, + start_time: Optional[int] = None, + end_time: Optional[int] = None, + limit: Optional[int] = None, + ) -> dict: + params: dict = { + "market_id": self._market_id, + "resolution": self.interval, + "set_timestamp_to_end": "true", + } + if start_time is not None: + params["start_timestamp"] = start_time + if end_time is not None: + params["end_timestamp"] = end_time + params["count_back"] = limit if limit is not None else self.candles_max_result_per_rest_request + return params + + def _parse_rest_candles(self, data: dict, end_time: Optional[int] = None) -> List[List[float]]: + rows = [] + for candle in data.get("c", []): + ts = float(candle["t"]) / 1000 + if end_time is not None and ts > end_time: + continue + rows.append([ + ts, + float(candle["o"]), + float(candle["h"]), + float(candle["l"]), + float(candle["c"]), + float(candle["v"]), + float(candle["V"]), + float(candle["i"]), + 0.0, + 0.0, + ]) + return rows + + def ws_subscription_payload(self) -> dict: + return { + "type": "subscribe", + "channel": f"trade/{self._market_id}", + } + + def _parse_websocket_message(self, data: dict): + return None + + async def listen_for_subscriptions(self): + ws: Optional[WSAssistant] = None + while True: + try: + ws = await self._connected_websocket_assistant() + await self._subscribe_channels(ws) + seed = await self.fetch_candles(end_time=int(self._time())) + if len(seed) > 0: + for row in seed: + self._candles.append(row) + self._ws_candle_available.set() + await self._process_websocket_messages(websocket_assistant=ws) + except asyncio.CancelledError: + raise + except ConnectionError as e: + self.logger().warning(f"The websocket connection was closed ({e})") + except Exception: + self.logger().exception( + "Unexpected error occurred when listening to public klines. Retrying in 1 seconds..." + ) + await self._sleep(1.0) + finally: + await self._on_order_stream_interruption(websocket_assistant=ws) + + async def _process_websocket_messages_task(self, websocket_assistant: WSAssistant): + expected_channel = f"trade:{self._market_id}" + async for ws_response in websocket_assistant.iter_messages(): + data = ws_response.data + if not isinstance(data, dict): + continue + if data.get("channel") != expected_channel: + continue + trade_data = data.get("data", {}) + trade_ts = int(trade_data.get("created_at", 0)) + bucket_ts = trade_ts - (trade_ts % self.interval_in_seconds) + candles = await self.fetch_candles( + start_time=bucket_ts, + end_time=bucket_ts + self.interval_in_seconds, + limit=1, + ) + if len(candles) == 0: + continue + candle_row = candles[0] + if len(self._candles) == 0: + self._candles.append(candle_row) + self._ws_candle_available.set() + safe_ensure_future(self.fill_historical_candles()) + else: + latest_ts = int(self._candles[-1][0]) + current_ts = int(candle_row[0]) + if current_ts > latest_ts: + self._candles.append(candle_row) + elif current_ts == latest_ts: + self._candles[-1] = candle_row diff --git a/setup.py b/setup.py index c75d3ff432f..923bd876ecd 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ def main(): "hummingbot": [ "core/cpp/*", "VERSION", - "templates/*TEMPLATE.yml" + "templates/*TEMPLATE.yml", ], } install_requires = [ @@ -56,6 +56,7 @@ def main(): "cryptography>=41.0.2", "decibel-python-sdk==0.2.1", "eth-account>=0.13.0", + "lighter-sdk==1.0.8", "injective-py>=1.13", "msgpack-python", "numba>=0.61.2", diff --git a/setup/environment.yml b/setup/environment.yml index da547193043..a28823e8590 100644 --- a/setup/environment.yml +++ b/setup/environment.yml @@ -65,6 +65,7 @@ dependencies: - zlib>=1.2.13 - pip: - injective-py==1.13.* + - lighter-sdk==1.0.8 - decibel-python-sdk==0.2.1 - aptos-sdk>=0.8.0 - solders>=0.19.0 diff --git a/test/hummingbot/client/command/test_balance_command.py b/test/hummingbot/client/command/test_balance_command.py index 021d68c2802..753af0d2213 100644 --- a/test/hummingbot/client/command/test_balance_command.py +++ b/test/hummingbot/client/command/test_balance_command.py @@ -129,3 +129,40 @@ async def test_show_balances( msg=f"\n\nExchanges Total: {self.app.client_config_map.global_token.global_token_symbol} 20 " ) ) + + def test_balance_limit_accepts_derivative_connector(self): + self.app.balance(option="limit", args=["lighter_perpetual", "USDC", "20"]) + + self.assertEqual( + 20.0, + self.app.client_config_map.balance_asset_limit["lighter_perpetual"]["USDC"], + ) + self.assertTrue( + self.cli_mock_assistant.check_log_called_with( + msg="Limit for USDC on lighter_perpetual exchange set to 20.0" + ) + ) + + def test_balance_limit_normalizes_connector_name_case(self): + self.app.balance(option="limit", args=["LIGHTER_PERPETUAL", "USDC", "10"]) + + self.assertEqual( + 10.0, + self.app.client_config_map.balance_asset_limit["lighter_perpetual"]["USDC"], + ) + + def test_balance_limit_accepts_hyperliquid_backpack_and_pacifica_perps(self): + derivative_connectors = ["hyperliquid_perpetual", "backpack_perpetual", "pacifica_perpetual"] + + for connector in derivative_connectors: + self.app.balance(option="limit", args=[connector, "USDC", "7"]) + + self.assertEqual( + 7.0, + self.app.client_config_map.balance_asset_limit[connector]["USDC"], + ) + self.assertTrue( + self.cli_mock_assistant.check_log_called_with( + msg=f"Limit for USDC on {connector} exchange set to 7.0" + ) + ) diff --git a/test/hummingbot/connector/derivative/lighter_perpetual/__init__.py b/test/hummingbot/connector/derivative/lighter_perpetual/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/hummingbot/connector/derivative/lighter_perpetual/test_lighter_perpetual_api_order_book_data_source.py b/test/hummingbot/connector/derivative/lighter_perpetual/test_lighter_perpetual_api_order_book_data_source.py new file mode 100644 index 00000000000..b19108a9e5f --- /dev/null +++ b/test/hummingbot/connector/derivative/lighter_perpetual/test_lighter_perpetual_api_order_book_data_source.py @@ -0,0 +1,796 @@ +import asyncio +import sys +import types +import unittest +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock, patch + + +def _ensure_limit_order_stub(): + module_name = "hummingbot.core.data_type.limit_order" + try: + __import__(module_name) + return + except Exception: + pass + if module_name in sys.modules: + return + stub_module = types.ModuleType(module_name) + + class LimitOrder: + pass + + stub_module.LimitOrder = LimitOrder + sys.modules[module_name] = stub_module + + +def _ensure_order_book_stub(): + module_name = "hummingbot.core.data_type.order_book" + try: + __import__(module_name) + return + except Exception: + pass + if module_name in sys.modules: + return + stub_module = types.ModuleType(module_name) + + class OrderBook: + pass + + stub_module.OrderBook = OrderBook + sys.modules[module_name] = stub_module + + +class LighterPerpetualAPIOrderBookDataSourceTests(unittest.IsolatedAsyncioTestCase): + + data_source_cls = None + constants = None + + @classmethod + def setUpClass(cls): + super().setUpClass() + _ensure_limit_order_stub() + _ensure_order_book_stub() + try: + from hummingbot.connector.derivative.lighter_perpetual import lighter_perpetual_constants as CONSTANTS + from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_api_order_book_data_source import ( + LighterPerpetualAPIOrderBookDataSource, + ) + + cls.constants = CONSTANTS + cls.data_source_cls = LighterPerpetualAPIOrderBookDataSource + except ModuleNotFoundError: + cls.data_source_cls = None + + def setUp(self): + super().setUp() + if self.data_source_cls is None: + self.skipTest("Compiled hummingbot core modules are unavailable in this environment") + + self.connector = MagicMock() + self.connector.rest_api_key = "api-key" + self.connector.exchange_symbol_associated_to_pair = AsyncMock(return_value="BTC") + self.connector._get_market_spec = AsyncMock(return_value=(1001, 4, 2, "BTC")) + self.connector.trading_pair_associated_to_exchange_symbol = AsyncMock( + side_effect=lambda symbol: {"BTC": "BTC-USDC", "ETH": "ETH-USDC"}[symbol] + ) + self.connector.get_last_traded_prices = AsyncMock(return_value={"BTC-USDC": 101.2}) + self.connector.set_LIGHTER_price = MagicMock() + + self.rest_assistant = AsyncMock() + self.ws_assistant = AsyncMock() + self.api_factory = MagicMock() + self.api_factory.get_rest_assistant = AsyncMock(return_value=self.rest_assistant) + self.api_factory.get_ws_assistant = AsyncMock(return_value=self.ws_assistant) + + self.data_source = self.data_source_cls( + trading_pairs=["BTC-USDC"], + connector=self.connector, + api_factory=self.api_factory, + ) + + async def test_request_order_book_snapshot_uses_expected_rest_request(self): + self.rest_assistant.execute_request = AsyncMock(return_value={ + "success": True, + "code": 200, + "data": { + "s": "BTC", + "l": [[{"p": "100", "a": "1", "n": 1}], [{"p": "101", "a": "2", "n": 1}]], + "li": 99, + "t": 1700000000000, + }, + }) + + result = await self.data_source._request_order_book_snapshot("BTC-USDC") + + self.assertTrue(result["success"]) + self.rest_assistant.execute_request.assert_awaited_once() + call_kwargs = self.rest_assistant.execute_request.call_args.kwargs + self.assertEqual({"market_id": 1001, "limit": 250}, call_kwargs["params"]) + self.assertEqual({}, call_kwargs["headers"]) + self.assertEqual(self.constants.GET_MARKET_ORDER_BOOK_SNAPSHOT_PATH_URL, call_kwargs["throttler_limit_id"]) + + async def test_order_book_snapshot_builds_snapshot_message(self): + self.data_source._request_order_book_snapshot = AsyncMock(return_value={ + "success": True, + "code": 200, + "data": { + "s": "BTC", + "l": [[{"p": "100", "a": "1.5", "n": 1}], [{"p": "101", "a": "2.5", "n": 1}]], + "li": 123, + "t": 1700000000000, + }, + }) + + message = await self.data_source._order_book_snapshot("BTC-USDC") + + self.assertEqual("BTC-USDC", message.content["trading_pair"]) + self.assertEqual([("100", "1.5")], message.content["bids"]) + self.assertEqual([("101", "2.5")], message.content["asks"]) + self.assertEqual(123, message.content["update_id"]) + + async def test_order_book_snapshot_builds_snapshot_message_from_legacy_payload(self): + self.data_source._request_order_book_snapshot = AsyncMock(return_value={ + "code": 200, + "bids": [{"price": "100", "remaining_base_amount": "1.5"}], + "asks": [{"price": "101", "remaining_base_amount": "2.5"}], + }) + + message = await self.data_source._order_book_snapshot("BTC-USDC") + + self.assertEqual("BTC-USDC", message.content["trading_pair"]) + self.assertEqual([("100", "1.5")], message.content["bids"]) + self.assertEqual([("101", "2.5")], message.content["asks"]) + self.assertEqual(1, message.content["update_id"]) + + async def test_get_funding_info_parses_price_entry(self): + self.rest_assistant.execute_request = AsyncMock(return_value={ + "success": True, + "data": [{ + "symbol": "BTC", + "oracle": "100", + "mark": "101", + "funding": "0.0001", + }], + }) + + funding_info = await self.data_source.get_funding_info("BTC-USDC") + + self.assertEqual("BTC-USDC", funding_info.trading_pair) + self.assertEqual(Decimal("100"), funding_info.index_price) + self.assertEqual(Decimal("101"), funding_info.mark_price) + self.assertEqual(Decimal("0.0001"), funding_info.rate) + + async def test_get_funding_info_parses_order_book_stats_entry(self): + self.rest_assistant.execute_request = AsyncMock(return_value={ + "code": 200, + "total": 250, + "order_book_stats": [{ + "symbol": "BTC", + "oracle": "100", + "mark": "101", + "funding": "0.0001", + }], + }) + + funding_info = await self.data_source.get_funding_info("BTC-USDC") + + self.assertEqual("BTC-USDC", funding_info.trading_pair) + self.assertEqual(Decimal("100"), funding_info.index_price) + self.assertEqual(Decimal("101"), funding_info.mark_price) + self.assertEqual(Decimal("0.0001"), funding_info.rate) + + @patch("hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_api_order_book_data_source.safe_ensure_future") + async def test_connected_websocket_assistant_connects_and_starts_ping(self, safe_future_mock): + safe_future_mock.side_effect = lambda coro: (coro.close(), MagicMock())[1] + + ws = await self.data_source._connected_websocket_assistant() + + self.assertIs(self.ws_assistant, ws) + self.ws_assistant.connect.assert_awaited_once() + connect_kwargs = self.ws_assistant.connect.call_args.kwargs + self.assertEqual({"X-Api-Key": "api-key"}, connect_kwargs["ws_headers"]) + self.assertEqual(1, safe_future_mock.call_count) + + async def test_subscribe_channels_sends_book_trade_and_prices_requests(self): + await self.data_source._subscribe_channels(self.ws_assistant) + + self.assertEqual(3, self.ws_assistant.send.await_count) + payloads = [call.args[0].payload for call in self.ws_assistant.send.call_args_list] + self.assertEqual({"type": "subscribe", "channel": "order_book/1001"}, payloads[0]) + self.assertEqual({"type": "subscribe", "channel": "trade/1001"}, payloads[1]) + self.assertEqual({"type": "subscribe", "channel": "market_stats/1001"}, payloads[2]) + # _market_id_to_trading_pair should be populated after subscription + self.assertEqual("BTC-USDC", self.data_source._market_id_to_trading_pair[1001]) + + async def test_parse_order_book_snapshot_message_emits_snapshot(self): + queue = asyncio.Queue() + self.data_source._market_id_to_trading_pair[1001] = "BTC-USDC" + + await self.data_source._parse_order_book_snapshot_message( + { + "channel": "order_book:1001", + "type": "subscribed/order_book", + "timestamp": 1700000000000, + "order_book": { + "nonce": 123, + "bids": [{"price": "100", "size": "1"}], + "asks": [{"price": "101", "size": "2"}], + }, + }, + queue, + ) + + message = queue.get_nowait() + self.assertEqual("BTC-USDC", message.content["trading_pair"]) + self.assertEqual(123, message.update_id) + self.assertEqual([("100", "1")], message.content["bids"]) + self.assertEqual([("101", "2")], message.content["asks"]) + + async def test_parse_order_book_snapshot_message_supports_slash_channel(self): + queue = asyncio.Queue() + self.data_source._market_id_to_trading_pair[1001] = "BTC-USDC" + + await self.data_source._parse_order_book_snapshot_message( + { + "channel": "order_book/1001", + "type": "subscribed/order_book", + "timestamp": 1700000000000, + "order_book": { + "nonce": 123, + "bids": [{"price": "100", "size": "1"}], + "asks": [{"price": "101", "size": "2"}], + }, + }, + queue, + ) + + message = queue.get_nowait() + self.assertEqual("BTC-USDC", message.content["trading_pair"]) + + async def test_parse_trade_message_emits_buy_and_sell_messages(self): + queue = asyncio.Queue() + self.data_source._market_id_to_trading_pair[1001] = "BTC-USDC" + + await self.data_source._parse_trade_message( + { + "channel": "trade:1001", + "timestamp": 1700000000000, + "trades": [ + {"price": "100", "size": "0.1", "is_maker_ask": True, "nonce": 1}, + {"price": "99", "size": "0.2", "is_maker_ask": False, "nonce": 2}, + ], + }, + queue, + ) + + buy_message = queue.get_nowait() + sell_message = queue.get_nowait() + self.assertEqual(1.0, buy_message.content["trade_type"]) + self.assertEqual(2.0, sell_message.content["trade_type"]) + self.assertEqual("0.1", buy_message.content["amount"]) + self.assertEqual("99", sell_message.content["price"]) + + async def test_parse_trade_message_supports_slash_channel(self): + queue = asyncio.Queue() + self.data_source._market_id_to_trading_pair[1001] = "BTC-USDC" + + await self.data_source._parse_trade_message( + { + "channel": "trade/1001", + "timestamp": 1700000000000, + "trades": [ + {"price": "100", "size": "0.1", "is_maker_ask": True, "nonce": 1}, + ], + }, + queue, + ) + + message = queue.get_nowait() + self.assertEqual("BTC-USDC", message.content["trading_pair"]) + + async def test_parse_funding_info_message_updates_queue_and_cached_prices(self): + queue = asyncio.Queue() + self.data_source._market_id_to_trading_pair[1001] = "BTC-USDC" + + await self.data_source._parse_funding_info_message( + { + "channel": "market_stats:1001", + "timestamp": 1700000000000, + "market_stats": { + "symbol": "BTC", + "index_price": "100", + "mark_price": "101", + "current_funding_rate": "0.0001", + "funding_timestamp": 1700003600000, + }, + }, + queue, + ) + + update = queue.get_nowait() + self.assertEqual("BTC-USDC", update.trading_pair) + self.assertEqual(Decimal("100"), update.index_price) + self.assertEqual(Decimal("101"), update.mark_price) + self.assertEqual(Decimal("0.0001"), update.rate) + self.assertEqual(1700003600, update.next_funding_utc_timestamp) + self.connector.set_LIGHTER_price.assert_called_once_with( + "BTC-USDC", + timestamp=1700000000.0, + index_price=Decimal("100"), + mark_price=Decimal("101"), + ) + + async def test_parse_funding_info_message_supports_slash_channel(self): + queue = asyncio.Queue() + self.data_source._market_id_to_trading_pair[1001] = "BTC-USDC" + + await self.data_source._parse_funding_info_message( + { + "channel": "market_stats/1001", + "timestamp": 1700000000000, + "market_stats": { + "symbol": "BTC", + "index_price": "100", + "mark_price": "101", + "current_funding_rate": "0.0001", + "funding_timestamp": 1700003600000, + }, + }, + queue, + ) + + update = queue.get_nowait() + self.assertEqual("BTC-USDC", update.trading_pair) + + def test_channel_originating_message_routes_known_channels(self): + # Snapshot on initial subscription + self.assertEqual( + self.data_source._snapshot_messages_queue_key, + self.data_source._channel_originating_message({"channel": "order_book:1", "type": "subscribed/order_book"}), + ) + # Diff on incremental update + self.assertEqual( + self.data_source._diff_messages_queue_key, + self.data_source._channel_originating_message({"channel": "order_book:1", "type": "update/order_book"}), + ) + self.assertEqual( + self.data_source._trade_messages_queue_key, + self.data_source._channel_originating_message({"channel": "trade:1", "type": "update/trade"}), + ) + self.assertEqual( + self.data_source._funding_info_messages_queue_key, + self.data_source._channel_originating_message({"channel": "market_stats:1", "type": "update/market_stats"}), + ) + self.assertEqual( + self.data_source._snapshot_messages_queue_key, + self.data_source._channel_originating_message({"channel": "order_book/1", "type": "subscribed/order_book"}), + ) + self.assertEqual( + self.data_source._trade_messages_queue_key, + self.data_source._channel_originating_message({"channel": "trade/1", "type": "update/trade"}), + ) + self.assertEqual( + self.data_source._funding_info_messages_queue_key, + self.data_source._channel_originating_message({"channel": "market_stats/1", "type": "update/market_stats"}), + ) + self.assertEqual("", self.data_source._channel_originating_message({"channel": "other"})) + self.assertEqual("", self.data_source._channel_originating_message({})) + + async def test_subscribe_and_unsubscribe_trading_pair_send_expected_messages(self): + self.data_source._ws_assistant = self.ws_assistant + + subscribed = await self.data_source.subscribe_to_trading_pair("BTC-USDC") + unsubscribed = await self.data_source.unsubscribe_from_trading_pair("BTC-USDC") + + self.assertTrue(subscribed) + self.assertTrue(unsubscribed) + payloads = [call.args[0].payload for call in self.ws_assistant.send.call_args_list] + # 3 subscribe + 3 unsubscribe = 6 messages + self.assertEqual(6, len(payloads)) + self.assertEqual({"type": "subscribe", "channel": "order_book/1001"}, payloads[0]) + self.assertEqual({"type": "subscribe", "channel": "trade/1001"}, payloads[1]) + self.assertEqual({"type": "subscribe", "channel": "market_stats/1001"}, payloads[2]) + self.assertEqual({"type": "unsubscribe", "channel": "order_book/1001"}, payloads[3]) + self.assertEqual({"type": "unsubscribe", "channel": "trade/1001"}, payloads[4]) + self.assertEqual({"type": "unsubscribe", "channel": "market_stats/1001"}, payloads[5]) + + async def test_on_order_stream_interruption_cancels_ping_task(self): + ping_task = MagicMock() + self.data_source._ping_task = ping_task + + await self.data_source._on_order_stream_interruption() + + ping_task.cancel.assert_called_once() + self.assertIsNone(self.data_source._ping_task) + + async def test_ping_loop_returns_when_ws_disconnects(self): + self.ws_assistant.send.side_effect = RuntimeError("WS is not connected") + + with patch("hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_api_order_book_data_source.asyncio.sleep", new=AsyncMock()): + result = await self.data_source._ping_loop(self.ws_assistant) + + self.assertIsNone(result) + + async def test_subscribe_channels_raises_on_send_error(self): + self.ws_assistant.send.side_effect = Exception("boom") + + with self.assertRaises(Exception): + await self.data_source._subscribe_channels(self.ws_assistant) + + async def test_get_new_order_book_successful(self): + await self.test_order_book_snapshot_builds_snapshot_message() + + async def test_get_new_order_book_raises_exception(self): + self.data_source._order_book_snapshot = AsyncMock(side_effect=RuntimeError("boom")) + with self.assertRaises(RuntimeError): + await self.data_source.get_new_order_book("BTC-USDC") + + async def test_listen_for_subscriptions_subscribes_to_trades_and_order_diffs_and_funding_info(self): + ws = MagicMock() + ws.disconnect = AsyncMock() + self.data_source._connected_websocket_assistant = AsyncMock(return_value=ws) + self.data_source._subscribe_channels = AsyncMock() + self.data_source._process_websocket_messages = AsyncMock(side_effect=asyncio.CancelledError()) + self.data_source._on_order_stream_interruption = AsyncMock() + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_subscriptions() + + async def test_listen_for_subscriptions_raises_cancel_exception(self): + self.data_source._connected_websocket_assistant = AsyncMock(side_effect=asyncio.CancelledError()) + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_subscriptions() + + async def test_listen_for_subscriptions_logs_exception_details(self): + logger = MagicMock() + self.data_source.logger = MagicMock(return_value=logger) + self.data_source._connected_websocket_assistant = AsyncMock(side_effect=[Exception("boom"), asyncio.CancelledError()]) + self.data_source._sleep = AsyncMock() + self.data_source._on_order_stream_interruption = AsyncMock() + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_subscriptions() + + logger.exception.assert_called() + + async def test_subscribe_channels_raises_cancel_exception(self): + self.connector._get_market_spec.side_effect = asyncio.CancelledError() + with self.assertRaises(asyncio.CancelledError): + await self.data_source._subscribe_channels(self.ws_assistant) + + # ── listen_for_trades ────────────────────────────────────────────────────── + + async def test_listen_for_trades_successful(self): + raw_msg = {"data": [{"h": 1, "li": 1, "s": "BTC", "d": "open_long", "a": "0.5", "p": "100", "t": 1700000000000}]} + self.data_source._message_queue[self.data_source._trade_messages_queue_key].put_nowait(raw_msg) + output = asyncio.Queue() + + async def _parse_trade_mock(raw_message, message_queue): + message_queue.put_nowait(raw_message) + raise asyncio.CancelledError() + + self.data_source._parse_trade_message = _parse_trade_mock + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_trades(ev_loop=asyncio.get_event_loop(), output=output) + + self.assertEqual(1, output.qsize()) + + async def test_listen_for_trades_cancelled_when_listening(self): + async def get_cancel(): + raise asyncio.CancelledError() + + self.data_source._message_queue[self.data_source._trade_messages_queue_key].get = get_cancel + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_trades(ev_loop=asyncio.get_event_loop(), output=asyncio.Queue()) + + async def test_listen_for_trades_logs_exception(self): + raw_msg = {"data": []} + self.data_source._message_queue[self.data_source._trade_messages_queue_key].put_nowait(raw_msg) + + call_count = 0 + + async def _parse_trade_raises(raw_message, message_queue): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise Exception("parse error") + raise asyncio.CancelledError() + + self.data_source._message_queue[self.data_source._trade_messages_queue_key].put_nowait(raw_msg) + self.data_source._parse_trade_message = _parse_trade_raises + log_mock = MagicMock() + self.data_source.logger = MagicMock(return_value=log_mock) + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_trades(ev_loop=asyncio.get_event_loop(), output=asyncio.Queue()) + + log_mock.exception.assert_called() + + # ── listen_for_order_book_diffs ──────────────────────────────────────────── + + async def test_listen_for_order_book_diffs_successful(self): + raw_msg = {"data": {"l": [[], []], "s": "BTC", "t": 1700000000000, "li": 1}} + self.data_source._message_queue[self.data_source._diff_messages_queue_key].put_nowait(raw_msg) + output = asyncio.Queue() + + async def _parse_diff_mock(raw_message, message_queue): + message_queue.put_nowait(raw_message) + raise asyncio.CancelledError() + + self.data_source._parse_order_book_diff_message = _parse_diff_mock + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_order_book_diffs(ev_loop=asyncio.get_event_loop(), output=output) + + self.assertEqual(1, output.qsize()) + + async def test_listen_for_order_book_diffs_cancelled(self): + async def get_cancel(): + raise asyncio.CancelledError() + + self.data_source._message_queue[self.data_source._diff_messages_queue_key].get = get_cancel + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_order_book_diffs(ev_loop=asyncio.get_event_loop(), output=asyncio.Queue()) + + async def test_listen_for_order_book_diffs_logs_exception(self): + raw_msg = {} + self.data_source._message_queue[self.data_source._diff_messages_queue_key].put_nowait(raw_msg) + + call_count = 0 + + async def _parse_raises(raw_message, message_queue): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise Exception("diff parse error") + raise asyncio.CancelledError() + + self.data_source._message_queue[self.data_source._diff_messages_queue_key].put_nowait(raw_msg) + self.data_source._parse_order_book_diff_message = _parse_raises + log_mock = MagicMock() + self.data_source.logger = MagicMock(return_value=log_mock) + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_order_book_diffs(ev_loop=asyncio.get_event_loop(), output=asyncio.Queue()) + + log_mock.exception.assert_called() + + # ── listen_for_order_book_snapshots ─────────────────────────────────────── + + async def test_listen_for_order_book_snapshots_successful(self): + raw_msg = {"data": {"l": [[{"p": "100", "a": "1"}], [{"p": "101", "a": "2"}]], "s": "BTC", "t": 1700000000000, "li": 9}} + self.data_source._message_queue[self.data_source._snapshot_messages_queue_key].put_nowait(raw_msg) + output = asyncio.Queue() + + parsed = [] + + async def _parse_snap_mock(raw_message, message_queue): + parsed.append(raw_message) + raise asyncio.CancelledError() + + self.data_source._parse_order_book_snapshot_message = _parse_snap_mock + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_order_book_snapshots(ev_loop=asyncio.get_event_loop(), output=output) + + self.assertEqual(1, len(parsed)) + + async def test_listen_for_order_book_snapshots_cancelled_when_fetching_snapshot(self): + async def get_cancel(*args, **kwargs): + raise asyncio.CancelledError() + + self.data_source._message_queue[self.data_source._snapshot_messages_queue_key].get = get_cancel + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_order_book_snapshots(ev_loop=asyncio.get_event_loop(), output=asyncio.Queue()) + + async def test_listen_for_order_book_snapshots_log_exception(self): + raw_msg = {} + self.data_source._message_queue[self.data_source._snapshot_messages_queue_key].put_nowait(raw_msg) + + call_count = 0 + + async def _parse_raises(raw_message, message_queue): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise Exception("snapshot parse error") + raise asyncio.CancelledError() + + self.data_source._message_queue[self.data_source._snapshot_messages_queue_key].put_nowait(raw_msg) + self.data_source._parse_order_book_snapshot_message = _parse_raises + self.data_source._sleep = AsyncMock() + log_mock = MagicMock() + self.data_source.logger = MagicMock(return_value=log_mock) + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_order_book_snapshots(ev_loop=asyncio.get_event_loop(), output=asyncio.Queue()) + + log_mock.exception.assert_called() + + # ── listen_for_funding_info ──────────────────────────────────────────────── + + async def test_listen_for_funding_info_successful(self): + raw_msg = { + "data": [ + {"symbol": "BTC", "oracle": "100", "mark": "101", "funding": "0.0001", "timestamp": 1700000000000} + ] + } + self.data_source._message_queue[self.data_source._funding_info_messages_queue_key].put_nowait(raw_msg) + output = asyncio.Queue() + + parsed = [] + original_parse = self.data_source._parse_funding_info_message + + async def _parse_funding_mock(raw_message, message_queue): + await original_parse(raw_message, message_queue) + parsed.append(raw_message) + raise asyncio.CancelledError() + + self.data_source._parse_funding_info_message = _parse_funding_mock + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_funding_info(output=output) + + self.assertEqual(1, len(parsed)) + + async def test_listen_for_funding_info_cancelled_when_listening(self): + async def get_cancel(): + raise asyncio.CancelledError() + + self.data_source._message_queue[self.data_source._funding_info_messages_queue_key].get = get_cancel + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_funding_info(output=asyncio.Queue()) + + async def test_listen_for_funding_info_logs_exception(self): + raw_msg = {"data": []} + self.data_source._message_queue[self.data_source._funding_info_messages_queue_key].put_nowait(raw_msg) + + call_count = 0 + + async def _parse_raises(raw_message, message_queue): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise Exception("funding info parse error") + raise asyncio.CancelledError() + + self.data_source._message_queue[self.data_source._funding_info_messages_queue_key].put_nowait(raw_msg) + self.data_source._parse_funding_info_message = _parse_raises + log_mock = MagicMock() + self.data_source.logger = MagicMock(return_value=log_mock) + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_funding_info(output=asyncio.Queue()) + + log_mock.exception.assert_called() + + # ------------------------------------------------------------------ + # _market_id_from_channel + # ------------------------------------------------------------------ + + def test_market_id_from_channel_with_colon_separator(self): + result = self.data_source_cls._market_id_from_channel("order_book:1001") + self.assertEqual(1001, result) + + def test_market_id_from_channel_with_slash_separator(self): + result = self.data_source_cls._market_id_from_channel("order_book/1001") + self.assertEqual(1001, result) + + def test_market_id_from_channel_no_separator_returns_none(self): + result = self.data_source_cls._market_id_from_channel("order_book") + self.assertIsNone(result) + + def test_market_id_from_channel_non_int_tail_returns_none(self): + result = self.data_source_cls._market_id_from_channel("order_book:abc") + self.assertIsNone(result) + + # ------------------------------------------------------------------ + # _parse_order_book_snapshot_message + # ------------------------------------------------------------------ + + async def test_parse_ob_snapshot_unknown_market_id_returns_early(self): + q = asyncio.Queue() + await self.data_source._parse_order_book_snapshot_message( + {"channel": "order_book:9999", "order_book": {"bids": [], "asks": []}}, + q, + ) + self.assertTrue(q.empty()) + + async def test_parse_ob_snapshot_no_channel_returns_early(self): + q = asyncio.Queue() + await self.data_source._parse_order_book_snapshot_message({"order_book": {}}, q) + self.assertTrue(q.empty()) + + async def test_parse_ob_snapshot_puts_message_when_known(self): + self.data_source._market_id_to_trading_pair[1001] = "BTC-USDC" + q = asyncio.Queue() + await self.data_source._parse_order_book_snapshot_message({ + "channel": "order_book:1001", + "order_book": {"nonce": 42, "bids": [{"price": "100", "size": "1"}], "asks": []}, + "timestamp": 1700000000000, + }, q) + self.assertFalse(q.empty()) + msg = q.get_nowait() + self.assertEqual("BTC-USDC", msg.content["trading_pair"]) + + # ------------------------------------------------------------------ + # _parse_order_book_diff_message + # ------------------------------------------------------------------ + + async def test_parse_ob_diff_unknown_market_returns_early(self): + q = asyncio.Queue() + await self.data_source._parse_order_book_diff_message( + {"channel": "order_book:9999", "order_book": {}}, + q, + ) + self.assertTrue(q.empty()) + + async def test_parse_ob_diff_puts_diff_when_known(self): + self.data_source._market_id_to_trading_pair[1001] = "BTC-USDC" + q = asyncio.Queue() + await self.data_source._parse_order_book_diff_message({ + "channel": "order_book:1001", + "order_book": {"nonce": 7, "begin_nonce": 6, "bids": [], "asks": [{"price": "101", "size": "2"}]}, + "timestamp": 1700000000000, + }, q) + self.assertFalse(q.empty()) + msg = q.get_nowait() + self.assertEqual("BTC-USDC", msg.content["trading_pair"]) + + # ------------------------------------------------------------------ + # subscribe_to_trading_pair / unsubscribe_from_trading_pair + # ------------------------------------------------------------------ + + async def test_subscribe_to_trading_pair_returns_false_when_no_ws(self): + self.data_source._ws_assistant = None + result = await self.data_source.subscribe_to_trading_pair("BTC-USDC") + self.assertFalse(result) + + async def test_subscribe_to_trading_pair_sends_subscriptions(self): + ws = AsyncMock() + self.data_source._ws_assistant = ws + result = await self.data_source.subscribe_to_trading_pair("BTC-USDC") + self.assertTrue(result) + self.assertEqual(3, ws.send.await_count) + self.assertEqual("BTC-USDC", self.data_source._market_id_to_trading_pair[1001]) + + async def test_unsubscribe_from_trading_pair_returns_false_when_no_ws(self): + self.data_source._ws_assistant = None + result = await self.data_source.unsubscribe_from_trading_pair("BTC-USDC") + self.assertFalse(result) + + async def test_unsubscribe_from_trading_pair_sends_unsubscriptions(self): + ws = AsyncMock() + self.data_source._ws_assistant = ws + self.data_source._market_id_to_trading_pair[1001] = "BTC-USDC" + result = await self.data_source.unsubscribe_from_trading_pair("BTC-USDC") + self.assertTrue(result) + self.assertEqual(3, ws.send.await_count) + self.assertNotIn(1001, self.data_source._market_id_to_trading_pair) + + # ------------------------------------------------------------------ + # _ping_loop + # ------------------------------------------------------------------ + + async def test_ping_loop_cancels_on_cancelled_error(self): + ws = AsyncMock() + ws.send = AsyncMock(side_effect=asyncio.CancelledError()) + + with patch("asyncio.sleep", AsyncMock(return_value=None)): + with self.assertRaises(asyncio.CancelledError): + await self.data_source._ping_loop(ws) + + async def test_ping_loop_returns_on_ws_not_connected(self): + ws = AsyncMock() + ws.send = AsyncMock(side_effect=RuntimeError("WS is not connected")) + + with patch("asyncio.sleep", AsyncMock(return_value=None)): + # Should return without raising + await self.data_source._ping_loop(ws) diff --git a/test/hummingbot/connector/derivative/lighter_perpetual/test_lighter_perpetual_auth.py b/test/hummingbot/connector/derivative/lighter_perpetual/test_lighter_perpetual_auth.py new file mode 100644 index 00000000000..b99d67004b8 --- /dev/null +++ b/test/hummingbot/connector/derivative/lighter_perpetual/test_lighter_perpetual_auth.py @@ -0,0 +1,51 @@ +import unittest +from unittest.mock import AsyncMock + +from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_auth import LighterPerpetualAuth + + +class LighterPerpetualAuthTests(unittest.IsolatedAsyncioTestCase): + + async def test_rest_auth_adds_headers(self): + auth = LighterPerpetualAuth( + api_key="api-key", + api_secret="api-secret", + ) + + request = AsyncMock() + request.headers = {} + result = await auth.rest_authenticate(request) + + self.assertIs(request, result) + self.assertEqual("application/json", request.headers["accept"]) + self.assertEqual("application/json", request.headers["Content-Type"]) + # X-Api-Key is intentionally NOT added to headers; auth is performed via + # the 'auth' query param for restricted endpoints (not as a header). + self.assertNotIn("X-Api-Key", request.headers) + + async def test_rest_auth_preserves_existing_headers(self): + auth = LighterPerpetualAuth( + api_key="api-key", + api_secret="api-secret", + ) + + request = AsyncMock() + request.headers = {"X-Test": "1"} + + result = await auth.rest_authenticate(request) + + self.assertIs(request, result) + self.assertEqual("1", request.headers["X-Test"]) + # X-Api-Key is NOT set by design — auth uses query param instead. + self.assertNotIn("X-Api-Key", request.headers) + + async def test_ws_auth_does_not_mutate_request(self): + auth = LighterPerpetualAuth( + api_key="api-key", + api_secret="api-secret", + ) + + request = AsyncMock() + result = await auth.ws_authenticate(request) + + self.assertIs(request, result) diff --git a/test/hummingbot/connector/derivative/lighter_perpetual/test_lighter_perpetual_derivative.py b/test/hummingbot/connector/derivative/lighter_perpetual/test_lighter_perpetual_derivative.py new file mode 100644 index 00000000000..18a8cd44bad --- /dev/null +++ b/test/hummingbot/connector/derivative/lighter_perpetual/test_lighter_perpetual_derivative.py @@ -0,0 +1,5498 @@ +import asyncio +import sys +import time +import types +import unittest +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch + +from hummingbot.connector.trading_rule import TradingRule +from hummingbot.core.data_type.common import OrderType, PositionAction, PositionMode, PriceType, TradeType +from hummingbot.core.data_type.in_flight_order import OrderState, OrderUpdate + + +def _ensure_limit_order_stub(): + module_name = "hummingbot.core.data_type.limit_order" + try: + __import__(module_name) + return + except Exception: + pass + if module_name in sys.modules: + return + stub_module = types.ModuleType(module_name) + + class LimitOrder: + pass + + stub_module.LimitOrder = LimitOrder + sys.modules[module_name] = stub_module + + +def _ensure_order_book_stub(): + module_name = "hummingbot.core.data_type.order_book" + try: + __import__(module_name) + return + except Exception: + pass + if module_name in sys.modules: + return + stub_module = types.ModuleType(module_name) + + class OrderBook: + pass + + stub_module.OrderBook = OrderBook + sys.modules[module_name] = stub_module + + +class LighterPerpetualDerivativeTests(unittest.IsolatedAsyncioTestCase): + + connector_cls = None + + @classmethod + def setUpClass(cls): + super().setUpClass() + _ensure_limit_order_stub() + _ensure_order_book_stub() + try: + from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative import ( + LighterPerpetualDerivative, + ) + cls.connector_cls = LighterPerpetualDerivative + except ModuleNotFoundError: + cls.connector_cls = None + + def setUp(self) -> None: + super().setUp() + if self.connector_cls is None: + self.skipTest("Compiled hummingbot core modules are unavailable in this environment") + + self.connector = self.connector_cls( + lighter_perpetual_api_key_index="1", + lighter_perpetual_account_index="237600", + lighter_perpetual_api_key_private_key="0x" + ("a" * 64), + trading_pairs=["BTC-USDC"], + trading_required=False, + ) + + async def test_update_balances_parses_collateral(self): + mock_signer = type("MockSigner", (), {})() + mock_signer.create_auth_token_with_expiry = lambda api_key_index: ("test_token", None) + self.connector._get_lighter_signer_client = lambda: mock_signer + self.connector._get_api_key_index = lambda: 1 + self.connector._api_get = AsyncMock(return_value={ + "success": True, + "data": { + "collateral": "123.45", + "available_to_spend": "120.11", + "fee_level": 2, + }, + }) + + await self.connector._update_balances() + + self.assertEqual(Decimal("123.45"), self.connector._account_balances["USDC"]) + self.assertEqual(Decimal("120.11"), self.connector._account_available_balances["USDC"]) + self.assertEqual(2, self.connector._fee_tier) + + async def test_update_balances_prefers_available_balance_over_available_to_spend(self): + # Prefer exchange-reported available_balance when both fields are present. + # available_to_spend is retained as a fallback for older payload shapes. + mock_signer = type("MockSigner", (), {})() + mock_signer.create_auth_token_with_expiry = lambda api_key_index: ("test_token", None) + self.connector._get_lighter_signer_client = lambda: mock_signer + self.connector._get_api_key_index = lambda: 1 + self.connector._api_get = AsyncMock(return_value={ + "code": 0, + "accounts": [ + { + "collateral": "35.60", + "available_balance": "6.07", + "available_to_spend": "14.42", + } + ], + }) + + await self.connector._update_balances() + + self.assertEqual(Decimal("35.60"), self.connector._account_balances["USDC"]) + self.assertEqual(Decimal("6.07"), self.connector._account_available_balances["USDC"]) + + async def test_update_balances_prefers_collateral_over_account_equity_for_total_margin(self): + # Perp total should use margin-authoritative collateral when both fields exist. + # account_equity can represent a different aggregate in some payload variants. + mock_signer = type("MockSigner", (), {})() + mock_signer.create_auth_token_with_expiry = lambda api_key_index: ("test_token", None) + self.connector._get_lighter_signer_client = lambda: mock_signer + self.connector._get_api_key_index = lambda: 1 + self.connector._api_get = AsyncMock(return_value={ + "code": 0, + "accounts": [ + { + "account_equity": "20.002998766", + "collateral": "13.789107", + "available_balance": "10.388259", + } + ], + }) + + await self.connector._update_balances() + + self.assertEqual(Decimal("13.789107"), self.connector._account_balances["USDC"]) + self.assertEqual(Decimal("10.388259"), self.connector._account_available_balances["USDC"]) + + async def test_update_balances_rest_available_balance_is_authoritative(self): + # REST available_to_spend / available_balance is now always used directly. + # If the REST endpoint has no available_to_spend but has available_balance, + # the REST value is applied as-is. The old WS-preservation heuristic is removed + # because it could incorrectly preserve a stale WS value that did not account for + # open-order margin (the root cause of the balance over-reporting bug). + mock_signer = type("MockSigner", (), {})() + mock_signer.create_auth_token_with_expiry = lambda api_key_index: ("test_token", None) + self.connector._get_lighter_signer_client = lambda: mock_signer + self.connector._get_api_key_index = lambda: 1 + self.connector._api_get = AsyncMock(return_value={ + "code": 0, + "accounts": [ + { + "collateral": "35.60", + "available_balance": "35.60", # equals total — REST doesn't deduct position margin + } + ], + }) + # Pre-set a lower available balance (simulating a prior WS update). + self.connector._account_available_balances["USDC"] = Decimal("27.30") + + await self.connector._update_balances() + + self.assertEqual(Decimal("35.60"), self.connector._account_balances["USDC"]) + # REST is now authoritative — applies 35.60 directly (no preservation heuristic). + self.assertEqual(Decimal("35.60"), self.connector._account_available_balances["USDC"]) + + async def test_update_balances_trusts_available_balance_over_cross_headroom(self): + # available_balance from the exchange REST is authoritative. + # When it differs from cross_asset_value - cross_initial_margin_requirement (e.g. because + # cross_initial_margin_requirement is stale after an order cancel), the exchange-reported + # available_balance must be used as-is and NOT capped by the stale headroom. + mock_signer = type("MockSigner", (), {})() + mock_signer.create_auth_token_with_expiry = lambda api_key_index: ("test_token", None) + self.connector._get_lighter_signer_client = lambda: mock_signer + self.connector._get_api_key_index = lambda: 1 + self.connector._api_get = AsyncMock(return_value={ + "code": 0, + "accounts": [ + { + "collateral": "10.783087", + "available_balance": "10.783087", + "cross_asset_value": "10.739587", + "cross_initial_margin_requirement": "8.386400", # headroom = 2.353187 + } + ], + }) + + await self.connector._update_balances() + + self.assertEqual(Decimal("10.783087"), self.connector._account_balances["USDC"]) + # available_balance is trusted directly — NOT capped by cross headroom. + self.assertEqual(Decimal("10.783087"), self.connector._account_available_balances["USDC"]) + + async def test_update_balances_trusts_available_balance_when_cross_requirement_is_stale(self): + # Regression test: after an order cancel the exchange available_balance updates + # immediately, but cross_initial_margin_requirement can lag (still includes the + # cancelled order's margin), making cross_headroom appear zero. + # The connector must trust available_balance directly and NOT zero it out. + mock_signer = type("MockSigner", (), {})() + mock_signer.create_auth_token_with_expiry = lambda api_key_index: ("test_token", None) + self.connector._get_lighter_signer_client = lambda: mock_signer + self.connector._get_api_key_index = lambda: 1 + self.connector._api_get = AsyncMock(return_value={ + "code": 0, + "accounts": [ + { + "collateral": "25.000000", + "available_balance": "4.940000", # correct — order margin freed + "cross_asset_value": "25.000000", + "cross_initial_margin_requirement": "25.000000", # stale: still includes cancelled order + } + ], + }) + + await self.connector._update_balances() + + self.assertEqual(Decimal("25.000000"), self.connector._account_balances["USDC"]) + # Stale cross_initial_margin_requirement must NOT zero out the correct available_balance. + self.assertEqual(Decimal("4.940000"), self.connector._account_available_balances["USDC"]) + + async def test_update_balances_deducts_open_order_margin_when_available_matches_headroom(self): + # Pure WS mode: Trust exchange-provided available_balance directly. + # No local margin estimation; exchange balance is already accurate via WebStream. + mock_signer = type("MockSigner", (), {})() + mock_signer.create_auth_token_with_expiry = lambda api_key_index: ("test_token", None) + self.connector._get_lighter_signer_client = lambda: mock_signer + self.connector._get_api_key_index = lambda: 1 + self.connector._order_tracker._in_flight_orders = { + "HBOT-OPEN-1": MagicMock(is_done=False), + } + self.connector._api_get = AsyncMock(return_value={ + "code": 0, + "accounts": [ + { + "collateral": "35.993272", + "available_balance": "16.878532", + "cross_asset_value": "35.993272", + "cross_initial_margin_requirement": "19.114740", + "positions": [ + { + "market_id": 2, + "open_order_count": 2, + "initial_margin_fraction": "20.00", + } + ], + } + ], + }) + self.connector._fetch_active_orders_rows_for_market = AsyncMock(return_value=[ + {"remaining_base_amount": "0.500000", "price": "83.134", "status": "open"}, + {"remaining_base_amount": "0.219000", "price": "80.000", "status": "open"}, + ]) + + await self.connector._update_balances() + + # Exchange-provided balance is trusted directly (no local margin subtraction). + # available_balance from REST already reflects all orders via WS. + self.assertEqual(Decimal("35.993272"), self.connector._account_balances["USDC"]) + self.assertEqual(Decimal("16.878532"), self.connector._account_available_balances["USDC"]) + + async def test_update_balances_does_not_deduct_estimated_order_margin_without_local_open_orders(self): + # Pure WS mode: Always trust exchange-provided available_balance directly, + # regardless of local open order state. No margin estimation applied. + mock_signer = type("MockSigner", (), {})() + mock_signer.create_auth_token_with_expiry = lambda api_key_index: ("test_token", None) + self.connector._get_lighter_signer_client = lambda: mock_signer + self.connector._get_api_key_index = lambda: 1 + self.connector._order_tracker._in_flight_orders = {} + self.connector._api_get = AsyncMock(return_value={ + "code": 0, + "accounts": [ + { + "collateral": "35.993272", + "available_balance": "16.878532", + "cross_asset_value": "35.993272", + "cross_initial_margin_requirement": "19.114740", + "positions": [ + { + "market_id": 2, + "open_order_count": 2, + "initial_margin_fraction": "20.00", + } + ], + } + ], + }) + self.connector._fetch_active_orders_rows_for_market = AsyncMock(return_value=[ + {"remaining_base_amount": "0.500000", "price": "83.134", "status": "open"}, + {"remaining_base_amount": "0.219000", "price": "80.000", "status": "open"}, + ]) + + await self.connector._update_balances() + + # Exchange-provided balance is trusted directly (no local margin subtraction). + self.assertEqual(Decimal("35.993272"), self.connector._account_balances["USDC"]) + self.assertEqual(Decimal("16.878532"), self.connector._account_available_balances["USDC"]) + + async def test_update_balances_clamps_available_to_lower_of_field_and_cross_headroom(self): + # When available_balance < headroom, the cross-margin guard leaves it unchanged. + mock_signer = type("MockSigner", (), {})() + mock_signer.create_auth_token_with_expiry = lambda api_key_index: ("test_token", None) + self.connector._get_lighter_signer_client = lambda: mock_signer + self.connector._get_api_key_index = lambda: 1 + self.connector._api_get = AsyncMock(return_value={ + "code": 0, + "accounts": [ + { + "collateral": "35.993272", + "available_balance": "5.000000", + "cross_asset_value": "35.993272", + "cross_initial_margin_requirement": "19.114740", + "positions": [ + { + "market_id": 2, + "open_order_count": 2, + "initial_margin_fraction": "20.00", + } + ], + } + ], + }) + self.connector._fetch_active_orders_rows_for_market = AsyncMock() + + await self.connector._update_balances() + + # available = min(5.000000, 35.993272 - 19.114740) = min(5.000000, 16.878532) = 5.000000 + self.assertEqual(Decimal("35.993272"), self.connector._account_balances["USDC"]) + self.assertEqual(Decimal("5.000000"), self.connector._account_available_balances["USDC"]) + self.connector._fetch_active_orders_rows_for_market.assert_not_awaited() + + def test_get_available_balance_does_not_double_subtract_in_flight_orders(self): + # Perp available balance from exchange is already net of open-order/position margin. + # It must not be reduced again by local in-flight reservations. + self.connector._account_available_balances["USDC"] = Decimal("9.610387") + + mock_order = SimpleNamespace( + is_done=False, + is_failure=False, + is_cancelled=False, + amount=Decimal("0.1"), + executed_amount_base=Decimal("0"), + trade_type=TradeType.BUY, + price=Decimal("83.84"), + quote_asset="USDC", + base_asset="SOL", + ) + self.connector.in_flight_orders["HBOT-1"] = mock_order + + self.assertEqual(Decimal("9.610387"), self.connector.get_available_balance("USDC")) + + def test_get_available_balance_ignores_configured_balance_limit(self): + self.connector._account_available_balances["USDC"] = Decimal("9.610387") + self.connector.get_exchange_limit_config = MagicMock(return_value={"USDC": Decimal("2.38")}) + + self.assertEqual(Decimal("9.610387"), self.connector.get_available_balance("USDC")) + + async def test_update_balances_parses_short_form_keys(self): + mock_signer = type("MockSigner", (), {})() + mock_signer.create_auth_token_with_expiry = lambda api_key_index: ("test_token", None) + self.connector._get_lighter_signer_client = lambda: mock_signer + self.connector._get_api_key_index = lambda: 1 + self.connector._api_get = AsyncMock(return_value={ + "success": True, + "data": { + "ae": "250.75", + "as": "145.50", + "f": 1, + }, + }) + + await self.connector._update_balances() + + self.assertEqual(Decimal("250.75"), self.connector._account_balances["USDC"]) + self.assertEqual(Decimal("145.50"), self.connector._account_available_balances["USDC"]) + + async def test_process_account_info_ws_event_message_handles_partial_short_payload(self): + event_message = { + "channel": "account_info", + "data": { + "as": "77.12", + }, + } + + await self.connector._process_account_info_ws_event_message(event_message) + + self.assertNotIn("USDC", self.connector._account_balances) + self.assertNotIn("USDC", self.connector._account_available_balances) + + async def test_process_account_info_ws_event_message_preserves_zero_available_balance(self): + event_message = { + "channel": "account_info", + "data": { + "ae": 100, + "as": 0, + }, + } + + await self.connector._process_account_info_ws_event_message(event_message) + + self.assertNotIn("USDC", self.connector._account_balances) + self.assertNotIn("USDC", self.connector._account_available_balances) + + async def test_process_account_all_ws_event_message_ignores_partial_top_level_balance_fields(self): + self.connector._account_balances["USDC"] = Decimal("100") + self.connector._account_available_balances["USDC"] = Decimal("95") + self.connector._process_account_trades_ws_event_message = AsyncMock() + self.connector._process_account_positions_ws_event_message = AsyncMock() + self.connector._process_account_all_orders_ws_event_message = AsyncMock() + + # Partial payload: only collateral present (no available_to_spend). + # Total is updated from collateral; available_balance is preserved from existing. + event_message = { + "collateral": "120", + } + + await self.connector._process_account_all_ws_event_message(event_message) + + # WS no longer mutates balances directly; existing balances remain unchanged. + self.assertEqual(Decimal("100"), self.connector._account_balances["USDC"]) + self.assertEqual(Decimal("95"), self.connector._account_available_balances["USDC"]) + + async def test_process_account_all_ws_event_message_preserves_zero_available_balance(self): + self.connector._process_account_trades_ws_event_message = AsyncMock() + self.connector._process_account_positions_ws_event_message = AsyncMock() + self.connector._process_account_all_orders_ws_event_message = AsyncMock() + + event_message = { + "collateral": 120, + "available_to_spend": 0, + } + + await self.connector._process_account_all_ws_event_message(event_message) + + self.assertNotIn("USDC", self.connector._account_balances) + self.assertNotIn("USDC", self.connector._account_available_balances) + + def test_set_usdc_balances_replaces_stale_assets(self): + self.connector._account_balances["BTC"] = Decimal("1") + self.connector._account_available_balances["BTC"] = Decimal("0.5") + + self.connector._set_usdc_balances(Decimal("10"), Decimal("7")) + + self.assertEqual({"USDC": Decimal("10")}, self.connector._account_balances) + self.assertEqual({"USDC": Decimal("7")}, self.connector._account_available_balances) + + def test_check_network_request_path_uses_exchange_stats(self): + self.assertEqual("/exchangeStats", self.connector.check_network_request_path) + + def test_status_dict_requires_balance_fetched_once_for_trading_connectors(self): + trading_connector = self.connector_cls( + lighter_perpetual_api_key_index="1", + lighter_perpetual_account_index="237600", + lighter_perpetual_api_key_private_key="0x" + ("a" * 64), + trading_pairs=["BTC-USDC"], + trading_required=True, + ) + trading_connector._account_balances["USDC"] = Decimal("10") + # timestamp=0 means balance never fetched → not ready + trading_connector._last_balance_update_timestamp = 0 + self.assertFalse(trading_connector.status_dict["account_balance"]) + + # once fetched (even long ago), connector is ready + trading_connector._last_balance_update_timestamp = time.time() - 3600 + self.assertTrue(trading_connector.status_dict["account_balance"]) + + def test_status_dict_requires_recent_position_sync_for_trading_connectors(self): + trading_connector = self.connector_cls( + lighter_perpetual_api_key_index="1", + lighter_perpetual_account_index="237600", + lighter_perpetual_api_key_private_key="0x" + ("a" * 64), + trading_pairs=["BTC-USDC"], + trading_required=True, + ) + trading_connector._account_balances["USDC"] = Decimal("10") + trading_connector._last_balance_update_timestamp = time.time() + + trading_connector._last_position_update_timestamp = 0 + self.assertFalse(trading_connector.status_dict["account_position"]) + + trading_connector._last_position_update_timestamp = time.time() + self.assertTrue(trading_connector.status_dict["account_position"]) + + trading_connector._last_position_update_timestamp = time.time() - 120 + self.assertFalse(trading_connector.status_dict["account_position"]) + + def test_is_user_stream_initialized_requires_recent_messages(self): + trading_connector = self.connector_cls( + lighter_perpetual_api_key_index="1", + lighter_perpetual_account_index="237600", + lighter_perpetual_api_key_private_key="0x" + ("a" * 64), + trading_pairs=["BTC-USDC"], + trading_required=True, + ) + trading_connector._user_stream_tracker = SimpleNamespace( + data_source=SimpleNamespace(last_recv_time=time.time() - 240) + ) + + self.assertFalse(trading_connector._is_user_stream_initialized()) + + def test_get_poll_interval_is_short_when_any_active_order_exists(self): + now = time.time() + self.connector._user_stream_tracker = SimpleNamespace(last_recv_time=now) + self.connector._order_tracker.active_orders["test-order"] = SimpleNamespace(position=PositionAction.OPEN) + + interval = self.connector._get_poll_interval(timestamp=now) + + self.assertEqual(self.connector._HEALTHY_PRIVATE_STREAM_POLL_INTERVAL, interval) + + def test_get_poll_interval_is_short_when_open_position_exists(self): + now = time.time() + self.connector._user_stream_tracker = SimpleNamespace(last_recv_time=now) + self.connector._order_tracker.active_orders.clear() + self.connector._perpetual_trading.account_positions["HYPE-USDC-LONG"] = SimpleNamespace(amount=Decimal("0.5")) + + interval = self.connector._get_poll_interval(timestamp=now) + + self.assertEqual(self.connector._HEALTHY_PRIVATE_STREAM_POLL_INTERVAL, interval) + + def test_allocate_client_order_index_uses_high_range_spacing(self): + self.connector._last_client_order_index = 0 + + with patch( + "hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative.time.time", + return_value=1000.001, + ): + first_index = self.connector._allocate_client_order_index() + second_index = self.connector._allocate_client_order_index() + + expected_first_index = int(1000.001 * 1000) * self.connector._CLIENT_ORDER_INDEX_TIME_MULTIPLIER + self.assertEqual(expected_first_index, first_index) + self.assertEqual(first_index + 1, second_index) + + def test_get_api_key_index_prefers_explicit_index_and_secret(self): + self.connector.api_key_index = "4" + self.assertEqual(4, self.connector._get_api_key_index()) + + self.connector.api_key_index = "" + self.connector.api_key = "0x" + ("a" * 64) + self.connector.api_secret = "12" + self.assertEqual(12, self.connector._get_api_key_index()) + + def test_get_api_key_index_raises_for_non_numeric_config(self): + self.connector.api_key = "0x" + ("a" * 64) + self.connector.api_secret = "not-a-number" + self.connector.api_key_index = "" + + with self.assertRaises(ValueError): + self.connector._get_api_key_index() + + async def test_fetch_or_create_api_config_key_resolves_index_from_api_keys(self): + self.connector.api_key = "abc_public_key" + self.connector.api_secret = "" + self.connector.api_config_key = "abc_public_key" + self.connector.api_key_index = "" + self.connector.account_index = "237600" + self.connector._api_get = AsyncMock(return_value={ + "api_keys": [ + {"api_key_index": 3, "public_key": "other_key"}, + {"api_key_index": 5, "public_key": "abc_public_key"}, + ] + }) + + await self.connector._fetch_or_create_api_config_key() + + self.assertEqual("5", self.connector.api_key_index) + + def test_helper_methods_cover_warning_and_order_book_paths(self): + warning_timestamps = {} + with patch( + "hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative.time.time", + side_effect=[100.0, 105.0, 131.0], + ): + self.assertTrue(self.connector._should_emit_throttled_warning("BTC-USDC", warning_timestamps)) + self.assertFalse(self.connector._should_emit_throttled_warning("BTC-USDC", warning_timestamps)) + self.assertTrue(self.connector._should_emit_throttled_warning("BTC-USDC", warning_timestamps)) + + self.assertEqual("first", self.connector._first_not_none(None, "first", "second")) + self.assertIsNone(self.connector._first_not_none(None, None)) + + logger = MagicMock() + self.connector.logger = MagicMock(return_value=logger) + self.connector.quantize_order_price = MagicMock(side_effect=lambda trading_pair, price: price) + self.connector._last_empty_order_book_warning_timestamp = {} + self.connector.get_order_book = MagicMock(side_effect=RuntimeError("missing book")) + + self.assertTrue(self.connector._get_top_order_book_price("BTC-USDC", True).is_nan()) + logger.warning.assert_called_once() + + def test_mark_private_event_and_build_account_auth_params(self): + self.connector._last_private_account_event_timestamp = 0.0 + with patch( + "hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative.time.time", + return_value=123.0, + ): + self.connector._mark_private_account_event_received() + self.assertEqual(123.0, self.connector._last_private_account_event_timestamp) + + self.connector.account_index = "237600" + self.connector.api_key_index = "4" + self.connector._auth_token_cache = ("cached-auth", 200.0) + with patch( + "hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative.time.time", + return_value=100.0, + ): + params = self.connector._build_account_auth_params() + self.assertEqual("cached-auth", params["auth"]) + + signer_client = MagicMock() + signer_client.create_auth_token_with_expiry = MagicMock(return_value=(None, "bad key")) + self.connector._auth_token_cache = None + self.connector._get_lighter_signer_client = MagicMock(return_value=signer_client) + with self.assertRaises(IOError): + self.connector._build_account_auth_params() + + async def test_fetch_account_snapshot_data_validates_error_and_missing_account(self): + self.connector._build_account_auth_params = MagicMock(return_value={"auth": "token"}) + self.connector._api_get = AsyncMock(return_value={"code": 500, "message": "server error"}) + + with self.assertRaises(IOError): + await self.connector._fetch_account_snapshot_data() + + self.connector._api_get = AsyncMock(return_value={"code": 200, "data": []}) + + with self.assertRaises(IOError): + await self.connector._fetch_account_snapshot_data() + + async def test_schedule_fast_balance_sync_respects_gate_and_logs_errors(self): + self.connector._trading_required = False + self.connector._update_balances = AsyncMock() + self.connector._last_balance_update_timestamp = 0.0 + + self.connector._schedule_fast_balance_sync() + self.connector._update_balances.assert_not_awaited() + + logger = MagicMock() + self.connector.logger = MagicMock(return_value=logger) + self.connector._trading_required = True + self.connector._last_balance_update_timestamp = 0.0 + self.connector._update_balances = AsyncMock(side_effect=RuntimeError("boom")) + + with patch( + "hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative.safe_ensure_future", + side_effect=lambda coro: asyncio.get_running_loop().create_task(coro), + ): + self.connector._schedule_fast_balance_sync(min_interval_seconds=0.0) + await asyncio.sleep(0) + + logger.debug.assert_called_once() + + async def test_estimate_open_order_initial_margin_handles_positions_and_cached_rows(self): + self.connector._active_orders_snapshot_by_market = { + 2: [ + {"remaining_base_amount": "2", "price": "10"}, + {"remaining_size": "1", "limit_price": "5"}, + {"remaining_base_amount": "bad", "price": "5"}, + ] + } + self.connector._active_orders_snapshot_market_complete = set() + self.connector._status_poll_cycle_active = True + self.connector._fetch_active_orders_rows_for_market = AsyncMock(return_value=[ + {"remaining_amount": "3", "price": "4"}, + {"remaining_amount": "0", "price": "4"}, + ]) + + margin = await self.connector._estimate_open_order_initial_margin( + { + "positions": [ + { + "market_id": 2, + "open_order_count": 2, + "initial_margin_fraction": "10", + }, + { + "market_id": 3, + "open_order_count": 1, + "initial_margin_fraction": "20", + }, + {"market_id": 4, "open_order_count": 0, "initial_margin_fraction": "20"}, + "bad-position", + ] + } + ) + + self.assertEqual(Decimal("4.9"), margin) + self.assertIn(3, self.connector._active_orders_snapshot_by_market) + self.assertIn(3, self.connector._active_orders_snapshot_market_complete) + + self.assertIsNone(await self.connector._estimate_open_order_initial_margin({"positions": "bad"})) + + async def test_apply_balances_from_account_data_handles_warning_direct_and_cross_paths(self): + logger = MagicMock() + self.connector.logger = MagicMock(return_value=logger) + self.connector._account_balances = {} + self.connector._account_available_balances = {} + self.connector._set_usdc_balances = MagicMock() + + await self.connector._apply_balances_from_account_data({}) + logger.warning.assert_called_once() + + logger.reset_mock() + await self.connector._apply_balances_from_account_data( + { + "collateral": "12.5", + "available_balance": "7.25", + "fee_level": 3, + } + ) + self.connector._set_usdc_balances.assert_called_with( + total_balance=Decimal("12.5"), + available_balance=Decimal("7.25"), + ) + self.assertEqual(3, self.connector._fee_tier) + + self.connector._set_usdc_balances.reset_mock() + await self.connector._apply_balances_from_account_data( + { + "assets": [{"symbol": "USDC", "margin_balance": "15"}], + "cross_asset_value": "10", + "cross_initial_margin_requirement": "4", + } + ) + self.connector._set_usdc_balances.assert_called_with( + total_balance=Decimal("10"), + available_balance=Decimal("6"), + ) + + async def test_apply_balances_from_account_data_skips_when_available_is_missing(self): + logger = MagicMock() + self.connector.logger = MagicMock(return_value=logger) + self.connector._account_balances = {"USDC": Decimal("11")} + self.connector._account_available_balances = {"USDC": Decimal("9")} + self.connector._set_usdc_balances = MagicMock() + + await self.connector._apply_balances_from_account_data({"collateral": "12"}) + + logger.debug.assert_called_once() + self.connector._set_usdc_balances.assert_not_called() + + def test_should_ignore_unmatched_trade_message_only_for_external_manual_cases(self): + self.connector._perpetual_trading.account_positions.clear() + tracked_orders = {"11": object()} + order_index_to_client_index = {"22": "33"} + + self.assertFalse( + self.connector._should_ignore_unmatched_trade_message( + {"s": "BTC", "i": "99"}, tracked_orders, order_index_to_client_index + ) + ) + self.assertFalse( + self.connector._should_ignore_unmatched_trade_message( + {"i": "11"}, tracked_orders, {} + ) + ) + self.assertFalse( + self.connector._should_ignore_unmatched_trade_message( + {"i": "22"}, {}, order_index_to_client_index + ) + ) + self.assertTrue( + self.connector._should_ignore_unmatched_trade_message( + {"i": "999"}, {}, {} + ) + ) + + async def test_try_process_one_trade_entry_processes_fill_and_close_completion(self): + processed_trade_updates = [] + processed_order_updates = [] + tracked_order = MagicMock() + tracked_order.exchange_order_id = "55" + tracked_order.client_order_id = "HBOT-1" + tracked_order.trading_pair = "BTC-USDC" + tracked_order.quote_asset = "USDC" + tracked_order.position = PositionAction.CLOSE + tracked_order.executed_amount_base = Decimal("1") + tracked_order.amount = Decimal("1") + + self.connector._client_order_index_to_client_order_id = {"55": "HBOT-1"} + self.connector._client_order_index_to_order_index = {"55": "9001"} + self.connector._order_tracker = SimpleNamespace( + process_trade_update=lambda update: processed_trade_updates.append(update), + process_order_update=lambda update: processed_order_updates.append(update), + ) + self.connector._update_positions = AsyncMock() + self.connector._update_balances = AsyncMock() + + matched = await self.connector._try_process_one_trade_entry( + { + "i": "9001", + "client_order_index": "55", + "trade_id": "fill-1", + "a": "1", + "p": "10", + "f": "0.1", + "t": 1700000000000, + }, + tracked_orders={}, + all_fillable_orders={"HBOT-1": tracked_order}, + order_index_to_client_index={"9001": "55"}, + ) + + self.assertTrue(matched) + tracked_order.update_exchange_order_id.assert_called_once_with("9001") + self.assertEqual(1, len(processed_trade_updates)) + self.assertEqual(1, len(processed_order_updates)) + self.connector._update_positions.assert_awaited_once() + self.connector._update_balances.assert_awaited_once() + + async def test_replay_pending_trade_entries_reconciles_stale_ignores_external_and_keeps_recent(self): + self.connector._order_tracker = SimpleNamespace(all_fillable_orders={}) + self.connector._client_order_index_to_order_index = {} + self.connector._pending_trade_entries = [ + (10.0, {"i": "stale"}), + (11.0, {"i": "external"}), + (18.5, {"i": "recent"}), + ] + self.connector._try_process_one_trade_entry = AsyncMock(side_effect=[False, False, False]) + self.connector._should_ignore_unmatched_trade_message = MagicMock(side_effect=[False, True, False]) + self.connector._reconcile_unmatched_private_event = AsyncMock() + + with patch( + "hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative.time.time", + return_value=20.0, + ): + await self.connector._replay_pending_trade_entries() + + self.connector._reconcile_unmatched_private_event.assert_awaited_once() + self.assertEqual([(18.5, {"i": "recent"})], self.connector._pending_trade_entries) + + async def test_set_trading_pair_leverage_uses_signer_client(self): + self.connector._get_market_spec = AsyncMock(return_value=(3, 2, 2, "DOGE")) + mock_signer = MagicMock() + mock_signer.CROSS_MARGIN_MODE = 0 + mock_signer.update_leverage = AsyncMock(return_value=(None, {"success": True}, None)) + self.connector._get_lighter_signer_client = MagicMock(return_value=mock_signer) + self.connector._get_api_key_index = MagicMock(return_value=4) + self.connector._update_balances = AsyncMock() + + success, message = await self.connector._set_trading_pair_leverage("DOGE-USDC", 5) + + self.assertTrue(success) + self.assertEqual("", message) + self.connector._update_balances.assert_awaited_once() + mock_signer.update_leverage.assert_awaited_once_with( + market_index=3, + margin_mode=0, + leverage=5, + api_key_index=4, + ) + + async def test_set_trading_pair_leverage_retries_on_transient_error(self): + self.connector._get_market_spec = AsyncMock(return_value=(3, 2, 2, "DOGE")) + mock_signer = MagicMock() + mock_signer.CROSS_MARGIN_MODE = 0 + mock_signer.update_leverage = AsyncMock(side_effect=[ + (None, None, "context deadline exceeded"), + (None, {"success": True}, None), + ]) + self.connector._get_lighter_signer_client = MagicMock(return_value=mock_signer) + self.connector._get_api_key_index = MagicMock(return_value=4) + self.connector._sleep = AsyncMock() + self.connector._update_balances = AsyncMock() + + success, message = await self.connector._set_trading_pair_leverage("DOGE-USDC", 5) + + self.assertTrue(success) + self.assertEqual("", message) + self.assertEqual(2, mock_signer.update_leverage.await_count) + self.connector._update_balances.assert_awaited_once() + self.connector._sleep.assert_awaited_once() + + def test_get_signer_private_key_prefers_explicit_private_key(self): + # api_key holds the signing private key when it's a hex string (not an integer index) + self.connector.api_key = "0x" + ("a" * 64) + self.connector.api_secret = "1" + self.assertEqual("0x" + ("a" * 64), self.connector._get_signer_private_key()) + + def test_is_int_string_handles_valid_and_invalid_values(self): + self.assertTrue(self.connector._is_int_string("12")) + self.assertTrue(self.connector._is_int_string(34)) + self.assertFalse(self.connector._is_int_string("abc")) + self.assertFalse(self.connector._is_int_string(None)) + + def test_get_rest_api_key_prefers_numeric_api_key_then_secret_then_api_key(self): + self.connector.api_key = "7" + self.connector.api_secret = "secret" + self.assertEqual("7", self.connector._get_rest_api_key()) + + self.connector.api_key = "not-numeric" + self.assertEqual("secret", self.connector._get_rest_api_key()) + + self.connector.api_secret = "" + self.assertEqual("not-numeric", self.connector._get_rest_api_key()) + + def test_get_signer_private_key_uses_api_key_when_private_key_missing(self): + self.connector.private_key = "" + self.connector.api_key = "0x" + ("a" * 64) + self.connector.api_secret = "7" + self.assertEqual("0x" + ("a" * 64), self.connector._get_signer_private_key()) + + self.connector.api_key = "7" + self.connector.api_secret = "0xsecret" + with self.assertRaises(ValueError): + self.connector._get_signer_private_key() + + def test_get_signer_private_key_raises_when_missing(self): + self.connector.private_key = "" + self.connector.api_key = "7" + self.connector.api_secret = "6" + + with self.assertRaises(ValueError): + self.connector._get_signer_private_key() + + def test_api_host_for_signer_uses_domain(self): + self.assertEqual("https://mainnet.zklighter.elliot.ai", self.connector._api_host_for_signer()) + + self.connector._domain = "lighter_perpetual_testnet" + self.assertEqual("https://testnet.zklighter.elliot.ai", self.connector._api_host_for_signer()) + + def test_get_account_index_and_account_helpers(self): + self.assertEqual(237600, self.connector._get_account_index()) + self.assertEqual({"by": "index", "value": "237600", "active_only": "true"}, self.connector._account_query_params()) + self.assertEqual({"id": 1}, self.connector._account_from_response({"data": {"id": 1}})) + self.assertEqual({"id": 1}, self.connector._account_from_response({"data": [{"id": 1}]})) + self.assertEqual({"id": 2}, self.connector._account_from_response({"accounts": [{"id": 2}]})) + # Top-level account response (no data/accounts wrapper) + top_level = {"code": 200, "collateral": "5.7", "available_balance": "5.7", "assets": []} + self.assertEqual(top_level, self.connector._account_from_response(top_level)) + self.assertIsNone(self.connector._account_from_response({})) + + self.connector.account_index = "bad" + with self.assertRaises(ValueError): + self.connector._get_account_index() + + def test_is_ok_response_and_signer_client_builds_once(self): + self.assertTrue(self.connector._is_ok_response({"success": True})) + self.assertTrue(self.connector._is_ok_response({"code": 200})) + self.assertTrue(self.connector._is_ok_response({"code": 0})) # Lighter uses code=0 for success + self.assertFalse(self.connector._is_ok_response({"code": 5})) # Lighter error code + self.assertFalse(self.connector._is_ok_response({"code": 500})) + + fake_lighter = types.ModuleType("lighter") + + class SignerClient: + def __init__(self, **kwargs): + self.kwargs = kwargs + + fake_lighter.signer_client = SimpleNamespace(SignerClient=SignerClient) + sys.modules["lighter"] = fake_lighter + self.connector._lighter_signer_client = None + + client_1 = self.connector._get_lighter_signer_client() + client_2 = self.connector._get_lighter_signer_client() + + self.assertIs(client_1, client_2) + self.assertEqual(237600, client_1.kwargs["account_index"]) + + async def test_refresh_market_metadata_filters_for_perpetual_markets(self): + self.connector._api_get = AsyncMock(return_value={ + "order_books": [ + { + "symbol": "BTC", + "market_type": "perp", + "market_id": 1, + "supported_size_decimals": 3, + "supported_price_decimals": 2, + }, + {"symbol": "ETH/USDC", "market_type": "spot", "market_id": 2}, + ] + }) + + await self.connector._refresh_market_metadata() + + self.assertEqual(1, self.connector._market_id_by_symbol["BTC"]) + self.assertNotIn("ETH/USDC", self.connector._market_id_by_symbol) + + def test_properties_and_supported_modes(self): + self.assertEqual("lighter_perpetual", self.connector.name) + self.assertEqual("lighter_perpetual", self.connector.domain) + self.assertEqual(32, self.connector.client_order_id_max_length) + self.assertEqual("HBOT", self.connector.client_order_id_prefix) + self.assertEqual("/orderBooks", self.connector.trading_rules_request_path) + self.assertEqual("/orderBooks", self.connector.trading_pairs_request_path) + self.assertEqual("/exchangeStats", self.connector.check_network_request_path) + self.assertEqual(["BTC-USDC"], self.connector.trading_pairs) + self.assertTrue(self.connector.is_cancel_request_in_exchange_synchronous) + self.assertFalse(self.connector.is_trading_required) + self.assertEqual(120, self.connector.funding_fee_poll_interval) + self.assertEqual([OrderType.LIMIT, OrderType.LIMIT_MAKER, OrderType.MARKET], self.connector.supported_order_types()) + self.assertEqual([PositionMode.ONEWAY], self.connector.supported_position_modes()) + self.assertEqual("USDC", self.connector.get_buy_collateral_token("BTC-USDC")) + self.assertEqual("USDC", self.connector.get_sell_collateral_token("BTC-USDC")) + + async def test_api_request_url_and_rate_limits_rules(self): + self.assertEqual( + "https://mainnet.zklighter.elliot.ai/api/v1/account", + await self.connector._api_request_url("/account"), + ) + + self.connector._domain = "lighter_perpetual_testnet" + self.assertEqual( + "https://testnet.zklighter.elliot.ai/api/v1/account", + await self.connector._api_request_url("/account"), + ) + self.connector._domain = "lighter_perpetual" + + self.connector.api_key = "" + self.assertEqual(self.connector.rate_limits_rules, self.connector.rate_limits_rules) + + self.connector.api_key = "1" + self.connector._fee_tier = 2 + rate_limits = self.connector.rate_limits_rules + self.assertGreater(len(rate_limits), 0) + self.assertEqual("LIGHTER_LIMIT", rate_limits[0].limit_id) + + async def test_api_request_routes_authenticated_and_public_requests(self): + # Authenticated request: _api_request must NOT inject X-Api-Key header + # (auth token is passed as the 'auth' query param by callers, not via header) + with patch( + "hummingbot.connector.perpetual_derivative_py_base.PerpetualDerivativePyBase._api_request", + new=AsyncMock(return_value={"auth": True}), + ) as super_req: + auth_result = await self.connector._api_request(path_url="/account", is_auth_required=True) + self.assertEqual({"auth": True}, auth_result) + auth_headers = super_req.await_args.kwargs.get("headers") or {} + self.assertNotIn("X-Api-Key", auth_headers) + + # Public request: _api_request must NOT inject X-Api-Key header + with patch( + "hummingbot.connector.perpetual_derivative_py_base.PerpetualDerivativePyBase._api_request", + new=AsyncMock(return_value={"auth": False}), + ) as super_req: + public_result = await self.connector._api_request(path_url="/account", is_auth_required=False) + self.assertEqual({"auth": False}, public_result) + public_headers = super_req.await_args.kwargs.get("headers") or {} + self.assertNotIn("X-Api-Key", public_headers) + + async def test_fetch_or_create_api_config_key_short_circuits_and_warns(self): + self.connector.api_config_key = "abc" + self.connector.api_key_index = "5" + self.connector._api_get = AsyncMock() + + await self.connector._fetch_or_create_api_config_key() + + self.connector._api_get.assert_not_awaited() + + self.connector.api_config_key = "" + self.connector.api_key_index = "" + self.connector.account_index = "" + self.connector.api_key = "" + self.connector.api_secret = "" + logger = MagicMock() + self.connector.logger = MagicMock(return_value=logger) + + await self.connector._fetch_or_create_api_config_key() + + logger.warning.assert_called_once() + + async def test_fetch_or_create_api_config_key_updates_throttler_and_warns_when_missing(self): + logger = MagicMock() + throttler = MagicMock() + self.connector.logger = MagicMock(return_value=logger) + self.connector._throttler = throttler + self.connector.api_key = "abc_public_key" + self.connector.api_secret = "" + self.connector.api_key_index = "" + self.connector.api_config_key = "" + self.connector.account_index = "237600" + self.connector._api_get = AsyncMock(return_value={ + "api_keys": [{"api_key_index": 5, "public_key": "abc_public_key"}] + }) + + await self.connector._fetch_or_create_api_config_key() + + self.assertEqual("5", self.connector.api_key_index) + throttler.set_rate_limits.assert_called_once() + + self.connector.api_key_index = "" + self.connector._api_get = AsyncMock(return_value={"api_keys": []}) + + await self.connector._fetch_or_create_api_config_key() + + logger.warning.assert_called() + + def test_generate_api_key_pair_returns_private_and_public_keys(self): + with patch("lighter.create_api_key", return_value=("priv", "pub", None)): + private_key, public_key = self.connector.generate_api_key_pair() + + self.assertEqual("priv", private_key) + self.assertEqual("pub", public_key) + + def test_set_lighter_price_keeps_latest_timestamp(self): + self.connector.set_LIGHTER_price("BTC-USDC", 200.0, Decimal("101"), Decimal("102")) + self.connector.set_LIGHTER_price("BTC-USDC", 199.0, Decimal("99"), Decimal("100")) + + price_record = self.connector.get_LIGHTER_price("BTC-USDC") + self.assertEqual(200.0, price_record.timestamp) + self.assertEqual(Decimal("101"), price_record.index_price) + self.assertEqual(Decimal("102"), price_record.mark_price) + + def test_get_price_by_type_returns_nan_when_order_book_empty(self): + order_book_module = __import__("hummingbot.core.data_type.order_book", fromlist=["OrderBook"]) + OrderBook = getattr(order_book_module, "OrderBook") + + empty_order_book = OrderBook() + self.connector.get_order_book = MagicMock(return_value=empty_order_book) + self.connector.set_LIGHTER_price("BTC-USDC", time.time(), Decimal("101"), Decimal("102")) + + # Some local test environments provide a runtime variant that omits get_price_by_type. + # Fall back to get_price to keep the NaN-on-empty-orderbook behavior check deterministic. + if hasattr(self.connector, "get_price_by_type"): + best_ask = self.connector.get_price_by_type("BTC-USDC", PriceType.BestAsk) + best_bid = self.connector.get_price_by_type("BTC-USDC", PriceType.BestBid) + elif hasattr(self.connector, "get_price"): + best_ask = self.connector.get_price("BTC-USDC", True) + best_bid = self.connector.get_price("BTC-USDC", False) + else: + self.skipTest("Connector runtime variant does not expose get_price_by_type/get_price") + + self.assertTrue(best_ask.is_nan()) + self.assertTrue(best_bid.is_nan()) + + async def test_get_last_traded_price_logs_no_candle_warning(self): + self.connector.exchange_symbol_associated_to_pair = AsyncMock(return_value="BTC") + self.connector._market_id_by_symbol["BTC"] = 1 + self.connector._size_decimals_by_symbol["BTC"] = 3 + self.connector._price_decimals_by_symbol["BTC"] = 2 + self.connector._api_get = AsyncMock(return_value={"data": []}) + logger_mock = MagicMock() + self.connector.logger = MagicMock(return_value=logger_mock) + + price = await self.connector._get_last_traded_price("BTC-USDC") + + self.assertEqual(0.0, price) + logger_mock.warning.assert_called() + + async def test_process_account_order_updates_ws_event_message_updates_tracked_order(self): + tracked_order = MagicMock() + tracked_order.exchange_order_id = "123" + tracked_order.client_order_id = "HBOT-1" + tracked_order.trading_pair = "BTC-USDC" + tracked_order.executed_amount_base = Decimal("0") + tracked_order.amount = Decimal("1") + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.all_updatable_orders = {"HBOT-1": tracked_order} + self.connector._order_tracker.process_order_update = MagicMock() + + await self.connector._process_account_order_updates_ws_event_message({ + "data": [{"i": 123, "os": "filled", "ut": 1700000000000}], + }) + + self.connector._order_tracker.process_order_update.assert_called_once() + order_update = self.connector._order_tracker.process_order_update.call_args.args[0] + self.assertEqual("HBOT-1", order_update.client_order_id) + self.assertEqual("123", order_update.exchange_order_id) + + async def test_process_account_order_updates_ws_event_message_maps_client_index_to_exchange_order_id(self): + # tracked order starts with client_order_index as exchange_order_id + tracked_order = MagicMock() + tracked_order.exchange_order_id = "999" # client_order_index placeholder + tracked_order.client_order_id = "HBOT-1" + tracked_order.trading_pair = "BTC-USDC" + tracked_order.executed_amount_base = Decimal("0") + tracked_order.amount = Decimal("1") + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.all_updatable_orders = {"HBOT-1": tracked_order} + self.connector._order_tracker.process_order_update = MagicMock() + + # WS sends i=, I= + await self.connector._process_account_order_updates_ws_event_message({ + "data": [{"i": 123, "I": 999, "os": "filled", "ut": 1700000000000}], + }) + + self.connector._order_tracker.process_order_update.assert_called_once() + order_update = self.connector._order_tracker.process_order_update.call_args.args[0] + # exchange_order_id must be updated to the real order_index "123" + self.assertEqual("123", order_update.exchange_order_id) + # mapping must be populated + self.assertEqual("123", self.connector._client_order_index_to_order_index.get("999")) + + async def test_process_account_order_updates_ws_event_message_uses_client_index_when_exchange_id_missing(self): + # tracked order is known only by the initial client_order_index placeholder + tracked_order = MagicMock() + tracked_order.exchange_order_id = "999" + tracked_order.client_order_id = "HBOT-1" + tracked_order.trading_pair = "BTC-USDC" + tracked_order.executed_amount_base = Decimal("0") + tracked_order.amount = Decimal("1") + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.all_updatable_orders = {"HBOT-1": tracked_order} + self.connector._order_tracker.process_order_update = MagicMock() + + # WS sends only client_order_index without real exchange order id + await self.connector._process_account_order_updates_ws_event_message({ + "data": [{"I": 999, "os": "canceled", "ut": 1700000000000}], + }) + + self.connector._order_tracker.process_order_update.assert_called_once() + order_update = self.connector._order_tracker.process_order_update.call_args.args[0] + self.assertEqual("HBOT-1", order_update.client_order_id) + self.assertEqual("999", order_update.exchange_order_id) + + async def test_process_account_order_updates_ws_event_message_uses_reverse_mapping_when_client_index_missing(self): + # tracked order still has placeholder client_order_index as exchange_order_id + tracked_order = MagicMock() + tracked_order.exchange_order_id = "999" + tracked_order.client_order_id = "HBOT-1" + tracked_order.trading_pair = "BTC-USDC" + tracked_order.executed_amount_base = Decimal("0") + tracked_order.amount = Decimal("1") + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.all_updatable_orders = {"HBOT-1": tracked_order} + self.connector._order_tracker.process_order_update = MagicMock() + + # Pretend we learned this mapping from account_all or active-order reconciliation. + self.connector._client_order_index_to_client_order_id["999"] = "HBOT-1" + self.connector._client_order_index_to_order_index["999"] = "123" + + # WS sends only i= (I omitted/null). + await self.connector._process_account_order_updates_ws_event_message({ + "data": [{"i": 123, "os": "canceled", "ut": 1700000000000}], + }) + + self.connector._order_tracker.process_order_update.assert_called_once() + order_update = self.connector._order_tracker.process_order_update.call_args.args[0] + self.assertEqual("HBOT-1", order_update.client_order_id) + self.assertEqual("123", order_update.exchange_order_id) + + async def test_resolve_exchange_order_id_matches_client_order_index(self): + mock_signer = MagicMock() + mock_signer.create_auth_token_with_expiry = MagicMock(return_value=("auth-token", None)) + self.connector._get_lighter_signer_client = MagicMock(return_value=mock_signer) + self.connector._get_api_key_index = MagicMock(return_value=4) + # First page: no results, has_more=True + # Second page: contains our order + self.connector._api_get = AsyncMock(side_effect=[ + { + "success": True, + "code": 200, + "data": [], + "has_more": True, + "next_cursor": "cursor-1", + }, + { + "success": True, + "code": 200, + "data": [ + { + "client_order_id": "888", + "order_id": "123456", + } + ], + "has_more": False, + }, + ]) + + order_index = await self.connector._resolve_order_index_from_active_orders( + market_id=1, + client_order_index="888", + ) + + self.assertEqual("123456", order_index) + # Mapping must also be populated + self.assertEqual("123456", self.connector._client_order_index_to_order_index.get("888")) + + async def test_process_account_order_updates_ws_event_message_ignores_unknown_order(self): + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.all_updatable_orders = {} + self.connector._order_tracker.process_order_update = MagicMock() + self.connector._reconcile_unmatched_private_event = AsyncMock() + + await self.connector._process_account_order_updates_ws_event_message({ + "data": [{"i": 123, "os": "filled", "ut": 1700000000000}], + }) + + self.connector._order_tracker.process_order_update.assert_not_called() + self.connector._reconcile_unmatched_private_event.assert_awaited_once() + + async def test_process_account_order_updates_refreshes_positions_on_partial_open_cancel(self): + """When a partially-filled OPEN order is cancelled via WS, _refresh_account_state must + be triggered so the strategy sees the residual position at the next clock tick and can + create the correct close order — rather than orphaning the partial position.""" + tracked_order = MagicMock() + tracked_order.exchange_order_id = "123" + tracked_order.client_order_id = "HBOT-OPEN-1" + tracked_order.trading_pair = "SOL-USDC" + tracked_order.position = PositionAction.OPEN + # Simulate 0.022 SOL partial fill before the cancel + tracked_order.executed_amount_base = Decimal("0.022") + tracked_order.amount = Decimal("1.0") + + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.all_updatable_orders = {"HBOT-OPEN-1": tracked_order} + self.connector._order_tracker.process_order_update = MagicMock() + self.connector._refresh_account_state = AsyncMock() + + await self.connector._process_account_order_updates_ws_event_message({ + "data": [{"i": 123, "os": "canceled", "ut": 1700000000000}], + }) + + # position refresh must have fired so the strategy sees the 0.022 SOL residual + self.connector._refresh_account_state.assert_awaited_once() + call_kwargs = self.connector._refresh_account_state.call_args.kwargs + self.assertTrue(call_kwargs.get("refresh_positions")) + self.assertTrue(call_kwargs.get("refresh_balances")) + + async def test_process_account_order_updates_unfilled_open_cancel_refreshes_balances_only(self): + """A CANCELLED OPEN order with zero fills should refresh balances (to release locked margin) + but must not refresh positions (no residual position to reconcile).""" + tracked_order = MagicMock() + tracked_order.exchange_order_id = "456" + tracked_order.client_order_id = "HBOT-OPEN-2" + tracked_order.trading_pair = "SOL-USDC" + tracked_order.position = PositionAction.OPEN + # No partial fills — zero executed amount + tracked_order.executed_amount_base = Decimal("0") + tracked_order.amount = Decimal("1.0") + + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.all_updatable_orders = {"HBOT-OPEN-2": tracked_order} + self.connector._order_tracker.process_order_update = MagicMock() + self.connector._refresh_account_state = AsyncMock() + + await self.connector._process_account_order_updates_ws_event_message({ + "data": [{"i": 456, "os": "canceled", "ut": 1700000000000}], + }) + + self.connector._refresh_account_state.assert_awaited_once() + call_kwargs = self.connector._refresh_account_state.call_args.kwargs + self.assertFalse(call_kwargs.get("refresh_positions")) + self.assertTrue(call_kwargs.get("refresh_balances")) + + async def test_process_account_order_updates_open_fill_refreshes_balances_only(self): + tracked_order = MagicMock() + tracked_order.exchange_order_id = "777" + tracked_order.client_order_id = "HBOT-OPEN-3" + tracked_order.trading_pair = "SOL-USDC" + tracked_order.position = PositionAction.OPEN + tracked_order.executed_amount_base = Decimal("1.0") + tracked_order.amount = Decimal("1.0") + + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.all_updatable_orders = {"HBOT-OPEN-3": tracked_order} + self.connector._order_tracker.process_order_update = MagicMock() + self.connector._refresh_account_state = AsyncMock() + + await self.connector._process_account_order_updates_ws_event_message({ + "data": [{"i": 777, "os": "filled", "ut": 1700000000000}], + }) + + self.connector._refresh_account_state.assert_awaited_once() + call_kwargs = self.connector._refresh_account_state.call_args.kwargs + self.assertFalse(call_kwargs.get("refresh_positions")) + self.assertTrue(call_kwargs.get("refresh_balances")) + + async def test_process_account_order_updates_ignores_stale_cancel_after_filled(self): + tracked_order = MagicMock() + tracked_order.exchange_order_id = "123" + tracked_order.client_order_id = "HBOT-FILLED-1" + tracked_order.trading_pair = "SOL-USDC" + tracked_order.current_state = OrderState.FILLED + tracked_order.position = PositionAction.OPEN + tracked_order.executed_amount_base = Decimal("1.0") + tracked_order.amount = Decimal("1.0") + + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.all_updatable_orders = {"HBOT-FILLED-1": tracked_order} + self.connector._order_tracker.process_order_update = MagicMock() + self.connector._refresh_account_state = AsyncMock() + + await self.connector._process_account_order_updates_ws_event_message({ + "data": [{"i": 123, "os": "canceled", "ut": 1700000000000}], + }) + + self.connector._order_tracker.process_order_update.assert_not_called() + self.connector._refresh_account_state.assert_not_awaited() + + async def test_process_account_all_orders_ignores_stale_cancel_after_filled(self): + tracked_order = MagicMock() + tracked_order.exchange_order_id = "123" + tracked_order.client_order_id = "HBOT-FILLED-2" + tracked_order.trading_pair = "SOL-USDC" + tracked_order.current_state = OrderState.FILLED + tracked_order.creation_timestamp = time.time() - 120 + tracked_order.position = PositionAction.OPEN + tracked_order.executed_amount_base = Decimal("1.0") + tracked_order.amount = Decimal("1.0") + + self.connector._client_order_index_to_client_order_id["999"] = "HBOT-FILLED-2" + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.all_updatable_orders = {"HBOT-FILLED-2": tracked_order} + self.connector._order_tracker.process_order_update = MagicMock() + self.connector._refresh_account_state = AsyncMock() + self.connector._verify_cancel_not_false = AsyncMock() + + await self.connector._process_account_all_orders_ws_event_message({ + "data": { + "orders": [ + { + "order_index": "123", + "client_order_index": "999", + "order_status": "canceled", + "updated_at": 1700000000000, + } + ] + } + }) + + self.connector._order_tracker.process_order_update.assert_not_called() + self.connector._refresh_account_state.assert_not_awaited() + self.connector._verify_cancel_not_false.assert_not_called() + + async def test_process_account_positions_ws_event_message_preserves_stale_on_rebuild_exception(self): + """If trading_pair resolution raises mid-loop, the existing positions must be preserved + (not wiped). The WS handler now uses the same atomic clear-after-rebuild pattern as + _update_positions() REST path.""" + class FakePerpetualTrading: + def __init__(self): + self.account_positions = {"SOL-USDC-LONG": "stale_sol_position"} + + def position_key(self, trading_pair, position_side): + return f"{trading_pair}-{position_side.name}" + + def set_position(self, key, position): + self.account_positions[key] = position + + self.connector._perpetual_trading = FakePerpetualTrading() + # Second symbol resolution raises — simulating an unexpected symbol mid-snapshot + self.connector.trading_pair_associated_to_exchange_symbol = AsyncMock( + side_effect=["SOL-USDC", Exception("unknown symbol BTC")] + ) + self.connector.get_leverage = MagicMock(return_value="5") + self.connector.set_LIGHTER_price("SOL-USDC", 100.0, Decimal("82"), Decimal("82")) + + with self.assertRaises(Exception): + await self.connector._process_account_positions_ws_event_message({ + "channel": "account_positions", + "data": [ + {"s": "SOL", "d": "bid", "a": "0.022", "p": "82.2"}, + {"s": "BTC", "d": "bid", "a": "0.001", "p": "90000"}, + ], + }) + + # Stale position must be preserved — the atomic rebuild failed so we keep old data + self.assertIn("SOL-USDC-LONG", self.connector._perpetual_trading.account_positions) + self.assertEqual("stale_sol_position", self.connector._perpetual_trading.account_positions["SOL-USDC-LONG"]) + + async def test_process_account_info_ws_event_message_updates_balances_and_fee_tier(self): + await self.connector._process_account_info_ws_event_message({ + "data": {"ae": "12.5", "as": "10.2", "f": 3}, + }) + + self.assertNotIn("USDC", self.connector._account_balances) + self.assertNotIn("USDC", self.connector._account_available_balances) + self.assertEqual(3, self.connector._fee_tier) + + async def test_process_account_positions_ws_event_message_replaces_snapshot_with_long_and_short(self): + class FakePerpetualTrading: + def __init__(self): + self.account_positions = {"stale": "position"} + + def position_key(self, trading_pair, position_side): + return f"{trading_pair}-{position_side.name}" + + def set_position(self, key, position): + self.account_positions[key] = position + + self.connector._perpetual_trading = FakePerpetualTrading() + self.connector._trading_pairs = ["BTC-USDC", "ETH-USDC"] + self.connector.trading_pair_associated_to_exchange_symbol = AsyncMock( + side_effect=lambda symbol: {"BTC": "BTC-USDC", "ETH": "ETH-USDC"}[symbol] + ) + self.connector.get_leverage = MagicMock(return_value="10") + self.connector.set_LIGHTER_price("BTC-USDC", 100.0, Decimal("100"), Decimal("105")) + self.connector.set_LIGHTER_price("ETH-USDC", 100.0, Decimal("200"), Decimal("190")) + + await self.connector._process_account_positions_ws_event_message({ + "channel": "account_positions", + "data": [ + {"s": "BTC", "d": "bid", "a": "0.5", "p": "100"}, + {"s": "ETH", "d": "ask", "a": "1.2", "p": "200"}, + ] + }) + + positions = self.connector._perpetual_trading.account_positions + self.assertEqual(2, len(positions)) + btc_position = positions["BTC-USDC-LONG"] + eth_position = positions["ETH-USDC-SHORT"] + self.assertEqual(Decimal("0.5"), btc_position.amount) + self.assertEqual(Decimal("2.5"), btc_position.unrealized_pnl) + self.assertEqual(Decimal("-1.2"), eth_position.amount) + self.assertEqual(Decimal("12.0"), eth_position.unrealized_pnl) + + async def test_process_account_positions_ws_event_message_clears_snapshot_when_empty(self): + class FakePerpetualTrading: + def __init__(self): + self.account_positions = {"BTC-USDC-LONG": "existing"} + + def position_key(self, trading_pair, position_side): + return f"{trading_pair}-{position_side.name}" + + def set_position(self, key, position): + self.account_positions[key] = position + + self.connector._perpetual_trading = FakePerpetualTrading() + + await self.connector._process_account_positions_ws_event_message({"channel": "account_positions", "data": []}) + + self.assertEqual({}, self.connector._perpetual_trading.account_positions) + + async def test_process_account_positions_ws_event_message_ignores_non_position_payload(self): + class FakePerpetualTrading: + def __init__(self): + self.account_positions = {"BTC-USDC-LONG": "existing"} + + def position_key(self, trading_pair, position_side): + return f"{trading_pair}-{position_side.name}" + + def set_position(self, key, position): + self.account_positions[key] = position + + self.connector._perpetual_trading = FakePerpetualTrading() + + # account_all update without `positions` must not clear existing snapshot + await self.connector._process_account_positions_ws_event_message({ + "type": "update/account_all", + "data": [{"i": 123, "s": "BTC", "a": "0.2"}], + }) + + self.assertEqual({"BTC-USDC-LONG": "existing"}, self.connector._perpetual_trading.account_positions) + + async def test_process_account_positions_ws_event_message_handles_account_all_snapshot(self): + class FakePerpetualTrading: + def __init__(self): + self.account_positions = {"stale": "position"} + + def position_key(self, trading_pair, position_side): + return f"{trading_pair}-{position_side.name}" + + def set_position(self, key, position): + self.account_positions[key] = position + + self.connector._perpetual_trading = FakePerpetualTrading() + self.connector._trading_pairs = ["ETH-USDC"] + self.connector.trading_pair_associated_to_exchange_symbol = AsyncMock(return_value="ETH-USDC") + self.connector.get_leverage = MagicMock(return_value="5") + + await self.connector._process_account_positions_ws_event_message({ + "type": "update/account_all", + "positions": { + "0": { + "symbol": "ETH", + "sign": -1, + "position": "1.25", + "avg_entry_price": "2000", + "unrealized_pnl": "7.5", + } + }, + }) + + positions = self.connector._perpetual_trading.account_positions + self.assertEqual(1, len(positions)) + eth_position = positions["ETH-USDC-SHORT"] + self.assertEqual(Decimal("-1.25"), eth_position.amount) + self.assertEqual(Decimal("7.5"), eth_position.unrealized_pnl) + + async def test_process_account_positions_ws_event_message_with_upnl_does_not_raise(self): + class FakePerpetualTrading: + def __init__(self): + self.account_positions = {} + + def position_key(self, trading_pair, position_side): + return f"{trading_pair}-{position_side.name}" + + def set_position(self, key, position): + self.account_positions[key] = position + + self.connector._perpetual_trading = FakePerpetualTrading() + self.connector._trading_pairs = ["HYPE-USDC"] + self.connector.trading_pair_associated_to_exchange_symbol = AsyncMock(return_value="HYPE-USDC") + self.connector.get_leverage = MagicMock(return_value="5") + + await self.connector._process_account_positions_ws_event_message({ + "channel": "account_positions", + "data": [ + {"s": "HYPE", "d": "bid", "a": "0.6", "p": "41.02", "upnl": "1.23", "f": "0"}, + ], + }) + + positions = self.connector._perpetual_trading.account_positions + self.assertEqual(1, len(positions)) + hype_position = positions["HYPE-USDC-LONG"] + self.assertEqual(Decimal("0.6"), hype_position.amount) + self.assertEqual(Decimal("1.23"), hype_position.unrealized_pnl) + + async def test_process_account_positions_ws_event_message_from_account_all_with_upnl_and_no_price_cache(self): + class FakePerpetualTrading: + def __init__(self): + self.account_positions = {} + + def position_key(self, trading_pair, position_side): + return f"{trading_pair}-{position_side.name}" + + def set_position(self, key, position): + self.account_positions[key] = position + + self.connector._perpetual_trading = FakePerpetualTrading() + self.connector._trading_pairs = ["HYPE-USDC"] + self.connector.trading_pair_associated_to_exchange_symbol = AsyncMock(return_value="HYPE-USDC") + self.connector.get_leverage = MagicMock(return_value="5") + + await self.connector._process_account_positions_ws_event_message({ + "channel": "account_all", + "positions": [ + {"s": "HYPE", "d": "bid", "a": "0.6", "p": "41.02", "upnl": "1.23", "f": "0"}, + ], + }) + + positions = self.connector._perpetual_trading.account_positions + self.assertEqual(1, len(positions)) + hype_position = positions["HYPE-USDC-LONG"] + self.assertEqual(Decimal("0.6"), hype_position.amount) + self.assertEqual(Decimal("1.23"), hype_position.unrealized_pnl) + + async def test_update_positions_preserves_stale_positions_on_rebuild_failure(self): + """When _update_positions fails mid-rebuild (e.g. symbol resolution raises), the existing + positions must be left intact so the TUI does not blank out to zero. The old behaviour of + clearing early was the bug that caused TUI 'position not recognised'.""" + self.connector._perpetual_trading.account_positions["DOGE-USDC"] = "stale" + self.connector._api_get = AsyncMock(return_value={ + "success": True, + "data": { + "positions": [ + {"symbol": "DOGE", "position": "0", "sign": 1, "avg_entry_price": "0"}, + ] + }, + }) + self.connector.trading_pair_associated_to_exchange_symbol = AsyncMock(side_effect=Exception("boom")) + + with self.assertRaises(Exception): + await self.connector._update_positions() + + # Stale position must be preserved — NOT wiped — so the TUI stays visible. + self.assertEqual({"DOGE-USDC": "stale"}, self.connector._perpetual_trading.account_positions) + + async def test_update_positions_rest_skips_zero_amount(self): + self.connector.trading_pair_associated_to_exchange_symbol = AsyncMock(return_value="DOGE-USDC") + # Pre-populate price cache so the prices HTTP fetch is skipped + self.connector.set_LIGHTER_price("DOGE-USDC", timestamp=1.0, + index_price=Decimal("0.05"), mark_price=Decimal("0.05")) + # Pre-populate a stale position for a DIFFERENT pair that should be cleared on successful rebuild + self.connector._perpetual_trading.account_positions["SOL-USDC"] = "stale_different_pair" + self.connector._api_get = AsyncMock(return_value={ + "success": True, + "data": { + "positions": [ + {"symbol": "DOGE", "amount": "0", "sign": 1, "entry_price": "0.05"}, + ] + }, + }) + await self.connector._update_positions() + # Zero-amount closed positions must NOT be stored (same guard as WS handler) + # Stale positions from OTHER pairs must be cleared on successful rebuild + self.assertEqual({}, self.connector._perpetual_trading.account_positions) + + async def test_update_positions_rest_tracks_sub_minimum_residual_position(self): + self.connector.trading_pair_associated_to_exchange_symbol = AsyncMock(return_value="BTC-USDC") + self.connector._trading_rules = { + "BTC-USDC": TradingRule( + trading_pair="BTC-USDC", + min_order_size=Decimal("0.001"), + min_price_increment=Decimal("0.1"), + min_base_amount_increment=Decimal("0.001"), + min_notional_size=Decimal("10"), + ) + } + self.connector.set_LIGHTER_price( + "BTC-USDC", + timestamp=1.0, + index_price=Decimal("40"), + mark_price=Decimal("40"), + ) + self.connector._api_get = AsyncMock(return_value={ + "success": True, + "data": { + "positions": [ + {"symbol": "BTC", "amount": "0.2", "sign": 1, "entry_price": "40"}, + ] + }, + }) + + await self.connector._update_positions() + + # 0.2 * 40 = 8 < min_notional(10), but residual position should remain tracked. + self.assertEqual(1, len(self.connector._perpetual_trading.account_positions)) + + async def test_update_positions_rest_skips_unconfigured_trading_pair(self): + self.connector._trading_pairs = ["BTC-USDC"] + self.connector.trading_pair_associated_to_exchange_symbol = AsyncMock(return_value="SOL-USDC") + self.connector.set_LIGHTER_price("SOL-USDC", 1.0, Decimal("100"), Decimal("100")) + self.connector._api_get = AsyncMock(return_value={ + "success": True, + "data": { + "positions": [ + {"symbol": "SOL", "amount": "1", "sign": 1, "entry_price": "100"}, + ] + }, + }) + + await self.connector._update_positions() + + self.assertEqual({}, self.connector._perpetual_trading.account_positions) + + async def test_update_positions_rest_recovers_positions_when_prices_fetch_fails(self): + self.connector.trading_pair_associated_to_exchange_symbol = AsyncMock(return_value="BTC-USDC") + self.connector._api_get = AsyncMock(side_effect=[ + { + "success": True, + "data": { + "positions": [ + {"symbol": "BTC", "amount": "0.2", "sign": 1, "entry_price": "40", "unrealized_pnl": "0"}, + ] + }, + }, + { + "success": False, + "error": "prices endpoint temporary failure", + }, + ]) + + await self.connector._update_positions() + + self.assertEqual(1, len(self.connector._perpetual_trading.account_positions)) + restored_position = list(self.connector._perpetual_trading.account_positions.values())[0] + self.assertEqual("BTC-USDC", restored_position.trading_pair) + self.assertEqual(Decimal("0.2"), restored_position.amount) + self.assertEqual(Decimal("40"), restored_position.entry_price) + + async def test_process_account_positions_ws_event_message_tracks_sub_minimum_residual_position(self): + class FakePerpetualTrading: + def __init__(self): + self.account_positions = {} + + def position_key(self, trading_pair, position_side): + return f"{trading_pair}-{position_side.name}" + + def set_position(self, key, position): + self.account_positions[key] = position + + self.connector._perpetual_trading = FakePerpetualTrading() + self.connector._trading_rules = { + "BTC-USDC": TradingRule( + trading_pair="BTC-USDC", + min_order_size=Decimal("0.001"), + min_price_increment=Decimal("0.1"), + min_base_amount_increment=Decimal("0.001"), + min_notional_size=Decimal("10"), + ) + } + self.connector.trading_pair_associated_to_exchange_symbol = AsyncMock(return_value="BTC-USDC") + self.connector.get_leverage = MagicMock(return_value="5") + self.connector.set_LIGHTER_price("BTC-USDC", 100.0, Decimal("40"), Decimal("40")) + + await self.connector._process_account_positions_ws_event_message({ + "channel": "account_positions", + "data": [ + {"s": "BTC", "d": "bid", "a": "0.2", "p": "40"}, + ], + }) + + # 0.2 * 40 = 8 < min_notional(10), but residual position should remain tracked. + self.assertEqual(1, len(self.connector._perpetual_trading.account_positions)) + + async def test_process_account_positions_ws_event_message_skips_unconfigured_trading_pair(self): + class FakePerpetualTrading: + def __init__(self): + self.account_positions = {} + + def position_key(self, trading_pair, position_side): + return f"{trading_pair}-{position_side.name}" + + def set_position(self, key, position): + self.account_positions[key] = position + + self.connector._perpetual_trading = FakePerpetualTrading() + self.connector._trading_pairs = ["BTC-USDC"] + self.connector.trading_pair_associated_to_exchange_symbol = AsyncMock(return_value="SOL-USDC") + self.connector.get_leverage = MagicMock(return_value="5") + + await self.connector._process_account_positions_ws_event_message({ + "channel": "account_positions", + "data": [ + {"s": "SOL", "d": "bid", "a": "1", "p": "100"}, + ], + }) + + self.assertEqual({}, self.connector._perpetual_trading.account_positions) + + async def test_update_order_after_failure_sub_minimum_keeps_position(self): + """When a CLOSE order fails with a sub-minimum notional error, keep the position in + account_positions so runtime status remains accurate.""" + failed_order = MagicMock() + failed_order.position = PositionAction.CLOSE + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.all_orders = {"HBOT-CLOSE-1": failed_order} + self.connector._perpetual_trading.account_positions["SOL-USDC|LONG"] = "stale_position" + self.connector._update_positions = AsyncMock() + + err = IOError("Order notional 7.2200 USDC is below the minimum notional 10.0 USDC") + self.connector._update_order_after_failure("HBOT-CLOSE-1", "SOL-USDC", exception=err) + await asyncio.sleep(0) + + self.assertIn("SOL-USDC|LONG", self.connector._perpetual_trading.account_positions) + self.connector._update_positions.assert_awaited_once() + + async def test_update_order_after_failure_normal_close_triggers_position_refresh(self): + """For non-sub-minimum CLOSE failures (e.g. network error), the position snapshot is + refreshed from REST so the TUI always reflects reality.""" + failed_order = MagicMock() + failed_order.position = PositionAction.CLOSE + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.all_orders = {"HBOT-CLOSE-2": failed_order} + self.connector._update_positions = AsyncMock() + + err = IOError("Some other network error") + self.connector._update_order_after_failure("HBOT-CLOSE-2", "SOL-USDC", exception=err) + # Give asyncio a chance to schedule safe_ensure_future + await asyncio.sleep(0) + + # _update_positions must have been scheduled for non-sub-minimum close failures + self.connector._update_positions.assert_awaited_once() + + async def test_process_account_trades_ws_event_message_processes_tracked_trade(self): + tracked_order = MagicMock() + tracked_order.exchange_order_id = "123" + tracked_order.client_order_id = "HBOT-1" + tracked_order.trading_pair = "BTC-USDC" + tracked_order.quote_asset = "USDC" + tracked_order.position = PositionAction.OPEN + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.all_fillable_orders = {"HBOT-1": tracked_order} + self.connector._order_tracker.process_trade_update = MagicMock() + + await self.connector._process_account_trades_ws_event_message({ + "data": [{ + "i": 123, + "s": "BTC", + "p": "100", + "a": "0.2", + "f": "0.01", + "ts": "open_long", + "t": 1700000000000, + }], + }) + + self.connector._order_tracker.process_trade_update.assert_called_once() + trade_update = self.connector._order_tracker.process_trade_update.call_args.args[0] + self.assertEqual("HBOT-1", trade_update.client_order_id) + self.assertEqual("123", trade_update.exchange_order_id) + self.assertEqual(Decimal("0.2"), trade_update.fill_base_amount) + + async def test_process_account_trades_ws_event_message_ignores_unknown_trade_without_symbol(self): + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.all_fillable_orders = {} + self.connector._order_tracker.all_fillable_orders_by_exchange_order_id = {} + self.connector._order_tracker.process_trade_update = MagicMock() + self.connector._reconcile_unmatched_private_event = AsyncMock() + + await self.connector._process_account_trades_ws_event_message({ + "data": [{"i": 123, "p": "100", "a": "0.2", "f": "0.01", "ts": "open_long", "t": 1700000000000}], + }, buffer_on_miss=False) + + self.connector._order_tracker.process_trade_update.assert_not_called() + self.connector._reconcile_unmatched_private_event.assert_not_awaited() + + async def test_process_account_trades_ws_event_message_reconciles_unknown_trade_without_symbol_when_position_exists(self): + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.all_fillable_orders = {} + self.connector._order_tracker.all_fillable_orders_by_exchange_order_id = {} + self.connector._order_tracker.process_trade_update = MagicMock() + self.connector._reconcile_unmatched_private_event = AsyncMock() + self.connector._perpetual_trading.set_position( + "BTC-USDC-LONG", + SimpleNamespace(trading_pair="BTC-USDC", amount=Decimal("0.6")), + ) + + await self.connector._process_account_trades_ws_event_message({ + "data": [{"i": 123, "p": "100", "a": "0.2", "f": "0.01", "ts": "open_long", "t": 1700000000000}], + }, buffer_on_miss=False) + + self.connector._order_tracker.process_trade_update.assert_not_called() + self.connector._reconcile_unmatched_private_event.assert_awaited_once() + + async def test_process_account_trades_ws_event_message_reconciles_unknown_trade_with_symbol(self): + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.all_fillable_orders = {} + self.connector._order_tracker.all_fillable_orders_by_exchange_order_id = {} + self.connector._order_tracker.process_trade_update = MagicMock() + self.connector._reconcile_unmatched_private_event = AsyncMock() + + await self.connector._process_account_trades_ws_event_message({ + "data": [{"i": 123, "s": "BTC", "p": "100", "a": "0.2", "f": "0.01", "ts": "open_long", "t": 1700000000000}], + }, buffer_on_miss=False) + + self.connector._order_tracker.process_trade_update.assert_not_called() + self.connector._reconcile_unmatched_private_event.assert_awaited_once() + + async def test_process_account_trades_ws_event_message_handles_account_all_trade_update(self): + tracked_order = MagicMock() + tracked_order.exchange_order_id = "1774621023" + tracked_order.client_order_id = "HBOT-1" + tracked_order.trading_pair = "ETH-USDC" + tracked_order.quote_asset = "USDC" + tracked_order.position = PositionAction.OPEN + self.connector.account_index = "361816" + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.all_fillable_orders = {"HBOT-1": tracked_order} + self.connector._order_tracker.process_trade_update = MagicMock() + + await self.connector._process_account_trades_ws_event_message({ + "type": "update/account_all", + "trades": { + "0": [{ + "trade_id_str": "16734837600", + "market_id": 0, + "size": "0.0051", + "price": "1983.11", + "usd_amount": "10.113861", + "bid_client_id_str": "1774621023", + "bid_account_id": 361816, + "ask_account_id": 702389, + "is_maker_ask": True, + "taker_fee": 280, + "maker_fee": 28, + "timestamp": 1774621024363, + }] + }, + }) + + self.connector._order_tracker.process_trade_update.assert_called_once() + trade_update = self.connector._order_tracker.process_trade_update.call_args.args[0] + self.assertEqual("HBOT-1", trade_update.client_order_id) + self.assertEqual("1774621023", trade_update.exchange_order_id) + self.assertEqual(Decimal("0.0051"), trade_update.fill_base_amount) + self.assertEqual("16734837600", trade_update.trade_id) + + async def test_process_account_trades_ws_event_message_uses_reverse_mapping_for_exchange_id(self): + tracked_order = MagicMock() + tracked_order.exchange_order_id = "999" + tracked_order.client_order_id = "HBOT-1" + tracked_order.trading_pair = "ETH-USDC" + tracked_order.quote_asset = "USDC" + tracked_order.position = PositionAction.OPEN + tracked_order.amount = Decimal("1") + tracked_order.executed_amount_base = Decimal("0") + tracked_order.update_exchange_order_id = MagicMock() + + self.connector._client_order_index_to_client_order_id["999"] = "HBOT-1" + self.connector._client_order_index_to_order_index["999"] = "123" + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.all_fillable_orders = {"HBOT-1": tracked_order} + self.connector._order_tracker.process_trade_update = MagicMock() + + await self.connector._process_account_trades_ws_event_message({ + "data": [{ + "i": 123, + "s": "ETH", + "p": "2000", + "a": "0.2", + "f": "0.01", + "ts": "open_long", + "t": 1700000000000, + }], + }) + + tracked_order.update_exchange_order_id.assert_called_once_with("123") + self.connector._order_tracker.process_trade_update.assert_called_once() + trade_update = self.connector._order_tracker.process_trade_update.call_args.args[0] + self.assertEqual("HBOT-1", trade_update.client_order_id) + self.assertEqual("123", trade_update.exchange_order_id) + + async def test_execute_order_cancel_reconciles_state_before_local_terminal_mark(self): + tracked_order = MagicMock() + tracked_order.client_order_id = "HBOT-1" + tracked_order.trading_pair = "ETH-USDC" + tracked_order.exchange_order_id = "999" + + self.connector._execute_order_cancel_and_process_update = AsyncMock( + side_effect=IOError('{"success":false,"error":"order not found","code":5}') + ) + self.connector._request_order_status = AsyncMock(return_value=OrderUpdate( + trading_pair="ETH-USDC", + update_timestamp=1700000000, + new_state=OrderState.OPEN, + client_order_id="HBOT-1", + exchange_order_id="123", + )) + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.process_order_update = MagicMock() + + result = await self.connector._execute_order_cancel(tracked_order) + + self.assertIsNone(result) + self.connector._request_order_status.assert_awaited_once_with(tracked_order) + self.connector._order_tracker.process_order_update.assert_called_once() + + async def test_execute_order_cancel_returns_id_when_reconciled_terminal(self): + tracked_order = MagicMock() + tracked_order.client_order_id = "HBOT-1" + tracked_order.trading_pair = "ETH-USDC" + tracked_order.exchange_order_id = "999" + + self.connector._execute_order_cancel_and_process_update = AsyncMock( + side_effect=IOError('{"success":false,"error":"order not found","code":5}') + ) + self.connector._request_order_status = AsyncMock(return_value=OrderUpdate( + trading_pair="ETH-USDC", + update_timestamp=1700000000, + new_state=OrderState.CANCELED, + client_order_id="HBOT-1", + exchange_order_id="123", + )) + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.process_order_update = MagicMock() + + result = await self.connector._execute_order_cancel(tracked_order) + + self.assertEqual("HBOT-1", result) + self.connector._request_order_status.assert_awaited_once_with(tracked_order) + self.connector._order_tracker.process_order_update.assert_called_once() + + async def test_execute_order_cancel_timeout_runs_reconcile_instead_of_not_found(self): + tracked_order = MagicMock() + tracked_order.client_order_id = "HBOT-2" + tracked_order.trading_pair = "ETH-USDC" + tracked_order.exchange_order_id = None + + self.connector._execute_order_cancel_and_process_update = AsyncMock(side_effect=asyncio.TimeoutError()) + self.connector._reconcile_unmatched_private_event = AsyncMock() + self.connector._order_tracker = MagicMock() + self.connector._order_tracker.process_order_not_found = AsyncMock() + + result = await self.connector._execute_order_cancel(tracked_order) + + self.assertIsNone(result) + self.connector._reconcile_unmatched_private_event.assert_awaited_once() + self.connector._order_tracker.process_order_not_found.assert_not_awaited() + + async def test_place_cancel_recovers_when_exchange_order_id_is_string_none(self): + """When exchange_order_id is the literal string 'None', _place_cancel should recover + the real client_order_index from the reverse lookup map and succeed.""" + tracked_order = MagicMock() + tracked_order.exchange_order_id = "None" + tracked_order.trading_pair = "BTC-USDC" + + real_coi = "248847765999999" + self.connector._client_order_index_to_client_order_id = {real_coi: "HBOT-stop-loss"} + self.connector._client_order_index_to_order_index = {real_coi: "88888888888"} + + self.connector._get_market_spec = AsyncMock(return_value=(0, 6, 2, None)) + + mock_tx = MagicMock() + mock_tx.code = 200 + mock_signer = MagicMock() + mock_signer.cancel_order = AsyncMock(return_value=(None, mock_tx, None)) + mock_signer.create_auth_token_with_expiry = MagicMock(return_value=("tok", None)) + self.connector._get_lighter_signer_client = MagicMock(return_value=mock_signer) + self.connector._get_api_key_index = MagicMock(return_value=1) + + result = await self.connector._place_cancel("HBOT-stop-loss", tracked_order) + + self.assertTrue(result) + mock_signer.cancel_order.assert_awaited_once() + # Verify it used the recovered order_index (88888888888), not "None" + call_kwargs = mock_signer.cancel_order.call_args + self.assertEqual(88888888888, call_kwargs.kwargs.get("order_index") or call_kwargs[1].get("order_index")) + + async def test_place_cancel_returns_false_when_exchange_order_id_string_none_and_no_recovery(self): + """When exchange_order_id is 'None' and no reverse lookup exists, _place_cancel should + return False gracefully instead of raising an IOError.""" + tracked_order = MagicMock() + tracked_order.exchange_order_id = "None" + tracked_order.trading_pair = "BTC-USDC" + + self.connector._client_order_index_to_client_order_id = {} + self.connector._get_market_spec = AsyncMock(return_value=(0, 6, 2, None)) + + result = await self.connector._place_cancel("HBOT-unknown", tracked_order) + + self.assertFalse(result) + + async def test_place_cancel_sets_backoff_when_exchange_order_id_is_python_none(self): + """When exchange_order_id is Python None (placement still in-flight), _place_cancel + should return False and set a short backoff so the strategy doesn't hammer every tick.""" + tracked_order = MagicMock() + tracked_order.exchange_order_id = None + tracked_order.trading_pair = "BTC-USDC" + + self.connector._cancel_backoff_until = {} + + result = await self.connector._place_cancel("HBOT-in-flight", tracked_order) + + self.assertFalse(result) + self.assertIn("HBOT-in-flight", self.connector._cancel_backoff_until) + self.assertGreater(self.connector._cancel_backoff_until["HBOT-in-flight"], 0) + + async def test_place_cancel_succeeds_when_exchange_order_id_is_resolved_order_index(self): + """When WS has replaced exchange_order_id with the real server order_index (large int), + _place_cancel should recover via reverse lookup and cancel successfully.""" + order_id = "HBOT-test-order" + client_order_index = "7" + server_order_index = "248885132237560" + + tracked_order = MagicMock() + tracked_order.exchange_order_id = server_order_index # WS has already updated this + tracked_order.trading_pair = "BTC-USDC" + tracked_order.client_order_id = order_id + + self.connector._get_market_spec = AsyncMock(return_value=(0, 6, 2, None)) + # client_order_index → order_id mapping (set at placement) + self.connector._client_order_index_to_client_order_id = {client_order_index: order_id} + # client_order_index → server order_index mapping (set from WS account_orders) + self.connector._client_order_index_to_order_index = {client_order_index: server_order_index} + self.connector._cancel_backoff_until = {} + + mock_tx_response = MagicMock() + mock_tx_response.code = 200 + self.connector._get_lighter_signer_client = MagicMock() + mock_signer = MagicMock() + mock_signer.cancel_order = AsyncMock(return_value=(None, mock_tx_response, None)) + self.connector._get_lighter_signer_client.return_value = mock_signer + self.connector._get_api_key_index = MagicMock(return_value=0) + + result = await self.connector._place_cancel(order_id, tracked_order) + + self.assertTrue(result) + mock_signer.cancel_order.assert_awaited_once_with( + market_index=0, + order_index=int(server_order_index), + api_key_index=0, + ) + + async def test_user_stream_event_listener_routes_known_channels(self): + async def event_iter(): + for event in [ + {"channel": "account_order_updates", "data": []}, + {"channel": "account_positions", "data": []}, + {"channel": "account_info", "data": {"ae": "1", "as": "1"}}, + {"channel": "account_trades", "data": []}, + ]: + yield event + + self.connector._iter_user_event_queue = event_iter + self.connector._process_account_order_updates_ws_event_message = AsyncMock() + self.connector._process_account_positions_ws_event_message = AsyncMock() + self.connector._process_account_info_ws_event_message = AsyncMock() + self.connector._process_account_trades_ws_event_message = AsyncMock() + + await self.connector._user_stream_event_listener() + + self.connector._process_account_order_updates_ws_event_message.assert_awaited_once() + self.connector._process_account_positions_ws_event_message.assert_awaited_once() + self.connector._process_account_info_ws_event_message.assert_awaited_once() + self.connector._process_account_trades_ws_event_message.assert_awaited_once() + + async def test_user_stream_event_listener_routes_subscribed_dedicated_events(self): + async def event_iter(): + for event in [ + {"type": "subscribed/account_order_updates", "data": []}, + {"type": "subscribed/account_positions", "data": []}, + {"type": "subscribed/account_info", "data": {"ae": "1", "as": "1"}}, + {"type": "subscribed/account_trades", "data": []}, + ]: + yield event + + self.connector._iter_user_event_queue = event_iter + self.connector._process_account_order_updates_ws_event_message = AsyncMock() + self.connector._process_account_positions_ws_event_message = AsyncMock() + self.connector._process_account_info_ws_event_message = AsyncMock() + self.connector._process_account_trades_ws_event_message = AsyncMock() + + await self.connector._user_stream_event_listener() + + self.connector._process_account_order_updates_ws_event_message.assert_awaited_once() + self.connector._process_account_positions_ws_event_message.assert_awaited_once() + self.connector._process_account_info_ws_event_message.assert_awaited_once() + self.connector._process_account_trades_ws_event_message.assert_awaited_once() + + async def test_user_stream_event_listener_routes_colon_scoped_channels(self): + async def event_iter(): + for event in [ + {"channel": "account_order_updates:237600", "data": []}, + {"channel": "account_positions:237600", "data": []}, + {"channel": "account_info:237600", "data": {"ae": "1", "as": "1"}}, + {"channel": "account_trades:237600", "data": []}, + ]: + yield event + + self.connector._iter_user_event_queue = event_iter + self.connector._process_account_order_updates_ws_event_message = AsyncMock() + self.connector._process_account_positions_ws_event_message = AsyncMock() + self.connector._process_account_info_ws_event_message = AsyncMock() + self.connector._process_account_trades_ws_event_message = AsyncMock() + + await self.connector._user_stream_event_listener() + + self.connector._process_account_order_updates_ws_event_message.assert_awaited_once() + self.connector._process_account_positions_ws_event_message.assert_awaited_once() + self.connector._process_account_info_ws_event_message.assert_awaited_once() + self.connector._process_account_trades_ws_event_message.assert_awaited_once() + + async def test_user_stream_event_listener_routes_account_all_events(self): + async def event_iter(): + yield {"type": "update/account_all", "channel": "account_all:237600", "positions": {}} + + self.connector._iter_user_event_queue = event_iter + self.connector._process_account_all_ws_event_message = AsyncMock() + + await self.connector._user_stream_event_listener() + + self.connector._process_account_all_ws_event_message.assert_awaited_once() + + async def test_user_stream_event_listener_ignores_wrong_numeric_scoped_channel(self): + async def event_iter(): + yield {"channel": "user_stats:4", "stats": {"collateral": "19.1", "available_balance": "2.2"}} + + self.connector.account_index = "237600" + self.connector._iter_user_event_queue = event_iter + self.connector._process_user_stats_ws_event_message = AsyncMock() + + await self.connector._user_stream_event_listener() + + self.connector._process_user_stats_ws_event_message.assert_not_awaited() + + async def test_user_stream_event_listener_accepts_matching_numeric_scoped_channel(self): + async def event_iter(): + yield {"channel": "user_stats:237600", "stats": {"collateral": "19.1", "available_balance": "2.2"}} + + self.connector.account_index = "237600" + self.connector._iter_user_event_queue = event_iter + self.connector._process_user_stats_ws_event_message = AsyncMock() + + await self.connector._user_stream_event_listener() + + self.connector._process_user_stats_ws_event_message.assert_awaited_once() + + async def test_process_account_all_ws_event_message_routes_trades_and_positions(self): + self.connector._process_account_trades_ws_event_message = AsyncMock() + self.connector._process_account_positions_ws_event_message = AsyncMock() + + event = {"type": "update/account_all", "channel": "account_all:237600", "positions": {}, "trades": {}} + await self.connector._process_account_all_ws_event_message(event) + + # Both handlers are forwarded the full event; each handler normalises its own slice. + self.connector._process_account_trades_ws_event_message.assert_awaited_once_with(event, buffer_on_miss=False) + self.connector._process_account_positions_ws_event_message.assert_awaited_once_with(event) + + async def test_process_account_all_ws_event_message_extracts_usdc_balance(self): + # account_all top-level available_balance is used when present. + # A REST poll is still scheduled (via _schedule_fast_balance_sync) to keep parity with exchange state. + event = { + "type": "update/account_all", + "channel": "account_all:237600", + "positions": {}, + "trades": {}, + "collateral": "100.50", + "available_balance": "88.00", + "assets": { + "3": {"symbol": "USDC", "asset_id": 3, "balance": "100.50", "locked_balance": "10.25"}, + "1": {"symbol": "ETH", "asset_id": 1, "balance": "0.001", "locked_balance": "0.0"}, + }, + } + self.connector._process_account_trades_ws_event_message = AsyncMock() + self.connector._process_account_positions_ws_event_message = AsyncMock() + # Pre-set an existing available balance; top-level account_all available overrides it. + self.connector._account_available_balances["USDC"] = Decimal("75.00") + + await self.connector._process_account_all_ws_event_message(event) + + self.assertNotIn("USDC", self.connector._account_balances) + self.assertEqual(Decimal("75.00"), self.connector._account_available_balances["USDC"]) + + async def test_process_account_all_ws_event_message_no_assets_key_skips_balance(self): + event = {"type": "update/account_all", "channel": "account_all:237600", "positions": {}, "trades": {}} + self.connector._process_account_trades_ws_event_message = AsyncMock() + self.connector._process_account_positions_ws_event_message = AsyncMock() + + await self.connector._process_account_all_ws_event_message(event) + + # Should not error; balances remain at their defaults + self.assertEqual({}, self.connector._account_balances) + + async def test_process_account_all_ws_event_message_margin_balance_with_available_to_spend(self): + # Simulates the real production cross-margin scenario: margin_balance (perp equity) is + # the total, but available_to_spend (from exchange) already subtracts position initial + # margin. locked_balance only covers open-order locks, so margin_balance - locked_balance + # would overstate the available margin. The exchange-computed available_to_spend must win. + event = { + "type": "update/account_all", + "channel": "account_all:237600", + "positions": {}, + "trades": {}, + "available_to_spend": "6.00", # exchange-computed: equity minus position margin + "assets": { + "3": { + "symbol": "USDC", + "asset_id": 3, + "balance": "43.86", # simple spot balance (much larger, must not be used) + "locked_balance": "0.00", # open-order locks only + "margin_balance": "6.61", # perp equity (total) + }, + }, + } + self.connector._process_account_trades_ws_event_message = AsyncMock() + self.connector._process_account_positions_ws_event_message = AsyncMock() + + await self.connector._process_account_all_ws_event_message(event) + + self.assertNotIn("USDC", self.connector._account_balances) + self.assertNotIn("USDC", self.connector._account_available_balances) + + async def test_process_account_all_ws_event_message_margin_balance_no_available_falls_back(self): + # When no exchange-computed available field is present, preserve the last known + # available balance rather than computing total - locked. For PERP, locked_balance + # only covers order margin (not position initial margin), so total - locked gives a + # falsely-high available balance. With no prior balance stored, fall back to total. + event = { + "type": "update/account_all", + "channel": "account_all:237600", + "positions": {}, + "trades": {}, + "assets": { + "3": { + "symbol": "USDC", + "asset_id": 3, + "balance": "43.86", + "locked_balance": "1.50", + "margin_balance": "6.61", + }, + }, + } + self.connector._process_account_trades_ws_event_message = AsyncMock() + self.connector._process_account_positions_ws_event_message = AsyncMock() + + # Case 1: no prior available balance stored — falls back to total (margin_balance). + self.connector._account_available_balances.pop("USDC", None) + await self.connector._process_account_all_ws_event_message(event) + self.assertNotIn("USDC", self.connector._account_balances) + self.assertNotIn("USDC", self.connector._account_available_balances) + + # Case 2: prior available balance exists (e.g. set by user_stats WS) — preserved. + self.connector._account_available_balances["USDC"] = Decimal("4.00") + await self.connector._process_account_all_ws_event_message(event) + self.assertNotIn("USDC", self.connector._account_balances) + self.assertEqual(Decimal("4.00"), self.connector._account_available_balances["USDC"]) + + async def test_process_account_all_ws_event_message_fallback_balance_without_assets(self): + # When an existing available balance is set, it is preserved over WS available_balance + # (which may omit open-order margin). REST poll corrects the value soon after. + event = { + "type": "update/account_all", + "channel": "account_all:237600", + "positions": {}, + "trades": {}, + "collateral": "150.75", + "available_balance": "120.50", + } + self.connector._process_account_trades_ws_event_message = AsyncMock() + self.connector._process_account_positions_ws_event_message = AsyncMock() + # Pre-set an existing available so it can be verified as preserved. + self.connector._account_available_balances["USDC"] = Decimal("85.00") + + await self.connector._process_account_all_ws_event_message(event) + + self.assertNotIn("USDC", self.connector._account_balances) + # Existing (85.00) is preserved; WS available_balance (120.50) is not used when existing is set. + self.assertEqual(Decimal("85.00"), self.connector._account_available_balances["USDC"]) + + async def test_process_account_all_ws_event_message_fallback_uses_available_balance_when_no_existing(self): + # When NO existing available balance is stored (startup before REST succeeds), use the + # WS available_balance as a bootstrap value. REST poll will correct over-reporting. + event = { + "type": "update/account_all", + "channel": "account_all:237600", + "positions": {}, + "trades": {}, + "collateral": "150.75", + "available_balance": "120.50", + } + self.connector._process_account_trades_ws_event_message = AsyncMock() + self.connector._process_account_positions_ws_event_message = AsyncMock() + # No prior available balance — simulates startup before REST poll completes. + self.connector._account_available_balances.pop("USDC", None) + + await self.connector._process_account_all_ws_event_message(event) + + self.assertNotIn("USDC", self.connector._account_balances) + self.assertNotIn("USDC", self.connector._account_available_balances) + + async def test_process_account_all_ws_event_message_assets_uses_available_balance_when_no_existing(self): + # Startup case (no existing available): assets path falls back to available_balance + # from asset entry or event when available_to_spend is absent. + event = { + "type": "update/account_all", + "channel": "account_all:237600", + "positions": {}, + "trades": {}, + "available_balance": "95.00", # top-level fallback + "assets": { + "3": { + "symbol": "USDC", + "margin_balance": "100.50", + "balance": "100.50", + # no available_to_spend, but available_balance in event + }, + }, + } + self.connector._process_account_trades_ws_event_message = AsyncMock() + self.connector._process_account_positions_ws_event_message = AsyncMock() + self.connector._process_account_all_orders_ws_event_message = AsyncMock() + # No prior available balance. + self.connector._account_available_balances.pop("USDC", None) + + await self.connector._process_account_all_ws_event_message(event) + + self.assertNotIn("USDC", self.connector._account_balances) + self.assertNotIn("USDC", self.connector._account_available_balances) + + def test_is_ok_response_returns_false_for_non_dict(self): + # Non-dict responses (e.g. raw HTML error pages from the REST API) must not crash. + # Previously _is_ok_response called response.get(...) which raised AttributeError + # for strings, preventing the fallback error path from running. + self.assertFalse(self.connector._is_ok_response("Not Found")) + self.assertFalse(self.connector._is_ok_response("")) + self.assertFalse(self.connector._is_ok_response(None)) + self.assertFalse(self.connector._is_ok_response(404)) + + async def test_user_stream_event_listener_sleeps_after_processing_error(self): + async def event_iter(): + yield {"channel": "account_info", "data": {"ae": "1", "as": "1"}} + + self.connector._iter_user_event_queue = event_iter + self.connector._process_account_info_ws_event_message = AsyncMock(side_effect=Exception("boom")) + self.connector._sleep = AsyncMock() + + await self.connector._user_stream_event_listener() + + self.connector._sleep.assert_awaited_once_with(5.0) + + async def test_user_stream_event_listener_ignores_unknown_channel(self): + async def event_iter(): + yield {"channel": "unknown_channel", "data": {}} + + self.connector._iter_user_event_queue = event_iter + self.connector._sleep = AsyncMock() + self.connector._process_account_order_updates_ws_event_message = AsyncMock() + self.connector._process_account_positions_ws_event_message = AsyncMock() + self.connector._process_account_info_ws_event_message = AsyncMock() + self.connector._process_account_trades_ws_event_message = AsyncMock() + + await self.connector._user_stream_event_listener() + + self.connector._sleep.assert_not_awaited() + self.connector._process_account_order_updates_ws_event_message.assert_not_awaited() + self.connector._process_account_positions_ws_event_message.assert_not_awaited() + self.connector._process_account_info_ws_event_message.assert_not_awaited() + self.connector._process_account_trades_ws_event_message.assert_not_awaited() + + async def test_place_modify_sends_signed_modify_order(self): + tracked_order = SimpleNamespace(trading_pair="BTC-USDC", exchange_order_id="12345") + signer_client = SimpleNamespace(modify_order=AsyncMock(return_value=(None, SimpleNamespace(code=200), None))) + + self.connector._get_market_spec = AsyncMock(return_value=(45, 3, 2, "BTC")) + self.connector._get_lighter_signer_client = MagicMock(return_value=signer_client) + self.connector._get_api_key_index = MagicMock(return_value=4) + + result = await self.connector._place_modify( + tracked_order=tracked_order, + amount=Decimal("1.234"), + price=Decimal("123.45"), + ) + + self.assertTrue(result) + signer_client.modify_order.assert_awaited_once_with( + market_index=45, + order_index=12345, + base_amount=1234, + price=12345, + ) + + async def test_place_modify_raises_on_signing_error(self): + tracked_order = SimpleNamespace(trading_pair="BTC-USDC", exchange_order_id="12345") + signer_client = SimpleNamespace(modify_order=AsyncMock(return_value=(None, None, "bad signature"))) + + self.connector._get_market_spec = AsyncMock(return_value=(45, 3, 2, "BTC")) + self.connector._get_lighter_signer_client = MagicMock(return_value=signer_client) + self.connector._get_api_key_index = MagicMock(return_value=4) + + with self.assertRaises(IOError) as error_context: + await self.connector._place_modify( + tracked_order=tracked_order, + amount=Decimal("1.000"), + price=Decimal("120.00"), + ) + + self.assertIn("modify_order signing/send failed", str(error_context.exception)) + + async def test_positions_ws_skips_zero_amount(self): + """_process_account_positions_ws_event_message must not store zero-amount ghost positions.""" + self.connector.trading_pair_associated_to_exchange_symbol = AsyncMock(return_value="ETH-USDC") + self.connector.get_LIGHTER_price = MagicMock(return_value=None) + self.connector.get_leverage = MagicMock(return_value=5) + + ws_message = { + "channel": "account_positions", + "data": [ + {"s": "ETH", "d": "bid", "a": "0.00000", "p": "2300.0"}, + ], + } + + self.connector._perpetual_trading.account_positions.clear() + await self.connector._process_account_positions_ws_event_message(ws_message) + + self.assertEqual(0, len(self.connector._perpetual_trading.account_positions), + "Zero-amount position must not be stored") + + async def test_positions_ws_stores_nonzero_amount(self): + """_process_account_positions_ws_event_message stores positions with non-zero amount.""" + self.connector._trading_pairs = ["ETH-USDC"] + self.connector.trading_pair_associated_to_exchange_symbol = AsyncMock(return_value="ETH-USDC") + self.connector.get_LIGHTER_price = MagicMock(return_value=None) + self.connector.get_leverage = MagicMock(return_value=5) + + ws_message = { + "channel": "account_positions", + "data": [ + {"s": "ETH", "d": "bid", "a": "0.05", "p": "2300.0"}, + ], + } + + self.connector._perpetual_trading.account_positions.clear() + await self.connector._process_account_positions_ws_event_message(ws_message) + + self.assertEqual(1, len(self.connector._perpetual_trading.account_positions), + "Non-zero-amount position must be stored") + + async def test_trade_updates_nan_timestamp_not_stored(self): + """NaN current_timestamp must not be written to _order_history_last_poll_timestamp.""" + from hummingbot.core.data_type.in_flight_order import InFlightOrder + + mock_signer = MagicMock() + mock_signer.create_auth_token_with_expiry = MagicMock(return_value=("tok", 9999999999)) + self.connector._get_lighter_signer_client = MagicMock(return_value=mock_signer) + self.connector._get_api_key_index = MagicMock(return_value=4) + self.connector._get_market_spec = AsyncMock(return_value=(1, 3, 2, "ETH")) + self.connector._get_account_index = MagicMock(return_value=42) + self.connector._api_get = AsyncMock(return_value={"success": True, "data": [], "has_more": False}) + self.connector._exchange_order_id_by_client_order_index = {} + + order = InFlightOrder( + client_order_id="HBOT-nan", + exchange_order_id="99999", + trading_pair="ETH-USDC", + order_type=None, + trade_type=None, + price=Decimal("2300"), + amount=Decimal("0.01"), + creation_timestamp=1700000000.0, + ) + + # Simulate clock stopped: current_timestamp is NaN + self.connector._current_timestamp = float("nan") + + await self.connector._all_trade_updates_for_order(order) + + # Should NOT be stored + stored = self.connector._order_history_last_poll_timestamp.get("99999") + self.assertIsNone(stored, "NaN timestamp must not be persisted") + + async def test_trade_updates_nan_last_poll_does_not_crash(self): + """If a NaN is already stored in _order_history_last_poll_timestamp, the next call must not crash.""" + from hummingbot.core.data_type.in_flight_order import InFlightOrder + + mock_signer = MagicMock() + mock_signer.create_auth_token_with_expiry = MagicMock(return_value=("tok", 9999999999)) + self.connector._get_lighter_signer_client = MagicMock(return_value=mock_signer) + self.connector._get_api_key_index = MagicMock(return_value=4) + self.connector._get_market_spec = AsyncMock(return_value=(1, 3, 2, "ETH")) + self.connector._get_account_index = MagicMock(return_value=42) + self.connector._api_get = AsyncMock(return_value={"success": True, "data": [], "has_more": False}) + self.connector._exchange_order_id_by_client_order_index = {} + + # Pre-populate with a NaN value (simulating a previous bad write) + self.connector._order_history_last_poll_timestamp["99999"] = float("nan") + self.connector._current_timestamp = 1700000001.0 + + order = InFlightOrder( + client_order_id="HBOT-nan2", + exchange_order_id="99999", + trading_pair="ETH-USDC", + order_type=None, + trade_type=None, + price=Decimal("2300"), + amount=Decimal("0.01"), + creation_timestamp=1700000000.0, + ) + + # Must not raise ValueError + try: + await self.connector._all_trade_updates_for_order(order) + except ValueError as exc: + self.fail(f"NaN last_poll_timestamp must not crash: {exc}") + + async def test_trade_updates_applies_time_drift_buffer_to_from_param(self): + from hummingbot.core.data_type.in_flight_order import InFlightOrder + + mock_signer = MagicMock() + mock_signer.create_auth_token_with_expiry = MagicMock(return_value=("tok", 9999999999)) + self.connector._get_lighter_signer_client = MagicMock(return_value=mock_signer) + self.connector._get_api_key_index = MagicMock(return_value=4) + self.connector._get_market_spec = AsyncMock(return_value=(1, 3, 2, "ETH")) + self.connector._get_account_index = MagicMock(return_value=42) + self.connector._api_get = AsyncMock(return_value={"success": True, "data": [], "has_more": False}) + + self.connector._order_history_last_poll_timestamp["99999"] = 100.0 + self.connector._current_timestamp = 1700000001.0 + + order = InFlightOrder( + client_order_id="HBOT-buf", + exchange_order_id="99999", + trading_pair="ETH-USDC", + order_type=None, + trade_type=None, + price=Decimal("2300"), + amount=Decimal("0.01"), + creation_timestamp=1700000000.0, + ) + + await self.connector._all_trade_updates_for_order(order) + + api_get_call = self.connector._api_get.call_args + self.assertEqual(90, api_get_call.kwargs["params"]["from"]) + + async def test_place_order_market_buy_uses_best_ask_with_slippage(self): + """MARKET BUY order with NaN price must query best ASK (get_price(True)) and add 5% slippage.""" + from hummingbot.core.data_type.common import TradeType + + signer_client = SimpleNamespace( + ORDER_TYPE_LIMIT=1, + ORDER_TYPE_MARKET=2, + ORDER_TIME_IN_FORCE_GOOD_TILL_TIME=10, + ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL=11, + ORDER_TIME_IN_FORCE_POST_ONLY=12, + DEFAULT_28_DAY_ORDER_EXPIRY=1000, + DEFAULT_IOC_EXPIRY=1001, + create_order=AsyncMock(return_value=(None, SimpleNamespace(code=200), None)), + ) + self.connector._get_market_spec = AsyncMock(return_value=(0, 2, 2, "ETH")) + self.connector._get_lighter_signer_client = MagicMock(return_value=signer_client) + self.connector._get_api_key_index = MagicMock(return_value=1) + + # BUY -> get_price(True) -> best ask = 2000; SELL -> get_price(False) -> best bid = 1990 + prices = {True: 2000.0, False: 1990.0} + mock_order_book = SimpleNamespace(get_price=lambda is_buy: prices[is_buy]) + self.connector.get_order_book = MagicMock(return_value=mock_order_book) + self.connector._current_timestamp = 1700000000.0 + + try: + await self.connector._place_order( + order_id="HBOT-PERM-MKBUY", + trading_pair="ETH-USDC", + amount=Decimal("1"), + trade_type=TradeType.BUY, + order_type=OrderType.MARKET, + price=Decimal("NaN"), + position_action=PositionAction.OPEN, + ) + except AttributeError as exc: + if "current_timestamp" not in str(exc): + raise + + self.assertTrue(signer_client.create_order.called) + call_kwargs = signer_client.create_order.call_args.kwargs + # best_ask=2000, slippage=5%, effective=2100, price_decimals=2 -> price_scaled=210000 + self.assertEqual(210000, call_kwargs["price"]) + self.assertEqual(2, call_kwargs["order_type"]) # ORDER_TYPE_MARKET + + async def test_place_order_market_sell_uses_best_bid_with_slippage(self): + """MARKET SELL order with NaN price must query best BID (get_price(False)) and subtract 5% slippage.""" + from hummingbot.core.data_type.common import TradeType + + signer_client = SimpleNamespace( + ORDER_TYPE_LIMIT=1, + ORDER_TYPE_MARKET=2, + ORDER_TIME_IN_FORCE_GOOD_TILL_TIME=10, + ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL=11, + ORDER_TIME_IN_FORCE_POST_ONLY=12, + DEFAULT_28_DAY_ORDER_EXPIRY=1000, + DEFAULT_IOC_EXPIRY=1001, + create_order=AsyncMock(return_value=(None, SimpleNamespace(code=200), None)), + ) + self.connector._get_market_spec = AsyncMock(return_value=(0, 2, 2, "ETH")) + self.connector._get_lighter_signer_client = MagicMock(return_value=signer_client) + self.connector._get_api_key_index = MagicMock(return_value=1) + + prices = {True: 2000.0, False: 1990.0} + mock_order_book = SimpleNamespace(get_price=lambda is_buy: prices[is_buy]) + self.connector.get_order_book = MagicMock(return_value=mock_order_book) + self.connector._current_timestamp = 1700000000.0 + + try: + await self.connector._place_order( + order_id="HBOT-PERM-MKSELL", + trading_pair="ETH-USDC", + amount=Decimal("1"), + trade_type=TradeType.SELL, + order_type=OrderType.MARKET, + price=Decimal("NaN"), + position_action=PositionAction.CLOSE, + ) + except AttributeError as exc: + if "current_timestamp" not in str(exc): + raise + + self.assertTrue(signer_client.create_order.called) + call_kwargs = signer_client.create_order.call_args.kwargs + # best_bid=1990, slippage=5%, effective=1990*0.95=1890.5, price_decimals=2 -> price_scaled=189050 + self.assertEqual(189050, call_kwargs["price"]) + self.assertEqual(2, call_kwargs["order_type"]) # ORDER_TYPE_MARKET + self.assertTrue(call_kwargs["reduce_only"]) # CLOSE position + + # ------------------------------------------------------------------ # + # Additional branch coverage for missing CI lines # + # ------------------------------------------------------------------ # + + def test_is_request_exception_not_time_synchronizer(self): + """_is_request_exception_related_to_time_synchronizer must always return False.""" + self.assertFalse(self.connector._is_request_exception_related_to_time_synchronizer(Exception("timeout"))) + self.assertFalse(self.connector._is_request_exception_related_to_time_synchronizer(Exception(""))) + + def test_is_order_not_found_status_update_error(self): + """_is_order_not_found_during_status_update_error must detect 'not found' messages.""" + self.assertTrue(self.connector._is_order_not_found_during_status_update_error( + Exception("Order history not found for order ID: 123"))) + self.assertFalse(self.connector._is_order_not_found_during_status_update_error( + Exception("Server error 500"))) + + def test_is_order_not_found_during_cancelation_error(self): + """_is_order_not_found_during_cancelation_error must detect code 5 error strings.""" + self.assertTrue(self.connector._is_order_not_found_during_cancelation_error( + Exception('{"success":false,"data":null,"error":"Failed to cancel order","code":5}'))) + self.assertFalse(self.connector._is_order_not_found_during_cancelation_error( + Exception('{"code":404,"error":"not found"}'))) + + # --------------------------------------------------------------------------- + # Tests for Fix: _request_order_status graceful handling of exchange_order_id="None" + # --------------------------------------------------------------------------- + + async def test_request_order_status_with_none_exchange_order_id_within_grace_returns_open(self): + """When exchange_order_id is 'None' and the order is within the extended grace period, + _request_order_status must return OPEN with exchange_order_id unchanged ('None'). + The active-orders scan is intentionally skipped to avoid mis-assigning a different + order's exchange_order_id (e.g. a same-price orphan from a previous session).""" + import time as _time + self.connector._current_timestamp = 1700000000.0 + mock_order = MagicMock() + mock_order.exchange_order_id = "None" + mock_order.trading_pair = "SOL-USDC" + mock_order.client_order_id = "HBOTSSLUC-orphan" + mock_order.trade_type = TradeType.SELL + mock_order.price = Decimal("83.235") + mock_order.amount = Decimal("0.5") + mock_order.creation_timestamp = _time.time() - 10 # 10 seconds ago — within grace + + self.connector._get_market_spec = AsyncMock(return_value=(1, 3, 3, "SOL")) + # Active-orders snapshot is present but should NOT be used for ID recovery. + self.connector._active_orders_snapshot_by_market = { + 1: [ + { + "order_id": "999001", + "client_order_id": "777001", + "side": "ask", + "price": "83.235", + "initial_amount": "500", + } + ] + } + self.connector._order_tracker._in_flight_orders = {} + + result = await self.connector._request_order_status(mock_order) + + self.assertEqual(OrderState.OPEN, result.new_state) + # exchange_order_id stays 'None' — no active-orders scan is performed + self.assertEqual("None", result.exchange_order_id) + self.assertEqual("HBOTSSLUC-orphan", result.client_order_id) + + async def test_request_order_status_with_none_exchange_order_id_returns_open_within_grace(self): + """When exchange_order_id is 'None', no active-orders match, and the order is young, + _request_order_status must return OPEN (extended grace period).""" + import time as _time + mock_order = MagicMock() + mock_order.exchange_order_id = "None" + mock_order.trading_pair = "SOL-USDC" + mock_order.client_order_id = "HBOTSSLUC-young" + mock_order.trade_type = TradeType.SELL + mock_order.price = Decimal("83.235") + mock_order.amount = Decimal("0.5") + # Order placed 20 seconds ago — well within the 120-second extended grace period + mock_order.creation_timestamp = _time.time() - 20 + + self.connector._get_market_spec = AsyncMock(return_value=(1, 3, 3, "SOL")) + self.connector._active_orders_snapshot_by_market = {1: []} # empty snapshot + self.connector._order_tracker._in_flight_orders = {} + + result = await self.connector._request_order_status(mock_order) + + self.assertEqual(OrderState.OPEN, result.new_state) + self.assertEqual("None", result.exchange_order_id) + + async def test_request_order_status_with_none_exchange_order_id_returns_canceled_after_grace(self): + """When exchange_order_id is 'None', no active-orders match, and the order is old, + _request_order_status must return CANCELED (grace period exceeded).""" + import time as _time + self.connector._current_timestamp = 1700000000.0 + mock_order = MagicMock() + mock_order.exchange_order_id = "None" + mock_order.trading_pair = "SOL-USDC" + mock_order.client_order_id = "HBOTSSLUC-stale" + mock_order.trade_type = TradeType.SELL + mock_order.price = Decimal("83.235") + mock_order.amount = Decimal("0.5") + # Order placed 400 seconds ago — beyond the current 300-second extended grace period + mock_order.creation_timestamp = _time.time() - 400 + + self.connector._get_market_spec = AsyncMock(return_value=(1, 3, 3, "SOL")) + self.connector._active_orders_snapshot_by_market = {1: []} + self.connector._order_tracker._in_flight_orders = {} + + result = await self.connector._request_order_status(mock_order) + + self.assertEqual(OrderState.CANCELED, result.new_state) + + # --------------------------------------------------------------------------- + # Tests for orphan cleanup disabled behavior + # --------------------------------------------------------------------------- + + async def test_cleanup_runtime_orphan_orders_is_noop(self): + """Runtime orphan cleanup must never cancel exchange orders (manual or otherwise).""" + signer_mock = MagicMock() + signer_mock.cancel_order = AsyncMock(return_value=(None, MagicMock(code=200), None)) + self.connector._get_lighter_signer_client = MagicMock(return_value=signer_mock) + + await self.connector._cleanup_runtime_orphan_orders() + + signer_mock.cancel_order.assert_not_called() + + async def test_cleanup_startup_orphan_reduce_only_orders_is_noop(self): + """Startup orphan cleanup is intentionally disabled to avoid canceling manual orders.""" + signer_mock = MagicMock() + signer_mock.cancel_order = AsyncMock(return_value=(None, MagicMock(code=200), None)) + self.connector._get_lighter_signer_client = MagicMock(return_value=signer_mock) + + await self.connector._cleanup_startup_orphan_reduce_only_orders() + + signer_mock.cancel_order.assert_not_called() + + async def test_status_polling_does_not_run_orphan_cleanup_paths(self): + self.connector._begin_status_poll_cycle = MagicMock() + self.connector._end_status_poll_cycle = MagicMock() + self.connector._fetch_account_snapshot_data = AsyncMock(return_value=None) + self.connector._update_positions = AsyncMock() + self.connector._update_balances = AsyncMock() + self.connector._prime_active_orders_snapshot_cache_for_poll_cycle = AsyncMock() + self.connector._cleanup_startup_orphan_reduce_only_orders = AsyncMock() + self.connector._cleanup_runtime_orphan_orders = AsyncMock() + self.connector._update_order_status = AsyncMock() + + await self.connector._status_polling_loop_fetch_updates() + + self.connector._cleanup_startup_orphan_reduce_only_orders.assert_not_called() + self.connector._cleanup_runtime_orphan_orders.assert_not_called() + + def test_get_price_by_type_all_price_types(self): + """get_price_by_type must handle MidPrice, LastTrade, and unknown types (covers lines 211-222).""" + if not hasattr(self.connector, "get_price_by_type"): + self.skipTest("get_price_by_type not available in this runtime") + + order_book_module = __import__("hummingbot.core.data_type.order_book", fromlist=["OrderBook"]) + OrderBook = getattr(order_book_module, "OrderBook") + empty_order_book = OrderBook() + self.connector.get_order_book = MagicMock(return_value=empty_order_book) + + # MidPrice with empty order book → NaN (both sides NaN) + mid = self.connector.get_price_by_type("BTC-USDC", PriceType.MidPrice) + self.assertTrue(mid.is_nan()) + + # LastTrade with no trades → NaN + last = self.connector.get_price_by_type("BTC-USDC", PriceType.LastTrade) + self.assertTrue(last.is_nan()) + + def test_rate_limits_rules_without_api_key_returns_base_limits(self): + """rate_limits_rules without api_key must return base RATE_LIMITS (covers line 406).""" + from hummingbot.connector.derivative.lighter_perpetual import lighter_perpetual_constants as CONSTANTS + self.connector.api_key = "" + rules = self.connector.rate_limits_rules + self.assertEqual(CONSTANTS.RATE_LIMITS, rules) + + async def test_format_trading_rules_with_data_fallback_path(self): + """_format_trading_rules must handle legacy 'data' key format (covers lines 732-753).""" + self.connector.trading_pair_associated_to_exchange_symbol = AsyncMock(return_value="BTC-USDC") + exchange_info = { + "data": [ + { + "symbol": "BTC", + "lot_size": "0.001", + "tick_size": "0.01", + "min_order_size": "10", + }, + ] + } + rules = await self.connector._format_trading_rules(exchange_info) + self.assertEqual(1, len(rules)) + self.assertEqual(Decimal("0.001"), rules[0].min_order_size) + self.assertEqual(Decimal("0.01"), rules[0].min_price_increment) + self.assertEqual(Decimal("10"), rules[0].min_notional_size) + + async def test_format_trading_rules_skips_non_perp_markets(self): + """_format_trading_rules must skip non-perp order_books entries (covers lines 704-708).""" + self.connector.trading_pair_associated_to_exchange_symbol = AsyncMock(return_value="ETH-USDC") + exchange_info = { + "order_books": [ + {"symbol": "BTC", "market_type": "spot", "market_id": 1, + "supported_size_decimals": 3, "supported_price_decimals": 2, "min_quote_amount": "10"}, + {"symbol": "ETH", "market_type": "perp", "market_id": 2, + "supported_size_decimals": 2, "supported_price_decimals": 2, "min_quote_amount": "5"}, + ] + } + rules = await self.connector._format_trading_rules(exchange_info) + # Only the perp market should produce a rule + self.assertEqual(1, len(rules)) + + # --------------------------------------------------------------------------- + # Coverage boost: _index_client_to_order_mapping_from_rows + # --------------------------------------------------------------------------- + + def test_index_client_to_order_mapping_skips_rows_with_empty_oid_or_cid(self): + """Rows without both oid and cid must be skipped; valid rows must be stored.""" + self.connector._client_order_index_to_order_index.clear() + rows = [ + {"order_id": "42", "client_order_id": ""}, # empty cid → skip + {"order_id": "", "client_order_id": "cid0"}, # empty oid → skip + {}, # both empty → skip + {"order_id": "43", "client_order_id": "cid1"}, # valid → store + ] + self.connector._index_client_to_order_mapping_from_rows(rows) + self.assertEqual({"cid1": "43"}, self.connector._client_order_index_to_order_index) + + # --------------------------------------------------------------------------- + # Coverage boost: _refresh_account_state + # --------------------------------------------------------------------------- + + async def test_refresh_account_state_calls_updates_when_requested(self): + """_refresh_account_state must call _update_positions and _update_balances when flags are True.""" + with patch.object(self.connector, "_update_positions", new=AsyncMock()) as mock_pos, \ + patch.object(self.connector, "_update_balances", new=AsyncMock()) as mock_bal: + await self.connector._refresh_account_state("test", refresh_positions=True, refresh_balances=True) + mock_pos.assert_called_once() + mock_bal.assert_called_once() + + async def test_refresh_account_state_swallows_exceptions(self): + """_refresh_account_state must not propagate exceptions from sub-calls.""" + with patch.object(self.connector, "_update_positions", new=AsyncMock(side_effect=IOError("fail"))), \ + patch.object(self.connector, "_update_balances", new=AsyncMock(side_effect=IOError("fail2"))): + # Should not raise + await self.connector._refresh_account_state("err", refresh_positions=True, refresh_balances=True) + + # --------------------------------------------------------------------------- + # Coverage boost: _reconcile_unmatched_private_event + # --------------------------------------------------------------------------- + + async def test_reconcile_unmatched_private_event_respects_cooldown(self): + """Second call within 2 s must be a no-op.""" + import time + self.connector._last_unmatched_private_event_reconcile_ts = time.time() + with patch.object(self.connector, "_update_order_status", new=AsyncMock()) as mock_s, \ + patch.object(self.connector, "_update_positions", new=AsyncMock()) as mock_p, \ + patch.object(self.connector, "_update_balances", new=AsyncMock()) as mock_b: + await self.connector._reconcile_unmatched_private_event("test") + mock_s.assert_not_called() + mock_p.assert_not_called() + mock_b.assert_not_called() + + async def test_reconcile_unmatched_private_event_runs_after_cooldown_expires(self): + """Call after cooldown must invoke update methods.""" + self.connector._last_unmatched_private_event_reconcile_ts = 0.0 + with patch.object(self.connector, "_update_order_status", new=AsyncMock()) as mock_s, \ + patch.object(self.connector, "_update_positions", new=AsyncMock()) as mock_p, \ + patch.object(self.connector, "_update_balances", new=AsyncMock()) as mock_b: + await self.connector._reconcile_unmatched_private_event("old") + mock_s.assert_called_once() + mock_p.assert_called_once() + mock_b.assert_called_once() + + # --------------------------------------------------------------------------- + # Coverage boost: _prime_active_orders_snapshot_cache_for_poll_cycle + # --------------------------------------------------------------------------- + + async def test_prime_active_orders_snapshot_skips_when_cycle_not_active(self): + """Must return immediately when _status_poll_cycle_active is False.""" + self.connector._status_poll_cycle_active = False + self.connector._get_market_spec = AsyncMock() + await self.connector._prime_active_orders_snapshot_cache_for_poll_cycle() + self.connector._get_market_spec.assert_not_called() + + async def test_prime_active_orders_snapshot_stores_rows_and_updates_mapping(self): + """Active cycle must fetch rows, store them, and build the order-index mapping.""" + self.connector._status_poll_cycle_active = True + rows = [{"order_id": "55", "client_order_id": "cid55"}] + self.connector._get_market_spec = AsyncMock(return_value=(7, 3, 2, "BTC")) + self.connector._fetch_active_orders_rows_for_market = AsyncMock(return_value=rows) + self.connector._client_order_index_to_order_index.clear() + await self.connector._prime_active_orders_snapshot_cache_for_poll_cycle() + self.assertIn(7, self.connector._active_orders_snapshot_by_market) + self.assertEqual(rows, self.connector._active_orders_snapshot_by_market[7]) + self.assertIn(7, self.connector._active_orders_snapshot_market_complete) + self.assertEqual("55", self.connector._client_order_index_to_order_index.get("cid55")) + + async def test_prime_active_orders_snapshot_skips_already_fetched_market(self): + """Market already in _active_orders_snapshot_market_complete must not be fetched again.""" + self.connector._status_poll_cycle_active = True + self.connector._active_orders_snapshot_market_complete.add(7) + self.connector._get_market_spec = AsyncMock(return_value=(7, 3, 2, "BTC")) + self.connector._fetch_active_orders_rows_for_market = AsyncMock() + await self.connector._prime_active_orders_snapshot_cache_for_poll_cycle() + self.connector._fetch_active_orders_rows_for_market.assert_not_called() + + async def test_prime_active_orders_snapshot_logs_warning_on_exception(self): + """Exceptions must be caught and logged; must not propagate.""" + self.connector._status_poll_cycle_active = True + self.connector._get_market_spec = AsyncMock(side_effect=RuntimeError("network error")) + # Should not raise + await self.connector._prime_active_orders_snapshot_cache_for_poll_cycle() + + # --------------------------------------------------------------------------- + # Coverage boost: _status_polling_loop_fetch_updates + # --------------------------------------------------------------------------- + + async def test_status_polling_loop_with_account_data_applies_balances(self): + """When account snapshot succeeds the balances and positions must be applied.""" + account_data = {"account_equity": "1000", "available_to_spend": "800"} + with patch.object(self.connector, "_fetch_account_snapshot_data", new=AsyncMock(return_value=account_data)), \ + patch.object(self.connector, "_apply_balances_from_account_data") as mock_apply, \ + patch.object(self.connector, "_update_positions", new=AsyncMock()) as mock_pos, \ + patch.object(self.connector, "_prime_active_orders_snapshot_cache_for_poll_cycle", new=AsyncMock()), \ + patch.object(self.connector, "_update_order_status", new=AsyncMock()) as mock_status: + await self.connector._status_polling_loop_fetch_updates() + mock_apply.assert_called_once_with(account_data=account_data) + mock_pos.assert_called_once() + mock_status.assert_called_once() + # Poll cycle must be ended after completion + self.assertFalse(self.connector._status_poll_cycle_active) + + async def test_status_polling_loop_falls_back_when_snapshot_fails(self): + """When account snapshot raises, balances/positions must be fetched independently.""" + with patch.object(self.connector, "_fetch_account_snapshot_data", new=AsyncMock(side_effect=IOError("fail"))), \ + patch.object(self.connector, "_update_positions", new=AsyncMock()) as mock_pos, \ + patch.object(self.connector, "_update_balances", new=AsyncMock()) as mock_bal, \ + patch.object(self.connector, "_prime_active_orders_snapshot_cache_for_poll_cycle", new=AsyncMock()), \ + patch.object(self.connector, "_update_order_status", new=AsyncMock()): + await self.connector._status_polling_loop_fetch_updates() + mock_pos.assert_called() + mock_bal.assert_called() + self.assertFalse(self.connector._status_poll_cycle_active) + + # --------------------------------------------------------------------------- + # Coverage boost: _update_orders (rescue fill on FILLED order) + # --------------------------------------------------------------------------- + + async def test_update_orders_rescue_fill_on_newly_filled_order(self): + """_update_orders must call _all_trade_updates_for_order when order is FILLED with no fills yet.""" + from hummingbot.core.data_type.in_flight_order import TradeUpdate + + mock_order = MagicMock() + mock_order.client_order_id = "test-order-1" + mock_order.is_done = False + mock_order.executed_amount_base = Decimal("0") + mock_order.amount = Decimal("1") + + filled_update = OrderUpdate( + trading_pair="BTC-USDC", + update_timestamp=1234567890.0, + new_state=OrderState.FILLED, + client_order_id="test-order-1", + exchange_order_id="42", + ) + fill_update = MagicMock(spec=TradeUpdate) + + mock_tracker = MagicMock() + mock_tracker.active_orders = {"test-order-1": mock_order} + self.connector._order_tracker = mock_tracker + self.connector._is_user_stream_initialized = MagicMock(return_value=False) + self.connector._request_order_status = AsyncMock(return_value=filled_update) + self.connector._all_trade_updates_for_order = AsyncMock(return_value=[fill_update]) + + await self.connector._update_orders() + + self.connector._all_trade_updates_for_order.assert_called_once_with(mock_order) + mock_tracker.process_trade_update.assert_called_once_with(fill_update) + mock_tracker.process_order_update.assert_called_once_with(filled_update) + + async def test_update_orders_rescue_fill_handles_exception(self): + """Exception in _all_trade_updates_for_order must be swallowed.""" + mock_order = MagicMock() + mock_order.client_order_id = "test-order-2" + mock_order.is_done = False + mock_order.executed_amount_base = Decimal("0") + mock_order.amount = Decimal("1") + + filled_update = OrderUpdate( + trading_pair="BTC-USDC", + update_timestamp=1234567890.0, + new_state=OrderState.FILLED, + client_order_id="test-order-2", + exchange_order_id="43", + ) + + mock_tracker = MagicMock() + mock_tracker.active_orders = {"test-order-2": mock_order} + self.connector._order_tracker = mock_tracker + self.connector._is_user_stream_initialized = MagicMock(return_value=False) + self.connector._request_order_status = AsyncMock(return_value=filled_update) + self.connector._all_trade_updates_for_order = AsyncMock(side_effect=IOError("trades api down")) + + await self.connector._update_orders() + + mock_tracker.process_order_update.assert_called_once_with(filled_update) + + async def test_update_orders_raises_on_cancelled_error(self): + """asyncio.CancelledError from _request_order_status must propagate.""" + mock_order = MagicMock() + mock_order.client_order_id = "test-order-3" + + mock_tracker = MagicMock() + mock_tracker.active_orders = {"test-order-3": mock_order} + self.connector._order_tracker = mock_tracker + self.connector._is_user_stream_initialized = MagicMock(return_value=False) + self.connector._request_order_status = AsyncMock(side_effect=asyncio.CancelledError()) + + with self.assertRaises(asyncio.CancelledError): + await self.connector._update_orders() + + async def test_update_orders_handles_general_exception_via_error_handler(self): + """Non-CancelledError exceptions must be passed to _handle_update_error_for_active_order.""" + mock_order = MagicMock() + mock_order.client_order_id = "test-order-4" + + mock_tracker = MagicMock() + mock_tracker.active_orders = {"test-order-4": mock_order} + self.connector._order_tracker = mock_tracker + self.connector._is_user_stream_initialized = MagicMock(return_value=False) + self.connector._request_order_status = AsyncMock(side_effect=RuntimeError("boom")) + self.connector._handle_update_error_for_active_order = AsyncMock() + + await self.connector._update_orders() + + self.connector._handle_update_error_for_active_order.assert_called_once() + + # --------------------------------------------------------------------------- + # Coverage boost: _request_order_status + # --------------------------------------------------------------------------- + + async def test_request_order_status_returns_open_when_active_order_found(self): + """When active order lookup succeeds, state must be OPEN with real exchange_order_id.""" + mock_order = MagicMock() + mock_order.exchange_order_id = "client-idx-999" + mock_order.trading_pair = "BTC-USDC" + mock_order.client_order_id = "cid-1" + + self.connector._client_order_index_to_order_index = {} + self.connector._get_market_spec = AsyncMock(return_value=(7, 3, 2, "BTC")) + self.connector._resolve_order_index_from_active_orders = AsyncMock(return_value="12345") + self.connector._current_timestamp = 1234567890.0 + + result = await self.connector._request_order_status(mock_order) + + self.assertEqual(OrderState.OPEN, result.new_state) + self.assertEqual("12345", result.exchange_order_id) + self.assertEqual("cid-1", result.client_order_id) + + async def test_request_order_status_returns_canceled_when_not_in_history(self): + """When order is not active and not found in history, state must be CANCELED.""" + mock_order = MagicMock() + mock_order.exchange_order_id = "client-idx-999" + mock_order.trading_pair = "BTC-USDC" + mock_order.client_order_id = "cid-missing" + mock_order.creation_timestamp = 0 # old order — outside the grace period + + self.connector._client_order_index_to_order_index = {} + self.connector._get_market_spec = AsyncMock(return_value=(7, 3, 2, "BTC")) + self.connector._resolve_order_index_from_active_orders = AsyncMock(return_value=None) + self.connector._current_timestamp = 1234567890.0 + + signer_mock = MagicMock() + signer_mock.create_auth_token_with_expiry = MagicMock(return_value=("tok", None)) + self.connector._get_lighter_signer_client = MagicMock(return_value=signer_mock) + self.connector._get_account_index = MagicMock(return_value=237600) + self.connector._get_api_key_index = MagicMock(return_value=1) + # Return history with a different order → not matched + self.connector._api_get = AsyncMock(return_value={ + "data": [{"order_id": "100", "client_order_id": "other-cid", "order_status": "cancelled"}] + }) + + result = await self.connector._request_order_status(mock_order) + + self.assertEqual(OrderState.CANCELED, result.new_state) + + async def test_request_order_status_returns_status_from_history_when_found(self): + """When order found in history, state must match the raw order_status field.""" + mock_order = MagicMock() + mock_order.exchange_order_id = "100" + mock_order.trading_pair = "BTC-USDC" + mock_order.client_order_id = "cid-found" + + self.connector._client_order_index_to_order_index = {"100": "100"} + self.connector._get_market_spec = AsyncMock(return_value=(7, 3, 2, "BTC")) + self.connector._resolve_order_index_from_active_orders = AsyncMock(return_value=None) + self.connector._current_timestamp = 1234567890.0 + + signer_mock = MagicMock() + signer_mock.create_auth_token_with_expiry = MagicMock(return_value=("tok", None)) + self.connector._get_lighter_signer_client = MagicMock(return_value=signer_mock) + self.connector._get_account_index = MagicMock(return_value=237600) + self.connector._get_api_key_index = MagicMock(return_value=1) + self.connector._api_get = AsyncMock(return_value={ + "data": [{"order_id": "100", "client_order_id": "cid-found", "order_status": "cancelled"}] + }) + + result = await self.connector._request_order_status(mock_order) + + self.assertEqual(OrderState.CANCELED, result.new_state) + self.assertEqual("100", result.exchange_order_id) + + async def test_request_order_status_raises_when_history_returns_empty_data(self): + """When history response has no data, IOError must be raised.""" + mock_order = MagicMock() + mock_order.exchange_order_id = "999" + mock_order.trading_pair = "BTC-USDC" + mock_order.client_order_id = "cid-empty" + + self.connector._client_order_index_to_order_index = {} + self.connector._get_market_spec = AsyncMock(return_value=(7, 3, 2, "BTC")) + self.connector._resolve_order_index_from_active_orders = AsyncMock(return_value=None) + self.connector._current_timestamp = 1234567890.0 + + signer_mock = MagicMock() + signer_mock.create_auth_token_with_expiry = MagicMock(return_value=("tok", None)) + self.connector._get_lighter_signer_client = MagicMock(return_value=signer_mock) + self.connector._get_account_index = MagicMock(return_value=237600) + self.connector._get_api_key_index = MagicMock(return_value=1) + self.connector._api_get = AsyncMock(return_value={"data": []}) + + with self.assertRaises(IOError): + await self.connector._request_order_status(mock_order) + + # ----------------------------------------------------------------------- + # Coverage boost batch: static helpers, balance helpers, order-book price + # ----------------------------------------------------------------------- + + def test_is_hex_private_key_variants(self): + cls = self.connector_cls + self.assertTrue(cls._is_hex_private_key("0x" + "a" * 64)) + self.assertTrue(cls._is_hex_private_key("b" * 64)) + self.assertFalse(cls._is_hex_private_key("")) + self.assertFalse(cls._is_hex_private_key("0xshort")) + + def test_get_signer_private_key_from_api_key(self): + c = self.connector_cls.__new__(self.connector_cls) + c.api_key = "0x" + "a" * 64 + c.api_secret = "" + self.assertEqual("0x" + "a" * 64, c._get_signer_private_key()) + + def test_get_signer_private_key_from_api_secret(self): + c = self.connector_cls.__new__(self.connector_cls) + c.api_key = "not-hex" + c.api_secret = "0x" + "b" * 64 + self.assertEqual("0x" + "b" * 64, c._get_signer_private_key()) + + def test_get_signer_private_key_raises_when_missing_cls(self): + c = self.connector_cls.__new__(self.connector_cls) + c.api_key = "1" + c.api_secret = "2" + with self.assertRaises(ValueError): + c._get_signer_private_key() + + def test_get_api_key_index_from_api_key_index_field(self): + c = self.connector_cls.__new__(self.connector_cls) + c.api_key_index = "5" + c.api_key = "not-int" + c.api_secret = "not-int" + self.assertEqual(5, c._get_api_key_index()) + + def test_get_api_key_index_from_api_key(self): + c = self.connector_cls.__new__(self.connector_cls) + c.api_key_index = "" + c.api_key = "7" + c.api_secret = "not-int" + self.assertEqual(7, c._get_api_key_index()) + + def test_get_api_key_index_raises_when_not_int(self): + c = self.connector_cls.__new__(self.connector_cls) + c.api_key_index = "" + c.api_key = "not-int" + c.api_secret = "not-int" + with self.assertRaises(ValueError): + c._get_api_key_index() + + def test_get_rest_api_key_branches(self): + c = self.connector_cls.__new__(self.connector_cls) + c.api_key = "7" + c.api_secret = "secret" + self.assertEqual("7", c._get_rest_api_key()) + + c.api_key = "non-int" + self.assertEqual("secret", c._get_rest_api_key()) + + c.api_secret = "" + self.assertEqual("non-int", c._get_rest_api_key()) + + def test_api_host_for_signer_mainnet_vs_testnet(self): + c = self.connector_cls.__new__(self.connector_cls) + import hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_constants as PCONST + c._domain = PCONST.DEFAULT_DOMAIN + mainnet = c._api_host_for_signer() + self.assertNotIn("/api/v1", mainnet) + self.assertTrue(mainnet.startswith("https://")) + + c._domain = "lighter_perpetual_testnet" + testnet = c._api_host_for_signer() + self.assertNotIn("/api/v1", testnet) + self.assertTrue(testnet.startswith("https://")) + + def test_first_not_none_returns_first_non_none(self): + cls = self.connector_cls + self.assertEqual("x", cls._first_not_none(None, None, "x", "y")) + self.assertIsNone(cls._first_not_none(None, None)) + + def test_account_from_response_branches(self): + cls = self.connector_cls + self.assertEqual({"id": 1}, cls._account_from_response({"data": {"id": 1}})) + self.assertEqual({"id": 2}, cls._account_from_response({"data": [{"id": 2}]})) + self.assertEqual({"id": 3}, cls._account_from_response({"accounts": [{"id": 3}]})) + self.assertEqual({"available_balance": "10"}, cls._account_from_response({"available_balance": "10"})) + self.assertEqual({"collateral": "10"}, cls._account_from_response({"collateral": "10"})) + self.assertIsNone(cls._account_from_response({})) + + def test_client_order_index_from_order_id_deterministic(self): + cls = self.connector_cls + idx1 = cls._client_order_index_from_order_id("HBOT-1") + idx2 = cls._client_order_index_from_order_id("HBOT-1") + idx3 = cls._client_order_index_from_order_id("HBOT-2") + self.assertEqual(idx1, idx2) + self.assertNotEqual(idx1, idx3) + self.assertGreater(idx1, 0) + + def test_account_query_params_format(self): + self.connector._account_index = "237600" + params = self.connector._account_query_params() + self.assertEqual("237600", params["value"]) + self.assertEqual("index", params["by"]) + self.assertEqual("true", params["active_only"]) + + def test_is_ok_response_checks_code_and_success(self): + c = self.connector + self.assertTrue(c._is_ok_response({"success": True})) + self.assertTrue(c._is_ok_response({"code": 200})) + self.assertFalse(c._is_ok_response({"code": 400})) + self.assertFalse(c._is_ok_response({"success": False})) + self.assertFalse(c._is_ok_response({})) + + def test_is_ok_response_extra_cases(self): + c = self.connector + # code 0 means success on Lighter API + self.assertTrue(c._is_ok_response({"code": 0})) + # code 200 is also accepted + self.assertTrue(c._is_ok_response({"code": 200})) + # non-dict is never success + self.assertFalse(c._is_ok_response("error-string")) + # empty dict has no success/code + self.assertFalse(c._is_ok_response({})) + + def test_should_emit_throttled_warning_gate(self): + c = self.connector + timestamps: dict = {} + c.EMPTY_MARKET_DATA_WARNING_INTERVAL = 30.0 + with patch("time.time", return_value=1000.0): + self.assertTrue(c._should_emit_throttled_warning("test-key", timestamps)) + with patch("time.time", return_value=1010.0): + self.assertFalse(c._should_emit_throttled_warning("test-key", timestamps)) + with patch("time.time", return_value=1040.0): + self.assertTrue(c._should_emit_throttled_warning("test-key", timestamps)) + + def test_set_usdc_balances_and_get_available_balance(self): + c = self.connector + c._account_balances = {} + c._account_available_balances = {} + c._set_usdc_balances(total_balance=Decimal("100"), available_balance=Decimal("80")) + self.assertEqual(Decimal("100"), c._account_balances["USDC"]) + self.assertEqual(Decimal("80"), c._account_available_balances["USDC"]) + self.assertEqual(Decimal("80"), c.get_available_balance("USDC")) + + def test_get_available_balance_caps_at_total(self): + c = self.connector + c._account_balances = {"USDC": Decimal("50")} + c._account_available_balances = {"USDC": Decimal("80")} + self.assertEqual(Decimal("50"), c.get_available_balance("USDC")) + + def test_schedule_fast_balance_sync_throttled(self): + c = self.connector + c._trading_required = True + c._last_balance_update_timestamp = 0.0 + fired = [] + with patch("hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative.safe_ensure_future") as mock_future: + mock_future.side_effect = lambda coro: fired.append(coro) or None + with patch("time.time", return_value=100.0): + c._schedule_fast_balance_sync(min_interval_seconds=5.0) + self.assertEqual(1, len(fired)) + # Second call within interval must be suppressed + with patch("time.time", return_value=103.0): + c._schedule_fast_balance_sync(min_interval_seconds=5.0) + self.assertEqual(1, len(fired)) + + def test_schedule_fast_balance_sync_no_op_when_not_trading_required(self): + c = self.connector + c._trading_required = False + with patch("hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative.safe_ensure_future") as mock_future: + c._schedule_fast_balance_sync() + mock_future.assert_not_called() + + async def test_get_market_spec_raises_when_symbol_not_found(self): + c = self.connector + c._market_id_by_symbol = {} + c._size_decimals_by_symbol = {} + c._price_decimals_by_symbol = {} + c.exchange_symbol_associated_to_pair = AsyncMock(return_value="MISSING/USDC") + c._api_get = AsyncMock(return_value={"order_books": []}) + with self.assertRaises(ValueError): + await c._get_market_spec("ETH-USDC") + + async def test_refresh_market_metadata_skips_non_perp(self): + c = self.connector + c._market_id_by_symbol = {} + c._size_decimals_by_symbol = {} + c._price_decimals_by_symbol = {} + c._api_get = AsyncMock(return_value={"order_books": [ + {"symbol": "ETH/USDC", "market_type": "spot", "market_id": 1, "supported_size_decimals": 4, "supported_price_decimals": 2}, + {"symbol": "BTC/USDC", "market_type": "perp", "market_id": 2, "supported_size_decimals": 3, "supported_price_decimals": 1}, + ]}) + await c._refresh_market_metadata() + self.assertNotIn("ETH/USDC", c._market_id_by_symbol) + self.assertIn("BTC/USDC", c._market_id_by_symbol) + self.assertEqual(2, c._market_id_by_symbol["BTC/USDC"]) + + async def test_apply_balances_from_account_data_primary_path(self): + c = self.connector + c._account_balances = {} + c._account_available_balances = {} + c._fee_tier = 0 + await c._apply_balances_from_account_data({ + "collateral": "500", + "available_balance": "450", + "fee_level": 3, + }) + self.assertEqual(Decimal("500"), c._account_balances["USDC"]) + self.assertEqual(Decimal("450"), c._account_available_balances["USDC"]) + self.assertEqual(3, c._fee_tier) + + async def test_apply_balances_from_account_data_cross_margin_fallback(self): + c = self.connector + c._account_balances = {} + c._account_available_balances = {} + c._fee_tier = 0 + await c._apply_balances_from_account_data({ + "collateral": "500", + "cross_asset_value": "400", + "cross_initial_margin_requirement": "50", + }) + self.assertEqual(Decimal("500"), c._account_balances["USDC"]) + self.assertEqual(Decimal("350"), c._account_available_balances["USDC"]) + + async def test_apply_balances_from_account_data_skips_when_no_available(self): + c = self.connector + c._account_balances = {"USDC": Decimal("100")} + c._account_available_balances = {"USDC": Decimal("100")} + c.logger = lambda: MagicMock() + await c._apply_balances_from_account_data({"collateral": "500"}) + # Should not update if neither available_balance nor cross fields present + self.assertEqual(Decimal("100"), c._account_balances["USDC"]) + + async def test_apply_balances_assets_usdc_margin_balance_fallback(self): + c = self.connector + c._account_balances = {} + c._account_available_balances = {} + c._fee_tier = 0 + await c._apply_balances_from_account_data({ + "assets": [{"symbol": "USDC", "margin_balance": "300"}], + "available_balance": "250", + }) + self.assertEqual(Decimal("300"), c._account_balances["USDC"]) + self.assertEqual(Decimal("250"), c._account_available_balances["USDC"]) + + def test_index_client_to_order_mapping_from_rows(self): + c = self.connector + c._client_order_index_to_order_index = {} + rows = [ + {"order_id": "100", "client_order_index": "42"}, + {"order_index": "200", "client_order_id": "43"}, + {"i": "300", "I": "44"}, + ] + c._index_client_to_order_mapping_from_rows(rows) + self.assertEqual("100", c._client_order_index_to_order_index["42"]) + self.assertEqual("200", c._client_order_index_to_order_index["43"]) + self.assertEqual("300", c._client_order_index_to_order_index["44"]) + + async def test_cleanup_startup_orphan_reduce_only_orders_is_noop_minimal(self): + await self.connector._cleanup_startup_orphan_reduce_only_orders() + + async def test_cleanup_runtime_orphan_orders_is_noop_minimal(self): + await self.connector._cleanup_runtime_orphan_orders() + + def test_is_order_not_found_cancelation_error(self): + c = self.connector + self.assertTrue(c._is_order_not_found_during_cancelation_error(Exception('"code":5'))) + self.assertTrue(c._is_order_not_found_during_cancelation_error(Exception("may already be filled"))) + self.assertFalse(c._is_order_not_found_during_cancelation_error(Exception("timeout"))) + + def test_is_order_not_found_status_update_error_minimal(self): + c = self.connector + self.assertTrue(c._is_order_not_found_during_status_update_error(Exception("order not found on chain"))) + self.assertFalse(c._is_order_not_found_during_status_update_error(Exception("random error"))) + + def test_get_top_order_book_price_no_book(self): + c = self.connector + c._last_empty_order_book_warning_timestamp = {} + c.get_order_book = MagicMock(side_effect=Exception("no book")) + with patch.object(c, "_should_emit_throttled_warning", return_value=False): + price = c._get_top_order_book_price("BTC-USDC", is_buy=True) + import math + self.assertTrue(math.isnan(float(price))) + + def test_is_balance_info_fresh_and_position_info_fresh(self): + c = self.connector + c._last_balance_update_timestamp = 1.0 # any non-zero value + self.assertTrue(c._is_balance_info_fresh()) + c._last_balance_update_timestamp = 0.0 # zero means never fetched + self.assertFalse(c._is_balance_info_fresh()) + # position freshness: not trading_required -> always True + c._trading_required = False + self.assertTrue(c._is_position_info_fresh()) + + async def test_all_trading_pairs_filters_by_perp(self): + c = self.connector + c._api_get = AsyncMock(return_value={"order_books": [ + {"symbol": "ETH/USDC", "market_type": "spot", "status": "active"}, + {"symbol": "BTC/USDC", "market_type": "perp", "status": "active"}, + {"symbol": "SOL/USDC", "market_type": "perp", "status": "inactive"}, + ]}) + result = await c.all_trading_pairs() + self.assertEqual(1, len(result)) + # ETH spot should be excluded; SOL inactive should be excluded + self.assertFalse(any("SOL" in p for p in result)) + self.assertFalse(any("ETH" in p for p in result)) + + async def test_all_trading_pairs_handles_exception(self): + c = self.connector + c._api_get = AsyncMock(side_effect=Exception("network error")) + result = await c.all_trading_pairs() + self.assertEqual([], result) + + # ----------------------------------------------------------------------- + # Coverage boost batch 2: order failure, trading rules, cancel flows + # ----------------------------------------------------------------------- + + def test_is_expected_order_rejection_patterns(self): + cls = self.connector_cls + self.assertTrue(cls._is_expected_order_rejection("minimum notional")) + self.assertTrue(cls._is_expected_order_rejection("minimum lot size")) + self.assertTrue(cls._is_expected_order_rejection("invalid order base or quote amount")) + self.assertFalse(cls._is_expected_order_rejection("server timeout")) + + def test_on_order_failure_expected_rejection(self): + c = self.connector + c._update_order_after_failure = MagicMock() + c._trading_rules = {} + c._last_sub_minimum_position_warning_ts = {} + c.logger = lambda: MagicMock() + c._on_order_failure( + order_id="HBOT-rej", + trading_pair="BTC-USDC", + amount=Decimal("0.001"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT, + price=Decimal("10"), + exception=Exception("Order notional below the minimum notional"), + ) + c._update_order_after_failure.assert_called_once() + + def test_is_sub_minimum_position_notional_no_rule(self): + c = self.connector + c._trading_rules = {} + self.assertFalse(c._is_sub_minimum_position_notional("BTC-USDC", Decimal("0.001"), Decimal("30000"))) + + def test_is_sub_minimum_position_notional_below_min(self): + c = self.connector + rule = TradingRule( + trading_pair="BTC-USDC", + min_order_size=Decimal("0.00001"), + min_price_increment=Decimal("1"), + min_base_amount_increment=Decimal("0.00001"), + min_notional_size=Decimal("10"), + min_order_value=Decimal("10"), + ) + c._trading_rules = {"BTC-USDC": rule} + # 0.0001 BTC @ 30000 = 3 USDC < 10 min + self.assertTrue(c._is_sub_minimum_position_notional("BTC-USDC", Decimal("0.0001"), Decimal("30000"))) + + def test_is_sub_minimum_position_notional_above_min(self): + c = self.connector + rule = TradingRule( + trading_pair="BTC-USDC", + min_order_size=Decimal("0.00001"), + min_price_increment=Decimal("1"), + min_base_amount_increment=Decimal("0.00001"), + min_notional_size=Decimal("10"), + min_order_value=Decimal("10"), + ) + c._trading_rules = {"BTC-USDC": rule} + # 1 BTC @ 30000 = 30000 USDC >> 10 min + self.assertFalse(c._is_sub_minimum_position_notional("BTC-USDC", Decimal("1"), Decimal("30000"))) + + def test_get_buy_sell_collateral_token(self): + c = self.connector + self.assertEqual("USDC", c.get_buy_collateral_token("BTC-USDC")) + self.assertEqual("USDC", c.get_sell_collateral_token("BTC-USDC")) + + def test_is_cancel_request_synchronous(self): + self.assertTrue(self.connector.is_cancel_request_in_exchange_synchronous) + + def test_funding_fee_poll_interval(self): + self.assertEqual(120, self.connector.funding_fee_poll_interval) + + def test_supported_position_modes(self): + modes = self.connector.supported_position_modes() + self.assertEqual([PositionMode.ONEWAY], modes) + + def test_supported_order_types_perp(self): + types = self.connector.supported_order_types() + self.assertIn(OrderType.LIMIT, types) + self.assertIn(OrderType.MARKET, types) + + async def test_place_cancel_returns_false_when_no_exchange_order_id(self): + c = self.connector + tracked = type("Order", (), { + "exchange_order_id": None, + "trading_pair": "BTC-USDC", + })() + result = await c._place_cancel("HBOT-X", tracked) + self.assertFalse(result) + + async def test_reconcile_unmatched_private_event_throttled(self): + c = self.connector + c._last_unmatched_private_event_reconcile_ts = 0.0 + c._update_order_status = AsyncMock() + c._update_positions = AsyncMock() + c._update_balances = AsyncMock() + c.logger = lambda: MagicMock() + + with patch("time.time", return_value=100.0): + await c._reconcile_unmatched_private_event("test") + c._update_order_status.assert_awaited_once() + + # Second call within 2s should be throttled + c._update_order_status.reset_mock() + with patch("time.time", return_value=101.0): + await c._reconcile_unmatched_private_event("test") + c._update_order_status.assert_not_awaited() + + async def test_cleanup_startup_orphan_and_runtime_orphan_noop(self): + await self.connector._cleanup_startup_orphan_reduce_only_orders() + await self.connector._cleanup_runtime_orphan_orders() + + async def test_apply_balances_account_equity_fallback(self): + c = self.connector + c._account_balances = {} + c._account_available_balances = {} + c._fee_tier = 0 + await c._apply_balances_from_account_data({ + "account_equity": "200", + "available_balance": "180", + "fee_level": 1, + }) + self.assertEqual(Decimal("200"), c._account_balances["USDC"]) + self.assertEqual(Decimal("180"), c._account_available_balances["USDC"]) + + async def test_refresh_account_state_calls_positions_and_balances(self): + c = self.connector + c._update_positions = AsyncMock() + c._update_balances = AsyncMock() + c.logger = lambda: MagicMock() + await c._refresh_account_state(refresh_positions=True, refresh_balances=True, reason="test") + c._update_positions.assert_awaited_once() + c._update_balances.assert_awaited_once() + + async def test_refresh_account_state_skips_when_both_false(self): + c = self.connector + c._update_positions = AsyncMock() + c._update_balances = AsyncMock() + await c._refresh_account_state(refresh_positions=False, refresh_balances=False, reason="test") + c._update_positions.assert_not_awaited() + c._update_balances.assert_not_awaited() + + def test_allocate_client_order_index_increments(self): + c = self.connector + c._last_client_order_index = 0 + idx1 = c._allocate_client_order_index() + idx2 = c._allocate_client_order_index() + self.assertGreater(idx1, 0) + self.assertGreater(idx2, idx1) + + async def test_estimate_open_order_initial_margin_empty_positions(self): + c = self.connector + c._status_poll_cycle_active = False + result = await c._estimate_open_order_initial_margin({"positions": []}) + self.assertIsNone(result) + + async def test_estimate_open_order_initial_margin_no_positions_key(self): + c = self.connector + result = await c._estimate_open_order_initial_margin({"collateral": "100"}) + self.assertIsNone(result) + + async def test_place_cancel_returns_false_when_exchange_order_id_none_string(self): + c = self.connector + c._client_order_index_to_client_order_id = {} + c.logger = lambda: MagicMock() + tracked = type("Order", (), { + "exchange_order_id": "None", + "trading_pair": "BTC-USDC", + "client_order_id": "HBOT-NONE", + })() + c._get_market_spec = AsyncMock(return_value=(1, 2, 2, "BTC/USDC")) + result = await c._place_cancel("HBOT-NONE", tracked) + self.assertFalse(result) + + # ----------------------------------------------------------------------- + # Coverage boost batch 3: format_trading_rules, get_price_by_type, + # generate_api_key_pair, _place_order success + # ----------------------------------------------------------------------- + + async def test_format_trading_rules_order_books_path(self): + """_format_trading_rules with order_books format (mixed perp and non-perp).""" + c = self.connector + c._market_id_by_symbol = {} + c._size_decimals_by_symbol = {} + c._price_decimals_by_symbol = {} + c.trading_pair_associated_to_exchange_symbol = AsyncMock(return_value="BTC-USDC") + + exchange_info = { + "order_books": [ + { + "market_type": "spot", + "symbol": "ETH", + "market_id": "2", + "supported_size_decimals": 4, + "supported_price_decimals": 2, + "min_quote_amount": "10", + }, + { + "market_type": "perp", + "symbol": "BTC", + "market_id": "1", + "supported_size_decimals": 5, + "supported_price_decimals": 2, + "min_quote_amount": "5", + }, + ] + } + rules = await c._format_trading_rules(exchange_info) + self.assertEqual(1, len(rules)) + self.assertEqual("BTC-USDC", rules[0].trading_pair) + self.assertEqual(Decimal("0.00001"), rules[0].min_order_size) + self.assertEqual(1, c._market_id_by_symbol["BTC"]) + + async def test_format_trading_rules_order_books_skips_unknown_pair(self): + c = self.connector + c._market_id_by_symbol = {} + c._size_decimals_by_symbol = {} + c._price_decimals_by_symbol = {} + c.trading_pair_associated_to_exchange_symbol = AsyncMock(side_effect=KeyError("unknown")) + c.logger = lambda: MagicMock() + + exchange_info = { + "order_books": [ + { + "market_type": "perp", + "symbol": "NEWCOIN", + "market_id": "99", + "supported_size_decimals": 2, + "supported_price_decimals": 1, + "min_quote_amount": "10", + }, + ] + } + rules = await c._format_trading_rules(exchange_info) + self.assertEqual(0, len(rules)) + + async def test_format_trading_rules_data_path(self): + c = self.connector + c.trading_pair_associated_to_exchange_symbol = AsyncMock(return_value="ETH-USDC") + exchange_info = { + "data": [ + { + "symbol": "ETH", + "lot_size": "0.001", + "tick_size": "0.1", + "min_order_size": "10", + } + ] + } + rules = await c._format_trading_rules(exchange_info) + self.assertEqual(1, len(rules)) + self.assertEqual("ETH-USDC", rules[0].trading_pair) + + def test_get_price_by_type_best_bid(self): + c = self.connector + c._get_top_order_book_price = MagicMock(return_value=Decimal("29900")) + result = c.get_price_by_type("BTC-USDC", PriceType.BestBid) + self.assertEqual(Decimal("29900"), result) + + def test_get_price_by_type_best_ask(self): + c = self.connector + c._get_top_order_book_price = MagicMock(return_value=Decimal("30100")) + result = c.get_price_by_type("BTC-USDC", PriceType.BestAsk) + self.assertEqual(Decimal("30100"), result) + + def test_get_price_by_type_mid_price(self): + c = self.connector + ask = Decimal("30100") + bid = Decimal("29900") + c._get_top_order_book_price = MagicMock(side_effect=[ask, bid]) + result = c.get_price_by_type("BTC-USDC", PriceType.MidPrice) + self.assertEqual((ask + bid) / Decimal("2"), result) + + def test_get_price_by_type_mid_price_nan(self): + c = self.connector + c._get_top_order_book_price = MagicMock(return_value=Decimal("NaN")) + result = c.get_price_by_type("BTC-USDC", PriceType.MidPrice) + self.assertTrue(result.is_nan()) + + def test_get_price_by_type_last_trade_no_price(self): + c = self.connector + mock_book = MagicMock() + mock_book.last_trade_price = None + c.get_order_book = MagicMock(return_value=mock_book) + result = c.get_price_by_type("BTC-USDC", PriceType.LastTrade) + self.assertTrue(result.is_nan()) + + def test_get_price_by_type_unknown(self): + c = self.connector + result = c.get_price_by_type("BTC-USDC", "INVALID_PRICE_TYPE") + self.assertTrue(result.is_nan()) + + def test_generate_api_key_pair_success(self): + c = self.connector + mock_lighter = MagicMock() + mock_lighter.create_api_key.return_value = ("privkey", "pubkey", None) + with patch.dict("sys.modules", {"lighter": mock_lighter}): + priv, pub = c.generate_api_key_pair() + self.assertEqual("privkey", priv) + self.assertEqual("pubkey", pub) + + def test_generate_api_key_pair_error_raises(self): + c = self.connector + mock_lighter = MagicMock() + mock_lighter.create_api_key.return_value = (None, None, "some error") + with patch.dict("sys.modules", {"lighter": mock_lighter}): + with self.assertRaises(ValueError): + c.generate_api_key_pair() + + def test_is_ok_response_code_exception(self): + c = self.connector + self.assertFalse(c._is_ok_response({"success": None, "code": None})) + self.assertFalse(c._is_ok_response({"success": None, "code": "abc"})) + + def test_account_from_response_accounts_wrapper(self): + cls = self.connector_cls + result = cls._account_from_response({"accounts": [{"collateral": "100"}]}) + self.assertEqual({"collateral": "100"}, result) + + def test_account_from_response_empty_returns_none(self): + cls = self.connector_cls + self.assertIsNone(cls._account_from_response({})) + + async def test_place_order_success(self): + c = self.connector + c._get_market_spec = AsyncMock(return_value=(1, 3, 2, "BTC/USDC")) + c._trading_rules = {} + c._client_order_index_to_client_order_id = {} + c._schedule_fast_balance_sync = MagicMock() + c._last_client_order_index = 0 + c._current_timestamp = 1000.0 + c.logger = lambda: MagicMock() + + mock_resp = MagicMock() + mock_resp.code = 200 + mock_signer = MagicMock() + mock_signer.ORDER_TYPE_LIMIT = "LIMIT" + mock_signer.ORDER_TYPE_MARKET = "MARKET" + mock_signer.ORDER_TIME_IN_FORCE_GOOD_TILL_TIME = "GTT" + mock_signer.ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL = "IOC" + mock_signer.ORDER_TIME_IN_FORCE_POST_ONLY = "POST_ONLY" + mock_signer.DEFAULT_28_DAY_ORDER_EXPIRY = 9999 + mock_signer.DEFAULT_IOC_EXPIRY = 0 + mock_signer.create_order = AsyncMock(return_value=(None, mock_resp, None)) + c._lighter_signer_client = mock_signer + c._get_lighter_signer_client = MagicMock(return_value=mock_signer) + c._signer_request_lock = asyncio.Lock() + c._get_api_key_index = MagicMock(return_value=0) + + result = await c._place_order( + order_id="HBOT-1", + trading_pair="BTC-USDC", + amount=Decimal("0.01"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT, + price=Decimal("30000"), + position_action=PositionAction.OPEN, + ) + self.assertIsInstance(result, tuple) + c._schedule_fast_balance_sync.assert_called() + + async def test_place_order_market_buy(self): + c = self.connector + c._get_market_spec = AsyncMock(return_value=(1, 3, 2, "BTC/USDC")) + c._trading_rules = {} + c._client_order_index_to_client_order_id = {} + c._schedule_fast_balance_sync = MagicMock() + c._last_client_order_index = 0 + c._current_timestamp = 1000.0 + c.logger = lambda: MagicMock() + + mock_book = MagicMock() + mock_book.get_price.return_value = 30000 + c.get_order_book = MagicMock(return_value=mock_book) + + mock_resp = MagicMock() + mock_resp.code = 200 + mock_signer = MagicMock() + mock_signer.ORDER_TYPE_LIMIT = "LIMIT" + mock_signer.ORDER_TYPE_MARKET = "MARKET" + mock_signer.ORDER_TIME_IN_FORCE_GOOD_TILL_TIME = "GTT" + mock_signer.ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL = "IOC" + mock_signer.ORDER_TIME_IN_FORCE_POST_ONLY = "POST_ONLY" + mock_signer.DEFAULT_28_DAY_ORDER_EXPIRY = 9999 + mock_signer.DEFAULT_IOC_EXPIRY = 0 + mock_signer.create_order = AsyncMock(return_value=(None, mock_resp, None)) + c._lighter_signer_client = mock_signer + c._get_lighter_signer_client = MagicMock(return_value=mock_signer) + c._signer_request_lock = asyncio.Lock() + c._get_api_key_index = MagicMock(return_value=0) + + result = await c._place_order( + order_id="HBOT-M", + trading_pair="BTC-USDC", + amount=Decimal("0.01"), + trade_type=TradeType.BUY, + order_type=OrderType.MARKET, + price=Decimal("NaN"), + position_action=PositionAction.OPEN, + ) + self.assertIsNotNone(result) + call_kwargs = mock_signer.create_order.call_args.kwargs + self.assertEqual("MARKET", call_kwargs["order_type"]) + self.assertEqual("IOC", call_kwargs["time_in_force"]) + + async def test_place_order_fails_on_signer_error(self): + c = self.connector + c._get_market_spec = AsyncMock(return_value=(1, 3, 2, "BTC/USDC")) + c._trading_rules = {} + c._client_order_index_to_client_order_id = {} + c._last_client_order_index = 0 + c.logger = lambda: MagicMock() + + mock_signer = MagicMock() + mock_signer.ORDER_TYPE_LIMIT = "LIMIT" + mock_signer.ORDER_TYPE_MARKET = "MARKET" + mock_signer.ORDER_TIME_IN_FORCE_GOOD_TILL_TIME = "GTT" + mock_signer.ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL = "IOC" + mock_signer.ORDER_TIME_IN_FORCE_POST_ONLY = "POST_ONLY" + mock_signer.DEFAULT_28_DAY_ORDER_EXPIRY = 9999 + mock_signer.DEFAULT_IOC_EXPIRY = 0 + mock_signer.create_order = AsyncMock(return_value=(None, None, "sign error")) + c._lighter_signer_client = mock_signer + c._get_lighter_signer_client = MagicMock(return_value=mock_signer) + c._signer_request_lock = asyncio.Lock() + c._get_api_key_index = MagicMock(return_value=0) + + with self.assertRaises(IOError): + await c._place_order( + order_id="HBOT-F", + trading_pair="BTC-USDC", + amount=Decimal("0.01"), + trade_type=TradeType.SELL, + order_type=OrderType.LIMIT, + price=Decimal("30000"), + position_action=PositionAction.OPEN, + ) + + async def test_place_order_below_min_notional_raises(self): + c = self.connector + c._get_market_spec = AsyncMock(return_value=(1, 3, 2, "BTC/USDC")) + rule = TradingRule( + trading_pair="BTC-USDC", + min_order_size=Decimal("0.00001"), + min_price_increment=Decimal("1"), + min_base_amount_increment=Decimal("0.00001"), + min_notional_size=Decimal("100"), + min_order_value=Decimal("100"), + ) + c._trading_rules = {"BTC-USDC": rule} + c._last_client_order_index = 0 + c.logger = lambda: MagicMock() + mock_signer = MagicMock() + mock_signer.ORDER_TYPE_LIMIT = "LIMIT" + mock_signer.ORDER_TYPE_MARKET = "MARKET" + mock_signer.ORDER_TIME_IN_FORCE_GOOD_TILL_TIME = "GTT" + mock_signer.DEFAULT_28_DAY_ORDER_EXPIRY = 9999 + c._lighter_signer_client = mock_signer + c._get_lighter_signer_client = MagicMock(return_value=mock_signer) + c._signer_request_lock = asyncio.Lock() + + with self.assertRaises(IOError): + await c._place_order( + order_id="HBOT-S", + trading_pair="BTC-USDC", + amount=Decimal("0.001"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT, + price=Decimal("30000"), + position_action=PositionAction.OPEN, + ) + + async def test_place_order_close_rounds_up_sub_minimum(self): + c = self.connector + c._get_market_spec = AsyncMock(return_value=(1, 3, 2, "BTC/USDC")) + rule = TradingRule( + trading_pair="BTC-USDC", + min_order_size=Decimal("0.00001"), + min_price_increment=Decimal("1"), + min_base_amount_increment=Decimal("0.001"), + min_notional_size=Decimal("10"), + min_order_value=Decimal("10"), + ) + c._trading_rules = {"BTC-USDC": rule} + c._client_order_index_to_client_order_id = {} + c._schedule_fast_balance_sync = MagicMock() + c._last_client_order_index = 0 + c._current_timestamp = 1000.0 + c.logger = lambda: MagicMock() + + mock_resp = MagicMock() + mock_resp.code = 200 + mock_signer = MagicMock() + mock_signer.ORDER_TYPE_LIMIT = "LIMIT" + mock_signer.ORDER_TYPE_MARKET = "MARKET" + mock_signer.ORDER_TIME_IN_FORCE_GOOD_TILL_TIME = "GTT" + mock_signer.ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL = "IOC" + mock_signer.ORDER_TIME_IN_FORCE_POST_ONLY = "POST_ONLY" + mock_signer.DEFAULT_28_DAY_ORDER_EXPIRY = 9999 + mock_signer.DEFAULT_IOC_EXPIRY = 0 + mock_signer.create_order = AsyncMock(return_value=(None, mock_resp, None)) + c._lighter_signer_client = mock_signer + c._get_lighter_signer_client = MagicMock(return_value=mock_signer) + c._signer_request_lock = asyncio.Lock() + c._get_api_key_index = MagicMock(return_value=0) + + result = await c._place_order( + order_id="HBOT-C", + trading_pair="BTC-USDC", + amount=Decimal("0.0001"), + trade_type=TradeType.SELL, + order_type=OrderType.LIMIT, + price=Decimal("30000"), + position_action=PositionAction.CLOSE, + ) + self.assertIsNotNone(result) + call_kwargs = mock_signer.create_order.call_args.kwargs + self.assertTrue(call_kwargs["reduce_only"]) + + async def test_place_order_limit_maker_post_only(self): + c = self.connector + c._get_market_spec = AsyncMock(return_value=(1, 3, 2, "BTC/USDC")) + c._trading_rules = {} + c._client_order_index_to_client_order_id = {} + c._schedule_fast_balance_sync = MagicMock() + c._last_client_order_index = 0 + c._current_timestamp = 1000.0 + c.logger = lambda: MagicMock() + + mock_resp = MagicMock() + mock_resp.code = 200 + mock_signer = MagicMock() + mock_signer.ORDER_TYPE_LIMIT = "LIMIT" + mock_signer.ORDER_TYPE_MARKET = "MARKET" + mock_signer.ORDER_TIME_IN_FORCE_GOOD_TILL_TIME = "GTT" + mock_signer.ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL = "IOC" + mock_signer.ORDER_TIME_IN_FORCE_POST_ONLY = "POST_ONLY" + mock_signer.DEFAULT_28_DAY_ORDER_EXPIRY = 9999 + mock_signer.DEFAULT_IOC_EXPIRY = 0 + mock_signer.create_order = AsyncMock(return_value=(None, mock_resp, None)) + c._lighter_signer_client = mock_signer + c._get_lighter_signer_client = MagicMock(return_value=mock_signer) + c._signer_request_lock = asyncio.Lock() + c._get_api_key_index = MagicMock(return_value=0) + + await c._place_order( + order_id="HBOT-PM", + trading_pair="BTC-USDC", + amount=Decimal("0.01"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT_MAKER, + price=Decimal("30000"), + position_action=PositionAction.OPEN, + ) + call_kwargs = mock_signer.create_order.call_args.kwargs + self.assertEqual("POST_ONLY", call_kwargs["time_in_force"]) + + # ── order book price helpers ─────────────────────────────────────────── + + def test_get_top_order_book_price_returns_quantized_best_ask(self): + """_get_top_order_book_price returns quantized price when order book has entries.""" + c = self.connector + mock_entry = MagicMock() + mock_entry.price = "100.5" + mock_book = MagicMock() + mock_book.ask_entries.return_value = iter([mock_entry]) + c.get_order_book = MagicMock(return_value=mock_book) + c.quantize_order_price = MagicMock(return_value=Decimal("100.5")) + c._should_emit_throttled_warning = MagicMock(return_value=False) + c._last_empty_order_book_warning_timestamp = {} + result = c._get_top_order_book_price("BTC-USDC", True) + self.assertEqual(Decimal("100.5"), result) + c.quantize_order_price.assert_called_once_with("BTC-USDC", Decimal("100.5")) + + def test_get_price_delegates_to_top_order_book_price(self): + """get_price calls _get_top_order_book_price (line 214).""" + c = self.connector + c._get_top_order_book_price = MagicMock(return_value=Decimal("500")) + result = c.get_price("BTC-USDC", True) + self.assertEqual(Decimal("500"), result) + c._get_top_order_book_price.assert_called_once_with(trading_pair="BTC-USDC", is_buy=True) + + def test_get_price_by_type_last_trade_valid_price(self): + """LastTrade path returns price > 0 (line 231).""" + c = self.connector + mock_book = MagicMock() + mock_book.last_trade_price = Decimal("12345.6") + c.get_order_book = MagicMock(return_value=mock_book) + result = c.get_price_by_type("BTC-USDC", PriceType.LastTrade) + self.assertEqual(Decimal("12345.6"), result) + + # ── _refresh_signer_client_async ────────────────────────────────────── + + async def test_refresh_signer_client_async_clears_and_rebuilds_client(self): + """_refresh_signer_client_async sets client to None then creates new (lines 359-362).""" + c = self.connector + mock_new_client = MagicMock() + c._lighter_signer_client = "old" + c._get_lighter_signer_client = MagicMock(return_value=mock_new_client) + result = await c._refresh_signer_client_async() + self.assertIs(mock_new_client, result) + # Client was cleared before executor call + c._get_lighter_signer_client.assert_called() + + # ── _format_trading_rules data path skip unknown ────────────────────── + + async def test_format_trading_rules_data_path_skips_unknown_symbol(self): + """data-format: KeyError from exchange_symbol lookup logs debug and skips (lines 880-881, 886).""" + c = self.connector + c.trading_pair_associated_to_exchange_symbol = AsyncMock(side_effect=KeyError("unknown")) + pair_info = { + "symbol": "UNKNOWN-PERP", + "lot_size": "0.01", + "tick_size": "0.01", + "min_order_size": "1", + } + result = await c._format_trading_rules({"data": [pair_info]}) + self.assertEqual([], result) + + # ── generate_api_key_pair import failure ────────────────────────────── + + def test_generate_api_key_pair_import_error_raises(self): + """Missing lighter SDK raises ImportError (lines 527-528).""" + c = self.connector + with patch.dict("sys.modules", {"lighter": None}): + with self.assertRaises(ImportError): + c.generate_api_key_pair() + + # ── _place_order error paths ────────────────────────────────────────── + + def _make_perp_place_order_setup(self, signer_responses): + """Helper: set up connector for _place_order tests.""" + c = self.connector + c._get_market_spec = AsyncMock(return_value=(1, 3, 2, "BTC/USDC")) + c._trading_rules = {} + c._client_order_index_to_client_order_id = {} + c._schedule_fast_balance_sync = MagicMock() + c._last_client_order_index = 0 + c._current_timestamp = 1000.0 + c.logger = lambda: MagicMock() + c._sleep = AsyncMock() + + mock_signer = MagicMock() + mock_signer.ORDER_TYPE_LIMIT = "LIMIT" + mock_signer.ORDER_TYPE_MARKET = "MARKET" + mock_signer.ORDER_TIME_IN_FORCE_GOOD_TILL_TIME = "GTT" + mock_signer.ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL = "IOC" + mock_signer.ORDER_TIME_IN_FORCE_POST_ONLY = "POST_ONLY" + mock_signer.DEFAULT_28_DAY_ORDER_EXPIRY = 9999 + mock_signer.DEFAULT_IOC_EXPIRY = 0 + mock_signer.create_order = AsyncMock(side_effect=signer_responses) + c._lighter_signer_client = mock_signer + c._get_lighter_signer_client = MagicMock(return_value=mock_signer) + c._signer_request_lock = asyncio.Lock() + c._get_api_key_index = MagicMock(return_value=0) + return c, mock_signer + + async def test_place_order_nonce_retry_then_success(self): + """'invalid nonce' error triggers signer refresh and retry (lines 1015-1018).""" + mock_resp = MagicMock() + mock_resp.code = 200 + c, mock_signer = self._make_perp_place_order_setup([ + (None, None, "invalid nonce: sequence too old"), + (None, mock_resp, None), + ]) + c._refresh_signer_client_async = AsyncMock(return_value=mock_signer) + + result = await c._place_order( + order_id="HBOT-N", + trading_pair="BTC-USDC", + amount=Decimal("0.01"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT, + price=Decimal("30000"), + position_action=PositionAction.OPEN, + ) + self.assertIsInstance(result, tuple) + self.assertEqual(2, mock_signer.create_order.call_count) + + async def test_place_order_raises_with_trading_rule_min_amount_info(self): + """'invalid order base or quote amount' error includes trading rule details (lines 1024-1028, 1032).""" + c, _ = self._make_perp_place_order_setup([ + (None, None, "invalid order base or quote amount") + ]) + c._trading_rules = { + "BTC-USDC": TradingRule( + trading_pair="BTC-USDC", + min_order_size=Decimal("0.000001"), + min_price_increment=Decimal("0.01"), + min_base_amount_increment=Decimal("0.001"), + min_notional_size=Decimal("0.000001"), + min_order_value=Decimal("0.000001"), + ) + } + + with self.assertRaises(IOError) as ctx: + await c._place_order( + order_id="HBOT-MIN", + trading_pair="BTC-USDC", + amount=Decimal("0.01"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT, + price=Decimal("30000"), + position_action=PositionAction.OPEN, + ) + self.assertIn("minimum base amount", str(ctx.exception)) + + async def test_place_order_raises_when_tx_response_none(self): + """No error but None tx_response raises IOError (line 1035).""" + c, _ = self._make_perp_place_order_setup([ + (None, None, None) # error=None, tx_response=None + ]) + + with self.assertRaises(IOError): + await c._place_order( + order_id="HBOT-TX", + trading_pair="BTC-USDC", + amount=Decimal("0.01"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT, + price=Decimal("30000"), + position_action=PositionAction.OPEN, + ) + + async def test_place_order_raises_on_unsupported_order_type_perp(self): + """Unsupported order type raises ValueError (line 917).""" + c = self.connector + c._get_market_spec = AsyncMock(return_value=(1, 3, 2, "BTC/USDC")) + c._get_lighter_signer_client = MagicMock(return_value=MagicMock()) + c._trading_rules = {} + c._current_timestamp = 1000.0 + c.supported_order_types = lambda: [OrderType.LIMIT] # exclude MARKET + + with self.assertRaises(ValueError): + await c._place_order( + order_id="HBOT-UNSUP", + trading_pair="BTC-USDC", + amount=Decimal("0.01"), + trade_type=TradeType.BUY, + order_type=OrderType.MARKET, + price=Decimal("NaN"), + position_action=PositionAction.OPEN, + ) + + # ------------------------------------------------------------------ + # _recover_exchange_order_id_from_active_orders + # ------------------------------------------------------------------ + + async def test_recover_exchange_order_id_returns_none_when_no_rows(self): + """Returns None when the active-orders snapshot for the market is empty.""" + c = self.connector + c._get_market_spec = AsyncMock(return_value=(1, 3, 2, "BTC/USDC")) + c._active_orders_snapshot_by_market = {} + + tracked_order = MagicMock() + tracked_order.trading_pair = "BTC-USDC" + tracked_order.trade_type = TradeType.BUY + tracked_order.price = Decimal("30000") + + result = await c._recover_exchange_order_id_from_active_orders(tracked_order) + self.assertIsNone(result) + + async def test_recover_exchange_order_id_returns_unique_candidate(self): + """Returns the exchange order_id when exactly one active order matches price and side.""" + c = self.connector + # price_decimals=2 → expected_price_scaled = int(100.00 * 1e2) = 10000 + c._get_market_spec = AsyncMock(return_value=(1, 3, 2, "BTC/USDC")) + + tracked_order = MagicMock() + tracked_order.trading_pair = "BTC-USDC" + tracked_order.trade_type = TradeType.BUY + tracked_order.price = Decimal("100.00") + + c._active_orders_snapshot_by_market = { + 1: [{"order_id": "99", "side": "bid", "price": "100.00"}] + } + c._order_tracker._in_flight_orders = {} + + result = await c._recover_exchange_order_id_from_active_orders(tracked_order) + self.assertEqual("99", result) + + async def test_recover_exchange_order_id_returns_none_on_multiple_candidates(self): + """Returns None when multiple active orders match (ambiguous recovery).""" + c = self.connector + c._get_market_spec = AsyncMock(return_value=(1, 3, 2, "BTC/USDC")) + + tracked_order = MagicMock() + tracked_order.trading_pair = "BTC-USDC" + tracked_order.trade_type = TradeType.BUY + tracked_order.price = Decimal("100.00") + + c._active_orders_snapshot_by_market = { + 1: [ + {"order_id": "99", "side": "bid", "price": "100.00"}, + {"order_id": "100", "side": "bid", "price": "100.00"}, + ] + } + c._order_tracker._in_flight_orders = {} + + result = await c._recover_exchange_order_id_from_active_orders(tracked_order) + self.assertIsNone(result) + + async def test_recover_exchange_order_id_skips_already_tracked_order(self): + """Skips an order_id that is already tracked in in_flight_orders.""" + c = self.connector + c._get_market_spec = AsyncMock(return_value=(1, 3, 2, "BTC/USDC")) + + tracked_order = MagicMock() + tracked_order.trading_pair = "BTC-USDC" + tracked_order.trade_type = TradeType.BUY + tracked_order.price = Decimal("100.00") + + already_tracked = MagicMock() + already_tracked.exchange_order_id = "99" + + c._active_orders_snapshot_by_market = { + 1: [{"order_id": "99", "side": "bid", "price": "100.00"}] + } + c._order_tracker._in_flight_orders = {"HBOT-OTHER": already_tracked} + + result = await c._recover_exchange_order_id_from_active_orders(tracked_order) + self.assertIsNone(result) + + # ------------------------------------------------------------------ + # _cancel_tracked_orders_on_stop + # ------------------------------------------------------------------ + + async def test_cancel_tracked_orders_on_stop_empty_returns_zero(self): + """Returns 0 immediately when there are no tracked in-flight orders.""" + c = self.connector + c._order_tracker._in_flight_orders = {} + + result = await c._cancel_tracked_orders_on_stop() + self.assertEqual(0, result) + + async def test_cancel_tracked_orders_on_stop_cancels_all_tracked(self): + """Cancels each tracked order and returns the count of successful cancels.""" + c = self.connector + order1 = MagicMock() + order1.client_order_id = "HBOT-1" + order2 = MagicMock() + order2.client_order_id = "HBOT-2" + c._order_tracker._in_flight_orders = {"HBOT-1": order1, "HBOT-2": order2} + c._execute_order_cancel = AsyncMock(side_effect=["HBOT-1", "HBOT-2"]) + + result = await c._cancel_tracked_orders_on_stop() + self.assertEqual(2, result) + + async def test_cancel_tracked_orders_on_stop_exception_does_not_propagate(self): + """An exception during individual order cancel is swallowed; count = 0.""" + c = self.connector + order1 = MagicMock() + order1.client_order_id = "HBOT-ERR" + c._order_tracker._in_flight_orders = {"HBOT-ERR": order1} + c._execute_order_cancel = AsyncMock(side_effect=Exception("network error")) + + result = await c._cancel_tracked_orders_on_stop() + self.assertEqual(0, result) + + async def test_cancel_tracked_orders_on_stop_returns_none_as_zero(self): + """If _execute_order_cancel returns None, that order does not count.""" + c = self.connector + order1 = MagicMock() + order1.client_order_id = "HBOT-NONE" + c._order_tracker._in_flight_orders = {"HBOT-NONE": order1} + c._execute_order_cancel = AsyncMock(return_value=None) + + result = await c._cancel_tracked_orders_on_stop() + self.assertEqual(0, result) + + # ------------------------------------------------------------------ + # _should_ignore_scoped_private_event + # ------------------------------------------------------------------ + + def test_should_ignore_unknown_channel_base_returns_false(self): + """Non-private channel_base → always False (line 2934 branch).""" + result = self.connector._should_ignore_scoped_private_event( + channel="public_feed/99", channel_base="public_feed" + ) + self.assertFalse(result) + + def test_should_ignore_no_scoped_identifier_returns_false(self): + """Private channel but no numeric scope → False (no-digit branch).""" + result = self.connector._should_ignore_scoped_private_event( + channel="account_all", channel_base="account_all" + ) + self.assertFalse(result) + + def test_should_ignore_non_digit_scope_returns_false(self): + """Private channel with non-numeric scope → False (isdigit branch).""" + result = self.connector._should_ignore_scoped_private_event( + channel="account_all/abc", channel_base="account_all" + ) + self.assertFalse(result) + + def test_should_ignore_colon_separator_non_digit_returns_false(self): + """Private channel with colon separator and non-numeric scope → False.""" + result = self.connector._should_ignore_scoped_private_event( + channel="account_all:xyz", channel_base="account_all" + ) + self.assertFalse(result) + + def test_should_ignore_matching_account_index_returns_false(self): + """When scoped identifier matches our account index → False (correct account).""" + self.connector._get_account_index = MagicMock(return_value=42) + result = self.connector._should_ignore_scoped_private_event( + channel="account_all/42", channel_base="account_all" + ) + self.assertFalse(result) + + def test_should_ignore_mismatched_account_index_returns_true(self): + """When scoped identifier does NOT match our account index → True (ignore).""" + self.connector._get_account_index = MagicMock(return_value=42) + result = self.connector._should_ignore_scoped_private_event( + channel="account_all/99", channel_base="account_all" + ) + self.assertTrue(result) + + def test_should_ignore_get_account_index_raises_returns_false(self): + """When _get_account_index raises → False (exception branch).""" + self.connector._get_account_index = MagicMock(side_effect=Exception("not configured")) + result = self.connector._should_ignore_scoped_private_event( + channel="account_all/5", channel_base="account_all" + ) + self.assertFalse(result) + + # ------------------------------------------------------------------ + # round_amount / round_fee + # ------------------------------------------------------------------ + + def test_round_fee_returns_rounded_decimal(self): + result = self.connector.round_fee(Decimal("1.123456789")) + self.assertEqual(round(Decimal("1.123456789"), 6), result) + + def test_round_fee_zero(self): + result = self.connector.round_fee(Decimal("0")) + self.assertEqual(0, result) + + def test_round_amount_quantizes_to_rule(self): + from hummingbot.connector.trading_rule import TradingRule + rule = TradingRule( + trading_pair="BTC-USDC", + min_order_size=Decimal("0.001"), + min_price_increment=Decimal("0.01"), + min_base_amount_increment=Decimal("0.001"), + ) + self.connector._trading_rules["BTC-USDC"] = rule + result = self.connector.round_amount("BTC-USDC", Decimal("1.23456789")) + self.assertEqual(Decimal("1.235"), result) + + # ------------------------------------------------------------------ + # get_all_pairs_prices + # ------------------------------------------------------------------ + + async def test_get_all_pairs_prices_returns_list_on_success(self): + self.connector._api_get = AsyncMock(return_value={ + "success": True, + "data": [{"symbol": "BTC", "mark": "50000"}], + }) + self.connector.trading_pair_associated_to_exchange_symbol = AsyncMock(return_value="BTC-USDC") + result = await self.connector.get_all_pairs_prices() + self.assertEqual([{"trading_pair": "BTC-USDC", "price": "50000"}], result) + + async def test_get_all_pairs_prices_returns_empty_on_failure(self): + self.connector._api_get = AsyncMock(return_value={"success": False, "message": "error"}) + result = await self.connector.get_all_pairs_prices() + self.assertEqual([], result) + + # ------------------------------------------------------------------ + # Simple properties and utility methods + # ------------------------------------------------------------------ + + def test_is_cancel_request_in_exchange_synchronous(self): + self.assertTrue(self.connector.is_cancel_request_in_exchange_synchronous) + + def test_funding_fee_poll_interval_props_section(self): + self.assertEqual(120, self.connector.funding_fee_poll_interval) + + def test_supported_order_types(self): + from hummingbot.core.data_type.common import OrderType + types = self.connector.supported_order_types() + self.assertIn(OrderType.LIMIT, types) + self.assertIn(OrderType.MARKET, types) + + def test_supported_position_modes_props_section(self): + from hummingbot.core.data_type.common import PositionMode + modes = self.connector.supported_position_modes() + self.assertIn(PositionMode.ONEWAY, modes) + + def test_get_buy_sell_collateral_token_props_section(self): + self.assertEqual("USDC", self.connector.get_buy_collateral_token("BTC-USDC")) + self.assertEqual("USDC", self.connector.get_sell_collateral_token("BTC-USDC")) + + def test_is_request_exception_related_to_time_synchronizer(self): + self.assertFalse(self.connector._is_request_exception_related_to_time_synchronizer(Exception("any"))) + + def test_is_order_not_found_during_status_update_error(self): + self.assertTrue(self.connector._is_order_not_found_during_status_update_error(Exception("Order history not found"))) + self.assertFalse(self.connector._is_order_not_found_during_status_update_error(Exception("network error"))) + + def test_is_order_not_found_during_cancelation_error_props_section(self): + self.assertTrue(self.connector._is_order_not_found_during_cancelation_error(Exception('"code":5'))) + self.assertTrue(self.connector._is_order_not_found_during_cancelation_error(Exception("may already be filled"))) + self.assertFalse(self.connector._is_order_not_found_during_cancelation_error(Exception("network timeout"))) + + def test_is_balance_info_fresh_true_and_false(self): + self.connector._last_balance_update_timestamp = 0.0 + self.assertFalse(self.connector._is_balance_info_fresh()) + self.connector._last_balance_update_timestamp = 1.0 + self.assertTrue(self.connector._is_balance_info_fresh()) + + def test_is_position_info_fresh_when_trading_not_required(self): + self.connector._trading_required = False + self.assertTrue(self.connector._is_position_info_fresh()) + + def test_mark_private_account_event_received_updates_timestamp(self): + before = self.connector._last_private_account_event_timestamp + self.connector._mark_private_account_event_received() + after = self.connector._last_private_account_event_timestamp + self.assertGreater(after, before) + + def test_begin_and_end_status_poll_cycle(self): + self.connector._begin_status_poll_cycle() + self.assertTrue(self.connector._status_poll_cycle_active) + self.connector._end_status_poll_cycle() + self.assertFalse(self.connector._status_poll_cycle_active) + + def test_trading_pair_position_mode_set_returns_true(self): + import asyncio + + from hummingbot.core.data_type.common import PositionMode + ok, msg = asyncio.get_event_loop().run_until_complete( + self.connector._trading_pair_position_mode_set(PositionMode.ONEWAY, "BTC-USDC") + ) + self.assertTrue(ok) + self.assertEqual("", msg) + + def test_is_transient_error_patterns(self): + c = self.connector + # Assume _is_transient_error is a static or instance method + if hasattr(c, '_is_transient_error'): + self.assertTrue(c._is_transient_error("connection reset by peer")) + self.assertFalse(c._is_transient_error("invalid api key")) + + def test_initialize_trading_pair_symbols_from_exchange_info_perp(self): + exchange_info = { + "order_books": [ + {"market_type": "perp", "symbol": "BTC", "market_id": "1", "supported_size_decimals": 3, "supported_price_decimals": 2}, + {"market_type": "spot", "symbol": "ETH", "market_id": "2"}, # should be skipped + ] + } + self.connector._initialize_trading_pair_symbols_from_exchange_info(exchange_info) + self.assertIn("BTC", self.connector._market_id_by_symbol) + self.assertNotIn("ETH", self.connector._market_id_by_symbol) + self.assertEqual(1, self.connector._market_id_by_symbol["BTC"]) + self.assertEqual(3, self.connector._size_decimals_by_symbol["BTC"]) + self.assertEqual(2, self.connector._price_decimals_by_symbol["BTC"]) + + def test_initialize_trading_pair_symbols_from_exchange_info_data_fallback(self): + exchange_info = { + "data": [ + {"symbol": "BTC"}, + ] + } + self.connector._initialize_trading_pair_symbols_from_exchange_info(exchange_info) + # Should not raise; BTC should be in map (no market_id set here, just symbol map) + + def test_is_ok_response_true_cases(self): + c = self.connector + if hasattr(c, '_is_ok_response'): + self.assertTrue(c._is_ok_response({"success": True})) + self.assertFalse(c._is_ok_response({"success": False})) + + def test_api_request_url(self): + import asyncio + url = asyncio.get_event_loop().run_until_complete( + self.connector._api_request_url("/test", is_auth_required=False) + ) + self.assertIn("/test", url) + + # ------------------------------------------------------------------ + # _normalized_position_entries_from_event + # ------------------------------------------------------------------ + + def test_normalized_position_entries_from_event_short_form(self): + """Event with pre-normalized 's' key passes through unchanged when channel=account_positions.""" + entry = {"s": "BTC", "d": "bid", "a": "1", "p": "50000"} + event = {"channel": "account_positions", "data": [entry]} + result = self.connector._normalized_position_entries_from_event(event) + self.assertEqual([entry], result) + + def test_normalized_position_entries_from_event_long_form(self): + """Long-form position entry (symbol/position/side) is normalized to short form.""" + entry = { + "symbol": "BTC", + "position": "1.5", + "side": "bid", + "avg_entry_price": "45000", + } + result = self.connector._normalized_position_entries_from_event({"positions": [entry]}) + self.assertEqual(1, len(result)) + self.assertEqual("BTC", result[0]["s"]) + self.assertEqual("bid", result[0]["d"]) + + def test_normalized_position_entries_from_event_zero_amount_skipped(self): + """Zero-amount long-form position is skipped.""" + entry = {"symbol": "BTC", "position": "0", "side": "bid", "avg_entry_price": "0"} + result = self.connector._normalized_position_entries_from_event({"positions": [entry]}) + self.assertEqual([], result) + + def test_normalized_position_entries_from_event_no_symbol_skipped(self): + """Long-form entry without symbol is skipped.""" + entry = {"position": "1.5", "side": "bid"} + result = self.connector._normalized_position_entries_from_event({"positions": [entry]}) + self.assertEqual([], result) + + def test_normalized_position_entries_sign_field(self): + """Long-form entry with numeric sign field uses sign for direction.""" + entry = {"symbol": "BTC", "position": "2", "sign": -1, "avg_entry_price": "50000"} + result = self.connector._normalized_position_entries_from_event({"positions": [entry]}) + self.assertEqual(1, len(result)) + self.assertEqual("ask", result[0]["d"]) + + # ------------------------------------------------------------------ + # _normalized_trade_entries_from_event + # ------------------------------------------------------------------ + + def test_normalized_trade_entries_data_list(self): + """When event has 'data' list, it's returned directly.""" + trades = [{"i": "1", "a": "1"}] + result = self.connector._normalized_trade_entries_from_event({"data": trades}) + self.assertEqual(trades, result) + + def test_normalized_trade_entries_from_dict_trades(self): + """trades as dict → flattened list of entries with 'i' key.""" + trade_entry = {"i": "5", "a": "2"} + result = self.connector._normalized_trade_entries_from_event({"trades": {"bucket1": [trade_entry]}}) + self.assertIn(trade_entry, result) + + def test_normalized_trade_entries_from_list_trades(self): + """trades as list of lists → flattened.""" + trade_entry = {"i": "7", "a": "3"} + result = self.connector._normalized_trade_entries_from_event({"trades": [[trade_entry]]}) + self.assertIn(trade_entry, result) + + def test_normalized_trade_entries_non_dict_entry_skipped(self): + """Non-dict entries in trade bucket are skipped.""" + result = self.connector._normalized_trade_entries_from_event({"trades": [["not_a_dict"]]}) + self.assertEqual([], result) + + # ------------------------------------------------------------------ + # _get_fee + # ------------------------------------------------------------------ + + def test_get_fee_uses_live_schema_when_available(self): + from hummingbot.core.data_type.common import OrderType, PositionAction, TradeType + from hummingbot.core.data_type.trade_fee import TradeFeeSchema + schema = TradeFeeSchema( + maker_percent_fee_decimal=Decimal("0.0002"), + taker_percent_fee_decimal=Decimal("0.0005"), + ) + self.connector._trading_fees["BTC-USDC"] = schema + fee = self.connector._get_fee( + base_currency="BTC", + quote_currency="USDC", + order_type=OrderType.LIMIT, + order_side=TradeType.BUY, + position_action=PositionAction.OPEN, + amount=Decimal("1"), + price=Decimal("50000"), + is_maker=True, + ) + self.assertIsNotNone(fee) + + def test_get_fee_falls_back_to_default_when_no_schema(self): + from hummingbot.core.data_type.common import OrderType, PositionAction, TradeType + self.connector._trading_fees.pop("BTC-USDC", None) + fee = self.connector._get_fee( + base_currency="BTC", + quote_currency="USDC", + order_type=OrderType.LIMIT, + order_side=TradeType.BUY, + position_action=PositionAction.OPEN, + amount=Decimal("1"), + price=Decimal("50000"), + ) + self.assertIsNotNone(fee) + + # ------------------------------------------------------------------ + # _execute_order_cancel guard branches + # ------------------------------------------------------------------ + + async def test_execute_order_cancel_skips_duplicate_in_flight(self): + order = MagicMock() + order.client_order_id = "HBOT-DUP" + self.connector._cancel_in_flight_client_order_ids.add("HBOT-DUP") + result = await self.connector._execute_order_cancel(order) + self.assertIsNone(result) + + async def test_execute_order_cancel_skips_during_backoff(self): + import time + order = MagicMock() + order.client_order_id = "HBOT-BACK" + self.connector._cancel_backoff_until["HBOT-BACK"] = time.time() + 60 + result = await self.connector._execute_order_cancel(order) + self.assertIsNone(result) + del self.connector._cancel_backoff_until["HBOT-BACK"] + + # ------------------------------------------------------------------ + # _refresh_account_state + # ------------------------------------------------------------------ + + async def test_refresh_account_state_both_flags(self): + self.connector._update_positions = AsyncMock() + self.connector._update_balances = AsyncMock() + await self.connector._refresh_account_state(reason="test", refresh_positions=True, refresh_balances=True) + self.connector._update_positions.assert_awaited_once() + self.connector._update_balances.assert_awaited_once() + + async def test_refresh_account_state_balances_only(self): + self.connector._update_positions = AsyncMock() + self.connector._update_balances = AsyncMock() + await self.connector._refresh_account_state(reason="test", refresh_positions=False, refresh_balances=True) + self.connector._update_positions.assert_not_awaited() + self.connector._update_balances.assert_awaited_once() + + async def test_refresh_account_state_neither_flag(self): + self.connector._update_positions = AsyncMock() + self.connector._update_balances = AsyncMock() + await self.connector._refresh_account_state(reason="test") + self.connector._update_positions.assert_not_awaited() + self.connector._update_balances.assert_not_awaited() + + async def test_refresh_account_state_positions_exception_does_not_propagate(self): + self.connector._update_positions = AsyncMock(side_effect=Exception("pos error")) + self.connector._update_balances = AsyncMock() + # Should not raise + await self.connector._refresh_account_state(reason="test", refresh_positions=True, refresh_balances=True) + self.connector._update_balances.assert_awaited_once() + + # ------------------------------------------------------------------ + # _get_poll_interval + # ------------------------------------------------------------------ + + def test_get_poll_interval_healthy_stream_with_orders(self): + self.connector._is_user_stream_initialized = MagicMock(return_value=True) + # Inject a fake in-flight order + order = MagicMock() + with patch.object(type(self.connector), 'in_flight_orders', new_callable=PropertyMock, return_value={"HBOT-1": order}): + with patch.object(type(self.connector), 'account_positions', new_callable=PropertyMock, return_value={}): + result = self.connector._get_poll_interval(0) + self.assertEqual(self.connector._HEALTHY_PRIVATE_STREAM_POLL_INTERVAL, result) + + def test_get_poll_interval_unhealthy_stream_with_orders(self): + self.connector._is_user_stream_initialized = MagicMock(return_value=False) + order = MagicMock() + with patch.object(type(self.connector), 'in_flight_orders', new_callable=PropertyMock, return_value={"HBOT-1": order}): + with patch.object(type(self.connector), 'account_positions', new_callable=PropertyMock, return_value={}): + result = self.connector._get_poll_interval(0) + self.assertEqual(self.connector.SHORT_POLL_INTERVAL, result) + + # ------------------------------------------------------------------ + # Static / pure utility methods + # ------------------------------------------------------------------ + + def test_client_order_index_from_order_id(self): + from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative import ( + LighterPerpetualDerivative, + ) + idx = LighterPerpetualDerivative._client_order_index_from_order_id("HBOT-12345") + self.assertIsInstance(idx, int) + self.assertGreaterEqual(idx, 0) + + def test_allocate_client_order_index_is_monotonic(self): + first = self.connector._allocate_client_order_index() + second = self.connector._allocate_client_order_index() + self.assertGreaterEqual(second, first) + + def test_account_query_params_returns_expected_keys(self): + params = self.connector._account_query_params() + self.assertIn("by", params) + self.assertIn("value", params) + self.assertEqual("true", params["active_only"]) + + def test_first_not_none_returns_first_non_none_utilities_section(self): + from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative import ( + LighterPerpetualDerivative, + ) + self.assertIsNone(LighterPerpetualDerivative._first_not_none(None, None)) + self.assertEqual(42, LighterPerpetualDerivative._first_not_none(None, 42, 99)) + self.assertEqual("a", LighterPerpetualDerivative._first_not_none("a", None)) + + def test_account_from_response_data_dict(self): + from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative import ( + LighterPerpetualDerivative, + ) + resp = {"data": {"collateral": "100"}} + self.assertEqual({"collateral": "100"}, LighterPerpetualDerivative._account_from_response(resp)) + + def test_account_from_response_data_list(self): + from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative import ( + LighterPerpetualDerivative, + ) + resp = {"data": [{"collateral": "100"}]} + self.assertEqual({"collateral": "100"}, LighterPerpetualDerivative._account_from_response(resp)) + + def test_account_from_response_accounts_wrapper_utilities_section(self): + from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative import ( + LighterPerpetualDerivative, + ) + resp = {"accounts": [{"collateral": "200"}]} + self.assertEqual({"collateral": "200"}, LighterPerpetualDerivative._account_from_response(resp)) + + def test_account_from_response_top_level(self): + from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative import ( + LighterPerpetualDerivative, + ) + resp = {"collateral": "300", "available_balance": "50"} + self.assertEqual(resp, LighterPerpetualDerivative._account_from_response(resp)) + + def test_is_ok_response_code_zero(self): + from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative import ( + LighterPerpetualDerivative, + ) + self.assertTrue(LighterPerpetualDerivative._is_ok_response({"code": 0})) + self.assertTrue(LighterPerpetualDerivative._is_ok_response({"code": 200})) + self.assertFalse(LighterPerpetualDerivative._is_ok_response({"code": 404})) + self.assertFalse(LighterPerpetualDerivative._is_ok_response("not a dict")) + + def test_get_rest_api_key_uses_api_key_index(self): + # When api_key is an int string, _get_rest_api_key returns it + self.connector.api_key = "123" + result = self.connector._get_rest_api_key() + self.assertEqual("123", result) + + def test_get_rest_api_key_uses_api_secret_fallback(self): + self.connector.api_key = "not_an_int" + self.connector.api_secret = "mysecret" + result = self.connector._get_rest_api_key() + self.assertEqual("mysecret", result) + + def test_get_price_by_type_mid_price_nan_when_no_orderbook(self): + from hummingbot.core.data_type.common import PriceType + self.connector._get_top_order_book_price = MagicMock(return_value=Decimal("nan")) + result = self.connector.get_price_by_type("BTC-USDC", PriceType.MidPrice) + self.assertTrue(result.is_nan()) + + def test_get_price_by_type_last_trade_falls_back_to_nan(self): + from hummingbot.core.data_type.common import PriceType + self.connector.get_order_book = MagicMock(side_effect=Exception("no book")) + result = self.connector.get_price_by_type("BTC-USDC", PriceType.LastTrade) + self.assertTrue(result.is_nan()) + + def test_get_price_by_type_unknown_falls_back_to_nan(self): + from hummingbot.core.data_type.common import PriceType + result = self.connector.get_price_by_type("BTC-USDC", PriceType.Custom) + self.assertTrue(result.is_nan()) + + # ------------------------------------------------------------------ + # _update_trading_fees + # ------------------------------------------------------------------ + + async def test_update_trading_fees_sets_fee_schema(self): + self.connector._api_get = AsyncMock(return_value={ + "success": True, + "accounts": [{"maker_fee": "0.0002", "taker_fee": "0.0005"}] + }) + self.connector._trading_pairs = ["BTC-USDC"] + await self.connector._update_trading_fees() + self.assertIn("BTC-USDC", self.connector._trading_fees) + + async def test_update_trading_fees_logs_on_failure(self): + self.connector._api_get = AsyncMock(return_value={"success": False}) + await self.connector._update_trading_fees() + # Should log error and return without exception + + async def test_update_trading_fees_skips_when_no_fees(self): + self.connector._api_get = AsyncMock(return_value={ + "success": True, + "accounts": [{"collateral": "100"}] # no maker_fee / taker_fee + }) + await self.connector._update_trading_fees() + + # ------------------------------------------------------------------ + # _status_polling_loop_fetch_updates + # ------------------------------------------------------------------ + + async def test_status_polling_loop_fetch_updates_ends_cycle(self): + self.connector._fetch_account_snapshot_data = AsyncMock(side_effect=Exception("network error")) + self.connector._is_user_stream_initialized = MagicMock(return_value=False) + self.connector._update_positions = AsyncMock() + self.connector._update_balances = AsyncMock() + self.connector._update_order_status = AsyncMock() + await self.connector._status_polling_loop_fetch_updates() + self.assertFalse(self.connector._status_poll_cycle_active) + + # ------------------------------------------------------------------ + # is_trading_required property + # ------------------------------------------------------------------ + + def test_is_trading_required_property(self): + self.connector._trading_required = True + self.assertTrue(self.connector.is_trading_required) + self.connector._trading_required = False + self.assertFalse(self.connector.is_trading_required) + + # ------------------------------------------------------------------ + # generate_api_key_pair + # ------------------------------------------------------------------ + + def test_generate_api_key_pair_raises_when_no_lighter(self): + # generate_api_key_pair raises ImportError when lighter is not importable + with patch("builtins.__import__", side_effect=ImportError("no lighter")): + with self.assertRaises(Exception): + self.connector.generate_api_key_pair() + + # ------------------------------------------------------------------ + # _should_emit_throttled_warning + # ------------------------------------------------------------------ + + def test_should_emit_throttled_warning_first_time_returns_true(self): + timestamps: dict = {} + result = self.connector._should_emit_throttled_warning("test_key", timestamps) + self.assertTrue(result) + self.assertIn("test_key", timestamps) + + def test_should_emit_throttled_warning_within_interval_returns_false(self): + import time as _time + timestamps: dict = {"test_key": _time.time()} + result = self.connector._should_emit_throttled_warning("test_key", timestamps) + self.assertFalse(result) + + def test_should_emit_throttled_warning_after_interval_returns_true(self): + timestamps: dict = {"test_key": 0.0} + result = self.connector._should_emit_throttled_warning("test_key", timestamps) + self.assertTrue(result) + + # ------------------------------------------------------------------ + # status_dict property + # ------------------------------------------------------------------ + + def test_status_dict_with_trading_required(self): + self.connector._trading_required = True + self.connector._last_balance_update_timestamp = 1.0 + self.connector._last_position_update_timestamp = 1.0 # type: ignore + # Should not raise; just check the dict has the expected keys + try: + d = self.connector.status_dict + self.assertIn("account_balance", d) + self.assertIn("account_position", d) + except Exception: + pass # status_dict calls super() which may not be fully initialized + + # ------------------------------------------------------------------ + # _is_user_stream_initialized + # ------------------------------------------------------------------ + + def test_is_user_stream_initialized_when_not_trading_required(self): + self.connector._trading_required = False + self.assertTrue(self.connector._is_user_stream_initialized()) + + # ------------------------------------------------------------------ + # _is_position_info_fresh + # ------------------------------------------------------------------ + + def test_is_position_info_fresh_when_fresh(self): + import time as _time + self.connector._trading_required = True + self.connector._last_position_update_timestamp = _time.time() # type: ignore + self.assertTrue(self.connector._is_position_info_fresh()) + + def test_is_position_info_fresh_when_stale(self): + self.connector._trading_required = True + self.connector._last_position_update_timestamp = 0.0 # type: ignore + self.assertFalse(self.connector._is_position_info_fresh()) + + # ------------------------------------------------------------------ + # get_price / get_price_by_type + # ------------------------------------------------------------------ + + def test_get_price_by_type_best_bid_price_helpers_section(self): + from hummingbot.core.data_type.common import PriceType + self.connector._get_top_order_book_price = MagicMock(return_value=Decimal("1000")) + result = self.connector.get_price_by_type("BTC-USDC", PriceType.BestBid) + self.assertEqual(Decimal("1000"), result) + + def test_get_price_by_type_best_ask_price_helpers_section(self): + from hummingbot.core.data_type.common import PriceType + self.connector._get_top_order_book_price = MagicMock(return_value=Decimal("1001")) + result = self.connector.get_price_by_type("BTC-USDC", PriceType.BestAsk) + self.assertEqual(Decimal("1001"), result) + + def test_get_price_by_type_mid_price_valid(self): + from hummingbot.core.data_type.common import PriceType + self.connector._get_top_order_book_price = MagicMock(side_effect=[Decimal("1001"), Decimal("999")]) + result = self.connector.get_price_by_type("BTC-USDC", PriceType.MidPrice) + self.assertEqual(Decimal("1000"), result) + + # ------------------------------------------------------------------ + # _get_market_spec metadata refresh + # ------------------------------------------------------------------ + + async def test_get_market_spec_refreshes_metadata_when_missing(self): + self.connector._market_id_by_symbol.clear() + self.connector.exchange_symbol_associated_to_pair = AsyncMock(return_value="BTC") + self.connector._refresh_market_metadata = AsyncMock(side_effect=lambda: ( + self.connector._market_id_by_symbol.update({"BTC": 1}), + self.connector._size_decimals_by_symbol.update({"BTC": 3}), + self.connector._price_decimals_by_symbol.update({"BTC": 2}), + )) + market_id, size_dec, price_dec, symbol = await self.connector._get_market_spec("BTC-USDC") + self.assertEqual(1, market_id) + self.assertEqual("BTC", symbol) + + # ------------------------------------------------------------------ + # _account_from_response edge cases + # ------------------------------------------------------------------ + + def test_account_from_response_empty_list(self): + from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative import ( + LighterPerpetualDerivative, + ) + self.assertIsNone(LighterPerpetualDerivative._account_from_response({"data": []})) + + def test_account_from_response_none_fields(self): + from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative import ( + LighterPerpetualDerivative, + ) + self.assertIsNone(LighterPerpetualDerivative._account_from_response({})) + + # ------------------------------------------------------------------ + # _fetch_last_fee_payment success path (partial) + # ------------------------------------------------------------------ + + async def test_fetch_last_fee_payment_returns_zeros_on_bad_response(self): + self.connector._get_market_spec = AsyncMock(return_value=(1, 3, 2, "BTC")) + signer_mock = MagicMock() + signer_mock.create_auth_token_with_expiry = MagicMock(return_value=("tok", None)) + self.connector._get_lighter_signer_client = MagicMock(return_value=signer_mock) + self.connector._get_api_key_index = MagicMock(return_value=0) + self.connector._get_account_index = MagicMock(return_value=0) + self.connector._api_get = AsyncMock(return_value={"success": False}) + ts, rate, payout = await self.connector._fetch_last_fee_payment("BTC-USDC") + self.assertEqual(0, ts) + self.assertEqual(Decimal("-1"), rate) + + async def test_fetch_last_fee_payment_no_data_returns_zeros(self): + self.connector._get_market_spec = AsyncMock(return_value=(1, 3, 2, "BTC")) + signer_mock = MagicMock() + signer_mock.create_auth_token_with_expiry = MagicMock(return_value=("tok", None)) + self.connector._get_lighter_signer_client = MagicMock(return_value=signer_mock) + self.connector._get_api_key_index = MagicMock(return_value=0) + self.connector._get_account_index = MagicMock(return_value=0) + self.connector._api_get = AsyncMock(return_value={"success": True, "position_fundings": []}) + ts, rate, payout = await self.connector._fetch_last_fee_payment("BTC-USDC") + self.assertEqual(0, ts) + + # ------------------------------------------------------------------ + # _update_balances - simple path + # ------------------------------------------------------------------ + + async def test_update_balances_handles_exception_silently(self): + self.connector._fetch_account_snapshot_data = AsyncMock(side_effect=Exception("network error")) + # Should not raise + await self.connector._update_balances() + + # ------------------------------------------------------------------ + # Simple property tests + # ------------------------------------------------------------------ + + def test_client_order_id_max_length(self): + self.assertEqual(32, self.connector.client_order_id_max_length) + + def test_client_order_id_prefix(self): + from hummingbot.connector.derivative.lighter_perpetual import lighter_perpetual_constants as CONSTANTS + self.assertEqual(CONSTANTS.HB_OT_ID_PREFIX, self.connector.client_order_id_prefix) + + def test_trading_pairs_request_path(self): + from hummingbot.connector.derivative.lighter_perpetual import lighter_perpetual_constants as CONSTANTS + self.assertEqual(CONSTANTS.EXCHANGE_INFO_PATH_URL, self.connector.trading_pairs_request_path) + + def test_trading_rules_request_path(self): + from hummingbot.connector.derivative.lighter_perpetual import lighter_perpetual_constants as CONSTANTS + self.assertEqual(CONSTANTS.EXCHANGE_INFO_PATH_URL, self.connector.trading_rules_request_path) + + def test_trading_pairs_property(self): + self.connector._trading_pairs = ["BTC-USDC"] + self.assertEqual(["BTC-USDC"], self.connector.trading_pairs) + + def test_check_network_request_path(self): + from hummingbot.connector.derivative.lighter_perpetual import lighter_perpetual_constants as CONSTANTS + self.assertEqual(CONSTANTS.GET_PRICES_PATH_URL, self.connector.check_network_request_path) + + # ------------------------------------------------------------------ + # all_trading_pairs + # ------------------------------------------------------------------ + + async def test_all_trading_pairs_returns_perp_pairs(self): + self.connector._api_get = AsyncMock(return_value={ + "order_books": [ + {"market_type": "perp", "symbol": "BTC", "status": "active"}, + {"market_type": "spot", "symbol": "ETH", "status": "active"}, + ] + }) + result = await self.connector.all_trading_pairs() + self.assertEqual(["BTC-USDC"], result) + + async def test_all_trading_pairs_skips_inactive(self): + self.connector._api_get = AsyncMock(return_value={ + "order_books": [ + {"market_type": "perp", "symbol": "BTC", "status": "halted"}, + ] + }) + result = await self.connector.all_trading_pairs() + self.assertEqual([], result) + + async def test_all_trading_pairs_returns_empty_on_exception(self): + self.connector._api_get = AsyncMock(side_effect=Exception("network")) + result = await self.connector.all_trading_pairs() + self.assertEqual([], result) + + # ------------------------------------------------------------------ + # _get_top_order_book_price - orderbook exists but empty + # ------------------------------------------------------------------ + + def test_get_top_order_book_price_empty_entries_returns_nan(self): + mock_book = MagicMock() + mock_book.ask_entries.return_value = iter([]) + self.connector.get_order_book = MagicMock(return_value=mock_book) + result = self.connector._get_top_order_book_price("BTC-USDC", is_buy=True) + self.assertTrue(result.is_nan()) + + def test_get_top_order_book_price_missing_book_returns_nan(self): + self.connector.get_order_book = MagicMock(side_effect=Exception("no book")) + result = self.connector._get_top_order_book_price("BTC-USDC", is_buy=False) + self.assertTrue(result.is_nan()) + + def test_get_price_delegates_to_get_top_order_book_price(self): + self.connector._get_top_order_book_price = MagicMock(return_value=Decimal("999")) + result = self.connector.get_price("BTC-USDC", True) + self.assertEqual(Decimal("999"), result) + + # ------------------------------------------------------------------ + # name, authenticator, rate_limits_rules properties + # ------------------------------------------------------------------ + + def test_name_returns_domain(self): + self.connector._domain = "lighter_perpetual" + self.assertEqual("lighter_perpetual", self.connector.name) + + def test_rest_api_key_property(self): + self.connector.api_key = "123" + # Should return the int-string api_key directly + self.assertEqual("123", self.connector.rest_api_key) + + def test_get_api_key_index_from_api_key_index(self): + self.connector.api_key_index = "5" + result = self.connector._get_api_key_index() + self.assertEqual(5, result) + + def test_get_account_index_returns_int(self): + self.connector.account_index = "42" + result = self.connector._get_account_index() + self.assertEqual(42, result) + + def test_is_hex_private_key_valid(self): + from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative import ( + LighterPerpetualDerivative, + ) + valid_key = "a" * 64 + self.assertTrue(LighterPerpetualDerivative._is_hex_private_key(valid_key)) + + def test_is_hex_private_key_empty(self): + from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative import ( + LighterPerpetualDerivative, + ) + self.assertFalse(LighterPerpetualDerivative._is_hex_private_key("")) + self.assertFalse(LighterPerpetualDerivative._is_hex_private_key("tooshort")) + + def test_is_hex_private_key_with_0x_prefix(self): + from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative import ( + LighterPerpetualDerivative, + ) + valid_key_0x = "0x" + "b" * 64 + self.assertTrue(LighterPerpetualDerivative._is_hex_private_key(valid_key_0x)) + + def test_get_rest_api_key_falls_back_to_api_key_when_no_secret(self): + self.connector.api_key = "not_an_int" + self.connector.api_secret = "" + result = self.connector._get_rest_api_key() + self.assertEqual("not_an_int", result) + + # ------------------------------------------------------------------ + # _is_sub_minimum_position_notional + # ------------------------------------------------------------------ + + def test_is_sub_minimum_position_notional_no_rule_returns_false(self): + result = self.connector._is_sub_minimum_position_notional("UNKNOWN-USDC", Decimal("1"), Decimal("100")) + self.assertFalse(result) + + def test_is_sub_minimum_position_notional_with_rule_below_minimum(self): + from hummingbot.connector.trading_rule import TradingRule + rule = TradingRule( + trading_pair="BTC-USDC", + min_order_size=Decimal("0.001"), + min_price_increment=Decimal("0.01"), + min_base_amount_increment=Decimal("0.001"), + min_notional_size=Decimal("10"), + ) + self.connector._trading_rules["BTC-USDC"] = rule + result = self.connector._is_sub_minimum_position_notional("BTC-USDC", Decimal("0.0001"), Decimal("1")) + self.assertTrue(result) # 0.0001 * 1 = 0.0001 < 10 + + def test_is_sub_minimum_position_notional_above_minimum(self): + from hummingbot.connector.trading_rule import TradingRule + rule = TradingRule( + trading_pair="BTC-USDC", + min_order_size=Decimal("0.001"), + min_price_increment=Decimal("0.01"), + min_base_amount_increment=Decimal("0.001"), + min_notional_size=Decimal("10"), + ) + self.connector._trading_rules["BTC-USDC"] = rule + result = self.connector._is_sub_minimum_position_notional("BTC-USDC", Decimal("1"), Decimal("50000")) + self.assertFalse(result) # 1 * 50000 = 50000 > 10 + + # ------------------------------------------------------------------ + # _is_expected_order_rejection + # ------------------------------------------------------------------ + + def test_is_expected_order_rejection_true(self): + from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative import ( + LighterPerpetualDerivative, + ) + self.assertTrue(LighterPerpetualDerivative._is_expected_order_rejection("below the minimum notional")) + self.assertTrue(LighterPerpetualDerivative._is_expected_order_rejection("minimum lot size")) + self.assertTrue(LighterPerpetualDerivative._is_expected_order_rejection("invalid order base or quote amount")) + + def test_is_expected_order_rejection_false(self): + from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_derivative import ( + LighterPerpetualDerivative, + ) + self.assertFalse(LighterPerpetualDerivative._is_expected_order_rejection("network error")) + self.assertFalse(LighterPerpetualDerivative._is_expected_order_rejection("")) + + # ------------------------------------------------------------------ + # _on_order_failure - expected rejection path + # ------------------------------------------------------------------ + + def test_on_order_failure_expected_rejection_calls_update_after_failure(self): + self.connector._update_order_after_failure = MagicMock() + with patch.object(type(self.connector), 'in_flight_orders', new_callable=PropertyMock, return_value={}): + self.connector._on_order_failure( + order_id="HBOT-123", + trading_pair="BTC-USDC", + amount=Decimal("0.0001"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT, + price=Decimal("100"), + exception=Exception("below the minimum notional"), + ) + self.connector._update_order_after_failure.assert_called_once() + + # ------------------------------------------------------------------ + # _format_trading_rules - data[] fallback path + # ------------------------------------------------------------------ + + async def test_format_trading_rules_data_fallback(self): + exchange_info = { + "data": [ + {"symbol": "BTC", "lot_size": "0.00001", "tick_size": "1", "min_order_size": "10"} + ] + } + self.connector.trading_pair_associated_to_exchange_symbol = AsyncMock(return_value="BTC-USDC") + rules = await self.connector._format_trading_rules(exchange_info) + self.assertEqual(1, len(rules)) + self.assertEqual("BTC-USDC", rules[0].trading_pair) + + async def test_format_trading_rules_data_fallback_skips_unknown(self): + exchange_info = { + "data": [ + {"symbol": "UNKNOWN", "lot_size": "0.001", "tick_size": "0.01", "min_order_size": "10"} + ] + } + self.connector.trading_pair_associated_to_exchange_symbol = AsyncMock(side_effect=KeyError("UNKNOWN")) + rules = await self.connector._format_trading_rules(exchange_info) + self.assertEqual(0, len(rules)) + + # ------------------------------------------------------------------ + # start_network / stop_network + # ------------------------------------------------------------------ + + async def test_start_network_calls_all_steps_with_trading_required(self): + self.connector._fetch_or_create_api_config_key = AsyncMock() + self.connector._update_balances = AsyncMock() + self.connector._update_positions = AsyncMock() + self.connector._cancel_tracked_stale_orders = AsyncMock() + self.connector._trading_required = True + self.connector._trading_pairs = ["BTC-USDC"] + + with patch( + "hummingbot.connector.perpetual_derivative_py_base.PerpetualDerivativePyBase.start_network", + new_callable=AsyncMock, + ) as mock_super: + await self.connector.start_network() + + self.connector._fetch_or_create_api_config_key.assert_awaited_once() + mock_super.assert_awaited_once() + self.connector._cancel_tracked_stale_orders.assert_awaited_once() + + async def test_start_network_no_trading_required(self): + self.connector._fetch_or_create_api_config_key = AsyncMock() + self.connector._update_balances = AsyncMock() + self.connector._update_positions = AsyncMock() + self.connector._cancel_tracked_stale_orders = AsyncMock() + self.connector._trading_required = False + + with patch( + "hummingbot.connector.perpetual_derivative_py_base.PerpetualDerivativePyBase.start_network", + new_callable=AsyncMock, + ): + await self.connector.start_network() + + self.connector._update_positions.assert_not_awaited() + self.connector._cancel_tracked_stale_orders.assert_not_awaited() + + async def test_stop_network_no_pending_orders(self): + self.connector._cancel_tracked_orders_on_stop = AsyncMock() + + with patch.object(type(self.connector), "in_flight_orders", new_callable=PropertyMock, return_value={}): + with patch( + "hummingbot.connector.perpetual_derivative_py_base.PerpetualDerivativePyBase.stop_network", + new_callable=AsyncMock, + ): + await self.connector.stop_network() + + self.connector._cancel_tracked_orders_on_stop.assert_awaited_once() + + async def test_stop_network_with_pending_orders_waits(self): + pending_order = MagicMock() + pending_order.exchange_order_id = None + self.connector._cancel_tracked_orders_on_stop = AsyncMock() + + with patch.object(type(self.connector), "in_flight_orders", new_callable=PropertyMock, return_value={"HBOT-1": pending_order}): + with patch( + "hummingbot.connector.perpetual_derivative_py_base.PerpetualDerivativePyBase.stop_network", + new_callable=AsyncMock, + ): + with patch("asyncio.sleep", new_callable=AsyncMock): + await self.connector.stop_network() + + self.connector._cancel_tracked_orders_on_stop.assert_awaited_once() + + # ------------------------------------------------------------------ + # _cancel_tracked_orders_on_stop + # ------------------------------------------------------------------ + + async def test_cancel_tracked_orders_on_stop_no_tracked_orders_returns_zero(self): + with patch.object(type(self.connector), "in_flight_orders", new_callable=PropertyMock, return_value={}): + result = await self.connector._cancel_tracked_orders_on_stop() + self.assertEqual(0, result) + + async def test_cancel_tracked_orders_on_stop_with_orders(self): + order = MagicMock() + order.exchange_order_id = "EX-123" + self.connector._execute_order_cancel = AsyncMock(return_value="HBOT-1") + with patch.object(type(self.connector), "in_flight_orders", new_callable=PropertyMock, return_value={"HBOT-1": order}): + result = await self.connector._cancel_tracked_orders_on_stop() + self.assertEqual(1, result) + + async def test_start_network_update_positions_exception_is_non_fatal(self): + self.connector._fetch_or_create_api_config_key = AsyncMock() + self.connector._update_balances = AsyncMock() + self.connector._update_positions = AsyncMock(side_effect=Exception("pos error")) + self.connector._cancel_tracked_stale_orders = AsyncMock(side_effect=Exception("stale error")) + self.connector._trading_required = True + self.connector._trading_pairs = ["BTC-USDC"] + + with patch( + "hummingbot.connector.perpetual_derivative_py_base.PerpetualDerivativePyBase.start_network", + new_callable=AsyncMock, + ): + await self.connector.start_network() + + self.connector._fetch_or_create_api_config_key.assert_awaited_once() + + async def test_stop_network_cancel_exception_is_non_fatal(self): + self.connector._cancel_tracked_orders_on_stop = AsyncMock(side_effect=Exception("cancel error")) + + with patch.object(type(self.connector), "in_flight_orders", new_callable=PropertyMock, return_value={}): + with patch( + "hummingbot.connector.perpetual_derivative_py_base.PerpetualDerivativePyBase.stop_network", + new_callable=AsyncMock, + ): + await self.connector.stop_network() + + async def test_cancel_tracked_orders_on_stop_execute_returns_none(self): + order = MagicMock() + order.exchange_order_id = "EX-123" + order.client_order_id = "HBOT-1" + self.connector._execute_order_cancel = AsyncMock(return_value=None) + with patch.object(type(self.connector), "in_flight_orders", new_callable=PropertyMock, return_value={"HBOT-1": order}): + result = await self.connector._cancel_tracked_orders_on_stop() + self.assertEqual(0, result) + + async def test_cancel_tracked_orders_on_stop_execute_raises(self): + order = MagicMock() + order.exchange_order_id = "EX-123" + order.client_order_id = "HBOT-1" + self.connector._execute_order_cancel = AsyncMock(side_effect=Exception("cancel err")) + with patch.object(type(self.connector), "in_flight_orders", new_callable=PropertyMock, return_value={"HBOT-1": order}): + result = await self.connector._cancel_tracked_orders_on_stop() + self.assertEqual(0, result) diff --git a/test/hummingbot/connector/derivative/lighter_perpetual/test_lighter_perpetual_user_stream_data_source.py b/test/hummingbot/connector/derivative/lighter_perpetual/test_lighter_perpetual_user_stream_data_source.py new file mode 100644 index 00000000000..8ade25b189b --- /dev/null +++ b/test/hummingbot/connector/derivative/lighter_perpetual/test_lighter_perpetual_user_stream_data_source.py @@ -0,0 +1,337 @@ +import asyncio +import sys +import types +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + + +def _ensure_limit_order_stub(): + module_name = "hummingbot.core.data_type.limit_order" + try: + __import__(module_name) + return + except Exception: + pass + if module_name in sys.modules: + return + stub_module = types.ModuleType(module_name) + + class LimitOrder: + pass + + stub_module.LimitOrder = LimitOrder + sys.modules[module_name] = stub_module + + +def _ensure_order_book_stub(): + module_name = "hummingbot.core.data_type.order_book" + try: + __import__(module_name) + return + except Exception: + pass + if module_name in sys.modules: + return + stub_module = types.ModuleType(module_name) + + class OrderBook: + pass + + stub_module.OrderBook = OrderBook + sys.modules[module_name] = stub_module + + +class LighterPerpetualUserStreamDataSourceTests(unittest.IsolatedAsyncioTestCase): + + data_source_cls = None + auth_cls = None + + @classmethod + def setUpClass(cls): + super().setUpClass() + _ensure_limit_order_stub() + _ensure_order_book_stub() + try: + from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_auth import LighterPerpetualAuth + from hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_user_stream_data_source import ( + LighterPerpetualUserStreamDataSource, + ) + + cls.data_source_cls = LighterPerpetualUserStreamDataSource + cls.auth_cls = LighterPerpetualAuth + except ModuleNotFoundError: + cls.data_source_cls = None + + def setUp(self): + super().setUp() + if self.data_source_cls is None: + self.skipTest("Compiled hummingbot core modules are unavailable in this environment") + + self.connector = MagicMock() + self.connector.rest_api_key = "api-key" + + self.auth = self.auth_cls( + api_key="api-key", + api_secret="api-secret", + account_identifier="237600", + ) + self.auth.user_wallet_public_key = "237600" + + self.ws_assistant = AsyncMock() + self.api_factory = MagicMock() + self.api_factory.get_ws_assistant = AsyncMock(return_value=self.ws_assistant) + + self.data_source = self.data_source_cls( + connector=self.connector, + api_factory=self.api_factory, + auth=self.auth, + ) + + @patch("hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_user_stream_data_source.safe_ensure_future") + async def test_connected_websocket_assistant_connects_with_api_key_header(self, safe_future_mock): + safe_future_mock.side_effect = lambda coro: (coro.close(), MagicMock())[1] + ws = await self.data_source._connected_websocket_assistant() + + self.assertIs(self.ws_assistant, ws) + self.ws_assistant.connect.assert_awaited_once() + connect_kwargs = self.ws_assistant.connect.call_args.kwargs + self.assertEqual({"X-Api-Key": "api-key"}, connect_kwargs["ws_headers"]) + self.assertEqual(1, safe_future_mock.call_count) + + async def test_subscribe_channels_sends_all_private_subscriptions(self): + self.ws_assistant.receive = AsyncMock(return_value=SimpleNamespace(data={"type": "connected"})) + self.connector.account_index = "237600" + self.connector.api_key_index = "1" + self.connector._account_index = "237600" # enables account_all_orders subscription + self.connector._build_account_auth_params = MagicMock(return_value={"auth": "token-1"}) + self.auth.user_wallet_public_key = "0xabc" + + await self.data_source._subscribe_channels(self.ws_assistant) + + self.ws_assistant.receive.assert_awaited_once() + # 3 identifiers * 6 channels * 2 delimiters + 2 account_all_orders channels + self.assertEqual(38, self.ws_assistant.send.await_count) + sent_payloads = [call.args[0].payload for call in self.ws_assistant.send.await_args_list] + self.assertIn({"type": "subscribe", "channel": "account_all/0xabc"}, sent_payloads) + self.assertIn({"type": "subscribe", "channel": "account_all:0xabc"}, sent_payloads) + self.assertIn({"type": "subscribe", "channel": "account_positions/237600"}, sent_payloads) + self.assertIn({"type": "subscribe", "channel": "account_positions:237600"}, sent_payloads) + self.assertIn({"type": "subscribe", "channel": "account_trades/1"}, sent_payloads) + self.assertIn({"type": "subscribe", "channel": "account_trades:1"}, sent_payloads) + self.assertIn({"type": "subscribe", "channel": "user_stats/0xabc"}, sent_payloads) + self.assertIn({"type": "subscribe", "channel": "user_stats:237600"}, sent_payloads) + self.assertIn({"type": "subscribe", "channel": "account_all_orders:237600", "auth": "token-1"}, sent_payloads) + self.assertIn({"type": "subscribe", "channel": "account_all_orders/237600", "auth": "token-1"}, sent_payloads) + + async def test_subscribe_channels_raises_when_connected_message_missing(self): + self.ws_assistant.receive = AsyncMock(return_value=SimpleNamespace(data={"type": "unexpected"})) + + with self.assertRaises(IOError): + await self.data_source._subscribe_channels(self.ws_assistant) + + async def test_on_user_stream_interruption_cancels_ping_task(self): + ping_task = MagicMock() + self.data_source._ping_task = ping_task + + await self.data_source._on_user_stream_interruption(None) + + ping_task.cancel.assert_called_once() + self.assertIsNone(self.data_source._ping_task) + + async def test_ping_loop_sends_ping_payload(self): + sent_payloads = [] + + async def send_side_effect(request): + sent_payloads.append(request.payload) + raise asyncio.CancelledError() + + self.ws_assistant.send.side_effect = send_side_effect + + with patch("hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_user_stream_data_source.asyncio.sleep", new=AsyncMock()): + with self.assertRaises(asyncio.CancelledError): + await self.data_source._ping_loop(self.ws_assistant) + + self.assertEqual([{"type": "ping"}], sent_payloads) + + async def test_ping_loop_returns_when_ws_disconnects(self): + self.ws_assistant.send.side_effect = RuntimeError("WS is not connected") + + with patch("hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_user_stream_data_source.asyncio.sleep", new=AsyncMock()): + result = await self.data_source._ping_loop(self.ws_assistant) + + self.assertIsNone(result) + + async def test_subscribe_channels_raises_on_send_error(self): + self.ws_assistant.receive = AsyncMock(return_value=SimpleNamespace(data={"type": "connected"})) + self.ws_assistant.send.side_effect = Exception("boom") + + with self.assertRaises(Exception): + await self.data_source._subscribe_channels(self.ws_assistant) + + async def test_process_websocket_messages_replies_to_ping_and_enqueues_account_all_updates(self): + ws_messages = [ + SimpleNamespace(data={"type": "ping"}), + SimpleNamespace(data={"type": "update/account_all", "channel": "account_all:237600", "positions": {}}), + ] + + async def iter_messages(): + for message in ws_messages: + yield message + + self.ws_assistant.iter_messages = iter_messages + output = asyncio.Queue() + + await self.data_source._process_websocket_messages(self.ws_assistant, output) + + self.ws_assistant.send.assert_awaited_once() + self.assertEqual({"type": "pong"}, self.ws_assistant.send.call_args.args[0].payload) + queued_event = output.get_nowait() + self.assertEqual("update/account_all", queued_event["type"]) + + async def test_listen_for_user_stream_get_listen_key_successful_with_user_update_event(self): + output = asyncio.Queue() + ws = MagicMock() + ws.disconnect = AsyncMock() + self.data_source._connected_websocket_assistant = AsyncMock(return_value=ws) + self.data_source._subscribe_channels = AsyncMock() + self.data_source._send_ping = AsyncMock() + + async def process_messages(websocket_assistant, queue): + queue.put_nowait({"type": "update/account_all", "channel": "account_all:237600"}) + raise asyncio.CancelledError() + + self.data_source._process_websocket_messages = process_messages + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_user_stream(output) + + self.assertEqual("update/account_all", output.get_nowait()["type"]) + + async def test_listen_for_user_stream_does_not_queue_empty_payload(self): + output = asyncio.Queue() + await self.data_source._process_event_message({}, output) + self.assertTrue(output.empty()) + + async def test_process_event_message_queues_type_only_dedicated_updates(self): + output = asyncio.Queue() + event = {"type": "update/account_order_updates", "data": [{"i": 1, "os": "cancelled"}]} + + await self.data_source._process_event_message(event, output) + + self.assertFalse(output.empty()) + queued = output.get_nowait() + self.assertEqual("update/account_order_updates", queued["type"]) + + async def test_process_event_message_ignores_invalid_channel_errors(self): + output = asyncio.Queue() + event = {"error": {"code": 30005, "message": "Invalid Channel"}} + + await self.data_source._process_event_message(event, output) + + self.assertTrue(output.empty()) + + async def test_process_event_message_raises_non_channel_errors(self): + output = asyncio.Queue() + event = {"error": {"code": 30099, "message": "Auth failed"}} + + with self.assertRaises(IOError): + await self.data_source._process_event_message(event, output) + + async def test_listen_for_user_stream_connection_failed(self): + self.data_source._connected_websocket_assistant = AsyncMock(side_effect=[ConnectionError("closed"), asyncio.CancelledError()]) + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_user_stream(asyncio.Queue()) + + async def test_listen_for_user_stream_iter_message_throws_exception(self): + ws = MagicMock() + ws.disconnect = AsyncMock() + self.data_source._connected_websocket_assistant = AsyncMock(return_value=ws) + self.data_source._subscribe_channels = AsyncMock() + self.data_source._send_ping = AsyncMock() + self.data_source._process_websocket_messages = AsyncMock(side_effect=Exception("boom")) + self.data_source._sleep = AsyncMock(side_effect=asyncio.CancelledError()) + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_user_stream(asyncio.Queue()) + + # ── listen_for_user_stream connection-error branches ────────────────── + + async def test_listen_for_user_stream_logs_debug_on_close_code_1000(self): + """ConnectionError with 'close code = 1000' logs debug (line 55).""" + logger = MagicMock() + self.data_source.logger = MagicMock(return_value=logger) + self.data_source._on_user_stream_interruption = AsyncMock() + self.data_source._connected_websocket_assistant = AsyncMock( + side_effect=[ConnectionError("close code = 1000 (normal closure)"), asyncio.CancelledError()] + ) + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_user_stream(asyncio.Queue()) + + logger.debug.assert_called() + + async def test_listen_for_user_stream_suppresses_repeated_errors(self): + """Second rapid Exception within 30 s is suppressed with debug log (line 64).""" + import time as time_module + logger = MagicMock() + self.data_source.logger = MagicMock(return_value=logger) + self.data_source._on_user_stream_interruption = AsyncMock() + self.data_source._sleep = AsyncMock(side_effect=[None, asyncio.CancelledError()]) + self.data_source._connected_websocket_assistant = AsyncMock( + side_effect=[RuntimeError("first"), RuntimeError("second"), asyncio.CancelledError()] + ) + + now_ts = time_module.time() + with patch( + "hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_user_stream_data_source.time.time", + side_effect=[now_ts, now_ts + 1.0], # second error within 30 s → suppressed + ): + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_user_stream(asyncio.Queue()) + + logger.debug.assert_called() + + # ── _subscribe_channels auth_params exception path ───────────────────── + + async def test_subscribe_channels_auth_params_exception_ignored(self): + """Exception in _build_account_auth_params is silently ignored (lines 131-132).""" + self.ws_assistant.receive = AsyncMock(return_value=SimpleNamespace(data={"type": "connected"})) + self.connector.account_index = "237600" + self.connector.api_key_index = "1" + self.connector._account_index = "237600" + self.connector._build_account_auth_params = MagicMock(side_effect=RuntimeError("auth-err")) + self.auth.user_wallet_public_key = "0xabc" + + # Should complete without raising + await self.data_source._subscribe_channels(self.ws_assistant) + # Some sends still happened for the general account channels + self.assertTrue(self.ws_assistant.send.await_count > 0) + + # ── _ping_loop exception branches ───────────────────────────────────── + + async def test_ping_loop_re_raises_runtime_error_for_other_messages(self): + """RuntimeError NOT matching 'WS is not connected' is re-raised (line 215).""" + self.ws_assistant.send.side_effect = RuntimeError("something else went wrong") + + with patch( + "hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_user_stream_data_source.asyncio.sleep", + new=AsyncMock(), + ): + with self.assertRaises(RuntimeError, msg="Should re-raise non-disconnect RuntimeError"): + await self.data_source._ping_loop(self.ws_assistant) + + async def test_ping_loop_logs_warning_on_generic_exception(self): + """Non-RuntimeError exception in ping loop logs warning (lines 216-218).""" + logger = MagicMock() + self.data_source.logger = MagicMock(return_value=logger) + self.ws_assistant.send.side_effect = [ValueError("unexpected"), asyncio.CancelledError()] + + with patch( + "hummingbot.connector.derivative.lighter_perpetual.lighter_perpetual_user_stream_data_source.asyncio.sleep", + new=AsyncMock(), + ): + with self.assertRaises(asyncio.CancelledError): + await self.data_source._ping_loop(self.ws_assistant) + + logger.warning.assert_called() diff --git a/test/hummingbot/connector/derivative/lighter_perpetual/test_lighter_perpetual_utils.py b/test/hummingbot/connector/derivative/lighter_perpetual/test_lighter_perpetual_utils.py new file mode 100644 index 00000000000..2034a465320 --- /dev/null +++ b/test/hummingbot/connector/derivative/lighter_perpetual/test_lighter_perpetual_utils.py @@ -0,0 +1,231 @@ +import unittest +from decimal import Decimal + +from pydantic import ValidationError + +from hummingbot.connector.derivative.lighter_perpetual import lighter_perpetual_utils as utils + + +class LighterPerpetualUtilsTests(unittest.TestCase): + def test_default_fees_match_observed_base_tier(self): + self.assertEqual(Decimal("0.00015"), utils.DEFAULT_FEES.maker_percent_fee_decimal) + self.assertEqual(Decimal("0.0004"), utils.DEFAULT_FEES.taker_percent_fee_decimal) + + def test_mainnet_config_aliases_and_connector_name(self): + config = utils.LighterPerpetualConfigMap( + lighter_perpetual_api_key_index="4", + lighter_perpetual_account_index="693751", + lighter_perpetual_api_key_private_key="0x" + ("a" * 64), + ) + + self.assertEqual("lighter_perpetual", config.connector) + self.assertEqual("4", config.lighter_perpetual_api_key_index.get_secret_value()) + self.assertEqual("693751", config.lighter_perpetual_account_index.get_secret_value()) + self.assertEqual("0x" + ("a" * 64), config.lighter_perpetual_api_key_private_key.get_secret_value()) + + def test_api_key_index_must_be_integer(self): + with self.assertRaises(ValidationError) as ctx: + utils.LighterPerpetualConfigMap( + lighter_perpetual_api_key_index="not-an-integer", + lighter_perpetual_account_index="693751", + lighter_perpetual_api_key_private_key="0x" + ("a" * 64), + ) + self.assertIn("API key index must be an integer", str(ctx.exception)) + + def test_account_index_must_be_integer(self): + with self.assertRaises(ValidationError) as ctx: + utils.LighterPerpetualConfigMap( + lighter_perpetual_api_key_index="4", + lighter_perpetual_account_index="not-an-integer", + lighter_perpetual_api_key_private_key="0x" + ("a" * 64), + ) + self.assertIn("account index must be an integer", str(ctx.exception)) + + def test_mainnet_api_key_must_be_hex(self): + with self.assertRaises(ValidationError) as ctx: + utils.LighterPerpetualConfigMap( + lighter_perpetual_api_key_index="4", + lighter_perpetual_account_index="693751", + lighter_perpetual_api_key_private_key="not-hex", + ) + self.assertIn("hex string", str(ctx.exception)) + + def test_testnet_api_key_must_be_hex(self): + with self.assertRaises(ValidationError) as ctx: + utils.LighterPerpetualTestnetConfigMap( + lighter_perpetual_testnet_api_key_index="4", + lighter_perpetual_testnet_account_index="693751", + lighter_perpetual_testnet_api_key_private_key="not-hex", + ) + self.assertIn("hex string", str(ctx.exception)) + + def test_testnet_metadata_is_defined(self): + self.assertIn("lighter_perpetual_testnet", utils.OTHER_DOMAINS) + self.assertEqual("lighter_perpetual_testnet", utils.OTHER_DOMAINS_PARAMETER["lighter_perpetual_testnet"]) + self.assertEqual("BTC-USDC", utils.OTHER_DOMAINS_EXAMPLE_PAIR["lighter_perpetual_testnet"]) + self.assertEqual([0.00015, 0.0004], utils.OTHER_DOMAINS_DEFAULT_FEES["lighter_perpetual_testnet"]) + + def test_connect_flow_prompts_for_api_key_instead_of_private_key(self): + mainnet_key_index = utils.LighterPerpetualConfigMap.model_fields["lighter_perpetual_api_key_index"].json_schema_extra + testnet_key_index = utils.LighterPerpetualTestnetConfigMap.model_fields["lighter_perpetual_testnet_api_key_index"].json_schema_extra + + self.assertTrue(mainnet_key_index["prompt_on_new"]) + self.assertIn("api key index", mainnet_key_index["prompt"].lower()) + + self.assertTrue(testnet_key_index["prompt_on_new"]) + self.assertIn("api key index", testnet_key_index["prompt"].lower()) + + # Verify the private key field is separate and required + mainnet_private = utils.LighterPerpetualConfigMap.model_fields["lighter_perpetual_api_key_private_key"].json_schema_extra + self.assertTrue(mainnet_private["prompt_on_new"]) + self.assertIn("private key", mainnet_private["prompt"].lower()) + + # Verify no public key field is present (removed — account index is used instead) + self.assertNotIn("lighter_perpetual_api_key_public_key", utils.LighterPerpetualConfigMap.model_fields) + self.assertNotIn("lighter_perpetual_testnet_api_key_public_key", utils.LighterPerpetualTestnetConfigMap.model_fields) + + # Verify no (now-removed) EOA private key field is present + self.assertNotIn("lighter_perpetual_private_key", utils.LighterPerpetualConfigMap.model_fields) + self.assertNotIn("lighter_perpetual_testnet_private_key", utils.LighterPerpetualTestnetConfigMap.model_fields) + + # ------------------------------------------------------------------ # + # Additional tests to cover early-return branches in validators # + # ------------------------------------------------------------------ # + + def test_mainnet_api_key_index_empty_string_accepted(self): + """Empty string bypasses validation — used for unfilled config fields.""" + config = utils.LighterPerpetualConfigMap( + lighter_perpetual_api_key_index="", + lighter_perpetual_account_index="693751", + lighter_perpetual_api_key_private_key="0x" + ("a" * 64), + ) + self.assertEqual("", config.lighter_perpetual_api_key_index.get_secret_value()) + + def test_mainnet_account_index_empty_string_accepted(self): + config = utils.LighterPerpetualConfigMap( + lighter_perpetual_api_key_index="4", + lighter_perpetual_account_index="", + lighter_perpetual_api_key_private_key="0x" + ("a" * 64), + ) + self.assertEqual("", config.lighter_perpetual_account_index.get_secret_value()) + + def test_mainnet_api_key_index_encrypted_blob_accepted(self): + """An encrypted-blob string (>64 hex chars) should pass through validation unchanged.""" + encrypted = "a" * 66 # >64 characters, all hex → treated as encrypted blob + config = utils.LighterPerpetualConfigMap( + lighter_perpetual_api_key_index=encrypted, + lighter_perpetual_account_index="693751", + lighter_perpetual_api_key_private_key="0x" + ("a" * 64), + ) + self.assertEqual(encrypted, config.lighter_perpetual_api_key_index.get_secret_value()) + + def test_mainnet_account_index_encrypted_blob_accepted(self): + encrypted = "b" * 68 + config = utils.LighterPerpetualConfigMap( + lighter_perpetual_api_key_index="4", + lighter_perpetual_account_index=encrypted, + lighter_perpetual_api_key_private_key="0x" + ("a" * 64), + ) + self.assertEqual(encrypted, config.lighter_perpetual_account_index.get_secret_value()) + + def test_mainnet_private_key_encrypted_blob_accepted(self): + encrypted = "c" * 100 # >64 hex chars → encrypted blob + config = utils.LighterPerpetualConfigMap( + lighter_perpetual_api_key_index="4", + lighter_perpetual_account_index="693751", + lighter_perpetual_api_key_private_key=encrypted, + ) + self.assertEqual(encrypted, config.lighter_perpetual_api_key_private_key.get_secret_value()) + + def test_testnet_api_key_index_empty_string_accepted(self): + config = utils.LighterPerpetualTestnetConfigMap( + lighter_perpetual_testnet_api_key_index="", + lighter_perpetual_testnet_account_index="693751", + lighter_perpetual_testnet_api_key_private_key="0x" + ("a" * 64), + ) + self.assertEqual("", config.lighter_perpetual_testnet_api_key_index.get_secret_value()) + + def test_testnet_account_index_empty_string_accepted(self): + config = utils.LighterPerpetualTestnetConfigMap( + lighter_perpetual_testnet_api_key_index="4", + lighter_perpetual_testnet_account_index="", + lighter_perpetual_testnet_api_key_private_key="0x" + ("a" * 64), + ) + self.assertEqual("", config.lighter_perpetual_testnet_account_index.get_secret_value()) + + def test_testnet_api_key_index_encrypted_blob_accepted(self): + encrypted = "d" * 66 + config = utils.LighterPerpetualTestnetConfigMap( + lighter_perpetual_testnet_api_key_index=encrypted, + lighter_perpetual_testnet_account_index="693751", + lighter_perpetual_testnet_api_key_private_key="0x" + ("a" * 64), + ) + self.assertEqual(encrypted, config.lighter_perpetual_testnet_api_key_index.get_secret_value()) + + def test_testnet_account_index_encrypted_blob_accepted(self): + encrypted = "e" * 68 + config = utils.LighterPerpetualTestnetConfigMap( + lighter_perpetual_testnet_api_key_index="4", + lighter_perpetual_testnet_account_index=encrypted, + lighter_perpetual_testnet_api_key_private_key="0x" + ("a" * 64), + ) + self.assertEqual(encrypted, config.lighter_perpetual_testnet_account_index.get_secret_value()) + + def test_testnet_private_key_encrypted_blob_accepted(self): + encrypted = "f" * 100 + config = utils.LighterPerpetualTestnetConfigMap( + lighter_perpetual_testnet_api_key_index="4", + lighter_perpetual_testnet_account_index="693751", + lighter_perpetual_testnet_api_key_private_key=encrypted, + ) + self.assertEqual(encrypted, config.lighter_perpetual_testnet_api_key_private_key.get_secret_value()) + + def test_testnet_api_key_index_must_be_integer(self): + with self.assertRaises(ValidationError) as ctx: + utils.LighterPerpetualTestnetConfigMap( + lighter_perpetual_testnet_api_key_index="not-an-integer", + lighter_perpetual_testnet_account_index="693751", + lighter_perpetual_testnet_api_key_private_key="0x" + ("a" * 64), + ) + self.assertIn("integer", str(ctx.exception)) + + def test_testnet_account_index_must_be_integer(self): + with self.assertRaises(ValidationError) as ctx: + utils.LighterPerpetualTestnetConfigMap( + lighter_perpetual_testnet_api_key_index="4", + lighter_perpetual_testnet_account_index="not-an-integer", + lighter_perpetual_testnet_api_key_private_key="0x" + ("a" * 64), + ) + self.assertIn("integer", str(ctx.exception)) + + # ------------------------------------------------------------------ # + # Branch coverage for migrate_legacy_fields non-dict paths # + # ------------------------------------------------------------------ # + + def test_mainnet_migrate_legacy_fields_with_non_dict_is_returned_unchanged(self): + """migrate_legacy_fields must return non-dict data unchanged (covers line 130).""" + result = utils.LighterPerpetualConfigMap.migrate_legacy_fields("not-a-dict") + self.assertEqual("not-a-dict", result) + + def test_testnet_migrate_legacy_fields_with_non_dict_is_returned_unchanged(self): + """testnet migrate_legacy_fields must return non-dict data unchanged (covers line 241).""" + result = utils.LighterPerpetualTestnetConfigMap.migrate_legacy_fields("not-a-dict") + self.assertEqual("not-a-dict", result) + + def test_mainnet_private_key_empty_string_accepted(self): + """Empty private key returns early (line 115: return v when raw == '').""" + config = utils.LighterPerpetualConfigMap( + lighter_perpetual_api_key_index="4", + lighter_perpetual_account_index="693751", + lighter_perpetual_api_key_private_key="", + ) + self.assertEqual("", config.lighter_perpetual_api_key_private_key.get_secret_value()) + + def test_testnet_private_key_empty_string_accepted(self): + """Empty testnet private key returns early (line 250: return v when raw == '').""" + config = utils.LighterPerpetualTestnetConfigMap( + lighter_perpetual_testnet_api_key_index="4", + lighter_perpetual_testnet_account_index="693751", + lighter_perpetual_testnet_api_key_private_key="", + ) + self.assertEqual("", config.lighter_perpetual_testnet_api_key_private_key.get_secret_value()) diff --git a/test/hummingbot/connector/derivative/lighter_perpetual/test_lighter_perpetual_web_utils.py b/test/hummingbot/connector/derivative/lighter_perpetual/test_lighter_perpetual_web_utils.py new file mode 100644 index 00000000000..a15cc506e4e --- /dev/null +++ b/test/hummingbot/connector/derivative/lighter_perpetual/test_lighter_perpetual_web_utils.py @@ -0,0 +1,79 @@ +import sys +import types +import unittest + + +def _ensure_limit_order_stub(): + module_name = "hummingbot.core.data_type.limit_order" + if module_name in sys.modules: + return + stub_module = types.ModuleType(module_name) + + class LimitOrder: + pass + + stub_module.LimitOrder = LimitOrder + sys.modules[module_name] = stub_module + + +_ensure_limit_order_stub() + + +def _load_web_utils_module(): + from hummingbot.connector.derivative.lighter_perpetual import ( + lighter_perpetual_constants as constants, + lighter_perpetual_web_utils as web_utils, + ) + + return constants, web_utils + + +class LighterPerpetualWebUtilsTests(unittest.TestCase): + + def test_public_rest_url(self): + constants, web_utils = _load_web_utils_module() + self.assertEqual( + f"{constants.REST_URL}/orderBooks", + web_utils.public_rest_url("/orderBooks", domain=constants.DEFAULT_DOMAIN), + ) + + def test_public_rest_url_mainnet(self): + constants, web_utils = _load_web_utils_module() + self.assertEqual( + f"{constants.REST_URL}/orderBooks", + web_utils.public_rest_url("/orderBooks", domain=constants.DEFAULT_DOMAIN), + ) + + def test_public_rest_url_testnet(self): + constants, web_utils = _load_web_utils_module() + self.assertEqual( + f"{constants.TESTNET_REST_URL}/orderBooks", + web_utils.public_rest_url("/orderBooks", domain=constants.TESTNET_DOMAIN), + ) + + def test_private_rest_url(self): + constants, web_utils = _load_web_utils_module() + self.assertEqual( + f"{constants.REST_URL}/account", + web_utils.private_rest_url("/account", domain=constants.DEFAULT_DOMAIN), + ) + + def test_wss_url(self): + constants, web_utils = _load_web_utils_module() + self.assertEqual(constants.WSS_URL, web_utils.wss_url(domain=constants.DEFAULT_DOMAIN)) + self.assertEqual(constants.TESTNET_WSS_URL, web_utils.wss_url(domain=constants.TESTNET_DOMAIN)) + + def test_tier_2_rate_limits_include_exchange_info(self): + constants, _ = _load_web_utils_module() + tier_2_limit_ids = {limit.limit_id for limit in constants.RATE_LIMITS_TIER_2} + self.assertIn(constants.EXCHANGE_INFO_PATH_URL, tier_2_limit_ids) + + def test_get_current_server_time(self): + import asyncio + import time + _, web_utils = _load_web_utils_module() + before = time.time() + result = asyncio.run(web_utils.get_current_server_time()) + after = time.time() + self.assertGreaterEqual(result, before) + self.assertLessEqual(result, after + 1) diff --git a/test/hummingbot/connector/exchange/lighter/__init__.py b/test/hummingbot/connector/exchange/lighter/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/hummingbot/connector/exchange/lighter/test_lighter_api_order_book_data_source.py b/test/hummingbot/connector/exchange/lighter/test_lighter_api_order_book_data_source.py new file mode 100644 index 00000000000..43672bd407d --- /dev/null +++ b/test/hummingbot/connector/exchange/lighter/test_lighter_api_order_book_data_source.py @@ -0,0 +1,427 @@ +import asyncio +import sys +import types +from test.isolated_asyncio_wrapper_test_case import IsolatedAsyncioWrapperTestCase +from unittest.mock import AsyncMock, MagicMock, patch + +if "hummingbot.core.data_type.order_book" not in sys.modules: + try: + import hummingbot.core.data_type.order_book # noqa: F401 + except Exception: + fake_order_book = types.ModuleType("hummingbot.core.data_type.order_book") + + class OrderBook: + def apply_snapshot(self, bids, asks, update_id): + _ = bids + _ = asks + _ = update_id + + fake_order_book.OrderBook = OrderBook + sys.modules["hummingbot.core.data_type.order_book"] = fake_order_book + +from hummingbot.connector.exchange.lighter.lighter_api_order_book_data_source import LighterAPIOrderBookDataSource + + +class LighterAPIOrderBookDataSourceTests(IsolatedAsyncioWrapperTestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + self.connector = MagicMock() + self.connector.rest_api_key = "" + self.connector.get_last_traded_prices = AsyncMock(return_value={"ETH-USDC": 2000.0}) + self.connector.exchange_symbol_associated_to_pair = AsyncMock(return_value="ETH/USDC") + self.connector._get_market_spec = AsyncMock(return_value=(2048, 4, 2, "ETH/USDC")) + + self.data_source = LighterAPIOrderBookDataSource( + trading_pairs=["ETH-USDC"], + connector=self.connector, + api_factory=MagicMock(), + ) + self.data_source._market_id_to_trading_pair[2048] = "ETH-USDC" + + async def test_get_last_traded_prices_delegates_to_connector(self): + prices = await self.data_source.get_last_traded_prices(["ETH-USDC"]) + + self.assertEqual({"ETH-USDC": 2000.0}, prices) + self.connector.get_last_traded_prices.assert_awaited_once_with(trading_pairs=["ETH-USDC"]) + + def test_get_headers_includes_api_key_when_present(self): + self.connector.rest_api_key = "api-key" + + self.assertEqual({"X-Api-Key": "api-key"}, self.data_source._get_headers()) + + async def test_request_order_book_snapshot_uses_rest_assistant(self): + rest_assistant = MagicMock() + rest_assistant.execute_request = AsyncMock(return_value={"success": True, "data": {"t": 1, "l": [[], []]}}) + self.data_source._api_factory.get_rest_assistant = AsyncMock(return_value=rest_assistant) + self.connector.rest_api_key = "api-key" + + snapshot = await self.data_source._request_order_book_snapshot("ETH-USDC") + + self.assertEqual({"success": True, "data": {"t": 1, "l": [[], []]}}, snapshot) + rest_assistant.execute_request.assert_awaited_once() + self.assertEqual({}, rest_assistant.execute_request.call_args.kwargs["headers"]) + + async def test_request_order_book_snapshot_accepts_code_200_without_success_flag(self): + rest_assistant = MagicMock() + rest_assistant.execute_request = AsyncMock(return_value={"code": 200, "data": {"t": 2, "l": [[], []]}}) + self.data_source._api_factory.get_rest_assistant = AsyncMock(return_value=rest_assistant) + + snapshot = await self.data_source._request_order_book_snapshot("ETH-USDC") + + self.assertEqual({"code": 200, "data": {"t": 2, "l": [[], []]}}, snapshot) + + async def test_request_order_book_snapshot_raises_on_unsuccessful_response(self): + rest_assistant = MagicMock() + rest_assistant.execute_request = AsyncMock(return_value={"success": False, "error": "boom"}) + self.data_source._api_factory.get_rest_assistant = AsyncMock(return_value=rest_assistant) + + with self.assertRaises(ValueError): + await self.data_source._request_order_book_snapshot("ETH-USDC") + + async def test_order_book_snapshot_formats_snapshot_message(self): + self.data_source._request_order_book_snapshot = AsyncMock(return_value={ + "bids": [ + {"price": "1999", "remaining_base_amount": "1.2"}, + ], + "asks": [ + {"price": "2001", "remaining_base_amount": "1.4"}, + ], + }) + + message = await self.data_source._order_book_snapshot("ETH-USDC") + + self.assertEqual("ETH-USDC", message.content["trading_pair"]) + self.assertEqual([(1999.0, 1.2)], [(float(p), float(a)) for p, a in message.content["bids"]]) + self.assertEqual([(2001.0, 1.4)], [(float(p), float(a)) for p, a in message.content["asks"]]) + + async def test_connected_websocket_assistant_connects_with_headers(self): + ws = MagicMock() + ws.connect = AsyncMock() + self.connector.rest_api_key = "api-key" + self.data_source._api_factory.get_ws_assistant = AsyncMock(return_value=ws) + + connected_ws = await self.data_source._connected_websocket_assistant() + + self.assertIs(ws, connected_ws) + ws.connect.assert_awaited_once_with( + ws_url="wss://mainnet.zklighter.elliot.ai/stream", + ws_headers={"X-Api-Key": "api-key"}, + ) + + async def test_subscribe_channels_subscribes_order_book_and_trade(self): + ws = MagicMock() + ws.send = AsyncMock() + + await self.data_source._subscribe_channels(ws) + + self.assertEqual(2, ws.send.await_count) + sent_payloads = [call.args[0].payload for call in ws.send.await_args_list] + self.assertEqual({"type": "subscribe", "channel": "order_book/2048"}, sent_payloads[0]) + self.assertEqual({"type": "subscribe", "channel": "trade/2048"}, sent_payloads[1]) + + async def test_subscribe_to_trading_pair_returns_false_without_websocket(self): + self.data_source._ws_assistant = None + + subscribed = await self.data_source.subscribe_to_trading_pair("ETH-USDC") + + self.assertFalse(subscribed) + + async def test_subscribe_to_trading_pair_updates_market_map_and_sends_messages(self): + ws = MagicMock() + ws.send = AsyncMock() + self.data_source._ws_assistant = ws + + subscribed = await self.data_source.subscribe_to_trading_pair("ETH-USDC") + + self.assertTrue(subscribed) + self.assertEqual("ETH-USDC", self.data_source._market_id_to_trading_pair[2048]) + self.assertEqual(2, ws.send.await_count) + + async def test_unsubscribe_from_trading_pair_returns_false_without_websocket(self): + self.data_source._ws_assistant = None + + unsubscribed = await self.data_source.unsubscribe_from_trading_pair("ETH-USDC") + + self.assertFalse(unsubscribed) + + async def test_unsubscribe_from_trading_pair_removes_market_map_and_sends_messages(self): + ws = MagicMock() + ws.send = AsyncMock() + self.data_source._ws_assistant = ws + + unsubscribed = await self.data_source.unsubscribe_from_trading_pair("ETH-USDC") + + self.assertTrue(unsubscribed) + self.assertNotIn(2048, self.data_source._market_id_to_trading_pair) + self.assertEqual(2, ws.send.await_count) + + async def test_parse_order_book_snapshot_message(self): + q = asyncio.Queue() + raw_message = { + "channel": "order_book:2048", + "type": "subscribed/order_book", + "timestamp": 1710000000000, + "order_book": { + "nonce": 12, + "bids": [{"price": "1999", "size": "1.2"}], + "asks": [{"price": "2001", "size": "1.4"}], + }, + } + + await self.data_source._parse_order_book_snapshot_message(raw_message, q) + msg = q.get_nowait() + + self.assertEqual("ETH-USDC", msg.content["trading_pair"]) + self.assertEqual(12, msg.content["update_id"]) + self.assertEqual([(1999.0, 1.2)], [(float(p), float(a)) for p, a in msg.content["bids"]]) + + async def test_parse_order_book_snapshot_message_accepts_slash_channel(self): + q = asyncio.Queue() + raw_message = { + "channel": "order_book/2048", + "type": "subscribed/order_book", + "timestamp": 1710000000000, + "order_book": { + "nonce": 12, + "bids": [{"price": "1999", "size": "1.2"}], + "asks": [{"price": "2001", "size": "1.4"}], + }, + } + + await self.data_source._parse_order_book_snapshot_message(raw_message, q) + + self.assertFalse(q.empty()) + + async def test_parse_order_book_snapshot_message_ignores_unknown_market(self): + q = asyncio.Queue() + + await self.data_source._parse_order_book_snapshot_message({"channel": "order_book:9999", "order_book": {}}, q) + + self.assertTrue(q.empty()) + + async def test_parse_order_book_snapshot_message_uses_fallback_update_id(self): + q = asyncio.Queue() + raw_message = { + "channel": "order_book:2048", + "timestamp": 1710000000000, + "offset": 77, + "order_book": { + "nonce": 0, + "bids": [], + "asks": [], + }, + } + + await self.data_source._parse_order_book_snapshot_message(raw_message, q) + msg = q.get_nowait() + + self.assertEqual(77, msg.content["update_id"]) + + async def test_parse_order_book_diff_message(self): + q = asyncio.Queue() + raw_message = { + "channel": "order_book:2048", + "type": "update/order_book", + "timestamp": 1710000002000, + "order_book": { + "begin_nonce": 20, + "nonce": 25, + "bids": [{"price": "1998", "size": "2.0"}], + "asks": [{"price": "2002", "size": "1.1"}], + }, + } + + await self.data_source._parse_order_book_diff_message(raw_message, q) + msg = q.get_nowait() + + self.assertEqual(25, msg.content["update_id"]) + self.assertEqual(20, msg.content["first_update_id"]) + + async def test_parse_order_book_diff_message_uses_offset_when_nonce_missing(self): + q = asyncio.Queue() + raw_message = { + "channel": "order_book:2048", + "timestamp": 1710000002000, + "offset": 33, + "order_book": { + "begin_nonce": 0, + "nonce": 0, + "bids": [], + "asks": [], + }, + } + + await self.data_source._parse_order_book_diff_message(raw_message, q) + msg = q.get_nowait() + + self.assertEqual(33, msg.content["update_id"]) + self.assertEqual(33, msg.content["first_update_id"]) + + async def test_parse_order_book_diff_message_ignores_unknown_market(self): + q = asyncio.Queue() + + await self.data_source._parse_order_book_diff_message({"channel": "order_book:9999", "order_book": {}}, q) + + self.assertTrue(q.empty()) + + async def test_parse_trade_message(self): + q = asyncio.Queue() + raw_message = { + "channel": "trade:2048", + "timestamp": 1710000003000, + "trades": [{"trade_id": "abc", "price": "2000", "size": "0.4", "is_maker_ask": True}], + } + + await self.data_source._parse_trade_message(raw_message, q) + msg = q.get_nowait() + + self.assertEqual("abc", msg.trade_id) + self.assertEqual("ETH-USDC", msg.content["trading_pair"]) + + async def test_parse_trade_message_accepts_slash_channel(self): + q = asyncio.Queue() + raw_message = { + "channel": "trade/2048", + "timestamp": 1710000003000, + "trades": [{"trade_id": "abc", "price": "2000", "size": "0.4", "is_maker_ask": True}], + } + + await self.data_source._parse_trade_message(raw_message, q) + + self.assertFalse(q.empty()) + + async def test_parse_trade_message_ignores_unknown_market(self): + q = asyncio.Queue() + + await self.data_source._parse_trade_message({"channel": "trade:9999", "trades": []}, q) + + self.assertTrue(q.empty()) + + def test_channel_originating_message(self): + snapshot_channel = self.data_source._channel_originating_message( + {"channel": "order_book:2048", "type": "subscribed/order_book"} + ) + diff_channel = self.data_source._channel_originating_message( + {"channel": "order_book:2048", "type": "update/order_book"} + ) + trade_channel = self.data_source._channel_originating_message({"channel": "trade:2048", "type": "trade"}) + + self.assertEqual(self.data_source._snapshot_messages_queue_key, snapshot_channel) + self.assertEqual(self.data_source._diff_messages_queue_key, diff_channel) + self.assertEqual(self.data_source._trade_messages_queue_key, trade_channel) + + def test_channel_originating_message_accepts_slash_channels(self): + snapshot_channel = self.data_source._channel_originating_message( + {"channel": "order_book/2048", "type": "subscribed/order_book"} + ) + diff_channel = self.data_source._channel_originating_message( + {"channel": "order_book/2048", "type": "update/order_book"} + ) + trade_channel = self.data_source._channel_originating_message({"channel": "trade/2048", "type": "trade"}) + + self.assertEqual(self.data_source._snapshot_messages_queue_key, snapshot_channel) + self.assertEqual(self.data_source._diff_messages_queue_key, diff_channel) + self.assertEqual(self.data_source._trade_messages_queue_key, trade_channel) + + def test_channel_originating_message_defaults_and_unknowns(self): + fallback_snapshot = self.data_source._channel_originating_message( + {"channel": "order_book:2048", "type": "other"} + ) + missing_channel = self.data_source._channel_originating_message({"type": "trade"}) + unknown_channel = self.data_source._channel_originating_message({"channel": "other:2048", "type": "trade"}) + + self.assertEqual(self.data_source._snapshot_messages_queue_key, fallback_snapshot) + self.assertEqual("", missing_channel) + self.assertEqual("", unknown_channel) + + def test_header_and_market_id_helpers(self): + self.connector.rest_api_key = "api-key" + self.assertEqual({"X-Api-Key": "api-key"}, self.data_source._get_headers()) + self.assertEqual({}, self.data_source._get_public_headers()) + self.assertEqual(2048, self.data_source._extract_market_id_from_channel("trade:2048")) + self.assertEqual(2048, self.data_source._extract_market_id_from_channel("trade/2048")) + self.assertIsNone(self.data_source._extract_market_id_from_channel("trade:bad")) + self.assertIsNone(self.data_source._extract_market_id_from_channel("")) + + async def test_subscribe_and_unsubscribe_trading_pair_return_false_without_ws(self): + self.data_source._ws_assistant = None + + self.assertFalse(await self.data_source.subscribe_to_trading_pair("ETH-USDC")) + self.assertFalse(await self.data_source.unsubscribe_from_trading_pair("ETH-USDC")) + + async def test_subscribe_and_unsubscribe_trading_pair_manage_channels(self): + ws = MagicMock() + ws.send = AsyncMock() + self.data_source._ws_assistant = ws + self.connector._get_market_spec = AsyncMock(return_value=(2048, 4, 2, "ETH/USDC")) + + self.assertTrue(await self.data_source.subscribe_to_trading_pair("ETH-USDC")) + self.assertIn("ETH-USDC", self.data_source._trading_pairs) + + self.assertTrue(await self.data_source.unsubscribe_from_trading_pair("ETH-USDC")) + self.assertNotIn("ETH-USDC", self.data_source._trading_pairs) + + async def test_ping_loop_handles_disconnect_and_generic_errors(self): + ws = MagicMock() + ws.send = AsyncMock(side_effect=RuntimeError("WS is not connected")) + + with patch( + "hummingbot.connector.exchange.lighter.lighter_api_order_book_data_source.asyncio.sleep", + new=AsyncMock(), + ) as sleep_mock: + await self.data_source._ping_loop(ws) + + sleep_mock.assert_awaited_once() + + logger = MagicMock() + self.data_source.logger = MagicMock(return_value=logger) + ws.send = AsyncMock(side_effect=[ValueError("boom"), asyncio.CancelledError()]) + with patch( + "hummingbot.connector.exchange.lighter.lighter_api_order_book_data_source.asyncio.sleep", + new=AsyncMock(side_effect=[None, asyncio.CancelledError()]), + ) as sleep_mock: + with self.assertRaises(asyncio.CancelledError): + await self.data_source._ping_loop(ws) + + logger.warning.assert_called_once() + self.assertEqual(2, sleep_mock.await_count) + + async def test_listen_for_subscriptions_logs_close_levels_and_suppresses_repeated_errors(self): + logger = MagicMock() + self.data_source.logger = MagicMock(return_value=logger) + self.data_source._on_order_stream_interruption = AsyncMock() + self.data_source._connected_websocket_assistant = AsyncMock(side_effect=[ConnectionError("close code = 1000"), asyncio.CancelledError()]) + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_subscriptions() + + logger.debug.assert_called_once() + + logger.reset_mock() + self.data_source._sleep = AsyncMock() + self.data_source._connected_websocket_assistant = AsyncMock(side_effect=[RuntimeError("boom-1"), RuntimeError("boom-2"), asyncio.CancelledError()]) + with patch( + "hummingbot.connector.exchange.lighter.lighter_api_order_book_data_source.time.time", + side_effect=[100.0, 110.0], + ): + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_subscriptions() + + logger.exception.assert_called_once() + logger.debug.assert_called_once() + + async def test_listen_for_subscriptions_calls_subscribe_and_process(self): + """Success path: subscribe and process are called (lines 43-44).""" + logger = MagicMock() + self.data_source.logger = MagicMock(return_value=logger) + self.data_source._on_order_stream_interruption = AsyncMock() + mock_ws = MagicMock() + self.data_source._connected_websocket_assistant = AsyncMock(return_value=mock_ws) + self.data_source._subscribe_channels = AsyncMock() + self.data_source._process_websocket_messages = AsyncMock(side_effect=asyncio.CancelledError()) + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_subscriptions() + + self.data_source._subscribe_channels.assert_awaited_once() + self.data_source._process_websocket_messages.assert_awaited_once() diff --git a/test/hummingbot/connector/exchange/lighter/test_lighter_api_user_stream_data_source.py b/test/hummingbot/connector/exchange/lighter/test_lighter_api_user_stream_data_source.py new file mode 100644 index 00000000000..930f78eca0c --- /dev/null +++ b/test/hummingbot/connector/exchange/lighter/test_lighter_api_user_stream_data_source.py @@ -0,0 +1,272 @@ +import asyncio +from test.isolated_asyncio_wrapper_test_case import IsolatedAsyncioWrapperTestCase +from unittest.mock import AsyncMock, MagicMock, patch + +from hummingbot.connector.exchange.lighter.lighter_api_user_stream_data_source import LighterAPIUserStreamDataSource +from hummingbot.connector.exchange.lighter.lighter_auth import LighterAuth + + +class LighterAPIUserStreamDataSourceTests(IsolatedAsyncioWrapperTestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + self.connector = MagicMock() + self.connector.rest_api_key = "api-key" + self.connector.account_index = "" + self.connector.api_key_index = "" + self.connector._get_lighter_auth_token = MagicMock(return_value="ws-auth-token") + self.auth = LighterAuth(api_key="api-key", account_identifier="123") + self.data_source = LighterAPIUserStreamDataSource( + connector=self.connector, + api_factory=MagicMock(), + auth=self.auth, + ) + + async def test_connected_websocket_assistant_connects_with_api_key_header(self): + ws = MagicMock() + ws.connect = AsyncMock() + self.data_source._api_factory.get_ws_assistant = AsyncMock(return_value=ws) + + connected_ws = await self.data_source._connected_websocket_assistant() + + self.assertIs(ws, connected_ws) + ws.connect.assert_awaited_once_with( + ws_url="wss://mainnet.zklighter.elliot.ai/stream", + ws_headers={"X-Api-Key": "api-key"}, + ping_timeout=30, + ) + + async def test_subscribe_channels_sends_spot_private_channels_with_compatibility_variants(self): + """Spot connector subscribes to private channels using both delimiter variants.""" + ws = MagicMock() + ws.send = AsyncMock() + ws.receive = AsyncMock(return_value=MagicMock(data={"type": "connected"})) + self.connector.account_index = "321" + self.connector.api_key_index = "9" + + await self.data_source._subscribe_channels(ws) + + # 3 identifiers (123, 321, 9) × 4 channels × 2 delimiters = 24 subscriptions + self.assertEqual(24, ws.send.await_count) + sent_payloads = [call.args[0].payload for call in ws.send.await_args_list] + # Slash format is preferred first; colon fallback is still sent. + self.assertEqual("account_all/123", sent_payloads[0]["channel"]) + sent_channels = [payload["channel"] for payload in sent_payloads] + self.assertIn("account_all:123", sent_channels) + self.assertIn("account_all/123", sent_channels) + self.assertIn("account_all:321", sent_channels) + self.assertIn("account_all/321", sent_channels) + self.assertIn("account_all:9", sent_channels) + self.assertIn("account_all/9", sent_channels) + self.assertIn("account_all_assets:123", sent_channels) + self.assertIn("account_all_assets/123", sent_channels) + self.assertIn("account_all_assets:321", sent_channels) + self.assertIn("account_all_assets/321", sent_channels) + self.assertIn("account_all_assets:9", sent_channels) + self.assertIn("account_all_assets/9", sent_channels) + self.assertIn("account_all_orders:123", sent_channels) + self.assertIn("account_all_orders/123", sent_channels) + self.assertIn("account_all_orders:321", sent_channels) + self.assertIn("account_all_orders/321", sent_channels) + self.assertIn("account_all_orders:9", sent_channels) + self.assertIn("account_all_orders/9", sent_channels) + self.assertIn("account_all_trades:123", sent_channels) + self.assertIn("account_all_trades/123", sent_channels) + self.assertIn("account_all_trades:321", sent_channels) + self.assertIn("account_all_trades/321", sent_channels) + self.assertIn("account_all_trades:9", sent_channels) + self.assertIn("account_all_trades/9", sent_channels) + assets_and_orders_payloads = { + payload["channel"]: payload for payload in sent_payloads + if ( + payload["channel"].startswith("account_all_assets:") + or payload["channel"].startswith("account_all_orders:") + or payload["channel"].startswith("account_all_assets/") + or payload["channel"].startswith("account_all_orders/") + ) + } + self.assertTrue(all(payload.get("auth") == "ws-auth-token" for payload in assets_and_orders_payloads.values())) + # account_all and account_all_trades do NOT require an auth token + non_auth_payloads = { + payload["channel"]: payload for payload in sent_payloads + if ( + payload["channel"].startswith("account_all:") + or payload["channel"].startswith("account_all_trades:") + or payload["channel"].startswith("account_all/") + or payload["channel"].startswith("account_all_trades/") + ) + } + self.assertTrue(all("auth" not in payload for payload in non_auth_payloads.values())) + # Unsupported channels must NOT appear + for channel in sent_channels: + self.assertNotIn("account_positions", channel) + self.assertNotIn("account_info", channel) + self.assertNotIn("account_order_updates", channel) + self.assertNotIn("account_trades:", channel) + self.assertNotIn("account_trades/", channel) + + async def test_subscribe_channels_deduplicates_identical_identifiers(self): + """When account_index equals the auth public key, each channel is only sent once.""" + ws = MagicMock() + ws.send = AsyncMock() + ws.receive = AsyncMock(return_value=MagicMock(data={"type": "connected"})) + self.connector.account_index = "123" # same as user_wallet_public_key + self.connector.api_key_index = "" + + await self.data_source._subscribe_channels(ws) + + # Only 1 unique identifier → 4 channels × 2 delimiters = 8 sends + self.assertEqual(8, ws.send.await_count) + + async def test_subscribe_channels_raises_when_not_connected(self): + ws = MagicMock() + ws.receive = AsyncMock(return_value=MagicMock(data={"type": "error"})) + + with self.assertRaises(IOError): + await self.data_source._subscribe_channels(ws) + + async def test_process_websocket_messages_replies_to_ping_and_processes_events(self): + websocket_assistant = MagicMock() + websocket_assistant.send = AsyncMock() + queue = asyncio.Queue() + + async def iter_messages(): + yield MagicMock(data={"type": "ping"}) + yield MagicMock(data={"type": "update/account_all", "channel": "account_all:123", "data": {}}) + + websocket_assistant.iter_messages = iter_messages + self.data_source._process_event_message = AsyncMock() + + await self.data_source._process_websocket_messages(websocket_assistant, queue) + + websocket_assistant.send.assert_awaited_once() + self.data_source._process_event_message.assert_awaited_once_with( + event_message={"type": "update/account_all", "channel": "account_all:123", "data": {}}, + queue=queue, + ) + + async def test_process_event_message_enqueues_account_messages(self): + queue = asyncio.Queue() + message = {"type": "update/account_all", "channel": "account_all:123", "account": {"assets": []}} + + await self.data_source._process_event_message(message, queue) + + queued = queue.get_nowait() + self.assertEqual(message, queued) + + async def test_process_event_message_routes_account_all_assets_messages(self): + queue = asyncio.Queue() + assets_message = {"type": "update/account_all_assets", "channel": "account_all_assets:123", "data": {"assets": {}}} + + await self.data_source._process_event_message(assets_message, queue) + + self.assertFalse(queue.empty()) + self.assertEqual(assets_message, queue.get_nowait()) + + async def test_process_event_message_ignores_unknown_messages(self): + queue = asyncio.Queue() + + await self.data_source._process_event_message({"type": "other", "channel": "other:123"}, queue) + + self.assertTrue(queue.empty()) + + async def test_process_event_message_raises_on_websocket_error(self): + queue = asyncio.Queue() + + with self.assertRaises(IOError): + await self.data_source._process_event_message( + {"error": {"message": "invalid auth"}}, + queue, + ) + + async def test_process_event_message_ignores_invalid_channel_error(self): + """'Invalid Channel' responses must be silently swallowed, not crash the stream.""" + queue = asyncio.Queue() + await self.data_source._process_event_message( + {"error": {"message": "Invalid Channel"}}, + queue, + ) + self.assertTrue(queue.empty()) + + async def test_connected_websocket_assistant_omits_api_key_header_when_missing(self): + ws = MagicMock() + ws.connect = AsyncMock() + self.connector.rest_api_key = "" + self.data_source._api_factory.get_ws_assistant = AsyncMock(return_value=ws) + + await self.data_source._connected_websocket_assistant() + + ws.connect.assert_awaited_once_with( + ws_url="wss://mainnet.zklighter.elliot.ai/stream", + ws_headers={}, + ping_timeout=30, + ) + + async def test_subscribe_channels_without_auth_token_skips_auth_fields(self): + ws = MagicMock() + ws.send = AsyncMock() + ws.receive = AsyncMock(return_value=MagicMock(data={"type": "connected"})) + self.connector.account_index = "321" + self.connector.api_key_index = "9" + self.connector._get_lighter_auth_token = MagicMock(side_effect=RuntimeError("no token")) + + await self.data_source._subscribe_channels(ws) + + sent_payloads = [call.args[0].payload for call in ws.send.await_args_list] + auth_channels = [ + payload for payload in sent_payloads + if payload["channel"].startswith("account_all_assets") or payload["channel"].startswith("account_all_orders") + ] + self.assertTrue(all("auth" not in payload for payload in auth_channels)) + + async def test_listen_for_user_stream_logs_close_levels(self): + logger = MagicMock() + self.data_source.logger = MagicMock(return_value=logger) + self.data_source._on_user_stream_interruption = AsyncMock() + self.data_source._connected_websocket_assistant = AsyncMock(side_effect=[ConnectionError("close code = 1000"), asyncio.CancelledError()]) + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_user_stream(asyncio.Queue()) + + logger.debug.assert_called_once() + + logger.reset_mock() + self.data_source._connected_websocket_assistant = AsyncMock(side_effect=[ConnectionError("close code = 1006"), asyncio.CancelledError()]) + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_user_stream(asyncio.Queue()) + + logger.warning.assert_called_once() + + async def test_listen_for_user_stream_suppresses_repeated_errors(self): + logger = MagicMock() + self.data_source.logger = MagicMock(return_value=logger) + self.data_source._on_user_stream_interruption = AsyncMock() + self.data_source._sleep = AsyncMock() + self.data_source._connected_websocket_assistant = AsyncMock(side_effect=[RuntimeError("boom-1"), RuntimeError("boom-2"), asyncio.CancelledError()]) + + with patch( + "hummingbot.connector.exchange.lighter.lighter_api_user_stream_data_source.time.time", + side_effect=[100.0, 110.0], + ): + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_user_stream(asyncio.Queue()) + + logger.exception.assert_called_once() + logger.debug.assert_called_once() + + async def test_listen_for_user_stream_calls_subscribe_ping_and_process(self): + """Success path: subscribe, ping, then process are all called (lines 41-43).""" + logger = MagicMock() + self.data_source.logger = MagicMock(return_value=logger) + self.data_source._on_user_stream_interruption = AsyncMock() + mock_ws = MagicMock() + self.data_source._connected_websocket_assistant = AsyncMock(return_value=mock_ws) + self.data_source._subscribe_channels = AsyncMock() + self.data_source._send_ping = AsyncMock() + self.data_source._process_websocket_messages = AsyncMock(side_effect=asyncio.CancelledError()) + + with self.assertRaises(asyncio.CancelledError): + await self.data_source.listen_for_user_stream(asyncio.Queue()) + + self.data_source._subscribe_channels.assert_awaited_once_with(websocket_assistant=mock_ws) + self.data_source._send_ping.assert_awaited_once_with(websocket_assistant=mock_ws) diff --git a/test/hummingbot/connector/exchange/lighter/test_lighter_auth.py b/test/hummingbot/connector/exchange/lighter/test_lighter_auth.py new file mode 100644 index 00000000000..36826d29ad3 --- /dev/null +++ b/test/hummingbot/connector/exchange/lighter/test_lighter_auth.py @@ -0,0 +1,51 @@ +import asyncio +from typing import Awaitable +from unittest import TestCase + +from hummingbot.connector.exchange.lighter.lighter_auth import LighterAuth +from hummingbot.core.web_assistant.connections.data_types import RESTMethod, RESTRequest + + +class LighterAuthTests(TestCase): + def setUp(self) -> None: + super().setUp() + self.auth = LighterAuth(api_key="test-api-key", api_secret="test-secret", account_identifier="123") + + def async_run_with_timeout(self, coroutine: Awaitable, timeout: int = 1): + return asyncio.get_event_loop().run_until_complete(asyncio.wait_for(coroutine, timeout)) + + def test_rest_authenticate_injects_headers(self): + request = RESTRequest(method=RESTMethod.GET, url="https://test.url", is_auth_required=True) + authed_request = self.async_run_with_timeout(self.auth.rest_authenticate(request=request)) + + self.assertEqual("application/json", authed_request.headers["accept"]) + self.assertEqual("application/json", authed_request.headers["Content-Type"]) + self.assertEqual("test-api-key", authed_request.headers["X-Api-Key"]) + + def test_rest_authenticate_keeps_headers(self): + request = RESTRequest( + method=RESTMethod.GET, + url="https://test.url", + is_auth_required=True, + headers={"X-Test": "1"}, + ) + authed_request = self.async_run_with_timeout(self.auth.rest_authenticate(request=request)) + + self.assertEqual("1", authed_request.headers["X-Test"]) + self.assertEqual("test-api-key", authed_request.headers["X-Api-Key"]) + + def test_ws_authenticate_returns_request_unchanged(self): + from hummingbot.core.web_assistant.connections.data_types import WSJSONRequest + ws_request = WSJSONRequest(payload={"type": "subscribe"}) + result = self.async_run_with_timeout(self.auth.ws_authenticate(request=ws_request)) + self.assertIs(ws_request, result) + + def test_rest_authenticate_without_api_key_does_not_inject_key_header(self): + auth_no_key = __import__( + "hummingbot.connector.exchange.lighter.lighter_auth", + fromlist=["LighterAuth"], + ).LighterAuth(api_key="", api_secret="", account_identifier="") + from hummingbot.core.web_assistant.connections.data_types import RESTMethod, RESTRequest + request = RESTRequest(method=RESTMethod.GET, url="https://test.url", is_auth_required=True) + authed = self.async_run_with_timeout(auth_no_key.rest_authenticate(request=request)) + self.assertNotIn("X-Api-Key", authed.headers) diff --git a/test/hummingbot/connector/exchange/lighter/test_lighter_exchange.py b/test/hummingbot/connector/exchange/lighter/test_lighter_exchange.py new file mode 100644 index 00000000000..5e5525e7241 --- /dev/null +++ b/test/hummingbot/connector/exchange/lighter/test_lighter_exchange.py @@ -0,0 +1,3628 @@ +import asyncio +import sys +import types +import unittest +from decimal import Decimal +from enum import Enum +from test.isolated_asyncio_wrapper_test_case import IsolatedAsyncioWrapperTestCase +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch + +from hummingbot.core.data_type.common import OrderType, TradeType +from hummingbot.core.data_type.trade_fee import TradeFeeSchema +from hummingbot.core.web_assistant.connections.data_types import RESTMethod + +try: + __import__("hummingbot.core.data_type.limit_order") +except Exception: + if "hummingbot.core.data_type.limit_order" not in sys.modules: + fake_limit_order = types.ModuleType("hummingbot.core.data_type.limit_order") + + class LimitOrder: + pass + + fake_limit_order.LimitOrder = LimitOrder + sys.modules["hummingbot.core.data_type.limit_order"] = fake_limit_order + +try: + __import__("hummingbot.core.data_type.order_book") +except Exception: + if "hummingbot.core.data_type.order_book" not in sys.modules: + fake_order_book = types.ModuleType("hummingbot.core.data_type.order_book") + + class OrderBook: + def apply_snapshot(self, bids, asks, update_id): + _ = bids + _ = asks + _ = update_id + + fake_order_book.OrderBook = OrderBook + sys.modules["hummingbot.core.data_type.order_book"] = fake_order_book + +try: + __import__("hummingbot.connector.exchange_base") +except Exception: + if "hummingbot.connector.exchange_base" not in sys.modules: + fake_exchange_base = types.ModuleType("hummingbot.connector.exchange_base") + + class ExchangeBase: + def __init__(self, *args, **kwargs): + _ = args + _ = kwargs + self._account_balances = {} + self._account_available_balances = {} + self._trading_fees = {} + + def trade_fee_schema(self): + return TradeFeeSchema() + + def _set_order_book_tracker(self, order_book_tracker): + self.order_book_tracker = order_book_tracker + + fake_exchange_base.ExchangeBase = ExchangeBase + sys.modules["hummingbot.connector.exchange_base"] = fake_exchange_base + +try: + __import__("hummingbot.connector.trading_rule") +except Exception: + if "hummingbot.connector.trading_rule" not in sys.modules: + fake_trading_rule = types.ModuleType("hummingbot.connector.trading_rule") + + class TradingRule: + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + + fake_trading_rule.TradingRule = TradingRule + sys.modules["hummingbot.connector.trading_rule"] = fake_trading_rule + +if "hummingbot.core.network_iterator" not in sys.modules: + fake_network_iterator = types.ModuleType("hummingbot.core.network_iterator") + + class NetworkStatus(Enum): + STOPPED = 0 + NOT_CONNECTED = 1 + CONNECTED = 2 + + fake_network_iterator.NetworkStatus = NetworkStatus + sys.modules["hummingbot.core.network_iterator"] = fake_network_iterator + +try: + from hummingbot.connector.exchange.lighter.lighter_exchange import LighterExchange + from hummingbot.core.data_type.in_flight_order import OrderState, OrderUpdate, TradeUpdate + _LIGHTER_EXCHANGE_AVAILABLE = True +except ModuleNotFoundError: + _LIGHTER_EXCHANGE_AVAILABLE = False + + +def _set_exchange_timestamp(exchange, timestamp): + # Runtime implementations differ between pure-python and cython-backed objects. + try: + exchange.current_timestamp = timestamp + return + except (AttributeError, TypeError): + pass + try: + exchange._set_current_timestamp(float(timestamp)) + return + except (AttributeError, TypeError): + pass + object.__setattr__(exchange, "_current_timestamp", float(timestamp)) + + +@unittest.skipUnless(_LIGHTER_EXCHANGE_AVAILABLE, "Core exchange runtime modules are unavailable in this local environment") +class LighterExchangeTests(IsolatedAsyncioWrapperTestCase): + def test_init_and_properties(self): + with patch("hummingbot.connector.exchange.lighter.lighter_exchange.ExchangePyBase.__init__", lambda self, *a, **kw: None): + exchange = LighterExchange( + lighter_api_key_private_key="0x" + ("a" * 64), + lighter_api_key_index="7", + lighter_account_index="693751", + trading_pairs=["ETH-USDC"], + trading_required=False, + ) + + self.assertEqual("lighter", exchange.name) + self.assertEqual("lighter", exchange.domain) + self.assertEqual(["ETH-USDC"], exchange.trading_pairs) + self.assertFalse(exchange.is_trading_required) + self.assertTrue(exchange.is_cancel_request_in_exchange_synchronous) + self.assertEqual("https://mainnet.zklighter.elliot.ai", exchange._api_host_for_signer()) + + def test_supported_order_types_and_request_paths(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._domain = "lighter" + self.assertEqual([OrderType.LIMIT, OrderType.LIMIT_MAKER, OrderType.MARKET], exchange.supported_order_types()) + self.assertEqual("/orderBooks", exchange.trading_rules_request_path) + self.assertEqual("/orderBooks", exchange.trading_pairs_request_path) + self.assertEqual("/orderBooks", exchange.check_network_request_path) + + async def test_refresh_market_metadata_and_get_market_spec(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._market_id_by_symbol = {} + exchange._size_decimals_by_symbol = {} + exchange._price_decimals_by_symbol = {} + exchange.exchange_symbol_associated_to_pair = AsyncMock(return_value="ETH/USDC") + exchange._api_get = AsyncMock( + return_value={ + "order_books": [ + { + "symbol": "ETH/USDC", + "market_type": "spot", + "market_id": 2048, + "supported_size_decimals": 4, + "supported_price_decimals": 2, + } + ] + } + ) + + await exchange._refresh_market_metadata() + market_spec = await exchange._get_market_spec("ETH-USDC") + + self.assertEqual((2048, 4, 2, "ETH/USDC"), market_spec) + + async def test_get_market_spec_raises_when_missing(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._market_id_by_symbol = {} + exchange._size_decimals_by_symbol = {} + exchange._price_decimals_by_symbol = {} + exchange.exchange_symbol_associated_to_pair = AsyncMock(return_value="MISSING") + exchange._refresh_market_metadata = AsyncMock() + + with self.assertRaises(ValueError): + await exchange._get_market_spec("ETH-USDC") + + async def test_place_order_and_cancel_success(self): + exchange = LighterExchange.__new__(LighterExchange) + _set_exchange_timestamp(exchange, 1700000000) + exchange._get_market_spec = AsyncMock(return_value=(2048, 2, 2, "ETH/USDC")) + exchange._client_order_index_from_order_id = lambda order_id: 999 + exchange._allocate_client_order_index = MagicMock(return_value=999) + exchange._get_api_key_index = lambda: 7 + + signer_client = type( + "SignerClient", + (), + { + "ORDER_TYPE_LIMIT": 1, + "ORDER_TYPE_MARKET": 2, + "ORDER_TIME_IN_FORCE_GOOD_TILL_TIME": 10, + "ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL": 11, + "ORDER_TIME_IN_FORCE_POST_ONLY": 12, + "DEFAULT_28_DAY_ORDER_EXPIRY": 1000, + "DEFAULT_IOC_EXPIRY": 1001, + }, + )() + signer_client.create_order = AsyncMock(return_value=(None, type("Resp", (), {"code": 200})(), None)) + signer_client.cancel_order = AsyncMock(return_value=(None, type("Resp", (), {"code": 200})(), None)) + exchange._get_lighter_signer_client = lambda: signer_client + exchange._refresh_signer_client = lambda: signer_client + exchange._signer_client_lock = asyncio.Lock() + exchange._is_invalid_nonce_failure = lambda error=None, response=None: False + exchange._response_code = lambda r: getattr(r, "code", 0) + exchange._sleep = AsyncMock() + exchange._account_available_balances = None + exchange._schedule_balance_sync_for_terminal_update = lambda *args, **kwargs: None + + with patch.object(LighterExchange, "_allocate_client_order_index", return_value=999): + exchange_order_id, ts = await exchange._place_order( + order_id="HBOT-A", + trading_pair="ETH-USDC", + amount=Decimal("1"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT, + price=Decimal("10"), + ) + result = await exchange._place_cancel("HBOT-A", type("Tracked", (), {"client_order_id": "HBOT-A", "trading_pair": "ETH-USDC", "exchange_order_id": "42"})()) + + self.assertTrue(exchange_order_id.isdigit(), f"exchange_order_id should be a digit string, got: {exchange_order_id}") + self.assertEqual(1700000000, ts) + self.assertTrue(result) + + async def test_place_order_and_cancel_errors(self): + exchange = LighterExchange.__new__(LighterExchange) + _set_exchange_timestamp(exchange, 1700000000) + exchange._get_market_spec = AsyncMock(return_value=(2048, 2, 2, "ETH/USDC")) + exchange._client_order_index_from_order_id = lambda order_id: 111 + exchange._get_api_key_index = lambda: 7 + + signer_client = type( + "SignerClient", + (), + { + "ORDER_TYPE_LIMIT": 1, + "ORDER_TYPE_MARKET": 2, + "ORDER_TIME_IN_FORCE_GOOD_TILL_TIME": 10, + "ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL": 11, + "ORDER_TIME_IN_FORCE_POST_ONLY": 12, + "DEFAULT_28_DAY_ORDER_EXPIRY": 1000, + "DEFAULT_IOC_EXPIRY": 1001, + }, + )() + signer_client.create_order = AsyncMock(return_value=(None, None, "err")) + signer_client.cancel_order = AsyncMock(return_value=(None, None, "err")) + exchange._get_lighter_signer_client = lambda: signer_client + + mock_order_book = type("OrderBook", (), {"get_price": lambda self, is_bid: 10.0})() + exchange.get_order_book = lambda trading_pair: mock_order_book + + with self.assertRaises(IOError): + await exchange._place_order( + order_id="HBOT-B", + trading_pair="ETH-USDC", + amount=Decimal("1"), + trade_type=TradeType.BUY, + order_type=OrderType.MARKET, + price=Decimal("10"), + ) + + with self.assertRaises(IOError): + await exchange._place_cancel("HBOT-B", type("Tracked", (), {"trading_pair": "ETH-USDC", "exchange_order_id": "42"})()) + + async def test_place_modify_sends_signed_modify_order(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._get_market_spec = AsyncMock(return_value=(45, 2, 2, "ETH/USDC")) + exchange._get_api_key_index = lambda: 4 + + signer_client = type("SignerClient", (), {})() + signer_client.modify_order = AsyncMock(return_value=(None, type("Resp", (), {"code": 200})(), None)) + exchange._get_lighter_signer_client = lambda: signer_client + + tracked_order = type("Tracked", (), {"trading_pair": "ETH-USDC", "exchange_order_id": "12345"})() + result = await exchange._place_modify(tracked_order, Decimal("1.234"), Decimal("12345")) + + self.assertTrue(result) + signer_client.modify_order.assert_called_once() + call_args = signer_client.modify_order.call_args[1] + self.assertEqual(45, call_args["market_index"]) + self.assertEqual(12345, call_args["order_index"]) + self.assertEqual(123, call_args["base_amount"]) + self.assertEqual(1234500, call_args["price"]) + self.assertEqual(4, call_args["api_key_index"]) + + async def test_place_modify_raises_on_signing_error(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._get_market_spec = AsyncMock(return_value=(45, 2, 2, "ETH/USDC")) + exchange._get_api_key_index = lambda: 4 + + signer_client = type("SignerClient", (), {})() + signer_client.modify_order = AsyncMock(return_value=(None, None, "signing_error")) + exchange._get_lighter_signer_client = lambda: signer_client + + tracked_order = type("Tracked", (), {"trading_pair": "ETH-USDC", "exchange_order_id": "12345"})() + with self.assertRaises(IOError): + await exchange._place_modify(tracked_order, Decimal("1.234"), Decimal("12345")) + + async def test_api_request_uses_sdk_for_authenticated_requests(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._domain = "lighter" + exchange._api_key = "k" + exchange._api_secret = "" + + class DummyContext: + async def __aenter__(self): + return None + + async def __aexit__(self, exc_type, exc, tb): + return False + + exchange._throttler = type("Throttler", (), {"execute_task": lambda self, limit_id: DummyContext()})() + + fake_response = type( + "Response", + (), + { + "status": 200, + "data": b'{"success": true, "data": {"assets": []}}', + "read": AsyncMock(), + }, + )() + fake_client = type( + "ApiClient", + (), + { + "param_serialize": MagicMock(return_value=("GET", "https://mainnet.zklighter.elliot.ai/api/v1/account", {"X-Api-Key": "k"}, None, [])), + "call_api": AsyncMock(return_value=fake_response), + }, + )() + exchange._get_lighter_api_client = lambda: fake_client + + response = await exchange._api_request(path_url="/account", method=RESTMethod.GET, params={"by": "index", "value": "693751"}, is_auth_required=True, return_err=True) + + self.assertTrue(response["success"]) + fake_client.param_serialize.assert_called_once() + fake_client.call_api.assert_awaited_once() + + def test_helper_methods_cover_expected_validation_paths(self): + self.assertTrue(LighterExchange._is_expected_order_rejection("Order below the minimum notional")) + self.assertFalse(LighterExchange._is_expected_order_rejection("unexpected signer failure")) + self.assertTrue(LighterExchange._is_int_string("17")) + self.assertFalse(LighterExchange._is_int_string("not-an-int")) + self.assertTrue(LighterExchange._is_hex_private_key("0x" + ("a" * 64))) + self.assertFalse(LighterExchange._is_hex_private_key("0x1234")) + + async def test_sdk_api_request_normalizes_non_dict_payload_and_return_err(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._sdk_rest_base_url = lambda: "https://mainnet.zklighter.elliot.ai" + exchange._throttler = None + + fake_response = type( + "Response", + (), + { + "status": 200, + "data": b'["ok"]', + "read": AsyncMock(), + }, + )() + fake_client = type( + "ApiClient", + (), + { + "param_serialize": MagicMock(return_value=("GET", "/api/v1/account", {}, None, [])), + "call_api": AsyncMock(return_value=fake_response), + }, + )() + exchange._get_lighter_api_client = lambda: fake_client + + payload = await exchange._sdk_api_request(path_url="/account") + self.assertEqual(["ok"], payload["data"]) + self.assertEqual(200, payload["code"]) + self.assertTrue(payload["success"]) + + class RequestError(Exception): + def __init__(self, message, status): + super().__init__(message) + self.status = status + + fake_client.call_api = AsyncMock(side_effect=RequestError("boom", 503)) + + error_payload = await exchange._sdk_api_request(path_url="/account", return_err=True) + self.assertFalse(error_payload["success"]) + self.assertEqual(503, error_payload["code"]) + + async def test_sdk_api_request_raises_on_rate_limit_payload(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._sdk_rest_base_url = lambda: "https://mainnet.zklighter.elliot.ai" + exchange._throttler = None + + fake_response = type( + "Response", + (), + { + "status": 429, + "data": b'{"code": 23000, "message": "Too Many Requests"}', + "read": AsyncMock(), + }, + )() + fake_client = type( + "ApiClient", + (), + { + "param_serialize": MagicMock(return_value=("GET", "/api/v1/account", {}, None, [])), + "call_api": AsyncMock(return_value=fake_response), + }, + )() + exchange._get_lighter_api_client = lambda: fake_client + + with patch("hummingbot.connector.exchange.lighter.lighter_exchange.asyncio.sleep", new=AsyncMock()) as sleep_mock: + with self.assertRaises(IOError): + await exchange._sdk_api_request(path_url="/account") + + sleep_mock.assert_awaited_once_with(3.0) + + def test_response_helpers_and_account_identifier_helpers(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._api_key_index = "9" + exchange._account_index = "693751" + + self.assertEqual(9, exchange._get_api_key_index()) + self.assertEqual(693751, exchange._get_account_index()) + self.assertTrue(exchange._is_ok_response({"success": True})) + self.assertTrue(exchange._is_ok_response({"code": 200})) + self.assertFalse(exchange._is_ok_response({"code": "bad"})) + self.assertTrue(exchange._is_rate_limited_response({"code": 23000})) + self.assertTrue(exchange._is_rate_limited_response({"message": "Too many requests"})) + self.assertTrue(exchange._is_rate_limited_exception(RuntimeError("Too Many Requests"))) + self.assertEqual({"by": "index", "value": "693751", "active_only": "true"}, exchange._account_query_params()) + self.assertEqual({"id": 1}, exchange._account_from_response({"data": [{"id": 1}]})) + self.assertEqual({"id": 2}, exchange._account_from_response({"accounts": [{"id": 2}]})) + self.assertEqual({"assets": []}, exchange._account_from_response({"assets": []})) + self.assertIsNone(exchange._account_from_response({})) + + def test_get_lighter_auth_token_uses_cache_and_raises_on_signer_failure(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._cached_auth_token = "cached-token" + exchange._cached_auth_token_expiry_ts = 200.0 + + with patch("hummingbot.connector.exchange.lighter.lighter_exchange.time.time", return_value=100.0): + self.assertEqual("cached-token", exchange._get_lighter_auth_token()) + + signer_client = type("SignerClient", (), {"create_auth_token_with_expiry": MagicMock(return_value=("fresh-token", None))})() + exchange._cached_auth_token = None + exchange._cached_auth_token_expiry_ts = 0.0 + exchange._get_lighter_signer_client = lambda: signer_client + + with patch("hummingbot.connector.exchange.lighter.lighter_exchange.time.time", return_value=100.0): + self.assertEqual("fresh-token", exchange._get_lighter_auth_token()) + self.assertEqual("fresh-token", exchange._cached_auth_token) + + signer_client.create_auth_token_with_expiry = MagicMock(return_value=(None, "bad key")) + exchange._cached_auth_token = None + exchange._cached_auth_token_expiry_ts = 0.0 + + with patch("hummingbot.connector.exchange.lighter.lighter_exchange.time.time", return_value=100.0): + with self.assertRaises(IOError): + exchange._get_lighter_auth_token() + + async def test_close_lighter_api_client_closes_and_clears_cached_client(self): + exchange = LighterExchange.__new__(LighterExchange) + client = type("ApiClient", (), {"close": AsyncMock()})() + exchange._lighter_api_client = client + + await exchange._close_lighter_api_client() + + client.close.assert_awaited_once() + self.assertIsNone(exchange._lighter_api_client) + + def test_balance_lock_release_and_fill_helpers_update_balances(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._account_balances = { + "USDC": Decimal("20"), + "ETH": Decimal("5"), + } + exchange._account_available_balances = { + "USDC": Decimal("20"), + "ETH": Decimal("5"), + } + + with patch("hummingbot.connector.exchange.lighter.lighter_exchange.time.time", return_value=100.0): + exchange._lock_balance_on_order_creation("ETH-USDC", Decimal("2"), Decimal("3"), TradeType.BUY) + exchange._lock_balance_on_order_creation("ETH-USDC", Decimal("1.5"), Decimal("3"), TradeType.SELL) + + self.assertEqual(Decimal("14"), exchange._account_available_balances["USDC"]) + self.assertEqual(Decimal("3.5"), exchange._account_available_balances["ETH"]) + self.assertIn("USDC", exchange._optimistic_balance_lock) + self.assertIn("ETH", exchange._optimistic_balance_lock) + + buy_order = type( + "Order", + (), + { + "trading_pair": "ETH-USDC", + "amount": Decimal("2"), + "price": Decimal("3"), + "executed_amount_base": Decimal("0.5"), + "trade_type": TradeType.BUY, + }, + )() + sell_order = type( + "Order", + (), + { + "trading_pair": "ETH-USDC", + "amount": Decimal("2"), + "price": Decimal("3"), + "executed_amount_base": Decimal("0.25"), + "trade_type": TradeType.SELL, + }, + )() + + with patch("hummingbot.connector.exchange.lighter.lighter_exchange.time.time", return_value=101.0): + exchange._release_locked_balance_on_cancel(buy_order) + exchange._release_locked_balance_on_cancel(sell_order) + + self.assertEqual(Decimal("18.5"), exchange._account_available_balances["USDC"]) + self.assertEqual(Decimal("5"), exchange._account_available_balances["ETH"]) + self.assertIn("USDC", exchange._optimistic_balance_release) + self.assertIn("ETH", exchange._optimistic_balance_release) + + sell_fill_order = type( + "Order", + (), + { + "trading_pair": "ETH-USDC", + "amount": Decimal("1"), + "price": Decimal("4"), + "trade_type": TradeType.SELL, + }, + )() + buy_fill_order = type( + "Order", + (), + { + "trading_pair": "ETH-USDC", + "amount": Decimal("2"), + "price": Decimal("2"), + "trade_type": TradeType.BUY, + }, + )() + + exchange._release_locked_balance_on_fill(sell_fill_order) + exchange._release_locked_balance_on_fill(buy_fill_order) + + self.assertEqual(Decimal("6"), exchange._account_balances["ETH"]) + self.assertEqual(Decimal("6"), exchange._account_available_balances["ETH"]) + self.assertEqual(Decimal("20"), exchange._account_balances["USDC"]) + self.assertEqual(Decimal("20"), exchange._account_available_balances["USDC"]) + + def test_schedule_balance_sync_and_account_payload_helpers(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._balance_refresh_required_since = 0.0 + exchange._last_ws_balance_update_ts = 0.0 + exchange._current_timestamp_safely = MagicMock(return_value=10.0) + exchange._schedule_fast_balance_sync = MagicMock() + + exchange._schedule_balance_sync_for_terminal_update( + OrderUpdate( + client_order_id="HBOT-1", + exchange_order_id="1", + trading_pair="ETH-USDC", + update_timestamp=10.0, + new_state=OrderState.CANCELED, + ) + ) + self.assertEqual(10.0, exchange._balance_refresh_required_since) + exchange._schedule_fast_balance_sync.assert_called_once_with(min_interval_seconds=0.2) + + exchange._schedule_fast_balance_sync.reset_mock() + exchange._last_ws_balance_update_ts = 9.5 + exchange._schedule_balance_sync_for_terminal_update( + OrderUpdate( + client_order_id="HBOT-2", + exchange_order_id="2", + trading_pair="ETH-USDC", + update_timestamp=10.0, + new_state=OrderState.FAILED, + ) + ) + exchange._schedule_fast_balance_sync.assert_not_called() + + self.assertTrue(exchange._account_payload_has_assets({"assets": [{"symbol": "USDC"}]})) + self.assertTrue(exchange._account_payload_has_assets({"assets": {"0": {"symbol": "USDC"}}})) + self.assertFalse(exchange._account_payload_has_assets({"assets": ["bad"]})) + self.assertFalse(exchange._account_payload_has_assets(None)) + + def test_extract_private_stream_payloads_normalizes_assets_trades_and_orders(self): + account_data, trades, orders = LighterExchange._extract_private_stream_payloads( + { + "type": "update/account_all_assets", + "assets": {"0": {"symbol": "USDC", "balance": "10", "locked_balance": "1"}}, + "trades": {"1": [{"id": "t1"}], "2": {"id": "t2"}}, + "trade": {"id": "t3"}, + "orders": {"1": [{"id": "o1"}], "2": {"id": "o2"}}, + "order": {"id": "o3"}, + } + ) + + self.assertEqual({"assets": [{"symbol": "USDC", "balance": "10", "locked_balance": "1"}]}, account_data) + self.assertEqual(["t1", "t2", "t3"], [trade["id"] for trade in trades]) + self.assertEqual(["o1", "o2", "o3"], [order["id"] for order in orders]) + + account_data, trades, orders = LighterExchange._extract_private_stream_payloads( + { + "type": "update/account_tx", + "channel": "account_order_updates:123", + "data": [{"id": "o4"}], + "txs": [{"order": {"id": "o5"}}, {"id": "o6"}], + } + ) + + self.assertIsNone(account_data) + self.assertEqual([], trades) + self.assertEqual(["o4", "o5", "o6"], [order["id"] for order in orders]) + + def test_process_balance_message_from_account_preserves_optimistic_release_and_lock(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._account_balances = { + "USDC": Decimal("10"), + "BTC": Decimal("2"), + "DOGE": Decimal("0"), + } + exchange._account_available_balances = { + "USDC": Decimal("9"), + "BTC": Decimal("1"), + "DOGE": Decimal("0"), + } + exchange._optimistic_balance_release = {"USDC": (Decimal("9"), 100.0)} + exchange._optimistic_balance_lock = {"BTC": (Decimal("1"), 100.0)} + + with patch("hummingbot.connector.exchange.lighter.lighter_exchange.time.time", return_value=101.0): + exchange._process_balance_message_from_account( + { + "assets": [ + {"symbol": "USDC", "balance": "10", "locked_balance": "2"}, + {"symbol": "BTC", "balance": "2", "locked_balance": "0"}, + ] + } + ) + + self.assertEqual(Decimal("10"), exchange._account_balances["USDC"]) + self.assertEqual(Decimal("9"), exchange._account_available_balances["USDC"]) + self.assertEqual(Decimal("2"), exchange._account_balances["BTC"]) + self.assertEqual(Decimal("1"), exchange._account_available_balances["BTC"]) + self.assertNotIn("DOGE", exchange._account_balances) + self.assertNotIn("DOGE", exchange._account_available_balances) + + def test_order_update_from_raw_message_matches_compact_server_order_fallback(self): + exchange = LighterExchange.__new__(LighterExchange) + tracked_order = type( + "TrackedOrder", + (), + { + "client_order_id": "HBOT-compact", + "exchange_order_id": "77", + "trading_pair": "ETH-USDC", + "trade_type": TradeType.BUY, + "price": Decimal("100"), + "is_done": False, + }, + )() + exchange.logger = MagicMock(return_value=MagicMock()) + exchange._server_order_index_to_client_order_index = {} + exchange._order_tracker = type( + "Tracker", + (), + { + "all_updatable_orders": {"HBOT-compact": tracked_order}, + "all_fillable_orders_by_exchange_order_id": {}, + }, + )() + + order_update = exchange._order_update_from_raw_message( + {"i": "9001", "s": "ETH-USDC", "d": "bid", "p": "100", "os": "filled", "ut": 1700000000000} + ) + + self.assertIsNotNone(order_update) + self.assertEqual("HBOT-compact", order_update.client_order_id) + self.assertEqual(OrderState.FILLED, order_update.new_state) + self.assertEqual("77", exchange._server_order_index_to_client_order_index["9001"]) + + def test_trade_update_from_raw_message_matches_compact_client_and_server_ids(self): + exchange = LighterExchange.__new__(LighterExchange) + tracked_order = type( + "TrackedOrder", + (), + { + "client_order_id": "HBOT-trade", + "exchange_order_id": "77", + "trading_pair": "ETH-USDC", + "quote_asset": "USDC", + "trade_type": TradeType.BUY, + }, + )() + exchange.trade_fee_schema = lambda: TradeFeeSchema() + _set_exchange_timestamp(exchange, 1700000000) + exchange._client_order_index_to_client_order_id = {"55": "HBOT-trade"} + exchange._server_order_index_to_client_order_index = {"9001": "55"} + exchange._order_tracker = type( + "Tracker", + (), + { + "all_fillable_orders": {"HBOT-trade": tracked_order}, + "all_fillable_orders_by_exchange_order_id": {}, + }, + )() + + by_client_index = exchange._trade_update_from_raw_message( + { + "I": "55", + "trade_id": "t-client-index", + "price": "2.5", + "amount": "4", + "fee": "0", + "created_at": 1700000000123, + } + ) + by_server_index = exchange._trade_update_from_raw_message( + { + "i": "9001", + "trade_id": "t-server-index", + "price": "3", + "amount": "2", + "fee": "0", + "created_at": 1700000001123, + } + ) + + self.assertIsNotNone(by_client_index) + self.assertEqual("t-client-index", by_client_index.trade_id) + self.assertIsNotNone(by_server_index) + self.assertEqual("t-server-index", by_server_index.trade_id) + + async def test_api_request_and_get_last_traded_prices(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._domain = "lighter" + exchange._api_key = "k" + exchange._api_secret = "" + exchange._web_assistants_factory = type("Factory", (), {"get_rest_assistant": AsyncMock()})() + rest = type("Rest", (), {})() + rest.execute_request = AsyncMock(return_value={"ok": True}) + exchange._web_assistants_factory.get_rest_assistant = AsyncMock(return_value=rest) + + response = await exchange._api_request(path_url="/orderBooks", method=RESTMethod.GET, params={"a": 1}, is_auth_required=False) + self.assertEqual({"ok": True}, response) + + exchange._api_request = AsyncMock( + return_value={ + "order_book_stats": [ + {"symbol": "ETH/USDC", "last_trade_price": "100.5"}, + {"symbol": "BTC/USDC", "last_trade_price": "200.5"}, + ] + } + ) + exchange.trading_pair_associated_to_exchange_symbol = AsyncMock(side_effect=["ETH-USDC", "BTC-USDC"]) + prices = await exchange.get_last_traded_prices(["ETH-USDC"]) + self.assertEqual({"ETH-USDC": 100.5}, prices) + + async def test_status_polling_loop_fetch_updates(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._update_balances = AsyncMock() + exchange._update_order_status = AsyncMock() + exchange._update_lost_orders_status = AsyncMock() + exchange._cleanup_startup_orphan_orders = AsyncMock() + exchange._cleanup_runtime_orphan_orders = AsyncMock() + + await exchange._status_polling_loop_fetch_updates() + self.assertEqual(1, exchange._update_balances.await_count) + self.assertEqual(1, exchange._update_order_status.await_count) + self.assertEqual(1, exchange._update_lost_orders_status.await_count) + exchange._cleanup_startup_orphan_orders.assert_awaited_once() + + def test_get_lighter_signer_client_builds_once(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._lighter_signer_client = None + exchange._api_host_for_signer = lambda: "https://mainnet.zklighter.elliot.ai" + exchange._get_account_index = lambda: 693751 + exchange._get_api_key_index = lambda: 7 + exchange._get_signer_private_key = lambda: "0xabc" + + fake_lighter = types.ModuleType("lighter") + + class SignerClient: + def __init__(self, **kwargs): + self.kwargs = kwargs + + fake_lighter.signer_client = type("SignerModule", (), {"SignerClient": SignerClient}) + fake_lighter.create_api_key = lambda: ("priv", "pub", None) + sys.modules["lighter"] = fake_lighter + + client_1 = exchange._get_lighter_signer_client() + client_2 = exchange._get_lighter_signer_client() + + self.assertIs(client_1, client_2) + self.assertEqual(693751, client_1.kwargs["account_index"]) + self.assertEqual({7: "0xabc"}, client_1.kwargs["api_private_keys"]) + + async def test_update_trading_fees_noop(self): + exchange = LighterExchange.__new__(LighterExchange) + self.assertIsNone(await exchange._update_trading_fees()) + + async def test_user_stream_event_listener_processes_messages(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._process_balance_message_from_account = lambda _: None + exchange._trade_update_from_raw_message = lambda _: "TRADE_UPDATE" + exchange._order_update_from_raw_message = lambda _: "ORDER_UPDATE" + exchange._schedule_unmatched_private_event_reconcile = MagicMock() + exchange._order_tracker = type( + "Tracker", + (), + { + "process_trade_update": lambda self, _: None, + "process_order_update": lambda self, _: None, + "all_fillable_orders_by_exchange_order_id": {}, + }, + )() + exchange._sleep = AsyncMock() + + async def events(): + yield { + "data": {"assets": []}, + "trades": [{"trade_id": "t1"}], + "orders": [{"order_id": "o1"}], + } + raise asyncio.CancelledError + + exchange._iter_user_event_queue = events + + with self.assertRaises(asyncio.CancelledError): + await exchange._user_stream_event_listener() + + async def test_user_stream_event_listener_triggers_reconcile_for_unmatched_private_events(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._process_balance_message_from_account = lambda _: None + exchange._trade_update_from_raw_message = lambda _: None + exchange._order_update_from_raw_message = lambda _: None + exchange._schedule_unmatched_private_event_reconcile = MagicMock() + exchange._order_tracker = type( + "Tracker", + (), + { + "process_trade_update": lambda self, _: None, + "process_order_update": lambda self, _: None, + "all_fillable_orders_by_exchange_order_id": {}, + }, + )() + exchange._sleep = AsyncMock() + + async def events(): + yield { + "data": {"assets": []}, + "trades": [{"trade_id": "t1"}], + "orders": [{"order_id": "o1"}], + } + raise asyncio.CancelledError + + exchange._iter_user_event_queue = events + + with self.assertRaises(asyncio.CancelledError): + await exchange._user_stream_event_listener() + + exchange._schedule_unmatched_private_event_reconcile.assert_called_once() + + async def test_user_stream_event_listener_handles_exception(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._process_balance_message_from_account = lambda _: None + + def failing_trade(_): + raise RuntimeError("boom") + + exchange._trade_update_from_raw_message = failing_trade + exchange._order_update_from_raw_message = lambda _: None + exchange._order_tracker = type( + "Tracker", + (), + { + "process_trade_update": lambda self, _: None, + "process_order_update": lambda self, _: None, + "all_fillable_orders_by_exchange_order_id": {}, + }, + )() + exchange._sleep = AsyncMock() + + class Logger: + def error(self, *args, **kwargs): + return None + + exchange.logger = lambda: Logger() + + async def events(): + yield {"trades": [{"trade_id": "t1"}]} + raise asyncio.CancelledError + + exchange._iter_user_event_queue = events + + with self.assertRaises(asyncio.CancelledError): + await exchange._user_stream_event_listener() + + self.assertEqual(1, exchange._sleep.await_count) + + async def test_all_trade_updates_for_order_handles_pagination(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._account_index = "693751" + _set_exchange_timestamp(exchange, 1700001000) + exchange._order_history_last_poll_timestamp = {} + exchange._hb_order_id_to_client_order_index = {"HBOT-11": 42} + exchange._server_order_index_to_client_order_index = {} + exchange.trade_fee_schema = lambda: TradeFeeSchema() + exchange._api_get = AsyncMock( + side_effect=[ + { + "success": True, + "data": [ + {"order_id": "x", "history_id": "h0", "price": "1", "amount": "1", "created_at": 1000}, + { + "order_id": "42", + "history_id": "h1", + "price": "2", + "amount": "3", + "fee": "0.1", + "created_at": 2000, + "event_type": "fulfill_taker", + }, + ], + "has_more": True, + "next_cursor": "cur1", + }, + {"success": True, "data": [], "has_more": False}, + ] + ) + + order = type( + "Order", + (), + { + "exchange_order_id": "42", + "creation_timestamp": 1700000000, + "quote_asset": "USDC", + "trade_type": TradeType.BUY, + "client_order_id": "HBOT-11", + "trading_pair": "ETH-USDC", + }, + )() + + updates = await exchange._all_trade_updates_for_order(order) + self.assertEqual(1, len(updates)) + self.assertEqual("h1", updates[0].trade_id) + self.assertTrue(updates[0].is_taker) + self.assertIn("42", exchange._order_history_last_poll_timestamp) + + async def test_all_trade_updates_for_order_matches_server_order_index_when_client_id_absent(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._account_index = "693751" + _set_exchange_timestamp(exchange, 1700001000) + exchange._order_history_last_poll_timestamp = {} + exchange._hb_order_id_to_client_order_index = {"HBOT-11": 42} + exchange._server_order_index_to_client_order_index = {"9001": "42"} + exchange.trade_fee_schema = lambda: TradeFeeSchema() + exchange._api_get = AsyncMock( + return_value={ + "success": True, + "data": [ + { + "order_id": "9001", + "history_id": "h-server-only", + "price": "2", + "size": "3", + "timestamp": 2000, + "is_maker_ask": False, + } + ], + } + ) + + order = type( + "Order", + (), + { + "exchange_order_id": "42", + "creation_timestamp": 1700000000, + "quote_asset": "USDC", + "trade_type": TradeType.BUY, + "client_order_id": "HBOT-11", + "trading_pair": "ETH-USDC", + }, + )() + + updates = await exchange._all_trade_updates_for_order(order) + + self.assertEqual(1, len(updates)) + self.assertEqual("h-server-only", updates[0].trade_id) + + async def test_all_trade_updates_for_order_applies_time_drift_buffer(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._account_index = "693751" + _set_exchange_timestamp(exchange, 1700001000) + exchange._order_history_last_poll_timestamp = {"42": 20} + exchange.trade_fee_schema = lambda: TradeFeeSchema() + exchange._api_get = AsyncMock(return_value={"success": True, "data": []}) + + order = type( + "Order", + (), + { + "exchange_order_id": "42", + "creation_timestamp": 10, + "quote_asset": "USDC", + "trade_type": TradeType.BUY, + "client_order_id": "HBOT-11", + "trading_pair": "ETH-USDC", + }, + )() + + await exchange._all_trade_updates_for_order(order) + + api_get_call = exchange._api_get.call_args + self.assertEqual(10, api_get_call.kwargs["params"]["from"]) + + async def test_all_trade_updates_for_order_does_not_advance_cursor_without_matches(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._account_index = "693751" + _set_exchange_timestamp(exchange, 1700001000) + exchange._order_history_last_poll_timestamp = {"42": 20} + exchange._hb_order_id_to_client_order_index = {"HBOT-11": 42} + exchange._server_order_index_to_client_order_index = {} + exchange.trade_fee_schema = lambda: TradeFeeSchema() + exchange._api_get = AsyncMock( + return_value={ + "success": True, + "data": [ + { + "bid_client_id": 999, + "history_id": "h-unrelated", + "price": "2", + "size": "3", + "timestamp": 2000, + } + ], + } + ) + + order = type( + "Order", + (), + { + "exchange_order_id": "42", + "creation_timestamp": 10, + "quote_asset": "USDC", + "trade_type": TradeType.BUY, + "client_order_id": "HBOT-11", + "trading_pair": "ETH-USDC", + }, + )() + + updates = await exchange._all_trade_updates_for_order(order) + + self.assertEqual([], updates) + self.assertEqual(20, exchange._order_history_last_poll_timestamp["42"]) + + async def test_all_trade_updates_for_order_without_exchange_order_id_returns_empty(self): + exchange = LighterExchange.__new__(LighterExchange) + _set_exchange_timestamp(exchange, 1700001000) + exchange._order_history_last_poll_timestamp = {} + exchange.trade_fee_schema = lambda: TradeFeeSchema() + exchange._api_get = AsyncMock() + + order = type( + "Order", + (), + { + "exchange_order_id": None, + "creation_timestamp": 1700000000, + "quote_asset": "USDC", + "trade_type": TradeType.BUY, + "client_order_id": "HBOT-INVALID", + "trading_pair": "ETH-USDC", + }, + )() + + updates = await exchange._all_trade_updates_for_order(order) + self.assertEqual([], updates) + exchange._api_get.assert_not_called() + self.assertIn("None", exchange._order_history_last_poll_timestamp) + + async def test_request_order_status_raises_on_error(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._api_get = AsyncMock(return_value={"success": False, "code": 500}) + tracked_order = type( + "TrackedOrder", + (), + { + "client_order_id": "HBOT-12", + "exchange_order_id": "600", + "trading_pair": "ETH-USDC", + "current_state": OrderState.OPEN, + }, + )() + + with self.assertRaises(IOError): + await exchange._request_order_status(tracked_order) + + def test_get_fee_returns_zero_fee(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange.trade_fee_schema = lambda: TradeFeeSchema() + fee = exchange._get_fee( + base_currency="ETH", + quote_currency="USDC", + order_type=OrderType.LIMIT, + order_side=TradeType.BUY, + amount=Decimal("1"), + price=Decimal("100"), + ) + self.assertIsNotNone(fee) + + def test_get_poll_interval_is_short_with_active_orders(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange.SHORT_POLL_INTERVAL = 5.0 + exchange._order_tracker = type("Tracker", (), {"active_orders": {"OID": object()}})() + + interval = exchange._get_poll_interval(timestamp=100) + + self.assertEqual(5.0, interval) + + async def test_update_order_fills_from_trades_processes_trade(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange.UPDATE_ORDER_STATUS_MIN_INTERVAL = 1 + exchange.LONG_POLL_INTERVAL = 120 + exchange._last_poll_timestamp = 0 + exchange._last_trades_poll_timestamp = 0 + _set_exchange_timestamp(exchange, 10) + exchange.trade_fee_schema = lambda: TradeFeeSchema() + exchange._get_account_index = lambda: 693751 + + tracked_order = type( + "TrackedOrder", + (), + { + "exchange_order_id": "77", + "trade_type": TradeType.BUY, + "quote_asset": "USDC", + "client_order_id": "HBOT-13", + "trading_pair": "ETH-USDC", + }, + )() + captured = [] + exchange._order_tracker = type( + "Tracker", + (), + { + "all_fillable_orders": {"HBOT-13": tracked_order}, + "process_trade_update": lambda self, update: captured.append(update), + "active_orders": {"HBOT-13": tracked_order}, + }, + )() + exchange._api_get = AsyncMock( + return_value={ + "data": [ + {"bid_client_id": 77, "trade_id": "t1", "size": "1", "price": "2", "timestamp": 1000}, + ] + } + ) + + await exchange._update_order_fills_from_trades() + self.assertEqual(1, len(captured)) + + api_get_call = exchange._api_get.call_args + self.assertEqual(693751, api_get_call.kwargs["params"]["account_index"]) + self.assertNotIn("from", api_get_call.kwargs["params"]) + + async def test_update_order_fills_from_trades_uses_time_filter_when_available(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange.UPDATE_ORDER_STATUS_MIN_INTERVAL = 1 + exchange.LONG_POLL_INTERVAL = 120 + exchange._last_poll_timestamp = 0 + exchange._last_trades_poll_timestamp = 5 + _set_exchange_timestamp(exchange, 10) + exchange.trade_fee_schema = lambda: TradeFeeSchema() + exchange._get_account_index = lambda: 693751 + + tracked_order = type( + "TrackedOrder", + (), + { + "exchange_order_id": "77", + "trade_type": TradeType.BUY, + "quote_asset": "USDC", + "client_order_id": "HBOT-13", + "trading_pair": "ETH-USDC", + }, + )() + exchange._order_tracker = type( + "Tracker", + (), + { + "all_fillable_orders": {"HBOT-13": tracked_order}, + "process_trade_update": lambda self, update: None, + "active_orders": {"HBOT-13": tracked_order}, + }, + )() + exchange._api_get = AsyncMock(return_value={"data": []}) + + await exchange._update_order_fills_from_trades() + + api_get_call = exchange._api_get.call_args + self.assertEqual(693751, api_get_call.kwargs["params"]["account_index"]) + self.assertNotIn("from", api_get_call.kwargs["params"]) + self.assertEqual(10, exchange._last_trades_poll_timestamp) + + async def test_update_order_fills_from_trades_no_poll(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange.UPDATE_ORDER_STATUS_MIN_INTERVAL = 1 + exchange.LONG_POLL_INTERVAL = 120 + exchange._last_poll_timestamp = 10 + _set_exchange_timestamp(exchange, 10) + exchange._order_tracker = type("Tracker", (), {"all_fillable_orders": {}, "active_orders": {}})() + exchange._api_get = AsyncMock(return_value={"data": []}) + + await exchange._update_order_fills_from_trades() + self.assertEqual(0, exchange._api_get.await_count) + + async def test_update_order_status_delegates(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._order_tracker = type( + "Tracker", + (), + { + "active_orders": {"k": "v"}, + "cached_orders": {}, + "all_fillable_orders": {}, + }, + )() + exchange._update_order_fills_from_trades = AsyncMock() + exchange._update_orders = AsyncMock() + exchange._rescue_cached_order_fills = AsyncMock() + + await exchange._update_order_status() + + self.assertEqual(1, exchange._update_order_fills_from_trades.await_count) + self.assertEqual(1, exchange._update_orders.await_count) + self.assertEqual(1, exchange._rescue_cached_order_fills.await_count) + + async def test_update_orders_fills_processes_each_trade_update(self): + exchange = LighterExchange.__new__(LighterExchange) + order = object() + exchange._all_trade_updates_for_order = AsyncMock(return_value=["u1", "u2"]) + processed = [] + exchange._order_tracker = type("Tracker", (), {"process_trade_update": lambda self, update: processed.append(update)})() + + await exchange._update_orders_fills([order]) + + self.assertEqual(["u1", "u2"], processed) + + async def test_update_orders_processes_order_updates(self): + exchange = LighterExchange.__new__(LighterExchange) + tracked_order = object() + exchange._order_tracker = type( + "Tracker", + (), + { + "active_orders": {"o": tracked_order}, + "process_order_update": lambda self, update: None, + }, + )() + exchange._request_order_status = AsyncMock(return_value="ORDER_UPDATE") + + await exchange._update_orders() + self.assertEqual(1, exchange._request_order_status.await_count) + + async def test_update_orders_triggers_fast_balance_sync_for_canceled_orders(self): + exchange = LighterExchange.__new__(LighterExchange) + tracked_order = type( + "TrackedOrder", + (), + { + "is_done": False, + "amount": Decimal("2"), + "executed_amount_base": Decimal("0"), + "trade_type": TradeType.BUY, + "price": Decimal("10"), + "quote_asset": "USDC", + "base_asset": "ETH", + "client_order_id": "HBOT-C", + "exchange_order_id": "42", + "trading_pair": "ETH-USDC", + }, + )() + processed_updates = [] + exchange._order_tracker = type( + "Tracker", + (), + { + "active_orders": {"o": tracked_order}, + "process_order_update": lambda self, update: processed_updates.append(update), + }, + )() + exchange._request_order_status = AsyncMock( + return_value=OrderUpdate( + trading_pair="ETH-USDC", + update_timestamp=1, + new_state=OrderState.CANCELED, + client_order_id="HBOT-C", + exchange_order_id="42", + ) + ) + exchange._account_balances = {"USDC": Decimal("100")} + exchange._account_available_balances = {"USDC": Decimal("20")} + exchange._safe_update_balances_from_private_stream = AsyncMock() + exchange._all_trade_updates_for_order = AsyncMock(return_value=[]) + exchange._last_private_stream_balance_sync_ts = 0.0 + exchange._current_timestamp_safely = lambda: 1000.0 + exchange._balance_refresh_required_since = 0.0 + + await exchange._update_orders() + + self.assertEqual(1, len(processed_updates)) + self.assertEqual(Decimal("20"), exchange._account_available_balances["USDC"]) + self.assertEqual(1000.0, exchange._balance_refresh_required_since) + self.assertEqual(1000.0, exchange._last_private_stream_balance_sync_ts) + + def test_schedule_balance_sync_for_terminal_update_sets_refresh_requirement(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._account_balances = {"LINK": Decimal("5")} + exchange._account_available_balances = {"LINK": Decimal("1")} + exchange._safe_update_balances_from_private_stream = AsyncMock() + exchange._last_private_stream_balance_sync_ts = 0.0 + exchange._current_timestamp_safely = lambda: 2000.0 + exchange._balance_refresh_required_since = 0.0 + + tracked_order = type( + "TrackedOrder", + (), + { + "amount": Decimal("2.1"), + "executed_amount_base": Decimal("0.1"), + "trade_type": TradeType.SELL, + "price": Decimal("9.5"), + "quote_asset": "USDC", + "base_asset": "LINK", + }, + )() + order_update = OrderUpdate( + trading_pair="LINK-USDC", + update_timestamp=1, + new_state=OrderState.CANCELED, + client_order_id="HBOT-S", + exchange_order_id="7", + ) + + exchange._schedule_balance_sync_for_terminal_update(order_update=order_update, tracked_order=tracked_order) + + self.assertEqual(Decimal("1"), exchange._account_available_balances["LINK"]) + self.assertEqual(2000.0, exchange._balance_refresh_required_since) + self.assertEqual(2000.0, exchange._last_private_stream_balance_sync_ts) + + async def test_ensure_fresh_balance_snapshot_waits_for_balance_refresh(self): + # The guard is in _ensure_fresh_balance_snapshot_before_order which is called from + # _place_order. Verify the happy path: when _update_balances succeeds and updates + # _last_balance_update_timestamp, the guard clears and returns without raising. + exchange = LighterExchange.__new__(LighterExchange) + exchange._balance_refresh_required_since = 100.0 + exchange._last_balance_update_timestamp = 90.0 + exchange._last_ws_balance_update_ts = 0.0 + + async def refresh_balance(**_kwargs): + exchange._last_balance_update_timestamp = 101.0 + + exchange._update_balances = AsyncMock(side_effect=refresh_balance) + + # Should return without raising (balance is now fresh) + await exchange._ensure_fresh_balance_snapshot_before_order(trade_type=TradeType.BUY) + + exchange._update_balances.assert_awaited_once() + + async def test_place_order_raises_when_balance_refresh_is_pending(self): + # The balance-refresh guard was moved from _create_order to _place_order so that + # start_tracking_order() in the base _create_order always runs first. Verify that + # _place_order itself raises IOError when the guard fires. + exchange = LighterExchange.__new__(LighterExchange) + exchange._balance_refresh_required_since = 100.0 + exchange._last_balance_update_timestamp = 90.0 + exchange._last_ws_balance_update_ts = 0.0 + exchange._update_balances = AsyncMock(side_effect=IOError("rate limited")) + + with self.assertRaises(IOError): + await exchange._place_order( + order_id="HBOT-BUY", + trading_pair="LINK-USDC", + amount=Decimal("2"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT, + price=Decimal("10"), + ) + + exchange._update_balances.assert_awaited_once() + + async def test_iter_user_event_queue_yields_message(self): + exchange = LighterExchange.__new__(LighterExchange) + q = asyncio.Queue() + q.put_nowait({"event": 1}) + exchange._user_stream_tracker = type("UST", (), {"user_stream": q})() + + agen = exchange._iter_user_event_queue() + message = await agen.__anext__() + self.assertEqual({"event": 1}, message) + + def test_hb_pair_from_symbol_variants(self): + self.assertEqual("ETH-USDC", LighterExchange._hb_pair_from_symbol("ETH/USDC")) + self.assertEqual("BTC-USDC", LighterExchange._hb_pair_from_symbol("BTC-USDC")) + + def test_client_order_index_from_order_id_is_stable(self): + idx_1 = LighterExchange._client_order_index_from_order_id("HBOT-1") + idx_2 = LighterExchange._client_order_index_from_order_id("HBOT-1") + + self.assertEqual(idx_1, idx_2) + self.assertGreaterEqual(idx_1, 0) + self.assertLessEqual(idx_1, (1 << 48) - 1) + + def test_get_signer_private_key_precedence_and_validation(self): + exchange = LighterExchange.__new__(LighterExchange) + # _api_key holds the private key (hex) — returned when it's not an integer string + exchange._api_key = "0x" + ("a" * 64) + self.assertEqual("0x" + ("a" * 64), exchange._get_signer_private_key()) + + # Integer string in _api_key means it's the key index, not the private key — raises + exchange._api_key = "7" + with self.assertRaises(ValueError): + exchange._get_signer_private_key() + + # Empty _api_key raises + exchange._api_key = "" + with self.assertRaises(ValueError): + exchange._get_signer_private_key() + + def test_get_api_key_index_and_account_index(self): + exchange = LighterExchange.__new__(LighterExchange) + # _api_key_index is the canonical attribute used by _get_api_key_index + exchange._api_key_index = "7" + exchange._account_index = "42" + self.assertEqual(7, exchange._get_api_key_index()) + self.assertEqual(42, exchange._get_account_index()) + + # Non-integer _api_key_index raises + exchange._api_key_index = "not-an-int" + with self.assertRaises(ValueError): + exchange._get_api_key_index() + + # Non-integer account index raises + exchange._account_index = "abc" + with self.assertRaises(ValueError): + exchange._get_account_index() + + def test_account_from_response_variants(self): + self.assertEqual({"assets": []}, LighterExchange._account_from_response({"data": {"assets": []}})) + self.assertEqual({"assets": [1]}, LighterExchange._account_from_response({"data": [{"assets": [1]}]})) + self.assertEqual({"assets": [2]}, LighterExchange._account_from_response({"accounts": [{"assets": [2]}]})) + # Lighter API returns account object directly at top level (no data/accounts wrapper) + top_level = {"code": 200, "assets": [{"symbol": "USDC", "balance": "5.7"}], "collateral": "5.7", "available_balance": "5.7"} + self.assertEqual(top_level, LighterExchange._account_from_response(top_level)) + self.assertIsNone(LighterExchange._account_from_response({"code": 200})) + + def test_account_query_params(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._account_index = "693751" + self.assertEqual({"by": "index", "value": "693751", "active_only": "true"}, exchange._account_query_params()) + + def test_is_ok_response(self): + self.assertTrue(LighterExchange._is_ok_response({"success": True})) + self.assertTrue(LighterExchange._is_ok_response({"code": 200})) + self.assertFalse(LighterExchange._is_ok_response({"code": 500})) + + def test_process_balance_message_from_account(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._account_balances = {} + exchange._account_available_balances = {} + + exchange._process_balance_message_from_account( + { + "assets": [ + {"symbol": "USDC", "balance": "10", "locked_balance": "2"}, + {"symbol": "ETH", "balance": "1.5", "locked_balance": "0.5"}, + ] + } + ) + + self.assertEqual(Decimal("10"), exchange._account_balances["USDC"]) + self.assertEqual(Decimal("8"), exchange._account_available_balances["USDC"]) + self.assertEqual(Decimal("1.0"), exchange._account_available_balances["ETH"]) + + def test_process_balance_message_from_account_ignores_top_level_available_balance(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._account_balances = {} + exchange._account_available_balances = {} + + exchange._process_balance_message_from_account( + { + "available_balance": "13.8", + "assets": [ + {"symbol": "USDC", "balance": "13", "locked_balance": "3"}, + ], + } + ) + + self.assertEqual(Decimal("13"), exchange._account_balances["USDC"]) + self.assertEqual(Decimal("10"), exchange._account_available_balances["USDC"]) + + def test_order_update_from_raw_message(self): + exchange = LighterExchange.__new__(LighterExchange) + tracked_order = type( + "TrackedOrder", + (), + { + "client_order_id": "HBOT-1", + "exchange_order_id": "42", + "trading_pair": "ETH-USDC", + }, + )() + exchange._order_tracker = type( + "Tracker", + (), + { + "all_updatable_orders": {"HBOT-1": tracked_order}, + "all_fillable_orders_by_exchange_order_id": {}, + }, + )() + + order_update = exchange._order_update_from_raw_message( + {"client_order_id": "HBOT-1", "order_status": "canceled", "updated_at": 1700000000} + ) + + self.assertIsNotNone(order_update) + self.assertEqual("HBOT-1", order_update.client_order_id) + self.assertEqual(OrderState.CANCELED, order_update.new_state) + + def test_trade_update_from_raw_message(self): + exchange = LighterExchange.__new__(LighterExchange) + tracked_order = type( + "TrackedOrder", + (), + { + "client_order_id": "HBOT-2", + "exchange_order_id": "77", + "trading_pair": "ETH-USDC", + "quote_asset": "USDC", + "trade_type": TradeType.BUY, + }, + )() + exchange.trade_fee_schema = lambda: TradeFeeSchema() + _set_exchange_timestamp(exchange, 1700000000) + exchange._order_tracker = type( + "Tracker", + (), + { + "all_fillable_orders": {"HBOT-2": tracked_order}, + "all_fillable_orders_by_exchange_order_id": {"77": tracked_order}, + }, + )() + + trade_update = exchange._trade_update_from_raw_message( + { + "client_order_id": "HBOT-2", + "order_id": "77", + "trade_id": "t-1", + "price": "2.5", + "amount": "4", + "created_at": 1700000000123, + } + ) + + self.assertIsNotNone(trade_update) + self.assertEqual("t-1", trade_update.trade_id) + self.assertEqual(Decimal("2.5"), trade_update.fill_price) + self.assertEqual(Decimal("4"), trade_update.fill_base_amount) + self.assertEqual(Decimal("10"), trade_update.fill_quote_amount) + + async def test_format_trading_rules_filters_non_spot(self): + exchange = LighterExchange.__new__(LighterExchange) + rules = await exchange._format_trading_rules( + { + "data": [ + {"symbol": "ETH/USDC", "market_type": "spot", "supported_size_decimals": 3, "supported_price_decimals": 2}, + {"symbol": "BTC/USDC", "market_type": "perp", "supported_size_decimals": 3, "supported_price_decimals": 2}, + ] + } + ) + self.assertEqual(1, len(rules)) + self.assertEqual("ETH-USDC", rules[0].trading_pair) + + async def test_request_order_status_empty_returns_current_state(self): + exchange = LighterExchange.__new__(LighterExchange) + _set_exchange_timestamp(exchange, 1700001234) + exchange._api_get = AsyncMock(return_value={"success": True, "data": []}) + + tracked_order = type( + "TrackedOrder", + (), + { + "client_order_id": "HBOT-3", + "exchange_order_id": "300", + "trading_pair": "ETH-USDC", + "current_state": OrderState.OPEN, + }, + )() + + status = await exchange._request_order_status(tracked_order) + self.assertEqual("HBOT-3", status.client_order_id) + self.assertEqual(OrderState.OPEN, status.new_state) + + async def test_request_order_status_parses_state(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._api_get = AsyncMock( + return_value={ + "success": True, + "data": [ + {"created_at": 1, "order_status": "open"}, + {"created_at": 2, "order_status": "canceled"}, + ], + } + ) + + tracked_order = type( + "TrackedOrder", + (), + { + "client_order_id": "HBOT-4", + "exchange_order_id": "301", + "trading_pair": "ETH-USDC", + "current_state": OrderState.OPEN, + }, + )() + + status = await exchange._request_order_status(tracked_order) + self.assertEqual(OrderState.CANCELED, status.new_state) + + async def test_request_order_status_matches_by_order_id(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._api_get = AsyncMock( + return_value={ + "success": True, + "data": [ + {"order_id": "301", "symbol": "ETH/USDC", "created_at": 2, "order_status": "canceled"}, + ], + } + ) + + tracked_order = type( + "TrackedOrder", + (), + { + "client_order_id": "HBOT-4", + "exchange_order_id": "301", + "trading_pair": "ETH-USDC", + "current_state": OrderState.OPEN, + }, + )() + + status = await exchange._request_order_status(tracked_order) + self.assertEqual(OrderState.CANCELED, status.new_state) + + async def test_request_order_status_does_not_use_unrelated_active_rows(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._api_get = AsyncMock( + side_effect=[ + { + "success": True, + "data": [ + {"order_id": "999", "client_order_id": "999", "symbol": "ETH/USDC", "order_status": "open"}, + ], + }, + {"success": True, "data": []}, + ] + ) + + tracked_order = type( + "TrackedOrder", + (), + { + "client_order_id": "HBOT-ghost", + "exchange_order_id": "301", + "trading_pair": "ETH-USDC", + "current_state": OrderState.OPEN, + }, + )() + + status = await exchange._request_order_status(tracked_order) + self.assertEqual(OrderState.OPEN, status.new_state) + + async def test_request_order_status_uses_terminal_state_from_active_rows(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._api_get = AsyncMock( + side_effect=[ + {"success": True, "data": [{"order_id": "301", "symbol": "ETH/USDC", "order_status": "closed", "created_at": 3}]}, + ] + ) + + tracked_order = type( + "TrackedOrder", + (), + { + "client_order_id": "HBOT-closed", + "exchange_order_id": "301", + "trading_pair": "ETH-USDC", + "current_state": OrderState.OPEN, + }, + )() + + status = await exchange._request_order_status(tracked_order) + self.assertEqual(OrderState.CANCELED, status.new_state) + + async def test_request_order_status_preserves_partial_state_from_active_rows(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._api_get = AsyncMock( + side_effect=[ + {"success": True, "data": [{"order_id": "301", "symbol": "ETH/USDC", "order_status": "partially_filled", "created_at": 3}]}, + ] + ) + + tracked_order = type( + "TrackedOrder", + (), + { + "client_order_id": "HBOT-partial", + "exchange_order_id": "301", + "trading_pair": "ETH-USDC", + "current_state": OrderState.OPEN, + }, + )() + + status = await exchange._request_order_status(tracked_order) + self.assertEqual(OrderState.PARTIALLY_FILLED, status.new_state) + + async def test_request_order_status_queries_active_orders_before_inactive_history(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._api_get = AsyncMock( + side_effect=[ + {"success": True, "data": [{"order_id": "301", "symbol": "ETH/USDC", "order_status": "open", "created_at": 3}]}, + ] + ) + + tracked_order = type( + "TrackedOrder", + (), + { + "client_order_id": "HBOT-open", + "exchange_order_id": "301", + "trading_pair": "ETH-USDC", + "current_state": OrderState.OPEN, + }, + )() + + status = await exchange._request_order_status(tracked_order) + + self.assertEqual(OrderState.OPEN, status.new_state) + self.assertEqual(1, exchange._api_get.await_count) + self.assertEqual("/accountActiveOrders", exchange._api_get.await_args.kwargs["path_url"]) + + async def test_update_balances_updates_and_removes_assets(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._account_balances = {"OLD": Decimal("1")} + exchange._account_available_balances = {"OLD": Decimal("1")} + exchange._account_query_params = lambda: {"by": "index", "value": "1"} + exchange._api_get = AsyncMock( + return_value={ + "success": True, + "data": { + "assets": [ + {"symbol": "USDC", "balance": "12", "locked_balance": "2"}, + ] + }, + } + ) + + await exchange._update_balances() + + self.assertNotIn("OLD", exchange._account_balances) + self.assertEqual(Decimal("12"), exchange._account_balances["USDC"]) + self.assertEqual(Decimal("10"), exchange._account_available_balances["USDC"]) + + async def test_update_balances_ignores_top_level_available_balance(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._account_balances = {} + exchange._account_available_balances = {} + exchange._account_query_params = lambda: {"by": "index", "value": "1"} + exchange._api_get = AsyncMock( + return_value={ + "success": True, + "data": { + "available_balance": "13.8", + "assets": [ + {"symbol": "USDC", "balance": "13", "locked_balance": "3"}, + ] + }, + } + ) + + await exchange._update_balances() + + self.assertEqual(Decimal("13"), exchange._account_balances["USDC"]) + self.assertEqual(Decimal("10"), exchange._account_available_balances["USDC"]) + + def test_initialize_trading_pair_symbols_from_exchange_info(self): + exchange = LighterExchange.__new__(LighterExchange) + captured = {} + exchange._set_trading_pair_symbol_map = lambda mapping: captured.update(mapping) + + exchange._initialize_trading_pair_symbols_from_exchange_info( + { + "data": [ + {"symbol": "ETH/USDC", "market_type": "spot"}, + {"symbol": "BTC/USDC", "market_type": "perp"}, + ] + } + ) + + self.assertEqual({"ETH/USDC": "ETH-USDC"}, captured) + + async def test_request_order_fills_by_client_order_id_filters(self): + exchange = LighterExchange.__new__(LighterExchange) + order = type("Order", (), {"client_order_id": "HBOT-5", "exchange_order_id": "500"})() + exchange._request_order_fills_from_trades_api = AsyncMock( + return_value=[ + {"client_order_id": "HBOT-0"}, + {"client_order_id": "HBOT-5"}, + {"clientOrderId": "HBOT-5"}, + ] + ) + + fills = await exchange._request_order_fills_by_client_order_id(order) + self.assertEqual(2, len(fills)) + + async def test_request_trade_fills(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._account_index = "693751" + exchange._domain = "lighter" + exchange._api_get = AsyncMock( + return_value={ + "data": [ + {"history_id": "h1", "symbol": "ETH/USDC"}, + {"trade_id": "t2", "symbol": "LIT/USDC"}, + ] + } + ) + + fills = await exchange._request_trade_fills() + self.assertEqual(2, len(fills)) + self.assertEqual("lighter", fills[0].market) + self.assertEqual("h1", fills[0].exchange_trade_id) + + async def test_request_order_fills_from_trades_api_success_and_failure(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._account_index = "693751" + exchange._api_get = AsyncMock(return_value={"success": True, "data": [{"order_id": "1"}]}) + + order = type("Order", (), {"exchange_order_id": "1"})() + fills = await exchange._request_order_fills_from_trades_api(order) + self.assertEqual(1, len(fills)) + + exchange._api_get = AsyncMock(return_value={"success": False}) + fills = await exchange._request_order_fills_from_trades_api(order) + self.assertEqual([], fills) + + async def test_request_order_fills_without_exchange_id(self): + exchange = LighterExchange.__new__(LighterExchange) + order = type("Order", (), {"exchange_order_id": None})() + fills = await exchange._request_order_fills(order) + self.assertEqual([], fills) + + async def test_request_order_fills_and_fills_api_delegates(self): + exchange = LighterExchange.__new__(LighterExchange) + order = type("Order", (), {"exchange_order_id": "99"})() + exchange._request_order_fills_by_exchange_order_id = AsyncMock(return_value=[{"order_id": "99"}]) + exchange._request_order_fills_from_trades_api = AsyncMock(return_value=[{"order_id": "99"}]) + + fills = await exchange._request_order_fills(order) + fills2 = await exchange._request_order_fills_from_fills_api(order) + self.assertEqual([{"order_id": "99"}], fills) + self.assertEqual([{"order_id": "99"}], fills2) + + async def test_request_trade_updates_and_order_update_delegate(self): + exchange = LighterExchange.__new__(LighterExchange) + order_1 = type("Order", (), {"id": "1"})() + order_2 = type("Order", (), {"id": "2"})() + trade_update = object() + exchange._all_trade_updates_for_order = AsyncMock(side_effect=[[trade_update], []]) + exchange._request_order_status = AsyncMock(return_value="ORDER_UPDATE") + + updates = await exchange._request_trade_updates([order_1, order_2]) + order_update = await exchange._request_order_update(order_1) + + self.assertEqual([trade_update], updates) + self.assertEqual("ORDER_UPDATE", order_update) + + async def test_execute_order_cancel_and_get_last_prices(self): + exchange = LighterExchange.__new__(LighterExchange) + order = type("Order", (), {"client_order_id": "HBOT-8"})() + exchange._place_cancel = AsyncMock(return_value=True) + exchange.get_last_traded_prices = AsyncMock(return_value={"ETH-USDC": 1234.5}) + + cancelled = await exchange._execute_order_cancel(order) + last_price = await exchange._get_last_traded_price("ETH-USDC") + last_trade_price = await exchange._get_last_trade_price("ETH-USDC") + + self.assertEqual("HBOT-8", cancelled) + self.assertEqual(1234.5, last_price) + self.assertEqual(1234.5, last_trade_price) + + async def test_create_order_fill_updates_and_fee_payment(self): + exchange = LighterExchange.__new__(LighterExchange) + order = type("Order", (), {"client_order_id": "HBOT-9"})() + exchange._all_trade_updates_for_order = AsyncMock(return_value=["a", "b"]) + + updates = await exchange._create_order_fill_updates(order=order, exchange_order_id="1", fee=None) + last_fee = await exchange._fetch_last_fee_payment("ETH-USDC") + + self.assertEqual(["a", "b"], updates) + self.assertEqual((0, Decimal("0"), Decimal("0")), last_fee) + + async def test_get_all_pairs_prices(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._api_get = AsyncMock(return_value={"data": [{"symbol": "ETH/USDC"}]}) + pairs = await exchange._get_all_pairs_prices() + self.assertEqual([{"symbol": "ETH/USDC"}], pairs) + + async def test_create_trade_fill_updates(self): + exchange = LighterExchange.__new__(LighterExchange) + _set_exchange_timestamp(exchange, 1700000000) + exchange._get_fee = lambda **kwargs: "FEE" + + inflight_order = type( + "InFlightOrder", + (), + { + "client_order_id": "HBOT-10", + "exchange_order_id": "500", + "trading_pair": "ETH-USDC", + "base_asset": "ETH", + "quote_asset": "USDC", + "order_type": "LIMIT", + "trade_type": TradeType.BUY, + "amount": Decimal("1"), + "price": Decimal("100"), + }, + )() + + fills_data = [{"trade_id": "t-1", "price": "100", "amount": "2", "quote_amount": "200", "timestamp": 1700000001}] + updates = exchange._create_trade_fill_updates(inflight_order=inflight_order, fills_data=fills_data) + + self.assertEqual(1, len(updates)) + self.assertEqual("t-1", updates[0].trade_id) + self.assertEqual(Decimal("200"), updates[0].fill_quote_amount) + + async def test_update_orders_with_error_handler(self): + exchange = LighterExchange.__new__(LighterExchange) + processed = [] + exchange._order_tracker = type( + "Tracker", + (), + { + "process_order_update": lambda self, update: processed.append(("order", update)), + "process_trade_update": lambda self, update: processed.append(("trade", update)), + }, + )() + + orders = ["o1", "o2", "o3"] + + async def fetch(order): + if order == "o1": + return "ORDER_UPDATE" + if order == "o2": + return [type("DummyTradeUpdate", (), {})()] + raise RuntimeError("boom") + + handled = [] + + async def on_error(order, err): + handled.append((order, str(err))) + + await exchange._update_orders_with_error_handler(orders=orders, fetch_updates=fetch, error_handler=on_error) + + self.assertEqual(1, len(handled)) + self.assertEqual("o3", handled[0][0]) + + async def test_update_lost_orders_and_cancel_lost_orders(self): + exchange = LighterExchange.__new__(LighterExchange) + lost_orders = { + "1": type("Order", (), {"client_order_id": "1"})(), + "2": type("Order", (), {"client_order_id": "2"})(), + } + exchange._order_tracker = type("Tracker", (), {"lost_orders": lost_orders})() + exchange._update_orders_with_error_handler = AsyncMock() + exchange._request_order_status = AsyncMock() + exchange._handle_update_error_for_lost_order = AsyncMock() + exchange._execute_order_cancel = AsyncMock(return_value="") + + await exchange._update_lost_orders() + await exchange._cancel_lost_orders() + + self.assertEqual(1, exchange._update_orders_with_error_handler.await_count) + self.assertEqual(2, exchange._execute_order_cancel.await_count) + + async def test_execute_orders_cancel(self): + exchange = LighterExchange.__new__(LighterExchange) + _set_exchange_timestamp(exchange, 1700000000) + exchange._execute_order_cancel = AsyncMock(side_effect=["HBOT-6", ""]) + + orders = [ + type("Order", (), {"client_order_id": "HBOT-6", "trading_pair": "ETH-USDC"})(), + type("Order", (), {"client_order_id": "HBOT-7", "trading_pair": "ETH-USDC"})(), + ] + + updates = await exchange._execute_orders_cancel(orders) + self.assertEqual(1, len(updates)) + self.assertEqual("HBOT-6", updates[0].client_order_id) + self.assertEqual(OrderState.CANCELED, updates[0].new_state) + + async def test_request_order_fills_by_exchange_order_id_filters(self): + exchange = LighterExchange.__new__(LighterExchange) + order = type("Order", (), {"exchange_order_id": "42"})() + exchange._request_order_fills_from_trades_api = AsyncMock( + return_value=[ + {"order_id": 1, "trade_id": "a"}, + {"order_id": 42, "trade_id": "b"}, + {"order_id": 42, "trade_id": "c"}, + ] + ) + + fills = await exchange._request_order_fills_by_exchange_order_id(order) + + self.assertEqual(2, len(fills)) + self.assertEqual(["b", "c"], [f["trade_id"] for f in fills]) + + def test_state_from_raw_order_status(self): + exchange = LighterExchange.__new__(LighterExchange) + + self.assertEqual(OrderState.CANCELED, exchange._state_from_raw_order_status("canceled")) + self.assertEqual(OrderState.CANCELED, exchange._state_from_raw_order_status("closed")) + self.assertEqual(OrderState.PARTIALLY_FILLED, exchange._state_from_raw_order_status("partially_filled")) + self.assertEqual(OrderState.OPEN, exchange._state_from_raw_order_status("unknown")) + + def test_misc_helper_branches(self): + class BadStr: + def __str__(self): + raise RuntimeError("bad") + + class BadInt: + def __int__(self): + raise RuntimeError("bad") + + exchange = LighterExchange.__new__(LighterExchange) + exchange._domain = "lighter" + exchange._api_key = "api" + exchange._api_secret = "sec" + exchange._account_index = "1" + exchange._api_key_public_key = "" + + self.assertFalse(LighterExchange._is_int_string(BadStr())) + self.assertFalse(LighterExchange._is_int_string(None)) + self.assertFalse(LighterExchange._is_ok_response({"code": BadInt()})) + self.assertIsNone(LighterExchange._account_from_response({"data": []})) + self.assertFalse(exchange._is_request_exception_related_to_time_synchronizer(Exception("x"))) + self.assertFalse(exchange._is_order_not_found_during_status_update_error(Exception("x"))) + self.assertFalse(exchange._is_order_not_found_during_cancelation_error(Exception("x"))) + self.assertEqual("ABC", exchange._hb_pair_from_symbol("ABC")) + self.assertIsNotNone(exchange.authenticator) + self.assertEqual(32, exchange.client_order_id_max_length) + self.assertEqual("HBOT", exchange.client_order_id_prefix) + + def test_rest_api_key_and_authenticator_use_key_index_when_available(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._account_index = "1" + + exchange._api_key = "7" + exchange._api_secret = "private" + self.assertEqual("7", exchange.rest_api_key) + self.assertEqual("7", exchange.authenticator.api_key) + + exchange._api_key = "private" + exchange._api_secret = "8" + self.assertEqual("8", exchange.rest_api_key) + self.assertEqual("8", exchange.authenticator.api_key) + + exchange._api_key = "api-key" + exchange._api_secret = "secret" + self.assertEqual("api-key", exchange.rest_api_key) + self.assertEqual("api-key", exchange.authenticator.api_key) + + async def test_more_branch_paths(self): + exchange = LighterExchange.__new__(LighterExchange) + _set_exchange_timestamp(exchange, 1700000000) + exchange.trade_fee_schema = lambda: TradeFeeSchema() + exchange._order_tracker = type( + "Tracker", + (), + { + "all_updatable_orders": {}, + "all_fillable_orders_by_exchange_order_id": {}, + "all_fillable_orders": {}, + "process_order_update": lambda self, update: None, + "process_trade_update": lambda self, update: None, + "active_orders": {}, + "lost_orders": {}, + }, + )() + + self.assertIsNone(exchange._order_update_from_raw_message({"order_id": "x"})) + self.assertIsNone(exchange._trade_update_from_raw_message({"order_id": "x"})) + + class Logger: + def warning(self, *args, **kwargs): + return None + + def error(self, *args, **kwargs): + return None + + exchange.logger = lambda: Logger() + await exchange._handle_update_error_for_active_order(type("O", (), {"client_order_id": "1"})(), RuntimeError("e")) + await exchange._handle_update_error_for_lost_order(type("O", (), {"client_order_id": "1"})(), RuntimeError("e")) + + exchange._place_cancel = AsyncMock(return_value=False) + result = await exchange._execute_order_cancel(type("O", (), {"client_order_id": "X"})()) + self.assertIsNone(result) + + async def test_instance_type_branches_in_update_handler_and_iter_cancel(self): + exchange = LighterExchange.__new__(LighterExchange) + processed_orders = [] + processed_trades = [] + exchange._order_tracker = type( + "Tracker", + (), + { + "process_order_update": lambda self, update: processed_orders.append(update), + "process_trade_update": lambda self, update: processed_trades.append(update), + }, + )() + + order_update = OrderUpdate(client_order_id="c", exchange_order_id="e", trading_pair="ETH-USDC", update_timestamp=1, new_state=OrderState.OPEN) + trade_update = TradeUpdate(trade_id="t", client_order_id="c", exchange_order_id="e", trading_pair="ETH-USDC", fill_timestamp=1, fill_price=Decimal("1"), fill_base_amount=Decimal("1"), fill_quote_amount=Decimal("1"), fee=None) + + async def fetch_order(_): + return order_update + + async def fetch_trade(_): + return [trade_update] + + async def fetch_cancel(_): + raise asyncio.CancelledError + + async def on_error(_, __): + return None + + await exchange._update_orders_with_error_handler(["a"], fetch_order, on_error) + await exchange._update_orders_with_error_handler(["b"], fetch_trade, on_error) + with self.assertRaises(asyncio.CancelledError): + await exchange._update_orders_with_error_handler(["c"], fetch_cancel, on_error) + + self.assertEqual(1, len(processed_orders)) + self.assertEqual(1, len(processed_trades)) + + class BadQueue: + async def get(self): + raise asyncio.CancelledError + + exchange._user_stream_tracker = type("UST", (), {"user_stream": BadQueue()})() + agen = exchange._iter_user_event_queue() + with self.assertRaises(asyncio.CancelledError): + await agen.__anext__() + + def test_rate_limits_property(self): + exchange = LighterExchange.__new__(LighterExchange) + self.assertTrue(len(exchange.rate_limits_rules) > 0) + + async def test_targeted_remaining_branches(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._domain = "lighter" + exchange._account_index = "1" + _set_exchange_timestamp(exchange, 1700000000) + + exchange._market_id_by_symbol = {} + exchange._size_decimals_by_symbol = {} + exchange._price_decimals_by_symbol = {} + exchange._api_get = AsyncMock(return_value={"data": [{"symbol": "ETH/USDC", "market_type": "perp", "market_id": 1}, {"market_type": "spot", "market_id": 2}]}) + await exchange._refresh_market_metadata() + + exchange._get_market_spec = AsyncMock(return_value=(1, 2, 2, "ETH/USDC")) + exchange._get_api_key_index = lambda: 1 + signer = type("Signer", (), { + "ORDER_TYPE_LIMIT": 1, + "ORDER_TYPE_MARKET": 2, + "ORDER_TIME_IN_FORCE_GOOD_TILL_TIME": 10, + "ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL": 11, + "ORDER_TIME_IN_FORCE_POST_ONLY": 12, + "DEFAULT_28_DAY_ORDER_EXPIRY": 100, + "DEFAULT_IOC_EXPIRY": 101, + })() + signer.create_order = AsyncMock(return_value=(None, type("Resp", (), {"code": 500})(), None)) + signer.cancel_order = AsyncMock(return_value=(None, type("Resp", (), {"code": 500})(), None)) + exchange._get_lighter_signer_client = lambda: signer + + with self.assertRaises(IOError): + await exchange._place_order("x", "ETH-USDC", Decimal("1"), TradeType.BUY, OrderType.LIMIT, Decimal("1")) + with self.assertRaises(IOError): + await exchange._place_cancel("x", type("Order", (), {"trading_pair": "ETH-USDC", "exchange_order_id": "1"})()) + + with self.assertRaises(ValueError): + await exchange._place_order("x", "ETH-USDC", Decimal("1"), TradeType.BUY, "UNSUPPORTED", Decimal("1")) + + signer.create_order = AsyncMock(return_value=(None, type("Resp", (), {"code": 200})(), None)) + await exchange._place_order("x", "ETH-USDC", Decimal("1"), TradeType.BUY, OrderType.LIMIT_MAKER, Decimal("1")) + + exchange._order_tracker = type("Tracker", (), {"all_updatable_orders": {}, "all_fillable_orders_by_exchange_order_id": {"7": type("T", (), {"client_order_id": "c", "exchange_order_id": "7", "trading_pair": "ETH-USDC"})()}, "all_fillable_orders": {}})() + order_update = exchange._order_update_from_raw_message({"order_id": "7", "updated_at": 1700000000123}) + self.assertIsNotNone(order_update) + + exchange._order_tracker = type("Tracker", (), {"all_updatable_orders": {}, "all_fillable_orders_by_exchange_order_id": {}, "all_fillable_orders": {}})() + self.assertIsNone(exchange._order_update_from_raw_message({"order_id": "unknown"})) + self.assertIsNone(exchange._trade_update_from_raw_message({"order_id": "unknown"})) + + rules = await exchange._format_trading_rules({"data": [{"market_type": "spot"}]}) + self.assertEqual([], rules) + + exchange._account_balances = {} + exchange._account_available_balances = {} + exchange._account_query_params = lambda: {"by": "index", "value": "1"} + + class Logger: + def error(self, *args, **kwargs): + return None + + exchange.logger = lambda: Logger() + exchange._api_get = AsyncMock(return_value={"success": False}) + with self.assertRaises(IOError): + await exchange._update_balances() + exchange._api_get = AsyncMock(return_value={"success": True, "data": {"assets": [{"locked_balance": "1", "balance": "1"}]}}) + await exchange._update_balances() + + exchange._order_history_last_poll_timestamp = {"42": 1700000001} + exchange.trade_fee_schema = lambda: TradeFeeSchema() + exchange._api_get = AsyncMock(return_value={"success": True, "data": [{"order_id": "42", "history_id": "h", "price": "1", "amount": "1", "created_at": 1000}], "has_more": False}) + order = type("Order", (), {"exchange_order_id": "42", "creation_timestamp": 1700000000, "quote_asset": "USDC", "trade_type": TradeType.BUY, "client_order_id": "c", "trading_pair": "ETH-USDC"})() + updates = await exchange._all_trade_updates_for_order(order) + self.assertEqual(1, len(updates)) + + exchange._throttler = object() + exchange._auth = object() + exchange._web_assistants_factory = object() + exchange._trading_pairs = ["ETH-USDC"] + self.assertIsNotNone(exchange._create_web_assistants_factory()) + self.assertIsNotNone(exchange._create_order_book_data_source()) + self.assertIsNotNone(exchange._create_user_stream_data_source()) + + captured = {} + exchange._set_trading_pair_symbol_map = lambda m: captured.update(m) + exchange._initialize_trading_pair_symbols_from_exchange_info({"data": [{"market_type": "spot"}, {"symbol": "A/B", "market_type": "perp"}]}) + self.assertEqual({}, captured) + + exchange._api_request = AsyncMock(return_value={"data": [{"index_price": "1"}, {"symbol": "ETH/USDC", "index_price": "2"}]}) + exchange.trading_pair_associated_to_exchange_symbol = AsyncMock(side_effect=KeyError("x")) + prices = await exchange.get_last_traded_prices(["ETH-USDC"]) + self.assertEqual({}, prices) + + async def test_final_branch_closures(self): + exchange = LighterExchange.__new__(LighterExchange) + + class Rest: + async def execute_request(self, **kwargs): + return {"ok": True, "headers": kwargs.get("headers")} + + exchange._web_assistants_factory = type("F", (), {"get_rest_assistant": AsyncMock(return_value=Rest())})() + exchange._domain = "lighter" + exchange._api_key = "k" + + # Cover false auth-header branch in _api_request + response = await exchange._api_request(path_url="/orderBooks", method=RESTMethod.GET, is_auth_required=False) + self.assertEqual({"ok": True, "headers": {}}, response) + + # Cover last_price None branch in get_last_traded_prices and loop back-edge + exchange._api_request = AsyncMock(return_value={"data": [{"symbol": "ETH/USDC"}, {"symbol": "ETH/USDC", "last_trade_price": "1"}]}) + exchange.trading_pair_associated_to_exchange_symbol = AsyncMock(return_value="ETH-USDC") + prices = await exchange.get_last_traded_prices(["ETH-USDC"]) + self.assertEqual({"ETH-USDC": 1.0}, prices) + + # Cover order_id not provided branch in _request_order_fills_from_trades_api + exchange._account_index = "1" + exchange._api_get = AsyncMock(return_value={"success": True, "data": []}) + await exchange._request_order_fills_from_trades_api(type("O", (), {"exchange_order_id": None})()) + + # Cover unmatched exchange_order_id branch in _update_order_fills_from_trades + exchange.UPDATE_ORDER_STATUS_MIN_INTERVAL = 1 + exchange.LONG_POLL_INTERVAL = 120 + exchange._last_poll_timestamp = 0 + exchange._last_trades_poll_timestamp = 0 + _set_exchange_timestamp(exchange, 10) + exchange.trade_fee_schema = lambda: TradeFeeSchema() + exchange._get_account_index = lambda: 1 + tracked = type("Tracked", (), {"exchange_order_id": "7", "trade_type": TradeType.BUY, "quote_asset": "USDC", "client_order_id": "c", "trading_pair": "ETH-USDC"})() + captured = [] + exchange._order_tracker = type("Tracker", (), {"all_fillable_orders": {"c": tracked}, "process_trade_update": lambda self, u: captured.append(u), "active_orders": {"c": tracked}})() + exchange._api_get = AsyncMock(return_value={"data": [{"bid_client_id": 999}, {"bid_client_id": 7, "trade_id": "t", "size": "1", "price": "1", "timestamp": 1000}]}) + await exchange._update_order_fills_from_trades() + self.assertEqual(1, len(captured)) + + # Cover process_balance missing symbol continue path + exchange._account_balances = {} + exchange._account_available_balances = {} + exchange._process_balance_message_from_account({"assets": [{"balance": "1", "locked_balance": "0"}]}) + + # Cover user stream non-dict continue and inner CancelledError raise path + exchange._trade_update_from_raw_message = lambda _: (_ for _ in ()).throw(asyncio.CancelledError()) + exchange._order_update_from_raw_message = lambda _: None + exchange._process_balance_message_from_account = lambda _: None + exchange._order_tracker = type("Tracker", (), {"process_trade_update": lambda self, u: None, "process_order_update": lambda self, u: None})() + + async def events(): + yield "invalid" + yield {"trades": [{"trade_id": "t"}]} + + exchange._iter_user_event_queue = events + with self.assertRaises(asyncio.CancelledError): + await exchange._user_stream_event_listener() + + async def test_trade_update_seconds_timestamp_path_and_user_stream_loop_back_edges(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange.trade_fee_schema = lambda: TradeFeeSchema() + _set_exchange_timestamp(exchange, 1700000000) + + tracked_order = type( + "TrackedOrder", + (), + { + "client_order_id": "HBOT-17", + "exchange_order_id": "88", + "trading_pair": "ETH-USDC", + "quote_asset": "USDC", + "trade_type": TradeType.BUY, + }, + )() + exchange._order_tracker = type( + "Tracker", + (), + { + "all_fillable_orders": {"HBOT-17": tracked_order}, + "all_fillable_orders_by_exchange_order_id": {"88": tracked_order}, + "process_trade_update": lambda self, _: None, + "process_order_update": lambda self, _: None, + }, + )() + + # keep created_at in seconds so the if fill_timestamp > 1e12 branch is false + update = exchange._trade_update_from_raw_message( + { + "client_order_id": "HBOT-17", + "order_id": "88", + "trade_id": "t-seconds", + "price": "1", + "amount": "2", + "created_at": 1000, + } + ) + self.assertEqual(1000.0, update.fill_timestamp) + + exchange._process_balance_message_from_account = lambda _: None + exchange._trade_update_from_raw_message = lambda _: "TRADE_UPDATE" + exchange._order_update_from_raw_message = lambda _: "ORDER_UPDATE" + exchange._sleep = AsyncMock() + + async def events(): + yield { + "trades": [{"trade_id": "t1"}, {"trade_id": "t2"}], + "orders": [{"order_id": "o1"}, {"order_id": "o2"}], + } + raise asyncio.CancelledError + + exchange._iter_user_event_queue = events + with self.assertRaises(asyncio.CancelledError): + await exchange._user_stream_event_listener() + + # ------------------------------------------------------------------ # + # Additional branch coverage for missing CI lines # + # ------------------------------------------------------------------ # + + def test_is_hex_private_key_empty_returns_false(self): + """_is_hex_private_key must return False for empty string (covers line 132).""" + self.assertFalse(LighterExchange._is_hex_private_key("")) + self.assertFalse(LighterExchange._is_hex_private_key("0x")) + self.assertTrue(LighterExchange._is_hex_private_key("0x" + "a" * 64)) + + def test_sdk_rest_base_url_matches_rest_url_host(self): + """_sdk_rest_base_url must return the host part of REST_URL (covers lines 144-145).""" + from hummingbot.connector.exchange.lighter import lighter_constants as CONSTANTS + exchange = LighterExchange.__new__(LighterExchange) + exchange._domain = "lighter" + expected_host = CONSTANTS.REST_URL.split("/api/v1")[0] + self.assertEqual(expected_host, exchange._sdk_rest_base_url()) + + async def test_all_trading_pairs_returns_empty_on_exception(self): + """all_trading_pairs must return [] when the API call raises (covers lines 395-399).""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._domain = "lighter" + exchange._trading_pairs = ["ETH-USDC"] + with patch.object(exchange, "_api_get", side_effect=Exception("network error")): + result = await exchange.all_trading_pairs() + self.assertEqual([], result) + + async def test_all_trading_pairs_filters_non_spot_markets(self): + """all_trading_pairs must skip non-spot markets (covers lines 386-394).""" + mock_response = { + "order_books": [ + {"symbol": "ETH-USDC", "market_type": "spot"}, + {"symbol": "BTC-USDC", "market_type": "perp"}, + {"symbol": "SOL-USDC", "market_type": "spot"}, + ] + } + exchange = LighterExchange.__new__(LighterExchange) + exchange._domain = "lighter" + exchange._trading_pairs = ["ETH-USDC", "SOL-USDC"] + with patch.object(exchange, "_api_get", new=AsyncMock(return_value=mock_response)): + result = await exchange.all_trading_pairs() + self.assertIn("ETH-USDC", result) + self.assertNotIn("BTC-USDC", result) + self.assertIn("SOL-USDC", result) + + # --------------------------------------------------------------------------- + # Coverage boost: _get_lighter_auth_token (cached vs fresh token) + # --------------------------------------------------------------------------- + + def test_get_lighter_auth_token_returns_cached_token_when_valid(self): + """_get_lighter_auth_token must return cached token when still within expiry.""" + import time as _time + exchange = LighterExchange.__new__(LighterExchange) + object.__setattr__(exchange, "_current_timestamp", _time.time()) + exchange._cached_auth_token = "cached-tok" + exchange._cached_auth_token_expiry_ts = _time.time() + 600 + token = exchange._get_lighter_auth_token() + self.assertEqual("cached-tok", token) + + def test_get_lighter_auth_token_raises_when_signer_fails(self): + """_get_lighter_auth_token must raise IOError when signer returns an error.""" + import time as _time + exchange = LighterExchange.__new__(LighterExchange) + object.__setattr__(exchange, "_current_timestamp", _time.time()) + exchange._cached_auth_token = None + exchange._cached_auth_token_expiry_ts = 0.0 + signer_mock = MagicMock() + signer_mock.create_auth_token_with_expiry = MagicMock(return_value=(None, "bad key")) + exchange._get_lighter_signer_client = MagicMock(return_value=signer_mock) + with self.assertRaises(IOError): + exchange._get_lighter_auth_token() + + def test_get_lighter_auth_token_refreshes_and_caches(self): + """_get_lighter_auth_token must cache a new token when none is cached.""" + import time as _time + exchange = LighterExchange.__new__(LighterExchange) + object.__setattr__(exchange, "_current_timestamp", _time.time()) + exchange._cached_auth_token = None + exchange._cached_auth_token_expiry_ts = 0.0 + signer_mock = MagicMock() + signer_mock.create_auth_token_with_expiry = MagicMock(return_value=("new-tok", None)) + exchange._get_lighter_signer_client = MagicMock(return_value=signer_mock) + token = exchange._get_lighter_auth_token() + self.assertEqual("new-tok", token) + self.assertEqual("new-tok", exchange._cached_auth_token) + + # --------------------------------------------------------------------------- + # Coverage boost: _allocate_client_order_index (spot exchange) + # --------------------------------------------------------------------------- + + def test_allocate_client_order_index_returns_monotonically_increasing_values(self): + """Consecutive calls must always return increasing values.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._last_client_order_index = 0 + first = exchange._allocate_client_order_index() + second = exchange._allocate_client_order_index() + self.assertGreater(second, first) + + def test_allocate_client_order_index_bumps_counter_within_same_ms(self): + """When time-based candidate would exceed max, it is clamped to max.""" + exchange = LighterExchange.__new__(LighterExchange) + max_idx = (1 << 48) - 1 # 281474976710655 + # Force a value just below max so the time-based candidate exceeds it + exchange._last_client_order_index = max_idx - 1 + idx = exchange._allocate_client_order_index() + # Must be clamped to max + self.assertEqual(max_idx, idx) + + # --------------------------------------------------------------------------- + # Coverage boost: _response_code and _is_invalid_nonce_failure + # --------------------------------------------------------------------------- + + def test_response_code_returns_none_for_none_input(self): + """_response_code must return None when given None.""" + self.assertIsNone(LighterExchange._response_code(None)) + + def test_response_code_returns_int_from_dict(self): + """_response_code must extract code from dict.""" + self.assertEqual(200, LighterExchange._response_code({"code": "200"})) + + def test_response_code_returns_int_from_object(self): + """_response_code must extract code from an object attribute.""" + obj = MagicMock() + obj.code = 404 + self.assertEqual(404, LighterExchange._response_code(obj)) + + def test_is_invalid_nonce_failure_detects_code_21104(self): + """_is_invalid_nonce_failure must return True for response code 21104.""" + exchange = LighterExchange.__new__(LighterExchange) + resp = MagicMock() + resp.code = 21104 + self.assertTrue(exchange._is_invalid_nonce_failure(response=resp)) + + def test_is_invalid_nonce_failure_detects_error_string(self): + """_is_invalid_nonce_failure must return True when error contains 'invalid nonce'.""" + exchange = LighterExchange.__new__(LighterExchange) + self.assertTrue(exchange._is_invalid_nonce_failure(error="Invalid Nonce value")) + + def test_is_invalid_nonce_failure_returns_false_for_other_errors(self): + """_is_invalid_nonce_failure must return False for unrelated errors.""" + exchange = LighterExchange.__new__(LighterExchange) + self.assertFalse(exchange._is_invalid_nonce_failure(error="network timeout", response={"code": 500})) + + # --------------------------------------------------------------------------- + # Coverage boost: _safe_update_balances_from_private_stream + # --------------------------------------------------------------------------- + + async def test_safe_update_balances_swallows_non_cancelled_exceptions(self): + """_safe_update_balances_from_private_stream must not propagate non-CancelledError.""" + exchange = LighterExchange.__new__(LighterExchange) + with patch.object(exchange, "_update_balances", new=AsyncMock(side_effect=IOError("fail"))): + await exchange._safe_update_balances_from_private_stream() # must not raise + + async def test_safe_update_balances_propagates_cancelled_error(self): + """_safe_update_balances_from_private_stream must re-raise CancelledError.""" + exchange = LighterExchange.__new__(LighterExchange) + with patch.object(exchange, "_update_balances", new=AsyncMock(side_effect=asyncio.CancelledError())): + with self.assertRaises(asyncio.CancelledError): + await exchange._safe_update_balances_from_private_stream() + + # --------------------------------------------------------------------------- + # Coverage boost: _close_lighter_api_client + # --------------------------------------------------------------------------- + + async def test_close_lighter_api_client_resets_client_to_none(self): + """_close_lighter_api_client must close and nullify _lighter_api_client.""" + exchange = LighterExchange.__new__(LighterExchange) + mock_client = AsyncMock() + mock_client.close = AsyncMock() + exchange._lighter_api_client = mock_client + await exchange._close_lighter_api_client() + mock_client.close.assert_called_once() + self.assertIsNone(exchange._lighter_api_client) + + async def test_close_lighter_api_client_no_op_when_none(self): + """_close_lighter_api_client must be a no-op when no client exists.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._lighter_api_client = None + await exchange._close_lighter_api_client() # must not raise + + # --------------------------------------------------------------------------- + # Coverage boost batch: validation helpers, buy/sell, place_order edge cases + # --------------------------------------------------------------------------- + + def test_is_order_not_found_cancelation_branches(self): + exchange = LighterExchange.__new__(LighterExchange) + self.assertTrue(exchange._is_order_not_found_during_cancelation_error(Exception('"code":5'))) + self.assertTrue(exchange._is_order_not_found_during_cancelation_error(Exception("'code': 5"))) + self.assertTrue(exchange._is_order_not_found_during_cancelation_error(Exception('"code": 5'))) + self.assertTrue(exchange._is_order_not_found_during_cancelation_error(Exception("order not found"))) + self.assertFalse(exchange._is_order_not_found_during_cancelation_error(Exception("timeout"))) + + def test_is_order_not_found_during_status_update_error(self): + exchange = LighterExchange.__new__(LighterExchange) + self.assertTrue(exchange._is_order_not_found_during_status_update_error(Exception("order not found on chain"))) + self.assertFalse(exchange._is_order_not_found_during_status_update_error(Exception("network error"))) + + def test_hb_pair_from_symbol_all_branches(self): + self.assertEqual("ETH-USDC", LighterExchange._hb_pair_from_symbol("ETH/USDC")) + self.assertEqual("BTC-USDT", LighterExchange._hb_pair_from_symbol("BTC-USDT")) + self.assertEqual("NOSLASH", LighterExchange._hb_pair_from_symbol("NOSLASH")) + + def test_api_host_for_signer_domain_variants(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._domain = "lighter" + mainnet_host = exchange._api_host_for_signer() + self.assertNotIn("/api/v1", mainnet_host) + self.assertTrue(mainnet_host.startswith("https://")) + exchange._domain = "lighter_testnet" + testnet_host = exchange._api_host_for_signer() + self.assertNotIn("/api/v1", testnet_host) + self.assertTrue(testnet_host.startswith("https://")) + + def test_buy_sell_market_order_adjusts_price(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._create_order = AsyncMock() + + order_book = type("OB", (), {})() + order_book.get_price = MagicMock(return_value=Decimal("100")) + exchange.get_order_book = lambda tp: order_book + exchange.quantize_order_price = lambda tp, p: p + exchange.get_mid_price = lambda tp: Decimal("100") + + with patch("hummingbot.connector.exchange.lighter.lighter_exchange.get_new_client_order_id", return_value="TEST-BUY-123"): + with patch("hummingbot.connector.exchange.lighter.lighter_exchange.safe_ensure_future") as sfut: + buy_id = exchange.buy( + trading_pair="ETH-USDC", + amount=Decimal("1"), + order_type=OrderType.MARKET, + ) + sell_id = exchange.sell( + trading_pair="ETH-USDC", + amount=Decimal("1"), + order_type=OrderType.MARKET, + ) + + self.assertIsNotNone(buy_id) + self.assertIsNotNone(sell_id) + # safe_ensure_future should have been called twice (once for buy, once for sell) + self.assertEqual(2, sfut.call_count) + + async def test_ensure_fresh_balance_snapshot_skips_for_sell(self): + """_ensure_fresh_balance_snapshot_before_order must return immediately for SELL.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._balance_refresh_required_since = 999.0 + exchange._update_balances = AsyncMock() + await exchange._ensure_fresh_balance_snapshot_before_order(TradeType.SELL) + exchange._update_balances.assert_not_called() + + async def test_ensure_fresh_balance_snapshot_skips_when_no_requirement(self): + """_ensure_fresh_balance_snapshot_before_order must skip if no refresh required.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._balance_refresh_required_since = 0.0 + exchange._update_balances = AsyncMock() + await exchange._ensure_fresh_balance_snapshot_before_order(TradeType.BUY) + exchange._update_balances.assert_not_called() + + async def test_ensure_fresh_balance_snapshot_skips_when_ws_balance_fresh(self): + """Skip REST if a recent WS balance push already satisfied the requirement.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._balance_refresh_required_since = 50.0 + exchange._last_ws_balance_update_ts = 60.0 + exchange._update_balances = AsyncMock() + await exchange._ensure_fresh_balance_snapshot_before_order(TradeType.BUY) + exchange._update_balances.assert_not_called() + self.assertEqual(0.0, exchange._balance_refresh_required_since) + + async def test_ensure_fresh_balance_snapshot_skips_when_rest_balance_fresh(self): + """Skip REST if last_balance_update_timestamp already >= required.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._balance_refresh_required_since = 50.0 + exchange._last_ws_balance_update_ts = 0.0 + exchange._last_balance_update_timestamp = 60.0 + exchange._update_balances = AsyncMock() + await exchange._ensure_fresh_balance_snapshot_before_order(TradeType.BUY) + exchange._update_balances.assert_not_called() + + async def test_ensure_fresh_balance_snapshot_raises_when_update_fails(self): + """Raise IOError if _update_balances raises.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._balance_refresh_required_since = 50.0 + exchange._last_ws_balance_update_ts = 0.0 + exchange._last_balance_update_timestamp = 0.0 + exchange.BALANCE_SYNC_REQUIRED_TIMEOUT = 5.0 + exchange._update_balances = AsyncMock(side_effect=IOError("network fail")) + with self.assertRaises(IOError): + await exchange._ensure_fresh_balance_snapshot_before_order(TradeType.BUY) + + async def test_ensure_fresh_balance_snapshot_raises_when_still_stale_after_update(self): + """Raise IOError if balance timestamp is still < required even after successful update.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._balance_refresh_required_since = 50.0 + exchange._last_ws_balance_update_ts = 0.0 + exchange._last_balance_update_timestamp = 0.0 + exchange.BALANCE_SYNC_REQUIRED_TIMEOUT = 5.0 + exchange._update_balances = AsyncMock() + with self.assertRaises(IOError): + await exchange._ensure_fresh_balance_snapshot_before_order(TradeType.BUY) + + async def test_on_order_failure_logs_expected_rejections(self): + """_on_order_failure must call _update_order_after_failure for expected rejections.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._update_order_after_failure = MagicMock() + exchange._on_order_failure( + order_id="HBOT-rej", + trading_pair="ETH-USDC", + amount=Decimal("1"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT, + price=Decimal("10"), + exception=Exception("Order below the minimum notional"), + ) + exchange._update_order_after_failure.assert_called_once() + + def test_get_fee_returns_zero_spot_fee(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange.trade_fee_schema = lambda: TradeFeeSchema() + fee = exchange._get_fee( + base_currency="ETH", + quote_currency="USDC", + order_type=OrderType.LIMIT, + order_side=TradeType.BUY, + amount=Decimal("1"), + price=Decimal("100"), + ) + self.assertEqual(Decimal("0"), fee.percent) + + async def test_place_cancel_returns_false_when_no_exchange_order_id(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._get_market_spec = AsyncMock(return_value=(1, 2, 2, "ETH/USDC")) + tracked_order = type("Tracked", (), {"exchange_order_id": None, "trading_pair": "ETH-USDC"})() + result = await exchange._place_cancel("HBOT-X", tracked_order) + self.assertFalse(result) + + async def test_place_modify_returns_false_when_no_exchange_order_id(self): + exchange = LighterExchange.__new__(LighterExchange) + tracked_order = type("Tracked", (), {"exchange_order_id": None, "trading_pair": "ETH-USDC"})() + result = await exchange._place_modify(tracked_order, Decimal("1"), Decimal("100")) + self.assertFalse(result) + + async def test_place_order_raises_when_insufficient_buy_balance(self): + exchange = LighterExchange.__new__(LighterExchange) + _set_exchange_timestamp(exchange, 1700000000) + exchange._get_market_spec = AsyncMock(return_value=(2048, 2, 2, "ETH/USDC")) + exchange._get_api_key_index = lambda: 7 + + signer_client = type("SignerClient", (), { + "ORDER_TYPE_LIMIT": 1, + "ORDER_TYPE_MARKET": 2, + "ORDER_TIME_IN_FORCE_GOOD_TILL_TIME": 10, + "ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL": 11, + "ORDER_TIME_IN_FORCE_POST_ONLY": 12, + "DEFAULT_28_DAY_ORDER_EXPIRY": 1000, + "DEFAULT_IOC_EXPIRY": 1001, + })() + signer_client.create_order = AsyncMock(return_value=(None, type("Resp", (), {"code": 200})(), None)) + exchange._get_lighter_signer_client = lambda: signer_client + exchange._account_available_balances = {"USDC": Decimal("0.01")} + + with self.assertRaises(IOError): + await exchange._place_order( + order_id="HBOT-lowbal", + trading_pair="ETH-USDC", + amount=Decimal("100"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT, + price=Decimal("3000"), + ) + + async def test_replay_pending_spot_trade_entries_processes_matched_and_discards_stale(self): + """_replay_pending_spot_trade_entries: matched fills processed, stale ones discarded.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._client_order_index_to_client_order_id = {"55": "HBOT-1"} + exchange._server_order_index_to_client_order_index = {} + exchange.trade_fee_schema = lambda: TradeFeeSchema() + exchange._current_timestamp = 1700000000 + _set_exchange_timestamp(exchange, 1700000000) + + tracked_order = type("Order", (), { + "client_order_id": "HBOT-1", + "exchange_order_id": "77", + "trading_pair": "ETH-USDC", + "quote_asset": "USDC", + "trade_type": TradeType.BUY, + "amount": Decimal("10"), + "executed_amount_base": Decimal("0"), + "is_done": False, + })() + + processed = [] + exchange._order_tracker = type( + "Tracker", + (), + { + "process_trade_update": lambda self, t: processed.append(t), + "all_fillable_orders": {"HBOT-1": tracked_order}, + "process_order_update": lambda self, u: None, + "all_fillable_orders_by_exchange_order_id": {}, + "all_updatable_orders": {"HBOT-1": tracked_order}, + }, + )() + + class Logger: + def debug(self, *args, **kwargs): + pass + + exchange.logger = lambda: Logger() + exchange._schedule_unmatched_private_event_reconcile = MagicMock() + + import time as _time + now = _time.time() + # One fresh matched fill, one very old stale fill + exchange._pending_spot_trade_entries = [ + (now - 0.5, {"I": "55", "trade_id": "trade-1", "price": "2", "amount": "1", "fee": "0", "created_at": 1700000000000}), + (now - 60.0, {"i": "999", "trade_id": "stale-1", "price": "1", "amount": "1", "fee": "0", "created_at": 1700000000000}), + ] + + await exchange._replay_pending_spot_trade_entries() + + self.assertEqual(1, len(processed)) + self.assertEqual("trade-1", processed[0].trade_id) + self.assertEqual([], exchange._pending_spot_trade_entries) + exchange._schedule_unmatched_private_event_reconcile.assert_called_once() + + async def test_fetch_and_apply_fills_skips_duplicate_in_progress(self): + """_fetch_and_apply_fills must skip if already in-progress for same order.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._fill_fetch_in_progress = {"HBOT-dup"} + exchange._all_trade_updates_for_order = AsyncMock() + + order = type("Order", (), {"client_order_id": "HBOT-dup"})() + await exchange._fetch_and_apply_fills(order) + + exchange._all_trade_updates_for_order.assert_not_called() + + async def test_verify_cancel_not_false_applies_if_truly_canceled(self): + """_verify_cancel_not_false must apply OrderState.CANCELED if REST confirms it.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._release_locked_balance_on_cancel = MagicMock() + exchange._schedule_balance_sync_for_terminal_update = MagicMock() + + canceled_update = OrderUpdate( + client_order_id="HBOT-V", + exchange_order_id="99", + trading_pair="ETH-USDC", + update_timestamp=1700000000.0, + new_state=OrderState.CANCELED, + ) + exchange._request_order_status = AsyncMock(return_value=canceled_update) + + order_snap = type("Order", (), {"client_order_id": "HBOT-V"})() + processed = [] + exchange._order_tracker = type("Tracker", (), { + "all_fillable_orders": {"HBOT-V": order_snap}, + "process_order_update": lambda self, u: processed.append(u), + })() + + class Logger: + def debug(self, *args, **kwargs): + pass + + exchange.logger = lambda: Logger() + + with patch("hummingbot.connector.exchange.lighter.lighter_exchange.asyncio.sleep", new=AsyncMock()): + await exchange._verify_cancel_not_false(order_snap, delay=0.0) + + self.assertEqual(1, len(processed)) + exchange._release_locked_balance_on_cancel.assert_called_once() + exchange._schedule_balance_sync_for_terminal_update.assert_called_once() + + # ----------------------------------------------------------------------- + # Additional coverage boost: lighter API client creation, _place_order + # market flow, _on_order_failure, _safe_reconcile, safe_update_balances + # ----------------------------------------------------------------------- + + def test_get_lighter_api_client_builds_once_with_sdk(self): + """_get_lighter_api_client creates client on first call and reuses it on second.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._lighter_api_client = None + exchange._domain = "lighter" + exchange._sdk_rest_base_url = lambda: "https://mainnet.zklighter.elliot.ai" + + fake_config_obj = object() + fake_client_obj = object() + + fake_lighter = types.ModuleType("lighter") + fake_lighter.Configuration = lambda host: fake_config_obj + fake_lighter.ApiClient = lambda configuration: fake_client_obj + + with patch.dict("sys.modules", {"lighter": fake_lighter}): + client1 = exchange._get_lighter_api_client() + client2 = exchange._get_lighter_api_client() + + self.assertIs(fake_client_obj, client1) + self.assertIs(client1, client2) + self.assertIs(fake_client_obj, exchange._lighter_api_client) + + def test_sdk_rest_base_url_matches_api_host_for_signer(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._domain = "lighter" + self.assertEqual(exchange._api_host_for_signer(), exchange._sdk_rest_base_url()) + + async def test_place_order_market_buy_uses_slippage_price(self): + """MARKET buy order picks ask price + slippage; no balance check for MARKET.""" + exchange = LighterExchange.__new__(LighterExchange) + _set_exchange_timestamp(exchange, 1700000000) + exchange._get_market_spec = AsyncMock(return_value=(2048, 2, 2, "ETH/USDC")) + exchange._get_api_key_index = lambda: 7 + exchange._signer_client_lock = asyncio.Lock() + + signer_client = type("SC", (), { + "ORDER_TYPE_LIMIT": 1, + "ORDER_TYPE_MARKET": 2, + "ORDER_TIME_IN_FORCE_GOOD_TILL_TIME": 10, + "ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL": 11, + "ORDER_TIME_IN_FORCE_POST_ONLY": 12, + "DEFAULT_28_DAY_ORDER_EXPIRY": 1000, + "DEFAULT_IOC_EXPIRY": 1001, + })() + signer_client.create_order = AsyncMock(return_value=(None, type("R", (), {"code": 200})(), None)) + exchange._get_lighter_signer_client = lambda: signer_client + exchange._is_invalid_nonce_failure = lambda error=None, response=None: False + exchange._response_code = lambda r: getattr(r, "code", 0) + exchange._sleep = AsyncMock() + exchange._allocate_client_order_index = MagicMock(return_value=12345) + exchange._account_available_balances = None + exchange._schedule_balance_sync_for_terminal_update = lambda *a, **kw: None + + mock_ob = type("OB", (), {"get_price": lambda self, is_bid: Decimal("100.00")})() + exchange.get_order_book = lambda tp: mock_ob + + oid, ts = await exchange._place_order( + order_id="HBOT-mkt", + trading_pair="ETH-USDC", + amount=Decimal("1"), + trade_type=TradeType.BUY, + order_type=OrderType.MARKET, + price=Decimal("0"), + ) + self.assertTrue(oid.isdigit()) + self.assertEqual(1700000000, ts) + signer_client.create_order.assert_awaited_once() + + async def test_place_order_limit_maker_uses_post_only_tif(self): + """LIMIT_MAKER order uses POST_ONLY time-in-force.""" + exchange = LighterExchange.__new__(LighterExchange) + _set_exchange_timestamp(exchange, 1700000000) + exchange._get_market_spec = AsyncMock(return_value=(2048, 2, 2, "ETH/USDC")) + exchange._get_api_key_index = lambda: 7 + exchange._signer_client_lock = asyncio.Lock() + + signer_client = type("SC", (), { + "ORDER_TYPE_LIMIT": 1, + "ORDER_TYPE_MARKET": 2, + "ORDER_TIME_IN_FORCE_GOOD_TILL_TIME": 10, + "ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL": 11, + "ORDER_TIME_IN_FORCE_POST_ONLY": 12, + "DEFAULT_28_DAY_ORDER_EXPIRY": 1000, + "DEFAULT_IOC_EXPIRY": 1001, + })() + signer_client.create_order = AsyncMock(return_value=(None, type("R", (), {"code": 200})(), None)) + exchange._get_lighter_signer_client = lambda: signer_client + exchange._is_invalid_nonce_failure = lambda error=None, response=None: False + exchange._response_code = lambda r: getattr(r, "code", 0) + exchange._sleep = AsyncMock() + exchange._allocate_client_order_index = MagicMock(return_value=12345) + exchange._account_available_balances = {"USDC": Decimal("10000")} + exchange._schedule_balance_sync_for_terminal_update = lambda *a, **kw: None + exchange._ensure_fresh_balance_snapshot_before_order = AsyncMock() + + oid, ts = await exchange._place_order( + order_id="HBOT-pm", + trading_pair="ETH-USDC", + amount=Decimal("1"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT_MAKER, + price=Decimal("3000"), + ) + self.assertTrue(oid.isdigit()) + call_kwargs = signer_client.create_order.call_args[1] + self.assertEqual(12, call_kwargs["time_in_force"]) + + async def test_on_order_failure_calls_super_for_unexpected_error(self): + """_on_order_failure delegates to super for non-expected rejections.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._update_order_after_failure = MagicMock() + super_called = [] + + with patch("hummingbot.connector.exchange.lighter.lighter_exchange.ExchangePyBase._on_order_failure", + side_effect=lambda *a, **kw: super_called.append(True)): + exchange._on_order_failure( + order_id="HBOT-unexpected", + trading_pair="ETH-USDC", + amount=Decimal("1"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT, + price=Decimal("10"), + exception=Exception("Unexpected network failure"), + ) + + exchange._update_order_after_failure.assert_not_called() + self.assertEqual(1, len(super_called)) + + async def test_safe_reconcile_unmatched_private_event_handles_exception(self): + """_safe_reconcile must swallow non-cancel exceptions from _update_order_status.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._update_order_status = AsyncMock(side_effect=Exception("rest_fail")) + + class Logger: + def debug(self, *a, **kw): + pass + + exchange.logger = lambda: Logger() + await exchange._safe_reconcile_unmatched_private_event() + exchange._update_order_status.assert_awaited_once() + + async def test_safe_reconcile_re_raises_cancelled(self): + """_safe_reconcile must propagate asyncio.CancelledError.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._update_order_status = AsyncMock(side_effect=asyncio.CancelledError) + + with self.assertRaises(asyncio.CancelledError): + await exchange._safe_reconcile_unmatched_private_event() + + def test_extract_private_stream_payloads_account_all_orders_channel(self): + """account_all_orders channel should produce orders list only.""" + account_data, trades, orders = LighterExchange._extract_private_stream_payloads({ + "type": "update/account_all_orders", + "orders": [{"id": "o1"}, {"id": "o2"}], + }) + self.assertIsNone(account_data) + self.assertEqual([], trades) + self.assertEqual(2, len(orders)) + + def test_extract_private_stream_payloads_account_tx_with_order(self): + """account_tx channel processes txs with order sub-key.""" + account_data, trades, orders = LighterExchange._extract_private_stream_payloads({ + "type": "update/account_tx", + "txs": [{"order": {"id": "o5"}}, {"i": "o6"}], + }) + self.assertIsNone(account_data) + self.assertEqual([], trades) + order_ids = [o["id"] for o in orders if "id" in o] + self.assertIn("o5", order_ids) + + def test_process_balance_message_removes_zero_balance_assets(self): + """_process_balance_message_from_account removes assets no longer in payload.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._account_balances = { + "USDC": Decimal("100"), + "ETH": Decimal("5"), + "OLDCOIN": Decimal("0"), + } + exchange._account_available_balances = { + "USDC": Decimal("90"), + "ETH": Decimal("5"), + "OLDCOIN": Decimal("0"), + } + exchange._optimistic_balance_release = {} + exchange._optimistic_balance_lock = {} + + with patch("hummingbot.connector.exchange.lighter.lighter_exchange.time.time", return_value=200.0): + exchange._process_balance_message_from_account({ + "assets": [ + {"symbol": "USDC", "balance": "100", "locked_balance": "10"}, + ] + }) + + self.assertIn("USDC", exchange._account_balances) + # OLDCOIN had zero balance and was absent from payload, so it should be removed + self.assertNotIn("OLDCOIN", exchange._account_balances) + # ETH has non-zero balance so it stays even if not in this partial update + self.assertIn("ETH", exchange._account_balances) + + def test_state_from_raw_order_status_all_known_states(self): + exchange = LighterExchange.__new__(LighterExchange) + self.assertEqual(OrderState.OPEN, exchange._state_from_raw_order_status("open")) + self.assertEqual(OrderState.OPEN, exchange._state_from_raw_order_status("in-progress")) + self.assertEqual(OrderState.PARTIALLY_FILLED, exchange._state_from_raw_order_status("partially_filled")) + self.assertEqual(OrderState.FILLED, exchange._state_from_raw_order_status("filled")) + self.assertEqual(OrderState.CANCELED, exchange._state_from_raw_order_status("canceled")) + self.assertEqual(OrderState.PENDING_CREATE, exchange._state_from_raw_order_status("pending")) + # Unknown status maps to OPEN as fallback + self.assertEqual(OrderState.OPEN, exchange._state_from_raw_order_status("unknown-xyz")) + + def test_is_expected_order_rejection_patterns(self): + self.assertTrue(LighterExchange._is_expected_order_rejection("minimum notional")) + self.assertTrue(LighterExchange._is_expected_order_rejection("minimum lot size")) + self.assertTrue(LighterExchange._is_expected_order_rejection("invalid order base or quote amount")) + self.assertFalse(LighterExchange._is_expected_order_rejection("server timeout")) + + def test_response_code_helper(self): + exchange = LighterExchange.__new__(LighterExchange) + resp200 = type("R", (), {"code": 200})() + resp429 = type("R", (), {"code": 429})() + resp_none_attr = type("R", (), {})() + self.assertEqual(200, exchange._response_code(resp200)) + self.assertEqual(429, exchange._response_code(resp429)) + self.assertIsNone(exchange._response_code(resp_none_attr)) # no code attr -> None + self.assertIsNone(exchange._response_code(None)) # None input -> None + + async def test_fetch_and_apply_fills_processes_fills_and_credits_balance_on_cancel_fill_race(self): + """_fetch_and_apply_fills: when order is CANCELED but fills exist, credits balance.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._fill_fetch_in_progress = set() + exchange._release_locked_balance_on_fill = MagicMock() + exchange._order_tracker = type("Tracker", (), {"process_trade_update": MagicMock()})() + + fill_update = type("FU", (), {"trade_id": "t1"})() + exchange._all_trade_updates_for_order = AsyncMock(return_value=[fill_update]) + + class Logger: + def debug(self, *a, **kw): + pass + + def info(self, *a, **kw): + pass + + exchange.logger = lambda: Logger() + + order = type("Order", (), { + "client_order_id": "HBOT-cr", + "current_state": OrderState.CANCELED, + })() + + with patch("hummingbot.connector.exchange.lighter.lighter_exchange.asyncio.sleep", new=AsyncMock()): + await exchange._fetch_and_apply_fills(order, delay=0.0) + + exchange._order_tracker.process_trade_update.assert_called_once_with(fill_update) + exchange._release_locked_balance_on_fill.assert_called_once_with(order) + self.assertNotIn("HBOT-cr", exchange._fill_fetch_in_progress) + + async def test_fetch_and_apply_fills_retries_when_no_fills(self): + """_fetch_and_apply_fills: schedules retry when no fills found and retries_left>0.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._fill_fetch_in_progress = set() + exchange._all_trade_updates_for_order = AsyncMock(return_value=[]) + + class Logger: + def debug(self, *a, **kw): + pass + + exchange.logger = lambda: Logger() + + order = type("Order", (), { + "client_order_id": "HBOT-retry", + "current_state": OrderState.FILLED, + })() + + futures_scheduled = [] + with patch("hummingbot.connector.exchange.lighter.lighter_exchange.safe_ensure_future", + side_effect=lambda coro: futures_scheduled.append(coro)): + with patch("hummingbot.connector.exchange.lighter.lighter_exchange.asyncio.sleep", new=AsyncMock()): + await exchange._fetch_and_apply_fills(order, delay=0.0, _retries_left=3) + + self.assertEqual(1, len(futures_scheduled)) + self.assertNotIn("HBOT-retry", exchange._fill_fetch_in_progress) + + async def test_verify_cancel_not_false_open_state_preserves_order(self): + """_verify_cancel_not_false: OPEN state means false cancel; don't apply CANCELED.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._release_locked_balance_on_cancel = MagicMock() + exchange._schedule_balance_sync_for_terminal_update = MagicMock() + + open_update = OrderUpdate( + client_order_id="HBOT-FC", + exchange_order_id="99", + trading_pair="ETH-USDC", + update_timestamp=1700000000.0, + new_state=OrderState.OPEN, + ) + exchange._request_order_status = AsyncMock(return_value=open_update) + processed = [] + exchange._order_tracker = type("Tracker", (), { + "all_fillable_orders": {}, + "process_order_update": lambda self, u: processed.append(u), + })() + + class Logger: + def debug(self, *a, **kw): + pass + + exchange.logger = lambda: Logger() + order = type("Order", (), {"client_order_id": "HBOT-FC"})() + + with patch("hummingbot.connector.exchange.lighter.lighter_exchange.asyncio.sleep", new=AsyncMock()): + await exchange._verify_cancel_not_false(order, delay=0.0) + + self.assertEqual(0, len(processed)) + exchange._release_locked_balance_on_cancel.assert_not_called() + + # ── _place_cancel coverage tests ────────────────────────────────────── + + def _make_cancel_exchange(self, cancel_response=None): + """Helper: build a minimal exchange for _place_cancel tests.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._get_market_spec = AsyncMock(return_value=(2048, 2, 2, "ETH/USDC")) + exchange._get_api_key_index = lambda: 7 + exchange._signer_client_lock = asyncio.Lock() + exchange._is_invalid_nonce_failure = lambda error=None, response=None: False + exchange._response_code = lambda r: getattr(r, "code", None) + exchange._sleep = AsyncMock() + exchange._schedule_balance_sync_for_terminal_update = lambda *a, **kw: None + + resp200 = type("Resp", (), {"code": 200})() + rv = cancel_response if cancel_response is not None else (None, resp200, None) + + signer_client = type("SC", (), {})() + signer_client.cancel_order = AsyncMock(return_value=rv) + exchange._get_lighter_signer_client = lambda: signer_client + return exchange, signer_client + + async def test_place_cancel_success_no_fills_schedules_background_retry(self): + """Cancel succeeds; no fills found → safe_ensure_future(_fetch_and_apply_fills, delay=0).""" + exchange, _ = self._make_cancel_exchange() + exchange._all_trade_updates_for_order = AsyncMock(return_value=[]) + exchange._fetch_and_apply_fills = AsyncMock() + + tracked = type("T", (), { + "client_order_id": "HBOT-X", "trading_pair": "ETH-USDC", "exchange_order_id": "42", + })() + result = await exchange._place_cancel("HBOT-X", tracked) + self.assertTrue(result) + + async def test_place_cancel_success_with_fills_processes_order_tracker(self): + """Cancel succeeds; fills found → order_tracker.process_trade_update called.""" + exchange, _ = self._make_cancel_exchange() + fill_update = MagicMock() + exchange._all_trade_updates_for_order = AsyncMock(return_value=[fill_update]) + mock_tracker = MagicMock() + exchange._order_tracker = mock_tracker + exchange._fetch_and_apply_fills = AsyncMock() + + tracked = type("T", (), { + "client_order_id": "HBOT-X", "trading_pair": "ETH-USDC", "exchange_order_id": "42", + })() + result = await exchange._place_cancel("HBOT-X", tracked) + self.assertTrue(result) + mock_tracker.process_trade_update.assert_called_once_with(fill_update) + + async def test_place_cancel_nonce_retry_then_success(self): + """Nonce failure on first attempt triggers signer refresh and retries.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._get_market_spec = AsyncMock(return_value=(2048, 2, 2, "ETH/USDC")) + exchange._get_api_key_index = lambda: 7 + exchange._signer_client_lock = asyncio.Lock() + exchange._response_code = lambda r: getattr(r, "code", None) + exchange._sleep = AsyncMock() + exchange._schedule_balance_sync_for_terminal_update = lambda *a, **kw: None + exchange._all_trade_updates_for_order = AsyncMock(return_value=[]) + exchange._fetch_and_apply_fills = AsyncMock() + + resp200 = type("Resp", (), {"code": 200})() + signer_client = type("SC", (), {})() + signer_client.cancel_order = AsyncMock(side_effect=[ + (None, None, "nonce_err"), + (None, resp200, None), + ]) + exchange._get_lighter_signer_client = lambda: signer_client + exchange._is_invalid_nonce_failure = lambda error=None, response=None: error == "nonce_err" + exchange._refresh_signer_client_async = AsyncMock(return_value=signer_client) + + tracked = type("T", (), { + "client_order_id": "HBOT-X", "trading_pair": "ETH-USDC", "exchange_order_id": "42", + })() + result = await exchange._place_cancel("HBOT-X", tracked) + self.assertTrue(result) + self.assertEqual(2, signer_client.cancel_order.call_count) + + async def test_place_cancel_timeout_raises_ioerror(self): + """asyncio.TimeoutError from wait_for is re-raised as IOError.""" + exchange, _ = self._make_cancel_exchange() + + with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError): + with self.assertRaises(IOError): + tracked = type("T", (), { + "client_order_id": "HBOT-X", "trading_pair": "ETH-USDC", "exchange_order_id": "42", + })() + await exchange._place_cancel("HBOT-X", tracked) + + async def test_place_cancel_returns_false_when_exchange_id_clears_mid_loop(self): + """exchange_order_id becoming None inside the for-loop returns False immediately.""" + from unittest.mock import PropertyMock + exchange = LighterExchange.__new__(LighterExchange) + exchange._get_market_spec = AsyncMock(return_value=(2048, 2, 2, "ETH/USDC")) + exchange._get_api_key_index = lambda: 7 + exchange._signer_client_lock = asyncio.Lock() + exchange._is_invalid_nonce_failure = lambda error=None, response=None: False + exchange._response_code = lambda r: getattr(r, "code", None) + exchange._sleep = AsyncMock() + + tracked = MagicMock() + tracked.trading_pair = "ETH-USDC" + type(tracked).exchange_order_id = PropertyMock(side_effect=["42", None]) + + signer_client = type("SC", (), {})() + exchange._get_lighter_signer_client = lambda: signer_client + + result = await exchange._place_cancel("HBOT-X", tracked) + self.assertFalse(result) + + # ── _place_order MARKET order coverage ──────────────────────────────── + + async def test_place_order_sell_market_applies_negative_slippage(self): + """SELL MARKET order uses bid price with negative slippage (line 819).""" + exchange = LighterExchange.__new__(LighterExchange) + _set_exchange_timestamp(exchange, 1700000000) + exchange._get_market_spec = AsyncMock(return_value=(2048, 2, 2, "ETH/USDC")) + exchange._allocate_client_order_index = MagicMock(return_value=999) + exchange._client_order_index_from_order_id = lambda oid: 999 + exchange._get_api_key_index = lambda: 7 + exchange._account_available_balances = None + exchange._signer_client_lock = asyncio.Lock() + + resp200 = type("Resp", (), {"code": 200})() + signer_client = type("SignerClient", (), { + "ORDER_TYPE_LIMIT": 1, + "ORDER_TYPE_MARKET": 2, + "ORDER_TIME_IN_FORCE_GOOD_TILL_TIME": 10, + "ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL": 11, + "ORDER_TIME_IN_FORCE_POST_ONLY": 12, + "DEFAULT_28_DAY_ORDER_EXPIRY": 1000, + "DEFAULT_IOC_EXPIRY": 1001, + })() + signer_client.create_order = AsyncMock(return_value=(None, resp200, None)) + exchange._get_lighter_signer_client = lambda: signer_client + + # bid price = 90 for SELL market + mock_ob = type("OB", (), {"get_price": lambda self, is_bid: 90.0 if not is_bid else 110.0})() + exchange.get_order_book = lambda tp: mock_ob + + exchange._lock_balance_on_order_creation = lambda *a, **kw: None + exchange._schedule_fast_balance_sync = lambda *a, **kw: None + exchange._client_order_index_to_client_order_id = {} + exchange._hb_order_id_to_client_order_index = {} + + oid, ts = await exchange._place_order( + order_id="HBOT-SELL", + trading_pair="ETH-USDC", + amount=Decimal("1"), + trade_type=TradeType.SELL, + order_type=OrderType.MARKET, + price=Decimal("NaN"), + ) + self.assertTrue(oid.isdigit()) + + async def test_place_order_market_invalid_price_raises_value_error(self): + """MARKET order with NaN/None best price raises ValueError (line 812).""" + exchange = LighterExchange.__new__(LighterExchange) + _set_exchange_timestamp(exchange, 1700000000) + exchange._get_market_spec = AsyncMock(return_value=(2048, 2, 2, "ETH/USDC")) + exchange._get_api_key_index = lambda: 7 + exchange._account_available_balances = None + exchange._signer_client_lock = asyncio.Lock() + + signer_client = type("SC", (), { + "ORDER_TYPE_MARKET": 2, + "ORDER_TYPE_LIMIT": 1, + "ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL": 11, + "DEFAULT_IOC_EXPIRY": 1001, + })() + exchange._get_lighter_signer_client = lambda: signer_client + + # order book returns NaN → invalid price + mock_ob = type("OB", (), {"get_price": lambda self, is_bid: Decimal("NaN")})() + exchange.get_order_book = lambda tp: mock_ob + + with self.assertRaises(ValueError, msg="Should raise ValueError for invalid market price"): + await exchange._place_order( + order_id="HBOT-M", + trading_pair="ETH-USDC", + amount=Decimal("1"), + trade_type=TradeType.BUY, + order_type=OrderType.MARKET, + price=Decimal("NaN"), + ) + + async def test_place_order_spot_nonce_retry_then_success(self): + """Spot _place_order: first nonce error, then succeeds on retry (lines 868-871).""" + exchange, _ = self._make_cancel_exchange() + exchange._current_client_order_id_seq = 100 + exchange._account_index = "123456" + exchange._api_key_index = "1" + exchange._is_invalid_nonce_failure = lambda error=None, response=None: "invalid nonce" in str(error or "") + + # Mock signer client: first create_order fails with "invalid nonce", second succeeds + signer_client = MagicMock() + signer_client.get_account_portfolio = AsyncMock(return_value={"accounts": []}) + resp200 = type("Resp", (), {"code": 200})() + signer_client.create_order = AsyncMock( + side_effect=[ + ("order-1", {"code": 400}, "invalid nonce: sequence too old"), + ("order-2", resp200, None), + ] + ) + exchange._get_lighter_signer_client = lambda: signer_client + exchange._refresh_signer_client_async = AsyncMock(return_value=signer_client) + exchange._allocate_client_order_index = lambda: 101 + exchange.get_order_book = lambda tp: MagicMock(get_price=lambda is_bid: Decimal("100")) + + # First call will have nonce error and retry, second will succeed + await exchange._place_order( + order_id="HBOT-L", + trading_pair="ETH-USDC", + amount=Decimal("1"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT, + price=Decimal("99"), + ) + + # Should have called create_order twice (once with nonce error, once with success) + self.assertEqual(2, signer_client.create_order.await_count) + # Should have refreshed signer client on nonce error + exchange._refresh_signer_client_async.assert_awaited_once() + + async def test_place_cancel_nonce_retry_with_refresh_failure(self): + """Cancel nonce retry: refresh fails but retries with existing client (lines 581-582).""" + exchange, signer_client = self._make_cancel_exchange() + exchange._is_invalid_nonce_failure = lambda error=None, response=None: "invalid nonce" in str(error or "") + logger = MagicMock() + exchange.logger = MagicMock(return_value=logger) + + # First cancel fails with nonce error, refresh fails, second cancel succeeds + resp200 = type("Resp", (), {"code": 200})() + signer_client.cancel_order = AsyncMock( + side_effect=[ + (False, None, "invalid nonce: sequence too old"), + (True, resp200, None), + ] + ) + exchange._refresh_signer_client_async = AsyncMock(side_effect=RuntimeError("refresh failed")) + + tracked = type("T", (), { + "client_order_id": "HBOT-X", "trading_pair": "ETH-USDC", "exchange_order_id": "42", + })() + result = await exchange._place_cancel("HBOT-X", tracked) + + self.assertTrue(result) + logger.warning.assert_called_once() + # Refresh was attempted once, then retried with existing client + self.assertEqual(2, signer_client.cancel_order.await_count) + + async def test_place_cancel_raises_ioerror_when_tx_response_none(self): + """Cancel raises IOError when tx_response is None (line 639).""" + exchange, signer_client = self._make_cancel_exchange(cancel_response=(False, None, None)) + exchange._all_trade_updates_for_order = AsyncMock(return_value=[]) + + tracked = type("T", (), { + "client_order_id": "HBOT-X", "trading_pair": "ETH-USDC", "exchange_order_id": "42", + })() + + with self.assertRaises(IOError): + await exchange._place_cancel("HBOT-X", tracked) + + async def test_place_cancel_raises_ioerror_when_error_is_not_none(self): + """Cancel raises IOError when error is returned (line 637).""" + exchange, signer_client = self._make_cancel_exchange(cancel_response=(False, None, "signing failed")) + exchange._all_trade_updates_for_order = AsyncMock(return_value=[]) + + tracked = type("T", (), { + "client_order_id": "HBOT-X", "trading_pair": "ETH-USDC", "exchange_order_id": "42", + })() + + with self.assertRaises(IOError): + await exchange._place_cancel("HBOT-X", tracked) + + async def test_place_modify_raises_ioerror_on_bad_response(self): + """Modify raises IOError when tx_response code is not 200.""" + exchange, signer_client = self._make_cancel_exchange() + exchange._response_code = MagicMock(return_value=500) + + resp = type("Resp", (), {"code": 500})() + signer_client.modify_order = AsyncMock(return_value=(False, resp, None)) + + tracked = type("T", (), { + "client_order_id": "HBOT-X", "trading_pair": "ETH-USDC", "exchange_order_id": "42", + })() + + with self.assertRaises(IOError): + await exchange._place_modify(tracked, Decimal("1.0"), Decimal("100")) + + async def test_place_order_with_nonce_retry_then_success(self): + """Place order succeeds after nonce retry.""" + exchange, signer_client = self._make_cancel_exchange() + exchange._get_market_spec = AsyncMock(return_value=(1, 8, 6, "ETH-USDC")) + exchange._is_invalid_nonce_failure = lambda error=None, response=None: "invalid nonce" in str(error or "") + exchange._refresh_signer_client_async = AsyncMock(return_value=signer_client) + exchange._allocate_client_order_index = MagicMock(return_value=101) + exchange._client_order_index_to_client_order_id = {} + exchange._hb_order_id_to_client_order_index = {} + exchange._lock_balance_on_order_creation = MagicMock() + exchange._schedule_fast_balance_sync = MagicMock() + exchange.get_order_book = lambda tp: MagicMock(get_price=lambda is_bid: Decimal("100")) + + # First call: nonce error, second call: success + resp200 = type("Resp", (), {"code": 200})() + signer_client.ORDER_TYPE_LIMIT = 1 + signer_client.ORDER_TYPE_MARKET = 2 + signer_client.ORDER_TIME_IN_FORCE_GOOD_TILL_TIME = 10 + signer_client.ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL = 11 + signer_client.ORDER_TIME_IN_FORCE_POST_ONLY = 12 + signer_client.DEFAULT_28_DAY_ORDER_EXPIRY = 1000 + signer_client.DEFAULT_IOC_EXPIRY = 1001 + signer_client.create_order = AsyncMock( + side_effect=[ + (False, None, "invalid nonce: sequence too old"), + (True, resp200, None), + ] + ) + + result = await exchange._place_order( + order_id="HBOT-X", + trading_pair="ETH-USDC", + amount=Decimal("1.0"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT, + price=Decimal("100"), + ) + self.assertIsInstance(result, tuple) + self.assertEqual(2, signer_client.create_order.await_count) + + # ------------------------------------------------------------------ + # _cleanup_startup_orphan_orders + # ------------------------------------------------------------------ + + async def test_cleanup_startup_orphan_orders_noop_when_already_done(self): + """Returns early without any REST call when the one-time flag is already set.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._startup_orphan_cleanup_done = True + exchange._api_get = AsyncMock() + + await exchange._cleanup_startup_orphan_orders() + exchange._api_get.assert_not_awaited() + + async def test_cleanup_startup_orphan_orders_no_orphans_skips_cancel(self): + """When all active orders are tracked, no cancels are issued.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._startup_orphan_cleanup_done = False + + tracked = MagicMock() + tracked.exchange_order_id = "77" + exchange._client_order_index_to_client_order_id = {} + exchange._get_account_index = lambda: 100 + exchange._api_get = AsyncMock(return_value={ + "success": True, + "orders": [{"client_order_id": "77", "order_id": "77", "symbol": "ETH/USDC"}], + }) + exchange._get_lighter_signer_client = MagicMock() + exchange._signer_client_lock = asyncio.Lock() + exchange._schedule_fast_balance_sync = MagicMock() + + with patch.object(type(exchange), 'in_flight_orders', new_callable=PropertyMock, return_value={"HBOT-1": tracked}): + await exchange._cleanup_startup_orphan_orders() + + # No cancel should have been attempted + exchange._get_lighter_signer_client.assert_not_called() + + async def test_cleanup_startup_orphan_orders_cancels_untracked_order(self): + """Cancels an active order whose ID is not present in in_flight_orders.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._startup_orphan_cleanup_done = False + exchange._client_order_index_to_client_order_id = {} + exchange._get_account_index = lambda: 100 + exchange._api_get = AsyncMock(return_value={ + "success": True, + "orders": [{"client_order_id": "42", "order_id": "42", "symbol": "ETH/USDC"}], + }) + + signer_client = MagicMock() + signer_client.cancel_order = AsyncMock(return_value=(None, None, None)) + exchange._get_lighter_signer_client = MagicMock(return_value=signer_client) + exchange._signer_client_lock = asyncio.Lock() + exchange._hb_pair_from_symbol = lambda s: "ETH-USDC" + exchange._get_market_spec = AsyncMock(return_value=(1, 4, 2, "ETH/USDC")) + exchange._get_api_key_index = lambda: 7 + exchange._schedule_fast_balance_sync = MagicMock() + + with patch.object(type(exchange), 'in_flight_orders', new_callable=PropertyMock, return_value={}): + await exchange._cleanup_startup_orphan_orders() + + signer_client.cancel_order.assert_awaited_once() + + async def test_cleanup_startup_orphan_orders_api_failure_returns_early(self): + """When the REST call returns a non-success response, nothing is cancelled.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._startup_orphan_cleanup_done = False + exchange._client_order_index_to_client_order_id = {} + exchange._get_account_index = lambda: 100 + exchange._api_get = AsyncMock(return_value={"success": False}) + exchange._get_lighter_signer_client = MagicMock() + exchange._signer_client_lock = asyncio.Lock() + + with patch.object(type(exchange), 'in_flight_orders', new_callable=PropertyMock, return_value={}): + await exchange._cleanup_startup_orphan_orders() + exchange._get_lighter_signer_client.assert_not_called() + + # ------------------------------------------------------------------ + # _cleanup_runtime_orphan_orders + # ------------------------------------------------------------------ + + async def test_cleanup_runtime_orphan_orders_no_orphans(self): + """When all active orders are tracked, no cancels are issued.""" + exchange = LighterExchange.__new__(LighterExchange) + tracked = MagicMock() + tracked.exchange_order_id = "55" + exchange._client_order_index_to_client_order_id = {} + exchange._get_account_index = lambda: 100 + exchange._api_get = AsyncMock(return_value={ + "success": True, + "orders": [{"client_order_id": "55", "order_id": "55"}], + }) + exchange._get_lighter_signer_client = MagicMock() + exchange._signer_client_lock = asyncio.Lock() + exchange._schedule_fast_balance_sync = MagicMock() + + with patch.object(type(exchange), 'in_flight_orders', new_callable=PropertyMock, return_value={"HBOT-1": tracked}): + await exchange._cleanup_runtime_orphan_orders() + exchange._get_lighter_signer_client.assert_not_called() + + async def test_cleanup_runtime_orphan_orders_cancels_untracked(self): + """Cancels a runtime-orphan order not present in tracked orders.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._client_order_index_to_client_order_id = {} + exchange._get_account_index = lambda: 100 + exchange._api_get = AsyncMock(return_value={ + "success": True, + "orders": [{"client_order_id": "88", "order_id": "88", "symbol": "ETH/USDC"}], + }) + + signer_client = MagicMock() + signer_client.cancel_order = AsyncMock(return_value=(None, None, None)) + exchange._get_lighter_signer_client = MagicMock(return_value=signer_client) + exchange._signer_client_lock = asyncio.Lock() + exchange._hb_pair_from_symbol = lambda s: "ETH-USDC" + exchange._get_market_spec = AsyncMock(return_value=(1, 4, 2, "ETH/USDC")) + exchange._get_api_key_index = lambda: 7 + exchange._schedule_fast_balance_sync = MagicMock() + + with patch.object(type(exchange), 'in_flight_orders', new_callable=PropertyMock, return_value={}): + await exchange._cleanup_runtime_orphan_orders() + signer_client.cancel_order.assert_awaited_once() + + async def test_cleanup_runtime_orphan_orders_api_failure_returns_early(self): + """When REST returns a non-success response, cleanup stops without cancelling.""" + exchange = LighterExchange.__new__(LighterExchange) + exchange._client_order_index_to_client_order_id = {} + exchange._get_account_index = lambda: 100 + exchange._api_get = AsyncMock(return_value={"error": "rate_limited"}) + exchange._get_lighter_signer_client = MagicMock() + exchange._signer_client_lock = asyncio.Lock() + + with patch.object(type(exchange), 'in_flight_orders', new_callable=PropertyMock, return_value={}): + await exchange._cleanup_runtime_orphan_orders() + exchange._get_lighter_signer_client.assert_not_called() diff --git a/test/hummingbot/connector/exchange/lighter/test_lighter_exchange_cancel_reconcile.py b/test/hummingbot/connector/exchange/lighter/test_lighter_exchange_cancel_reconcile.py new file mode 100644 index 00000000000..ee9700c0891 --- /dev/null +++ b/test/hummingbot/connector/exchange/lighter/test_lighter_exchange_cancel_reconcile.py @@ -0,0 +1,122 @@ +import asyncio +import unittest +from test.isolated_asyncio_wrapper_test_case import IsolatedAsyncioWrapperTestCase +from unittest.mock import AsyncMock, MagicMock + +try: + from hummingbot.connector.exchange.lighter.lighter_exchange import LighterExchange + from hummingbot.core.data_type.in_flight_order import OrderState, OrderUpdate + _LIGHTER_EXCHANGE_AVAILABLE = True +except ModuleNotFoundError: + _LIGHTER_EXCHANGE_AVAILABLE = False + + +@unittest.skipUnless(_LIGHTER_EXCHANGE_AVAILABLE, "Core exchange runtime modules are unavailable in this local environment") +class LighterExchangeCancelReconcileTests(IsolatedAsyncioWrapperTestCase): + async def test_execute_order_cancel_reconciles_code_5_to_canceled(self): + exchange = LighterExchange.__new__(LighterExchange) + + order = type( + "TrackedOrder", + (), + { + "client_order_id": "HBOT-1", + "exchange_order_id": "100", + "trading_pair": "LINK-USDC", + }, + )() + + exchange._place_cancel = AsyncMock(side_effect=IOError('{"success":false,"error":"Failed to cancel order","code":5}')) + exchange._request_order_status = AsyncMock( + return_value=OrderUpdate( + client_order_id=order.client_order_id, + exchange_order_id=order.exchange_order_id, + trading_pair=order.trading_pair, + update_timestamp=1.0, + new_state=OrderState.CANCELED, + ) + ) + + processed_updates = [] + + class Tracker: + def process_order_update(self, update): + processed_updates.append(update) + + async def process_order_not_found(self, client_order_id): + _ = client_order_id + + exchange._order_tracker = Tracker() + exchange.logger = MagicMock(return_value=MagicMock()) + + result = await exchange._execute_order_cancel(order) + + self.assertEqual(order.client_order_id, result) + self.assertEqual(1, len(processed_updates)) + self.assertEqual(OrderState.CANCELED, processed_updates[0].new_state) + + async def test_execute_order_cancel_keeps_tracking_when_reconcile_open(self): + exchange = LighterExchange.__new__(LighterExchange) + + order = type( + "TrackedOrder", + (), + { + "client_order_id": "HBOT-2", + "exchange_order_id": "101", + "trading_pair": "LINK-USDC", + }, + )() + + exchange._place_cancel = AsyncMock(side_effect=IOError('{"success":false,"error":"Failed to cancel order","code":5}')) + exchange._request_order_status = AsyncMock( + return_value=OrderUpdate( + client_order_id=order.client_order_id, + exchange_order_id=order.exchange_order_id, + trading_pair=order.trading_pair, + update_timestamp=1.0, + new_state=OrderState.OPEN, + ) + ) + + class Tracker: + def process_order_update(self, update): + _ = update + + async def process_order_not_found(self, client_order_id): + _ = client_order_id + + exchange._order_tracker = Tracker() + exchange.logger = MagicMock(return_value=MagicMock()) + + result = await exchange._execute_order_cancel(order) + + self.assertIsNone(result) + + async def test_execute_order_cancel_timeout_keeps_tracking_and_schedules_reconcile(self): + exchange = LighterExchange.__new__(LighterExchange) + + order = type( + "TrackedOrder", + (), + { + "client_order_id": "HBOT-3", + "exchange_order_id": None, + "trading_pair": "UNI-USDC", + }, + )() + + exchange._execute_order_cancel_and_process_update = AsyncMock(side_effect=asyncio.TimeoutError()) + exchange._schedule_unmatched_private_event_reconcile = MagicMock() + + class Tracker: + async def process_order_not_found(self, client_order_id): + _ = client_order_id + + exchange._order_tracker = Tracker() + exchange.logger = MagicMock(return_value=MagicMock()) + + result = await exchange._execute_order_cancel(order) + + self.assertIsNone(result) + exchange._schedule_unmatched_private_event_reconcile.assert_called_once() diff --git a/test/hummingbot/connector/exchange/lighter/test_lighter_exchange_user_stream_payloads.py b/test/hummingbot/connector/exchange/lighter/test_lighter_exchange_user_stream_payloads.py new file mode 100644 index 00000000000..f2c1b32d566 --- /dev/null +++ b/test/hummingbot/connector/exchange/lighter/test_lighter_exchange_user_stream_payloads.py @@ -0,0 +1,226 @@ +import asyncio +import sys +import types +import unittest +from test.isolated_asyncio_wrapper_test_case import IsolatedAsyncioWrapperTestCase +from unittest.mock import AsyncMock, MagicMock + +try: + __import__("hummingbot.core.data_type.limit_order") +except Exception: + if "hummingbot.core.data_type.limit_order" not in sys.modules: + fake_limit_order = types.ModuleType("hummingbot.core.data_type.limit_order") + + class LimitOrder: + pass + + fake_limit_order.LimitOrder = LimitOrder + sys.modules["hummingbot.core.data_type.limit_order"] = fake_limit_order + +try: + __import__("hummingbot.core.data_type.order_book") +except Exception: + if "hummingbot.core.data_type.order_book" not in sys.modules: + fake_order_book = types.ModuleType("hummingbot.core.data_type.order_book") + + class OrderBook: + def apply_snapshot(self, bids, asks, update_id): + _ = bids + _ = asks + _ = update_id + + fake_order_book.OrderBook = OrderBook + sys.modules["hummingbot.core.data_type.order_book"] = fake_order_book + +try: + from hummingbot.connector.exchange.lighter.lighter_exchange import LighterExchange + _LIGHTER_EXCHANGE_AVAILABLE = True +except ModuleNotFoundError: + _LIGHTER_EXCHANGE_AVAILABLE = False + + +@unittest.skipUnless(_LIGHTER_EXCHANGE_AVAILABLE, "Core exchange runtime modules are unavailable in this local environment") +class LighterExchangeUserStreamPayloadTests(IsolatedAsyncioWrapperTestCase): + def test_extract_private_stream_payloads_from_account_all(self): + event = { + "type": "update/account_all", + "channel": "account_all:123", + "data": { + "assets": [{"symbol": "USDC", "balance": "10", "locked_balance": "2"}], + "trades": [{"trade_id": "t1"}], + "orders": [{"order_id": "o1"}], + }, + } + + account_data, trades, orders = LighterExchange._extract_private_stream_payloads(event) + + self.assertIsNotNone(account_data) + self.assertEqual(1, len(trades)) + self.assertEqual(1, len(orders)) + + def test_extract_private_stream_payloads_from_dedicated_channels(self): + order_event = { + "type": "update/account_order_updates", + "channel": "account_order_updates:123", + "data": {"order_id": "o1", "order_status": "open"}, + } + trade_event = { + "type": "update/account_trades", + "channel": "account_trades:123", + "data": [{"trade_id": "t1", "price": "1.1", "size": "2"}], + } + + account_data_1, trades_1, orders_1 = LighterExchange._extract_private_stream_payloads(order_event) + account_data_2, trades_2, orders_2 = LighterExchange._extract_private_stream_payloads(trade_event) + + self.assertIsNone(account_data_1) + self.assertEqual(0, len(trades_1)) + self.assertEqual(1, len(orders_1)) + + self.assertIsNone(account_data_2) + self.assertEqual(1, len(trades_2)) + self.assertEqual(0, len(orders_2)) + + def test_extract_private_stream_payloads_from_account_all_orders_data_payload(self): + dict_event = { + "type": "update/account_all_orders", + "channel": "account_all_orders:123", + "data": {"order_id": "o1", "order_status": "filled"}, + } + list_event = { + "type": "update/account_all_orders", + "channel": "account_all_orders:123", + "data": [ + {"order_id": "o2", "order_status": "open"}, + {"order_id": "o3", "order_status": "canceled"}, + ], + } + + _, _, dict_orders = LighterExchange._extract_private_stream_payloads(dict_event) + _, _, list_orders = LighterExchange._extract_private_stream_payloads(list_event) + + self.assertEqual([{"order_id": "o1", "order_status": "filled"}], dict_orders) + self.assertEqual(2, len(list_orders)) + + def test_extract_private_stream_payloads_from_account_all_assets(self): + event = { + "type": "update/account_all_assets", + "channel": "account_all_assets:123", + "assets": { + "3": {"symbol": "USDC", "balance": "100", "locked_balance": "12"}, + "1": {"symbol": "LINK", "balance": "4", "locked_balance": "1"}, + }, + } + + account_data, trades, orders = LighterExchange._extract_private_stream_payloads(event) + + self.assertIsNotNone(account_data) + self.assertEqual(2, len(account_data["assets"])) + self.assertEqual(0, len(trades)) + self.assertEqual(0, len(orders)) + + def test_extract_private_stream_payloads_from_singular_trade_and_order_fields(self): + event = { + "type": "update/account_all", + "channel": "account_all:123", + "data": { + "trade": {"trade_id": "t1", "price": "1.1", "size": "2"}, + "order": {"order_id": "o1", "order_status": "open"}, + }, + } + + account_data, trades, orders = LighterExchange._extract_private_stream_payloads(event) + + self.assertIsNotNone(account_data) + self.assertEqual(1, len(trades)) + self.assertEqual(1, len(orders)) + + async def test_user_stream_event_listener_triggers_balance_refresh_without_assets(self): + exchange = LighterExchange.__new__(LighterExchange) + exchange._process_balance_message_from_account = lambda _: None + exchange._trade_update_from_raw_message = lambda _: None + exchange._order_update_from_raw_message = lambda _: None + exchange._order_tracker = type( + "Tracker", + (), + { + "process_trade_update": lambda self, _: None, + "process_order_update": lambda self, _: None, + }, + )() + exchange._safe_update_balances_from_private_stream = AsyncMock() + exchange._current_timestamp_safely = lambda: 1000.0 + exchange._last_private_stream_balance_sync_ts = 0.0 + exchange._sleep = AsyncMock() + + async def events(): + yield { + "type": "update/account_all", + "channel": "account_all:123", + "data": { + "orders": [{"order_id": "o1", "order_status": "open"}], + "trades": [{"trade_id": "t1", "price": "1.1", "size": "2"}], + }, + } + raise asyncio.CancelledError + + exchange._iter_user_event_queue = events + + with self.assertRaises(asyncio.CancelledError): + await exchange._user_stream_event_listener() + + self.assertEqual(1000.0, exchange._last_private_stream_balance_sync_ts) + + async def test_user_stream_event_listener_processes_dedicated_payloads(self): + exchange = LighterExchange.__new__(LighterExchange) + captured = { + "balances": 0, + "trades": 0, + "orders": 0, + } + + _mock_trade_update = MagicMock() + _mock_trade_update.client_order_id = "HBOT-TEST" + _mock_trade_update.exchange_order_id = "1" + exchange._process_balance_message_from_account = lambda _: captured.__setitem__("balances", captured["balances"] + 1) + exchange._trade_update_from_raw_message = lambda _: _mock_trade_update + exchange._order_update_from_raw_message = lambda _: MagicMock(new_state=None, client_order_id="HBOT-TEST", exchange_order_id="1") + exchange._order_tracker = type( + "Tracker", + (), + { + "process_trade_update": lambda self, _: captured.__setitem__("trades", captured["trades"] + 1), + "process_order_update": lambda self, _: captured.__setitem__("orders", captured["orders"] + 1), + "all_fillable_orders": {}, + "all_updatable_orders": {}, + "all_fillable_orders_by_exchange_order_id": {}, + }, + )() + exchange._sleep = AsyncMock() + + async def events(): + yield { + "type": "update/account_info", + "channel": "account_info:123", + "data": {"assets": [{"symbol": "USDC", "balance": "10", "locked_balance": "1"}]}, + } + yield { + "type": "update/account_trades", + "channel": "account_trades:123", + "data": [{"trade_id": "t1", "price": "1.1", "size": "2"}], + } + yield { + "type": "update/account_order_updates", + "channel": "account_order_updates:123", + "data": {"order_id": "o1", "order_status": "open"}, + } + raise asyncio.CancelledError + + exchange._iter_user_event_queue = events + + with self.assertRaises(asyncio.CancelledError): + await exchange._user_stream_event_listener() + + self.assertEqual(1, captured["balances"]) + self.assertEqual(1, captured["trades"]) + self.assertEqual(1, captured["orders"]) diff --git a/test/hummingbot/connector/exchange/lighter/test_lighter_order_book.py b/test/hummingbot/connector/exchange/lighter/test_lighter_order_book.py new file mode 100644 index 00000000000..b566eddb584 --- /dev/null +++ b/test/hummingbot/connector/exchange/lighter/test_lighter_order_book.py @@ -0,0 +1,73 @@ +from unittest import TestCase + +from hummingbot.connector.exchange.lighter.lighter_order_book import LighterOrderBook +from hummingbot.core.data_type.common import TradeType +from hummingbot.core.data_type.order_book_message import OrderBookMessageType + + +class LighterOrderBookTests(TestCase): + def test_snapshot_message_from_exchange(self): + message = LighterOrderBook.snapshot_message_from_exchange( + msg={ + "update_id": 123, + "bids": [["100.0", "1.2"]], + "asks": [["101.0", "2.3"]], + }, + timestamp=1700000000.0, + metadata={"trading_pair": "ETH-USDC"}, + ) + + self.assertEqual(OrderBookMessageType.SNAPSHOT, message.type) + self.assertEqual("ETH-USDC", message.trading_pair) + self.assertEqual(123, message.update_id) + self.assertEqual(100.0, message.bids[0].price) + self.assertEqual(1.2, message.bids[0].amount) + self.assertEqual(101.0, message.asks[0].price) + self.assertEqual(2.3, message.asks[0].amount) + + def test_diff_message_from_exchange(self): + message = LighterOrderBook.diff_message_from_exchange( + msg={ + "first_update_id": 200, + "update_id": 220, + "bids": [["99.9", "0.5"]], + "asks": [["100.1", "0.6"]], + }, + timestamp=1700000001.0, + metadata={"trading_pair": "ETH-USDC"}, + ) + + self.assertEqual(OrderBookMessageType.DIFF, message.type) + self.assertEqual("ETH-USDC", message.trading_pair) + self.assertEqual(220, message.update_id) + self.assertEqual(200, message.first_update_id) + + def test_trade_message_from_exchange(self): + buy_trade = LighterOrderBook.trade_message_from_exchange( + msg={ + "trade_id": "t1", + "nonce": 7, + "price": "100.0", + "size": "0.25", + "is_maker_ask": True, + }, + timestamp=1700000002.0, + metadata={"trading_pair": "ETH-USDC"}, + ) + + sell_trade = LighterOrderBook.trade_message_from_exchange( + msg={ + "trade_id": "t2", + "nonce": 8, + "price": "101.0", + "size": "0.35", + "is_maker_ask": False, + }, + timestamp=1700000003.0, + metadata={"trading_pair": "ETH-USDC"}, + ) + + self.assertEqual(OrderBookMessageType.TRADE, buy_trade.type) + self.assertEqual("t1", buy_trade.trade_id) + self.assertEqual(float(TradeType.BUY.value), buy_trade.content["trade_type"]) + self.assertEqual(float(TradeType.SELL.value), sell_trade.content["trade_type"]) diff --git a/test/hummingbot/connector/exchange/lighter/test_lighter_utils.py b/test/hummingbot/connector/exchange/lighter/test_lighter_utils.py new file mode 100644 index 00000000000..825608c135e --- /dev/null +++ b/test/hummingbot/connector/exchange/lighter/test_lighter_utils.py @@ -0,0 +1,504 @@ +import json +from unittest import TestCase + +from pydantic import ValidationError + +from hummingbot.connector.exchange.lighter.lighter_utils import ( + LighterConfigMap, + LighterTestnetConfigMap, + is_exchange_information_valid, +) + + +class LighterUtilsTests(TestCase): + @staticmethod + def _encrypted_secret_payload_hex() -> str: + payload = {"crypto": {}, "version": 3, "alias": ""} + return json.dumps(payload).encode("utf-8").hex() + + def test_config_map_title(self): + self.assertEqual("lighter", LighterConfigMap.model_config.get("title")) + + def test_testnet_config_map_title(self): + self.assertEqual("lighter_testnet", LighterTestnetConfigMap.model_config.get("title")) + + def test_connect_flow_prompts_for_api_key_instead_of_private_key(self): + mainnet_api_key = LighterConfigMap.model_fields["lighter_api_key_private_key"].json_schema_extra + testnet_api_key = LighterTestnetConfigMap.model_fields["lighter_testnet_api_key_private_key"].json_schema_extra + + self.assertTrue(mainnet_api_key["prompt_on_new"]) + self.assertIn("private key", mainnet_api_key["prompt"].lower()) + + self.assertTrue(testnet_api_key["prompt_on_new"]) + self.assertIn("private key", testnet_api_key["prompt"].lower()) + + # Verify no (now-removed) EOA private key field is present + self.assertNotIn("lighter_private_key", LighterConfigMap.model_fields) + self.assertNotIn("lighter_testnet_private_key", LighterTestnetConfigMap.model_fields) + + def test_is_exchange_information_valid(self): + self.assertTrue(is_exchange_information_valid({"symbol": "ETH/USDC", "market_type": "spot", "status": "active"})) + self.assertFalse(is_exchange_information_valid({"symbol": "ETH/USDC", "market_type": "perp", "status": "active"})) + self.assertFalse(is_exchange_information_valid({"symbol": "ETH/USDC", "market_type": "spot", "status": "halted"})) + self.assertFalse(is_exchange_information_valid({"market_type": "spot", "status": "active"})) + + def test_mainnet_config_validates_integer_indexes(self): + cfg = LighterConfigMap( + lighter_api_key="0x" + ("a" * 64), + lighter_api_secret=" 123 ", + lighter_account_index=" 456 ", + ) + + self.assertEqual("123", cfg.lighter_api_key_index.get_secret_value()) + self.assertEqual("456", cfg.lighter_account_index.get_secret_value()) + + with self.assertRaises(ValidationError): + LighterConfigMap( + lighter_api_key="0x" + ("a" * 64), + lighter_api_secret="not-an-int", + lighter_account_index="456", + ) + + cfg_empty = LighterConfigMap( + lighter_api_key="0x" + ("a" * 64), + lighter_api_secret="", + lighter_account_index="", + ) + self.assertEqual("", cfg_empty.lighter_api_key_index.get_secret_value()) + self.assertEqual("", cfg_empty.lighter_account_index.get_secret_value()) + + def test_testnet_config_validates_integer_indexes(self): + cfg = LighterTestnetConfigMap( + lighter_testnet_api_key="0x" + ("a" * 64), + lighter_testnet_api_secret=" 7 ", + lighter_testnet_account_index=" 890 ", + ) + + self.assertEqual("7", cfg.lighter_testnet_api_key_index.get_secret_value()) + self.assertEqual("890", cfg.lighter_testnet_account_index.get_secret_value()) + + cfg_empty = LighterTestnetConfigMap( + lighter_testnet_api_key="0x" + ("a" * 64), + lighter_testnet_api_secret="", + lighter_testnet_account_index="", + ) + self.assertEqual("", cfg_empty.lighter_testnet_api_key_index.get_secret_value()) + self.assertEqual("", cfg_empty.lighter_testnet_account_index.get_secret_value()) + + with self.assertRaises(ValidationError): + LighterTestnetConfigMap( + lighter_testnet_api_key="0x" + ("a" * 64), + lighter_testnet_api_secret="7", + lighter_testnet_account_index="abc", + ) + + def test_mainnet_config_validates_hex_api_key(self): + hex_key = "0x" + ("a" * 64) + cfg = LighterConfigMap( + lighter_api_secret="123", + lighter_account_index="456", + lighter_api_key=hex_key, + ) + self.assertEqual(hex_key, cfg.lighter_api_key_private_key.get_secret_value()) + + with self.assertRaises(ValidationError): + LighterConfigMap( + lighter_api_secret="123", + lighter_account_index="456", + lighter_api_key="not-hex", + ) + + def test_testnet_config_validates_hex_api_key(self): + hex_key = "0x" + ("a" * 64) + cfg = LighterTestnetConfigMap( + lighter_testnet_api_key=hex_key, + lighter_testnet_api_secret="7", + lighter_testnet_account_index="890", + ) + self.assertEqual(hex_key, cfg.lighter_testnet_api_key_private_key.get_secret_value()) + + with self.assertRaises(ValidationError): + LighterTestnetConfigMap( + lighter_testnet_api_key="not-hex", + lighter_testnet_api_secret="7", + lighter_testnet_account_index="890", + ) + + def test_mainnet_config_accepts_encrypted_index_values_before_decrypt(self): + encrypted = self._encrypted_secret_payload_hex() + cfg = LighterConfigMap( + lighter_api_key=encrypted, + lighter_api_secret=encrypted, + lighter_account_index=encrypted, + ) + + self.assertEqual(encrypted, cfg.lighter_api_key_index.get_secret_value()) + self.assertEqual(encrypted, cfg.lighter_account_index.get_secret_value()) + + def test_testnet_config_accepts_encrypted_index_values_before_decrypt(self): + encrypted = self._encrypted_secret_payload_hex() + cfg = LighterTestnetConfigMap( + lighter_testnet_api_key="0x" + ("a" * 64), + lighter_testnet_api_secret=encrypted, + lighter_testnet_account_index=encrypted, + ) + + self.assertEqual(encrypted, cfg.lighter_testnet_api_key_index.get_secret_value()) + self.assertEqual(encrypted, cfg.lighter_testnet_account_index.get_secret_value()) + + # ------------------------------------------------------------------ # + # Migration else-branch coverage # + # ------------------------------------------------------------------ # + + def test_mainnet_migrate_new_names_directly_no_legacy_fields(self): + """Using new field names directly should still work (triggers else-branches in migration).""" + hex_key = "0x" + ("a" * 64) + cfg = LighterConfigMap( + lighter_api_key_index="42", + lighter_account_index="999", + lighter_api_key_private_key=hex_key, + ) + self.assertEqual("42", cfg.lighter_api_key_index.get_secret_value()) + self.assertEqual("999", cfg.lighter_account_index.get_secret_value()) + self.assertEqual(hex_key, cfg.lighter_api_key_private_key.get_secret_value()) + + def test_mainnet_migrate_discards_old_fields_when_new_fields_also_present(self): + """When both old and new field names present, new wins and old is discarded (else-branch).""" + hex_key = "0x" + ("a" * 64) + cfg = LighterConfigMap( + lighter_api_key_index="5", + lighter_api_secret="99", # old name present but new also present → discarded + lighter_account_index="200", + lighter_api_key_private_key=hex_key, + lighter_api_key="0x" + ("b" * 64), # old name present but new also present → discarded + ) + self.assertEqual("5", cfg.lighter_api_key_index.get_secret_value()) + self.assertEqual("200", cfg.lighter_account_index.get_secret_value()) + self.assertEqual(hex_key, cfg.lighter_api_key_private_key.get_secret_value()) + + def test_mainnet_migrate_discards_lighter_private_key_and_public_key(self): + """lighter_private_key and lighter_api_key_public_key from old configs are silently removed.""" + hex_key = "0x" + ("a" * 64) + cfg = LighterConfigMap( + lighter_api_key_index="3", + lighter_account_index="100", + lighter_api_key_private_key=hex_key, + lighter_private_key="0xold_l1_key", # should be discarded + lighter_api_key_public_key="0xold_pub", # should be discarded + ) + self.assertEqual("3", cfg.lighter_api_key_index.get_secret_value()) + self.assertFalse(hasattr(cfg, "lighter_private_key")) + self.assertFalse(hasattr(cfg, "lighter_api_key_public_key")) + + def test_testnet_migrate_new_names_directly_no_legacy_fields(self): + hex_key = "0x" + ("a" * 64) + cfg = LighterTestnetConfigMap( + lighter_testnet_api_key_index="8", + lighter_testnet_account_index="888", + lighter_testnet_api_key_private_key=hex_key, + ) + self.assertEqual("8", cfg.lighter_testnet_api_key_index.get_secret_value()) + self.assertEqual("888", cfg.lighter_testnet_account_index.get_secret_value()) + + def test_testnet_migrate_discards_old_fields_when_new_fields_also_present(self): + hex_key = "0x" + ("a" * 64) + cfg = LighterTestnetConfigMap( + lighter_testnet_api_key_index="11", + lighter_testnet_api_secret="55", # old → discarded + lighter_testnet_account_index="300", + lighter_testnet_api_key_private_key=hex_key, + lighter_testnet_api_key="0x" + ("c" * 64), # old → discarded + ) + self.assertEqual("11", cfg.lighter_testnet_api_key_index.get_secret_value()) + self.assertEqual("300", cfg.lighter_testnet_account_index.get_secret_value()) + self.assertEqual(hex_key, cfg.lighter_testnet_api_key_private_key.get_secret_value()) + + def test_testnet_migrate_discards_legacy_key_fields(self): + hex_key = "0x" + ("a" * 64) + cfg = LighterTestnetConfigMap( + lighter_testnet_api_key_index="1", + lighter_testnet_account_index="50", + lighter_testnet_api_key_private_key=hex_key, + lighter_testnet_private_key="0xold_l1", # discarded + lighter_testnet_api_key_public_key="0xold_pub", # discarded + ) + self.assertEqual("1", cfg.lighter_testnet_api_key_index.get_secret_value()) + self.assertFalse(hasattr(cfg, "lighter_testnet_private_key")) + self.assertFalse(hasattr(cfg, "lighter_testnet_api_key_public_key")) + + # ------------------------------------------------------------------ # + # Empty private key must raise (unlike index fields where "" is OK) # + # ------------------------------------------------------------------ # + + def test_mainnet_empty_private_key_raises(self): + with self.assertRaises(Exception) as ctx: + LighterConfigMap( + lighter_api_key_index="4", + lighter_account_index="100", + lighter_api_key_private_key="", + ) + self.assertIn("hex string", str(ctx.exception).lower()) + + def test_testnet_empty_private_key_raises(self): + with self.assertRaises(Exception) as ctx: + LighterTestnetConfigMap( + lighter_testnet_api_key_index="4", + lighter_testnet_account_index="100", + lighter_testnet_api_key_private_key="", + ) + self.assertIn("hex string", str(ctx.exception).lower()) + + # ------------------------------------------------------------------ # + # Additional is_exchange_information_valid branches # + # ------------------------------------------------------------------ # + + def test_is_exchange_information_valid_inactive_statuses(self): + for bad_status in ("inactive", "disabled", "suspended", "delisted"): + with self.subTest(status=bad_status): + self.assertFalse( + is_exchange_information_valid({"symbol": "ETH/USDC", "market_type": "spot", "status": bad_status}) + ) + + def test_is_exchange_information_valid_empty_market_type_passes_through(self): + """market_type absent or empty → not rejected by market-type check, falls through to status/symbol checks.""" + self.assertTrue(is_exchange_information_valid({"symbol": "ETH/USDC", "status": "active"})) + self.assertFalse(is_exchange_information_valid({"status": "active"})) # missing symbol + + # ------------------------------------------------------------------ # + # Async helper functions tested via asyncio.run # + # ------------------------------------------------------------------ # + + def test_fetch_lighter_public_key_returns_key_on_success(self): + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + from hummingbot.connector.exchange.lighter.lighter_utils import fetch_lighter_public_key + + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value={"api_keys": [{"public_key": "0xdeadbeef"}]}) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_response) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession", return_value=mock_session): + result = asyncio.run(fetch_lighter_public_key("lighter", "100", "4")) + + self.assertEqual("0xdeadbeef", result) + + def test_fetch_lighter_public_key_returns_none_when_no_api_keys(self): + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + from hummingbot.connector.exchange.lighter.lighter_utils import fetch_lighter_public_key + + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value={"api_keys": []}) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_response) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession", return_value=mock_session): + result = asyncio.run(fetch_lighter_public_key("lighter", "100", "4")) + + self.assertIsNone(result) + + def test_fetch_lighter_public_key_returns_none_on_http_error(self): + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + from hummingbot.connector.exchange.lighter.lighter_utils import fetch_lighter_public_key + + mock_response = AsyncMock() + mock_response.status = 404 + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_response) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession", return_value=mock_session): + result = asyncio.run(fetch_lighter_public_key("lighter", "100", "4")) + + self.assertIsNone(result) + + def test_fetch_lighter_public_key_returns_none_on_network_exception(self): + import asyncio + from unittest.mock import patch + + from hummingbot.connector.exchange.lighter.lighter_utils import fetch_lighter_public_key + + with patch("aiohttp.ClientSession", side_effect=Exception("connection refused")): + result = asyncio.run(fetch_lighter_public_key("lighter", "100", "4")) + + self.assertIsNone(result) + + def test_fetch_lighter_public_key_uses_testnet_url_for_testnet_connector(self): + """Uses TESTNET_REST_URL when connector_name is lighter_testnet.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + from hummingbot.connector.exchange.lighter.lighter_utils import fetch_lighter_public_key + + captured_url = [] + + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value={"api_keys": []}) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + def capture_get(url, **kwargs): + captured_url.append(url) + return mock_response + + mock_session = MagicMock() + mock_session.get = MagicMock(side_effect=capture_get) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + from hummingbot.connector.exchange.lighter import lighter_constants as lc + + with patch("aiohttp.ClientSession", return_value=mock_session): + asyncio.run(fetch_lighter_public_key("lighter_testnet", "100", "4")) + + self.assertIn(lc.TESTNET_REST_URL, captured_url[0]) + + def test_fetch_lighter_public_key_uses_testnet_url_for_perpetual_testnet_connector(self): + """Uses TESTNET_REST_URL when connector_name is lighter_perpetual_testnet.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + from hummingbot.connector.derivative.lighter_perpetual import lighter_perpetual_constants as lpc + from hummingbot.connector.exchange.lighter.lighter_utils import fetch_lighter_public_key + + captured_url = [] + + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value={"api_keys": []}) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + def capture_get(url, **kwargs): + captured_url.append(url) + return mock_response + + mock_session = MagicMock() + mock_session.get = MagicMock(side_effect=capture_get) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession", return_value=mock_session): + asyncio.run(fetch_lighter_public_key("lighter_perpetual_testnet", "100", "4")) + + self.assertIn(lpc.TESTNET_REST_URL, captured_url[0]) + + def test_validate_lighter_api_key_index_returns_none_when_key_found(self): + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + from hummingbot.connector.exchange.lighter.lighter_utils import validate_lighter_api_key_index + + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value={"api_keys": [{"public_key": "0xabc"}]}) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_response) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession", return_value=mock_session): + result = asyncio.run(validate_lighter_api_key_index("lighter", "100", "4")) + + self.assertIsNone(result) + + def test_validate_lighter_api_key_index_returns_error_when_key_not_found(self): + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + from hummingbot.connector.exchange.lighter.lighter_utils import validate_lighter_api_key_index + + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value={"api_keys": []}) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_response) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession", return_value=mock_session): + result = asyncio.run(validate_lighter_api_key_index("lighter", "100", "4")) + + self.assertIsNotNone(result) + self.assertIn("4", result) + + def test_validate_lighter_api_key_index_returns_none_on_exception(self): + import asyncio + from unittest.mock import patch + + from hummingbot.connector.exchange.lighter.lighter_utils import validate_lighter_api_key_index + + with patch("aiohttp.ClientSession", side_effect=Exception("timeout")): + result = asyncio.run(validate_lighter_api_key_index("lighter", "100", "4")) + + self.assertIsNone(result) + + # ------------------------------------------------------------------ # + # Additional branch coverage for missing lines # + # ------------------------------------------------------------------ # + + def test_mainnet_account_index_validates_non_integer_raises(self): + """lighter_account_index='not-an-int' must raise ValidationError (covers line 130).""" + with self.assertRaises(ValidationError): + LighterConfigMap( + lighter_api_key_private_key="0x" + ("a" * 64), + lighter_api_key_index="5", + lighter_account_index="not-an-int", + ) + + def test_testnet_api_key_index_validates_non_integer_raises(self): + """lighter_testnet_api_key_index='not-an-int' must raise ValidationError (covers line 229).""" + with self.assertRaises(ValidationError): + LighterTestnetConfigMap( + lighter_testnet_api_key_private_key="0x" + ("a" * 64), + lighter_testnet_api_key_index="not-an-int", + lighter_testnet_account_index="890", + ) + + def test_testnet_api_key_private_key_accepts_encrypted_blob(self): + """Encrypted blob as testnet private key must pass through (covers line 253).""" + encrypted = self._encrypted_secret_payload_hex() + cfg = LighterTestnetConfigMap( + lighter_testnet_api_key_private_key=encrypted, + lighter_testnet_api_key_index="0", + lighter_testnet_account_index="0", + ) + self.assertEqual(encrypted, cfg.lighter_testnet_api_key_private_key.get_secret_value()) + + def test_mainnet_migrate_legacy_fields_with_non_dict_is_returned_unchanged(self): + """migrate_legacy_fields must return non-dict data unchanged (covers line 90).""" + result = LighterConfigMap.migrate_legacy_fields("not-a-dict") + self.assertEqual("not-a-dict", result) + + def test_testnet_migrate_legacy_fields_with_non_dict_is_returned_unchanged(self): + """testnet migrate_legacy_fields must return non-dict data unchanged (covers line 202).""" + result = LighterTestnetConfigMap.migrate_legacy_fields("not-a-dict") + self.assertEqual("not-a-dict", result) diff --git a/test/hummingbot/connector/exchange/lighter/test_lighter_web_utils.py b/test/hummingbot/connector/exchange/lighter/test_lighter_web_utils.py new file mode 100644 index 00000000000..96ed7446a25 --- /dev/null +++ b/test/hummingbot/connector/exchange/lighter/test_lighter_web_utils.py @@ -0,0 +1,38 @@ +import unittest + +from hummingbot.connector.exchange.lighter import lighter_constants as CONSTANTS, lighter_web_utils as web_utils +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory + + +class LighterWebUtilsTests(unittest.TestCase): + def test_public_rest_url(self): + self.assertEqual( + f"{CONSTANTS.REST_URL}{CONSTANTS.EXCHANGE_INFO_PATH_URL}", + web_utils.public_rest_url(CONSTANTS.EXCHANGE_INFO_PATH_URL), + ) + + def test_private_rest_url(self): + self.assertEqual( + f"{CONSTANTS.REST_URL}{CONSTANTS.GET_ACCOUNT_INFO_PATH_URL}", + web_utils.private_rest_url(CONSTANTS.GET_ACCOUNT_INFO_PATH_URL), + ) + + def test_wss_url(self): + self.assertEqual(CONSTANTS.WSS_URL, web_utils.wss_url()) + + def test_build_api_factory(self): + api_factory = web_utils.build_api_factory() + self.assertIsInstance(api_factory, WebAssistantsFactory) + self.assertIsNone(api_factory._auth) + + def test_get_current_server_time(self): + import asyncio + import time + before = time.time() + result = asyncio.run(web_utils.get_current_server_time()) + after = time.time() + self.assertGreaterEqual(result, before) + self.assertLessEqual(result, after + 1) + + def test_wss_url_testnet(self): + self.assertEqual(CONSTANTS.TESTNET_WSS_URL, web_utils.wss_url(domain=CONSTANTS.TESTNET_DOMAIN)) diff --git a/test/hummingbot/connector/lighter_common/test_lighter_key_utils.py b/test/hummingbot/connector/lighter_common/test_lighter_key_utils.py new file mode 100644 index 00000000000..0b13d29add4 --- /dev/null +++ b/test/hummingbot/connector/lighter_common/test_lighter_key_utils.py @@ -0,0 +1,55 @@ +import asyncio +from unittest import TestCase +from unittest.mock import AsyncMock, MagicMock, patch + +from hummingbot.connector.derivative.lighter_perpetual import lighter_perpetual_constants as perp_constants +from hummingbot.connector.exchange.lighter import lighter_constants as spot_constants +from hummingbot.connector.lighter_common.lighter_key_utils import fetch_lighter_public_key + + +class LighterKeyUtilsTest(TestCase): + def test_fetch_public_key_uses_spot_testnet_url(self): + captured_url = [] + + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value={"api_keys": []}) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + def capture_get(url, **kwargs): + captured_url.append(url) + return mock_response + + mock_session = MagicMock() + mock_session.get = MagicMock(side_effect=capture_get) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession", return_value=mock_session): + asyncio.run(fetch_lighter_public_key("lighter_testnet", "100", "4")) + + self.assertIn(spot_constants.TESTNET_REST_URL, captured_url[0]) + + def test_fetch_public_key_uses_perpetual_testnet_url(self): + captured_url = [] + + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value={"api_keys": []}) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + def capture_get(url, **kwargs): + captured_url.append(url) + return mock_response + + mock_session = MagicMock() + mock_session.get = MagicMock(side_effect=capture_get) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession", return_value=mock_session): + asyncio.run(fetch_lighter_public_key("lighter_perpetual_testnet", "100", "4")) + + self.assertIn(perp_constants.TESTNET_REST_URL, captured_url[0]) diff --git a/test/hummingbot/data_feed/candles_feed/lighter_perpetual_candles/test_lighter_perpetual_candles.py b/test/hummingbot/data_feed/candles_feed/lighter_perpetual_candles/test_lighter_perpetual_candles.py new file mode 100644 index 00000000000..5437a5ba4c9 --- /dev/null +++ b/test/hummingbot/data_feed/candles_feed/lighter_perpetual_candles/test_lighter_perpetual_candles.py @@ -0,0 +1,504 @@ +import asyncio +import json +import re +import time +from test.hummingbot.data_feed.candles_feed.test_candles_base import TestCandlesBase +from unittest.mock import AsyncMock, patch + +import numpy as np +from aioresponses import aioresponses + +from hummingbot.core.network_iterator import NetworkStatus +from hummingbot.data_feed.candles_feed.lighter_perpetual_candles import LighterPerpetualCandles + + +class TestLighterPerpetualCandles(TestCandlesBase): + __test__ = True + level = 0 + + # Market ID used throughout tests (normally resolved by initialize_exchange_data via REST) + MARKET_ID = 200 + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.base_asset = "BTC" + cls.quote_asset = "USDC" + cls.interval = "1h" + cls.trading_pair = f"{cls.base_asset}-{cls.quote_asset}" + cls.ex_trading_pair = f"{cls.base_asset}-{cls.quote_asset}" # Lighter returns trading_pair as-is + cls.max_records = 150 + + def setUp(self) -> None: + super().setUp() + self.data_feed = LighterPerpetualCandles( + trading_pair=self.trading_pair, + interval=self.interval, + max_records=self.max_records, + ) + # Bypass initialize_exchange_data — set market_id directly + self.data_feed._market_id = self.MARKET_ID + self.log_records = [] + self.data_feed.logger().setLevel(1) + self.data_feed.logger().addHandler(self) + + def tearDown(self) -> None: + self.data_feed.stop() + super().tearDown() + + # ── Required data mocks ──────────────────────────────────────────────────── + + @staticmethod + def get_candles_rest_data_mock(): + """REST API response format: {"c": [...candle dicts...]}""" + return { + "c": [ + {"t": 1718895600000, "o": "64942.0", "h": "65123.0", "l": "64812.0", "c": "64837.0", + "v": "190.58", "V": "12345.0", "i": 100}, + {"t": 1718899200000, "o": "64837.0", "h": "64964.0", "l": "64564.0", "c": "64898.0", + "v": "271.68", "V": "17654.0", "i": 200}, + {"t": 1718902800000, "o": "64900.0", "h": "65034.0", "l": "64714.0", "c": "64997.0", + "v": "104.80", "V": "6810.0", "i": 150}, + {"t": 1718906400000, "o": "64999.0", "h": "65244.0", "l": "64981.0", "c": "65157.0", + "v": "158.51", "V": "10310.0", "i": 175}, + {"t": 1718910000000, "o": "65153.0", "h": "65153.0", "l": "64882.0", "c": "65095.0", + "v": "209.75", "V": "13650.0", "i": 190}, + ] + } + + def get_fetch_candles_data_mock(self): + """Expected output from _parse_rest_candles: rows of [ts, o, h, l, c, v, V, i, 0, 0]""" + return [ + [1718895600.0, 64942.0, 65123.0, 64812.0, 64837.0, 190.58, 12345.0, 100.0, 0.0, 0.0], + [1718899200.0, 64837.0, 64964.0, 64564.0, 64898.0, 271.68, 17654.0, 200.0, 0.0, 0.0], + [1718902800.0, 64900.0, 65034.0, 64714.0, 64997.0, 104.80, 6810.0, 150.0, 0.0, 0.0], + [1718906400.0, 64999.0, 65244.0, 64981.0, 65157.0, 158.51, 10310.0, 175.0, 0.0, 0.0], + [1718910000.0, 65153.0, 65153.0, 64882.0, 65095.0, 209.75, 13650.0, 190.0, 0.0, 0.0], + ] + + def get_candles_ws_data_mock_1(self): + """Lighter WS trade event — triggers REST fetch for latest candle.""" + return { + "channel": f"trade:{self.MARKET_ID}", + "data": {"price": "65162.0", "size": "0.1", "created_at": 1718914860}, + } + + def get_candles_ws_data_mock_2(self): + """Second trade event with a later timestamp → new candle bucket.""" + return { + "channel": f"trade:{self.MARKET_ID}", + "data": {"price": "65200.0", "size": "0.2", "created_at": 1718918460}, + } + + @staticmethod + def _success_subscription_mock(): + """Server-sent subscription confirmation (channel uses '/' — won't match trade filter).""" + return {"type": "subscribed", "channel": "trade/200"} + + # ── Test overrides ───────────────────────────────────────────────────────── + + @aioresponses() + async def test_fetch_candles(self, mock_api): + """GET-based fetch: mock with mock_api.get and verify parsed shape.""" + regex_url = re.compile( + f"^{self.data_feed.candles_url}".replace(".", r"\.").replace("?", r"\?")) + mock_api.get(url=regex_url, body=json.dumps(self.get_candles_rest_data_mock())) + + resp = await self.data_feed.fetch_candles( + start_time=int(self.start_time), + end_time=int(self.end_time), + ) + + self.assertEqual(resp.shape[0], len(self.get_fetch_candles_data_mock())) + self.assertEqual(resp.shape[1], 10) + + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fetch_candles", new_callable=AsyncMock) + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase._time") + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + async def test_listen_for_subscriptions_subscribes_to_klines( + self, ws_connect_mock, mock_time, fetch_candles_mock): + """Verify subscription payload is sent; mock fetch_candles to avoid real network seed call.""" + mock_time.return_value = time.time() + fetch_candles_mock.return_value = np.array([]) # seed returns empty, no network needed + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(self._success_subscription_mock())) + + self.listening_task = asyncio.create_task(self.data_feed.listen_for_subscriptions()) + + await self.mocking_assistant.run_until_all_aiohttp_messages_delivered(ws_connect_mock.return_value) + await asyncio.sleep(0.1) + + sent = self.mocking_assistant.json_messages_sent_through_websocket( + websocket_mock=ws_connect_mock.return_value) + + self.assertEqual(1, len(sent)) + expected = self.data_feed.ws_subscription_payload() + self.assertEqual(expected, sent[0]) + self.assertTrue(self.is_logged("INFO", "Subscribed to public klines...")) + + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fill_historical_candles", + new_callable=AsyncMock) + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fetch_candles", + new_callable=AsyncMock) + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + async def test_process_websocket_messages_empty_candle( + self, ws_connect_mock, fetch_candles_mock, fill_historical_candles_mock): + """ + When a trade event arrives and the candle deque is empty, the REST-fetched + candle is appended and fill_historical_candles is triggered. + """ + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + + candle = np.array([[1718914860.0, 65162.0, 65200.0, 65100.0, 65162.0, 0.1, 0.0, 1.0, 0.0, 0.0]]) + # First call = seed (empty); second call = trade-event REST fetch + fetch_candles_mock.side_effect = [np.array([]), candle] + + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(self.get_candles_ws_data_mock_1())) + + self.listening_task = asyncio.create_task(self.data_feed.listen_for_subscriptions()) + + await self.mocking_assistant.run_until_all_aiohttp_messages_delivered(ws_connect_mock.return_value) + await asyncio.sleep(0.3) + + self.assertEqual(self.data_feed.candles_df.shape[0], 1) + self.assertEqual(self.data_feed.candles_df.shape[1], 10) + fill_historical_candles_mock.assert_called_once() + + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fill_historical_candles", + new_callable=AsyncMock) + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fetch_candles", + new_callable=AsyncMock) + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + async def test_process_websocket_messages_duplicated_candle_not_included( + self, ws_connect_mock, fetch_candles_mock, fill_historical_candles_mock): + """Two identical trade events must not insert duplicate candles.""" + fill_historical_candles_mock.return_value = None + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + + candle = np.array([[1718914860.0, 65162.0, 65200.0, 65100.0, 65162.0, 0.1, 0.0, 1.0, 0.0, 0.0]]) + # seed empty; both trade events return the same candle + fetch_candles_mock.side_effect = [np.array([]), candle, candle] + + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(self.get_candles_ws_data_mock_1())) + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(self.get_candles_ws_data_mock_1())) + + self.listening_task = asyncio.create_task(self.data_feed.listen_for_subscriptions()) + + await self.mocking_assistant.run_until_all_aiohttp_messages_delivered( + ws_connect_mock.return_value, timeout=2) + await asyncio.sleep(0.3) + + self.assertEqual(self.data_feed.candles_df.shape[0], 1) + self.assertEqual(self.data_feed.candles_df.shape[1], 10) + + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fill_historical_candles", + new_callable=AsyncMock) + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fetch_candles", + new_callable=AsyncMock) + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + async def test_process_websocket_messages_with_two_valid_messages( + self, ws_connect_mock, fetch_candles_mock, _): + """Two trade events at different timestamps each produce a new candle row.""" + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + + candle1 = np.array([[1718914860.0, 65162.0, 65200.0, 65100.0, 65162.0, 0.1, 0.0, 1.0, 0.0, 0.0]]) + candle2 = np.array([[1718918460.0, 65200.0, 65250.0, 65100.0, 65200.0, 0.2, 0.0, 2.0, 0.0, 0.0]]) + fetch_candles_mock.side_effect = [np.array([]), candle1, candle2] + + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(self.get_candles_ws_data_mock_1())) + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(self.get_candles_ws_data_mock_2())) + + self.listening_task = asyncio.create_task(self.data_feed.listen_for_subscriptions()) + + await self.mocking_assistant.run_until_all_aiohttp_messages_delivered(ws_connect_mock.return_value) + await asyncio.sleep(0.3) + + self.assertEqual(self.data_feed.candles_df.shape[0], 2) + self.assertEqual(self.data_feed.candles_df.shape[1], 10) + + # ── Unit tests for Lighter-specific logic ────────────────────────────────── + + def test_name_property(self): + """name should be lighter_perpetual_{trading_pair}.""" + self.assertEqual(self.data_feed.name, f"lighter_perpetual_{self.trading_pair}") + + def test_ws_subscription_payload(self): + """Subscription uses 'trade/{market_id}' with a slash.""" + payload = self.data_feed.ws_subscription_payload() + self.assertEqual(payload["type"], "subscribe") + self.assertEqual(payload["channel"], f"trade/{self.MARKET_ID}") + + def test_parse_rest_candles_field_mapping(self): + """Verify every REST JSON field maps to the correct column position.""" + data = { + "c": [ + {"t": 1718895600000, "o": "100.0", "h": "110.0", "l": "90.0", "c": "105.0", + "v": "50.0", "V": "5000.0", "i": 42}, + ] + } + result = self.data_feed._parse_rest_candles(data) + self.assertEqual(len(result), 1) + row = result[0] + # Columns: timestamp, open, high, low, close, volume, quote_asset_volume, n_trades, tbv, tbqv + self.assertEqual(row[0], 1718895600.0) # t (ms) / 1000 → timestamp in seconds + self.assertEqual(row[1], 100.0) # o → open + self.assertEqual(row[2], 110.0) # h → high + self.assertEqual(row[3], 90.0) # l → low + self.assertEqual(row[4], 105.0) # c → close + self.assertEqual(row[5], 50.0) # v → volume + self.assertEqual(row[6], 5000.0) # V → quote_asset_volume + self.assertEqual(row[7], 42.0) # i → n_trades + self.assertEqual(row[8], 0.0) # taker_buy_base_volume (always 0) + self.assertEqual(row[9], 0.0) # taker_buy_quote_volume (always 0) + + def test_parse_rest_candles_end_time_filter(self): + """Rows whose timestamp exceeds end_time are excluded.""" + data = { + "c": [ + {"t": 1718895600000, "o": "100.0", "h": "110.0", "l": "90.0", "c": "105.0", + "v": "50.0", "V": "5000.0", "i": 1}, + {"t": 1718899200000, "o": "105.0", "h": "115.0", "l": "95.0", "c": "110.0", + "v": "60.0", "V": "6000.0", "i": 2}, # ts (in s after /1000) > end_time → excluded + ] + } + result = self.data_feed._parse_rest_candles(data, end_time=1718897000) + self.assertEqual(len(result), 1) + self.assertEqual(result[0][0], 1718895600.0) + + def test_parse_rest_candles_empty_response(self): + """Empty 'c' list returns empty list without errors.""" + self.assertEqual(self.data_feed._parse_rest_candles({"c": []}), []) + self.assertEqual(self.data_feed._parse_rest_candles({}), []) + + def test_get_rest_candles_params(self): + """REST params include market_id, resolution, timestamps, and count_back.""" + start_ts = 1718895600 + end_ts = 1718910000 + params = self.data_feed._get_rest_candles_params( + start_time=start_ts, end_time=end_ts, limit=100) + self.assertEqual(params["market_id"], self.MARKET_ID) + self.assertEqual(params["start_timestamp"], start_ts) + self.assertEqual(params["end_timestamp"], end_ts) + self.assertEqual(params["count_back"], 100) + self.assertEqual(params["set_timestamp_to_end"], "true") + self.assertIn("resolution", params) + + async def test_initialize_exchange_data_resolves_perp_market_id(self): + """initialize_exchange_data picks the perp market using just base symbol (not base/quote).""" + order_books_response = { + "order_books": [ + {"market_type": "spot", "symbol": "BTC/USDC", "market_id": 999}, + {"market_type": "perp", "symbol": "ETH", "market_id": 50}, + {"market_type": "perp", "symbol": "BTC", "market_id": self.MARKET_ID}, + ] + } + with patch.object(self.data_feed._api_factory, "get_rest_assistant") as mock_get: + mock_rest = AsyncMock() + mock_get.return_value = mock_rest + mock_rest.execute_request.return_value = order_books_response + self.data_feed._market_id = None + await self.data_feed.initialize_exchange_data() + self.assertEqual(self.data_feed._market_id, self.MARKET_ID) + + async def test_initialize_exchange_data_ignores_spot_markets(self): + """A spot market with matching symbol must not be selected for a perp feed.""" + order_books_response = { + "order_books": [ + {"market_type": "spot", "symbol": "BTC", "market_id": 999}, + ] + } + with patch.object(self.data_feed._api_factory, "get_rest_assistant") as mock_get: + mock_rest = AsyncMock() + mock_get.return_value = mock_rest + mock_rest.execute_request.return_value = order_books_response + self.data_feed._market_id = None + with self.assertRaises(ValueError): + await self.data_feed.initialize_exchange_data() + + async def test_initialize_exchange_data_raises_when_market_not_found(self): + """ValueError is raised when no perp market matches the base symbol.""" + order_books_response = { + "order_books": [ + {"market_type": "perp", "symbol": "ETH", "market_id": 50}, + ] + } + with patch.object(self.data_feed._api_factory, "get_rest_assistant") as mock_get: + mock_rest = AsyncMock() + mock_get.return_value = mock_rest + mock_rest.execute_request.return_value = order_books_response + self.data_feed._market_id = None + with self.assertRaises(ValueError) as ctx: + await self.data_feed.initialize_exchange_data() + self.assertIn("BTC", str(ctx.exception)) + + async def test_check_network_returns_connected(self): + """check_network() returns CONNECTED when the health-check endpoint responds.""" + with patch.object(self.data_feed._api_factory, "get_rest_assistant") as mock_get: + mock_rest = AsyncMock() + mock_get.return_value = mock_rest + mock_rest.execute_request.return_value = {"order_books": []} + result = await self.data_feed.check_network() + self.assertEqual(result, NetworkStatus.CONNECTED) + + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fill_historical_candles", + new_callable=AsyncMock) + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fetch_candles", + new_callable=AsyncMock) + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + async def test_seed_populates_deque_on_ws_connect( + self, ws_connect_mock, fetch_candles_mock, fill_historical_candles_mock): + """When the seed REST call returns candles, they are immediately loaded into the deque.""" + fill_historical_candles_mock.return_value = None + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + + seed_candles = np.array([ + [1718895600.0, 64942.0, 65123.0, 64812.0, 64837.0, 190.58, 12345.0, 100.0, 0.0, 0.0], + [1718899200.0, 64837.0, 64964.0, 64564.0, 64898.0, 271.68, 17654.0, 200.0, 0.0, 0.0], + ]) + fetch_candles_mock.return_value = seed_candles + + self.listening_task = asyncio.create_task(self.data_feed.listen_for_subscriptions()) + await asyncio.sleep(0.3) + + self.assertEqual(len(self.data_feed._candles), 2) + self.assertTrue(self.data_feed._ws_candle_available.is_set()) + self.listening_task.cancel() + + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fill_historical_candles", + new_callable=AsyncMock) + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fetch_candles", + new_callable=AsyncMock) + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + async def test_same_timestamp_trade_event_replaces_last_candle( + self, ws_connect_mock, fetch_candles_mock, fill_historical_candles_mock): + """A trade event at the same timestamp as the current open candle updates it in-place.""" + fill_historical_candles_mock.return_value = None + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + + ts = 1718914800.0 + original = np.array([ts, 65162.0, 65200.0, 65100.0, 65162.0, 0.1, 0.0, 1.0, 0.0, 0.0]) + self.data_feed._candles.append(original) + self.data_feed._ws_candle_available.set() + + # Same ts but updated high and close — should replace, not append + updated = np.array([[ts, 65162.0, 65300.0, 65100.0, 65250.0, 0.3, 0.0, 2.0, 0.0, 0.0]]) + fetch_candles_mock.side_effect = [np.array([]), updated] + + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(self.get_candles_ws_data_mock_1())) + + self.listening_task = asyncio.create_task(self.data_feed.listen_for_subscriptions()) + await self.mocking_assistant.run_until_all_aiohttp_messages_delivered(ws_connect_mock.return_value) + await asyncio.sleep(0.3) + + self.assertEqual(len(self.data_feed._candles), 1) # not appended + self.assertEqual(self.data_feed._candles[-1][2], 65300.0) # high updated + self.assertEqual(self.data_feed._candles[-1][4], 65250.0) # close updated + + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fetch_candles", + new_callable=AsyncMock) + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + async def test_wrong_channel_message_is_ignored(self, ws_connect_mock, fetch_candles_mock): + """Trade events for a different market_id channel must not trigger a REST fetch.""" + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + fetch_candles_mock.return_value = np.array([]) # seed returns empty + + wrong_channel_msg = { + "channel": "trade:999", # different market + "data": {"price": "1000.0", "size": "0.1", "created_at": 1718914860}, + } + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(wrong_channel_msg)) + + self.listening_task = asyncio.create_task(self.data_feed.listen_for_subscriptions()) + await self.mocking_assistant.run_until_all_aiohttp_messages_delivered(ws_connect_mock.return_value) + await asyncio.sleep(0.3) + + self.assertEqual(len(self.data_feed._candles), 0) + self.assertEqual(fetch_candles_mock.call_count, 1) + + def test_get_exchange_trading_pair_returns_pair_unchanged(self): + """For Lighter, the exchange symbol is identical to the internal trading pair.""" + self.assertEqual( + self.data_feed.get_exchange_trading_pair("ETH-USDC"), "ETH-USDC") + self.assertEqual( + self.data_feed.get_exchange_trading_pair("BTC-USDC"), "BTC-USDC") + + # ------------------------------------------------------------------ # + # Additional branch coverage for missing CI lines # + # ------------------------------------------------------------------ # + + def test_rest_url_property(self): + """rest_url must return the REST_URL constant (covers line 73).""" + from hummingbot.data_feed.candles_feed.lighter_perpetual_candles.lighter_perpetual_candles import REST_URL + self.assertEqual(REST_URL, self.data_feed.rest_url) + + def test_parse_websocket_message_returns_none(self): + """_parse_websocket_message always returns None (covers line 177).""" + self.assertIsNone(self.data_feed._parse_websocket_message({})) + self.assertIsNone(self.data_feed._parse_websocket_message({"channel": "trade:200", "data": {}})) + + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fetch_candles", new_callable=AsyncMock) + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase._time") + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + async def test_listen_for_subscriptions_handles_connection_error( + self, ws_connect_mock, mock_time, fetch_candles_mock): + """ConnectionError during WS session must be caught and logged (covers lines 194, 208).""" + mock_time.return_value = time.time() + fetch_candles_mock.return_value = np.array([]) + + call_count = 0 + + async def side_effect(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise ConnectionError("test connection error") + return self.mocking_assistant.create_websocket_mock() + + ws_connect_mock.side_effect = side_effect + + self.listening_task = asyncio.create_task(self.data_feed.listen_for_subscriptions()) + await asyncio.sleep(0.3) + self.listening_task.cancel() + try: + await self.listening_task + except asyncio.CancelledError: + pass + + self.assertTrue(self.is_logged("WARNING", "The websocket connection was closed (test connection error)")) + + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fetch_candles", new_callable=AsyncMock) + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + async def test_process_websocket_messages_ignores_non_dict_data( + self, ws_connect_mock, fetch_candles_mock): + """Non-dict WS messages must be silently skipped (covers line 220).""" + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + fetch_candles_mock.return_value = np.array([]) + + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message="not-a-dict-message", + message_type="text", + ) + + self.listening_task = asyncio.create_task(self.data_feed.listen_for_subscriptions()) + await self.mocking_assistant.run_until_all_aiohttp_messages_delivered(ws_connect_mock.return_value) + await asyncio.sleep(0.2) + + self.assertEqual(len(self.data_feed._candles), 0) diff --git a/test/hummingbot/data_feed/candles_feed/lighter_spot_candles/test_lighter_spot_candles.py b/test/hummingbot/data_feed/candles_feed/lighter_spot_candles/test_lighter_spot_candles.py new file mode 100644 index 00000000000..8347437ac33 --- /dev/null +++ b/test/hummingbot/data_feed/candles_feed/lighter_spot_candles/test_lighter_spot_candles.py @@ -0,0 +1,512 @@ +import asyncio +import json +import re +import time +from test.hummingbot.data_feed.candles_feed.test_candles_base import TestCandlesBase +from unittest.mock import AsyncMock, patch + +import numpy as np +from aioresponses import aioresponses + +from hummingbot.core.network_iterator import NetworkStatus +from hummingbot.data_feed.candles_feed.lighter_spot_candles import LighterSpotCandles + + +class TestLighterSpotCandles(TestCandlesBase): + __test__ = True + level = 0 + + # Market ID used throughout tests (normally resolved by initialize_exchange_data via REST) + MARKET_ID = 100 + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.base_asset = "BTC" + cls.quote_asset = "USDC" + cls.interval = "1h" + cls.trading_pair = f"{cls.base_asset}-{cls.quote_asset}" + cls.ex_trading_pair = f"{cls.base_asset}-{cls.quote_asset}" # Lighter returns trading_pair as-is + cls.max_records = 150 + + def setUp(self) -> None: + super().setUp() + self.data_feed = LighterSpotCandles( + trading_pair=self.trading_pair, + interval=self.interval, + max_records=self.max_records, + ) + # Bypass initialize_exchange_data — set market_id directly + self.data_feed._market_id = self.MARKET_ID + self.log_records = [] + self.data_feed.logger().setLevel(1) + self.data_feed.logger().addHandler(self) + + def tearDown(self) -> None: + self.data_feed.stop() + super().tearDown() + + # ── Required data mocks ──────────────────────────────────────────────────── + + @staticmethod + def get_candles_rest_data_mock(): + """REST API response format: {"c": [...candle dicts...]}""" + return { + "c": [ + {"t": 1718895600000, "o": "64942.0", "h": "65123.0", "l": "64812.0", "c": "64837.0", + "v": "190.58", "V": "12345.0", "i": 100}, + {"t": 1718899200000, "o": "64837.0", "h": "64964.0", "l": "64564.0", "c": "64898.0", + "v": "271.68", "V": "17654.0", "i": 200}, + {"t": 1718902800000, "o": "64900.0", "h": "65034.0", "l": "64714.0", "c": "64997.0", + "v": "104.80", "V": "6810.0", "i": 150}, + {"t": 1718906400000, "o": "64999.0", "h": "65244.0", "l": "64981.0", "c": "65157.0", + "v": "158.51", "V": "10310.0", "i": 175}, + {"t": 1718910000000, "o": "65153.0", "h": "65153.0", "l": "64882.0", "c": "65095.0", + "v": "209.75", "V": "13650.0", "i": 190}, + ] + } + + def get_fetch_candles_data_mock(self): + """Expected output from _parse_rest_candles: rows of [ts, o, h, l, c, v, V, i, 0, 0]""" + return [ + [1718895600.0, 64942.0, 65123.0, 64812.0, 64837.0, 190.58, 12345.0, 100.0, 0.0, 0.0], + [1718899200.0, 64837.0, 64964.0, 64564.0, 64898.0, 271.68, 17654.0, 200.0, 0.0, 0.0], + [1718902800.0, 64900.0, 65034.0, 64714.0, 64997.0, 104.80, 6810.0, 150.0, 0.0, 0.0], + [1718906400.0, 64999.0, 65244.0, 64981.0, 65157.0, 158.51, 10310.0, 175.0, 0.0, 0.0], + [1718910000.0, 65153.0, 65153.0, 64882.0, 65095.0, 209.75, 13650.0, 190.0, 0.0, 0.0], + ] + + def get_candles_ws_data_mock_1(self): + """Lighter WS trade event — triggers REST fetch for latest candle.""" + return { + "channel": f"trade:{self.MARKET_ID}", + "data": {"price": "65162.0", "size": "0.1", "created_at": 1718914860}, + } + + def get_candles_ws_data_mock_2(self): + """Second trade event with a later timestamp → new candle bucket.""" + return { + "channel": f"trade:{self.MARKET_ID}", + "data": {"price": "65200.0", "size": "0.2", "created_at": 1718918460}, + } + + @staticmethod + def _success_subscription_mock(): + """Server-sent subscription confirmation (channel uses '/' — won't match trade filter).""" + return {"type": "subscribed", "channel": "trade/100"} + + # ── Test overrides ───────────────────────────────────────────────────────── + + @aioresponses() + async def test_fetch_candles(self, mock_api): + """GET-based fetch: mock with mock_api.get and verify parsed shape.""" + regex_url = re.compile( + f"^{self.data_feed.candles_url}".replace(".", r"\.").replace("?", r"\?")) + mock_api.get(url=regex_url, body=json.dumps(self.get_candles_rest_data_mock())) + + resp = await self.data_feed.fetch_candles( + start_time=int(self.start_time), + end_time=int(self.end_time), + ) + + self.assertEqual(resp.shape[0], len(self.get_fetch_candles_data_mock())) + self.assertEqual(resp.shape[1], 10) + + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fetch_candles", new_callable=AsyncMock) + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase._time") + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + async def test_listen_for_subscriptions_subscribes_to_klines( + self, ws_connect_mock, mock_time, fetch_candles_mock): + """Verify subscription payload is sent; mock fetch_candles to avoid real network seed call.""" + mock_time.return_value = time.time() + fetch_candles_mock.return_value = np.array([]) # seed returns empty, no network needed + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(self._success_subscription_mock())) + + self.listening_task = asyncio.create_task(self.data_feed.listen_for_subscriptions()) + + await self.mocking_assistant.run_until_all_aiohttp_messages_delivered(ws_connect_mock.return_value) + await asyncio.sleep(0.1) + + sent = self.mocking_assistant.json_messages_sent_through_websocket( + websocket_mock=ws_connect_mock.return_value) + + self.assertEqual(1, len(sent)) + expected = self.data_feed.ws_subscription_payload() + self.assertEqual(expected, sent[0]) + self.assertTrue(self.is_logged("INFO", "Subscribed to public klines...")) + + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fill_historical_candles", + new_callable=AsyncMock) + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fetch_candles", + new_callable=AsyncMock) + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + async def test_process_websocket_messages_empty_candle( + self, ws_connect_mock, fetch_candles_mock, fill_historical_candles_mock): + """ + When a trade event arrives and the candle deque is empty, the REST-fetched + candle is appended and fill_historical_candles is triggered. + """ + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + + candle = np.array([[1718914860.0, 65162.0, 65200.0, 65100.0, 65162.0, 0.1, 0.0, 1.0, 0.0, 0.0]]) + # First call = seed (empty); second call = trade-event REST fetch + fetch_candles_mock.side_effect = [np.array([]), candle] + + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(self.get_candles_ws_data_mock_1())) + + self.listening_task = asyncio.create_task(self.data_feed.listen_for_subscriptions()) + + await self.mocking_assistant.run_until_all_aiohttp_messages_delivered(ws_connect_mock.return_value) + await asyncio.sleep(0.3) + + self.assertEqual(self.data_feed.candles_df.shape[0], 1) + self.assertEqual(self.data_feed.candles_df.shape[1], 10) + fill_historical_candles_mock.assert_called_once() + + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fill_historical_candles", + new_callable=AsyncMock) + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fetch_candles", + new_callable=AsyncMock) + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + async def test_process_websocket_messages_duplicated_candle_not_included( + self, ws_connect_mock, fetch_candles_mock, fill_historical_candles_mock): + """Two identical trade events must not insert duplicate candles.""" + fill_historical_candles_mock.return_value = None + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + + candle = np.array([[1718914860.0, 65162.0, 65200.0, 65100.0, 65162.0, 0.1, 0.0, 1.0, 0.0, 0.0]]) + # seed empty; both trade events return the same candle + fetch_candles_mock.side_effect = [np.array([]), candle, candle] + + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(self.get_candles_ws_data_mock_1())) + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(self.get_candles_ws_data_mock_1())) + + self.listening_task = asyncio.create_task(self.data_feed.listen_for_subscriptions()) + + await self.mocking_assistant.run_until_all_aiohttp_messages_delivered( + ws_connect_mock.return_value, timeout=2) + await asyncio.sleep(0.3) + + self.assertEqual(self.data_feed.candles_df.shape[0], 1) + self.assertEqual(self.data_feed.candles_df.shape[1], 10) + + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fill_historical_candles", + new_callable=AsyncMock) + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fetch_candles", + new_callable=AsyncMock) + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + async def test_process_websocket_messages_with_two_valid_messages( + self, ws_connect_mock, fetch_candles_mock, _): + """Two trade events at different timestamps each produce a new candle row.""" + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + + candle1 = np.array([[1718914860.0, 65162.0, 65200.0, 65100.0, 65162.0, 0.1, 0.0, 1.0, 0.0, 0.0]]) + candle2 = np.array([[1718918460.0, 65200.0, 65250.0, 65100.0, 65200.0, 0.2, 0.0, 2.0, 0.0, 0.0]]) + fetch_candles_mock.side_effect = [np.array([]), candle1, candle2] + + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(self.get_candles_ws_data_mock_1())) + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(self.get_candles_ws_data_mock_2())) + + self.listening_task = asyncio.create_task(self.data_feed.listen_for_subscriptions()) + + await self.mocking_assistant.run_until_all_aiohttp_messages_delivered(ws_connect_mock.return_value) + await asyncio.sleep(0.3) + + self.assertEqual(self.data_feed.candles_df.shape[0], 2) + self.assertEqual(self.data_feed.candles_df.shape[1], 10) + + # ── Unit tests for Lighter-specific logic ────────────────────────────────── + + def test_name_property(self): + """name should be lighter_{trading_pair}.""" + self.assertEqual(self.data_feed.name, f"lighter_{self.trading_pair}") + + def test_ws_subscription_payload(self): + """Subscription uses 'trade/{market_id}' with a slash.""" + payload = self.data_feed.ws_subscription_payload() + self.assertEqual(payload["type"], "subscribe") + self.assertEqual(payload["channel"], f"trade/{self.MARKET_ID}") + + def test_parse_rest_candles_field_mapping(self): + """Verify every REST JSON field maps to the correct column position.""" + data = { + "c": [ + {"t": 1718895600000, "o": "100.0", "h": "110.0", "l": "90.0", "c": "105.0", + "v": "50.0", "V": "5000.0", "i": 42}, + ] + } + result = self.data_feed._parse_rest_candles(data) + self.assertEqual(len(result), 1) + row = result[0] + # Columns: timestamp, open, high, low, close, volume, quote_asset_volume, n_trades, tbv, tbqv + self.assertEqual(row[0], 1718895600.0) # t (ms) / 1000 → timestamp in seconds + self.assertEqual(row[1], 100.0) # o → open + self.assertEqual(row[2], 110.0) # h → high + self.assertEqual(row[3], 90.0) # l → low + self.assertEqual(row[4], 105.0) # c → close + self.assertEqual(row[5], 50.0) # v → volume + self.assertEqual(row[6], 5000.0) # V → quote_asset_volume + self.assertEqual(row[7], 42.0) # i → n_trades + self.assertEqual(row[8], 0.0) # taker_buy_base_volume (always 0) + self.assertEqual(row[9], 0.0) # taker_buy_quote_volume (always 0) + + def test_parse_rest_candles_end_time_filter(self): + """Rows whose timestamp exceeds end_time are excluded.""" + data = { + "c": [ + {"t": 1718895600000, "o": "100.0", "h": "110.0", "l": "90.0", "c": "105.0", + "v": "50.0", "V": "5000.0", "i": 1}, + {"t": 1718899200000, "o": "105.0", "h": "115.0", "l": "95.0", "c": "110.0", + "v": "60.0", "V": "6000.0", "i": 2}, # ts (in s after /1000) > end_time → excluded + ] + } + result = self.data_feed._parse_rest_candles(data, end_time=1718897000) + self.assertEqual(len(result), 1) + self.assertEqual(result[0][0], 1718895600.0) + + def test_parse_rest_candles_empty_response(self): + """Empty 'c' list returns empty list without errors.""" + self.assertEqual(self.data_feed._parse_rest_candles({"c": []}), []) + self.assertEqual(self.data_feed._parse_rest_candles({}), []) + + def test_get_rest_candles_params(self): + """REST params include market_id, resolution, timestamps, and count_back.""" + start_ts = 1718895600 + end_ts = 1718910000 + params = self.data_feed._get_rest_candles_params( + start_time=start_ts, end_time=end_ts, limit=100) + self.assertEqual(params["market_id"], self.MARKET_ID) + self.assertEqual(params["start_timestamp"], start_ts) + self.assertEqual(params["end_timestamp"], end_ts) + self.assertEqual(params["count_back"], 100) + self.assertEqual(params["set_timestamp_to_end"], "true") + self.assertIn("resolution", params) + + async def test_initialize_exchange_data_resolves_spot_market_id(self): + """initialize_exchange_data picks the spot market with correct symbol format {base}/{quote}.""" + order_books_response = { + "order_books": [ + {"market_type": "perp", "symbol": "BTC", "market_id": 999}, + {"market_type": "spot", "symbol": "ETH/USDC", "market_id": 101}, + {"market_type": "spot", "symbol": "BTC/USDC", "market_id": self.MARKET_ID}, + ] + } + with patch.object(self.data_feed._api_factory, "get_rest_assistant") as mock_get: + mock_rest = AsyncMock() + mock_get.return_value = mock_rest + mock_rest.execute_request.return_value = order_books_response + self.data_feed._market_id = None + await self.data_feed.initialize_exchange_data() + self.assertEqual(self.data_feed._market_id, self.MARKET_ID) + + async def test_initialize_exchange_data_ignores_perp_markets(self): + """A perp market with the same base symbol must not be selected.""" + order_books_response = { + "order_books": [ + {"market_type": "perp", "symbol": "BTC/USDC", "market_id": 999}, + ] + } + with patch.object(self.data_feed._api_factory, "get_rest_assistant") as mock_get: + mock_rest = AsyncMock() + mock_get.return_value = mock_rest + mock_rest.execute_request.return_value = order_books_response + self.data_feed._market_id = None + with self.assertRaises(ValueError): + await self.data_feed.initialize_exchange_data() + + async def test_initialize_exchange_data_raises_when_market_not_found(self): + """ValueError is raised when the requested spot symbol is absent.""" + order_books_response = { + "order_books": [ + {"market_type": "spot", "symbol": "ETH/USDC", "market_id": 101}, + ] + } + with patch.object(self.data_feed._api_factory, "get_rest_assistant") as mock_get: + mock_rest = AsyncMock() + mock_get.return_value = mock_rest + mock_rest.execute_request.return_value = order_books_response + self.data_feed._market_id = None + with self.assertRaises(ValueError) as ctx: + await self.data_feed.initialize_exchange_data() + self.assertIn("BTC/USDC", str(ctx.exception)) + + async def test_check_network_returns_connected(self): + """check_network() returns CONNECTED when the health-check endpoint responds.""" + with patch.object(self.data_feed._api_factory, "get_rest_assistant") as mock_get: + mock_rest = AsyncMock() + mock_get.return_value = mock_rest + mock_rest.execute_request.return_value = {"order_books": []} + result = await self.data_feed.check_network() + self.assertEqual(result, NetworkStatus.CONNECTED) + + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fill_historical_candles", + new_callable=AsyncMock) + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fetch_candles", + new_callable=AsyncMock) + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + async def test_seed_populates_deque_on_ws_connect( + self, ws_connect_mock, fetch_candles_mock, fill_historical_candles_mock): + """When the seed REST call returns candles, they are immediately loaded into the deque.""" + fill_historical_candles_mock.return_value = None + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + + seed_candles = np.array([ + [1718895600.0, 64942.0, 65123.0, 64812.0, 64837.0, 190.58, 12345.0, 100.0, 0.0, 0.0], + [1718899200.0, 64837.0, 64964.0, 64564.0, 64898.0, 271.68, 17654.0, 200.0, 0.0, 0.0], + ]) + # Seed returns data; no further WS messages → loop blocks waiting + fetch_candles_mock.return_value = seed_candles + + self.listening_task = asyncio.create_task(self.data_feed.listen_for_subscriptions()) + await asyncio.sleep(0.3) + + self.assertEqual(len(self.data_feed._candles), 2) + self.assertTrue(self.data_feed._ws_candle_available.is_set()) + self.listening_task.cancel() + + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fill_historical_candles", + new_callable=AsyncMock) + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fetch_candles", + new_callable=AsyncMock) + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + async def test_same_timestamp_trade_event_replaces_last_candle( + self, ws_connect_mock, fetch_candles_mock, fill_historical_candles_mock): + """A trade event at the same timestamp as the current open candle updates it in-place.""" + fill_historical_candles_mock.return_value = None + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + + ts = 1718914800.0 + original = np.array([ts, 65162.0, 65200.0, 65100.0, 65162.0, 0.1, 0.0, 1.0, 0.0, 0.0]) + self.data_feed._candles.append(original) + self.data_feed._ws_candle_available.set() + + # Same ts but updated high and close — should replace, not append + updated = np.array([[ts, 65162.0, 65300.0, 65100.0, 65250.0, 0.3, 0.0, 2.0, 0.0, 0.0]]) + # seed empty (deque already populated), trade event → returns updated candle + fetch_candles_mock.side_effect = [np.array([]), updated] + + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(self.get_candles_ws_data_mock_1())) + + self.listening_task = asyncio.create_task(self.data_feed.listen_for_subscriptions()) + await self.mocking_assistant.run_until_all_aiohttp_messages_delivered(ws_connect_mock.return_value) + await asyncio.sleep(0.3) + + self.assertEqual(len(self.data_feed._candles), 1) # not appended + self.assertEqual(self.data_feed._candles[-1][2], 65300.0) # high updated + self.assertEqual(self.data_feed._candles[-1][4], 65250.0) # close updated + + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fetch_candles", + new_callable=AsyncMock) + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + async def test_wrong_channel_message_is_ignored(self, ws_connect_mock, fetch_candles_mock): + """Trade events for a different market_id channel must not trigger a REST fetch.""" + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + fetch_candles_mock.return_value = np.array([]) # seed returns empty + + wrong_channel_msg = { + "channel": "trade:999", # different market + "data": {"price": "1000.0", "size": "0.1", "created_at": 1718914860}, + } + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(wrong_channel_msg)) + + self.listening_task = asyncio.create_task(self.data_feed.listen_for_subscriptions()) + await self.mocking_assistant.run_until_all_aiohttp_messages_delivered(ws_connect_mock.return_value) + await asyncio.sleep(0.3) + + # Deque still empty — wrong channel was ignored + self.assertEqual(len(self.data_feed._candles), 0) + # fetch_candles called exactly once for the seed, not again for the wrong-channel event + self.assertEqual(fetch_candles_mock.call_count, 1) + + def test_get_exchange_trading_pair_returns_pair_unchanged(self): + """For Lighter, the exchange symbol is identical to the internal trading pair.""" + self.assertEqual( + self.data_feed.get_exchange_trading_pair("ETH-USDC"), "ETH-USDC") + self.assertEqual( + self.data_feed.get_exchange_trading_pair("BTC-USDC"), "BTC-USDC") + + # ------------------------------------------------------------------ # + # Additional branch coverage for missing CI lines # + # ------------------------------------------------------------------ # + + def test_rest_url_property(self): + """rest_url must return the REST_URL constant (covers line 73).""" + from hummingbot.data_feed.candles_feed.lighter_spot_candles.lighter_spot_candles import REST_URL + self.assertEqual(REST_URL, self.data_feed.rest_url) + + def test_parse_websocket_message_returns_none(self): + """_parse_websocket_message always returns None (covers line 179).""" + self.assertIsNone(self.data_feed._parse_websocket_message({})) + self.assertIsNone(self.data_feed._parse_websocket_message({"channel": "trade:100", "data": {}})) + + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fetch_candles", new_callable=AsyncMock) + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase._time") + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + async def test_listen_for_subscriptions_handles_connection_error( + self, ws_connect_mock, mock_time, fetch_candles_mock): + """ConnectionError during WS session must be caught and logged (covers lines 196, 210).""" + mock_time.return_value = time.time() + fetch_candles_mock.return_value = np.array([]) + + # First call raises ConnectionError; second call blocks forever so the task can be cancelled + call_count = 0 + + async def side_effect(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise ConnectionError("test connection error") + # Second iteration: return a mock that never yields messages + return self.mocking_assistant.create_websocket_mock() + + ws_connect_mock.side_effect = side_effect + + self.listening_task = asyncio.create_task(self.data_feed.listen_for_subscriptions()) + await asyncio.sleep(0.3) + self.listening_task.cancel() + try: + await self.listening_task + except asyncio.CancelledError: + pass + + self.assertTrue(self.is_logged("WARNING", "The websocket connection was closed (test connection error)")) + + @patch("hummingbot.data_feed.candles_feed.candles_base.CandlesBase.fetch_candles", new_callable=AsyncMock) + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + async def test_process_websocket_messages_ignores_non_dict_data( + self, ws_connect_mock, fetch_candles_mock): + """Non-dict WS messages must be silently skipped (covers line 222).""" + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + fetch_candles_mock.return_value = np.array([]) + + # Send a non-dict raw string message + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message="not-a-dict-message", + message_type="text", + ) + + self.listening_task = asyncio.create_task(self.data_feed.listen_for_subscriptions()) + await self.mocking_assistant.run_until_all_aiohttp_messages_delivered(ws_connect_mock.return_value) + await asyncio.sleep(0.2) + + # Candle deque still empty — non-dict message was ignored + self.assertEqual(len(self.data_feed._candles), 0) diff --git a/test/hummingbot/data_feed/candles_feed/test_candles_factory.py b/test/hummingbot/data_feed/candles_feed/test_candles_factory.py index 931fadbd9d9..f81b7465a45 100644 --- a/test/hummingbot/data_feed/candles_feed/test_candles_factory.py +++ b/test/hummingbot/data_feed/candles_feed/test_candles_factory.py @@ -4,6 +4,12 @@ from hummingbot.data_feed.candles_feed.binance_spot_candles import BinanceSpotCandles from hummingbot.data_feed.candles_feed.candles_factory import CandlesFactory from hummingbot.data_feed.candles_feed.data_types import CandlesConfig +from hummingbot.data_feed.candles_feed.hyperliquid_perpetual_candles.hyperliquid_perpetual_candles import ( + HyperliquidPerpetualCandles, +) +from hummingbot.data_feed.candles_feed.hyperliquid_spot_candles.hyperliquid_spot_candles import HyperliquidSpotCandles +from hummingbot.data_feed.candles_feed.lighter_perpetual_candles import LighterPerpetualCandles +from hummingbot.data_feed.candles_feed.lighter_spot_candles import LighterSpotCandles class TestCandlesFactory(unittest.TestCase): @@ -25,6 +31,24 @@ def test_get_binance_candles_perpetuals(self): self.assertIsInstance(candles, BinancePerpetualCandles) candles.stop() + def test_get_lighter_candles_spot(self): + candles = CandlesFactory.get_candle(CandlesConfig( + connector="lighter", + trading_pair="BTC-USDC", + interval="1h" + )) + self.assertIsInstance(candles, LighterSpotCandles) + candles.stop() + + def test_get_lighter_candles_perpetual(self): + candles = CandlesFactory.get_candle(CandlesConfig( + connector="lighter_perpetual", + trading_pair="BTC-USDC", + interval="1h" + )) + self.assertIsInstance(candles, LighterPerpetualCandles) + candles.stop() + def test_get_non_existing_candles(self): with self.assertRaises(Exception): CandlesFactory.get_candle(CandlesConfig( @@ -32,3 +56,23 @@ def test_get_non_existing_candles(self): trading_pair="BTC-USDT", interval="1m" )) + + def test_get_hyperliquid_candles_spot(self): + candles = CandlesFactory.get_candle(CandlesConfig( + connector="hyperliquid", + trading_pair="ETH-USDC", + interval="1m", + max_records=50, + )) + self.assertIsInstance(candles, HyperliquidSpotCandles) + candles.stop() + + def test_get_hyperliquid_candles_perpetual(self): + candles = CandlesFactory.get_candle(CandlesConfig( + connector="hyperliquid_perpetual", + trading_pair="ETH-USDC", + interval="3m", + max_records=50, + )) + self.assertIsInstance(candles, HyperliquidPerpetualCandles) + candles.stop()