diff --git a/hummingbot/connector/derivative/aevo_perpetual/__init__.py b/hummingbot/connector/derivative/aevo_perpetual/__init__.py new file mode 100644 index 00000000000..fe396f0d303 --- /dev/null +++ b/hummingbot/connector/derivative/aevo_perpetual/__init__.py @@ -0,0 +1,5 @@ +from .aevo_perpetual_derivative import AevoPerpetualDerivative + +__all__ = [ + "AevoPerpetualDerivative", +] diff --git a/hummingbot/connector/derivative/aevo_perpetual/aevo_perpetual_api_order_book_data_source.py b/hummingbot/connector/derivative/aevo_perpetual/aevo_perpetual_api_order_book_data_source.py new file mode 100644 index 00000000000..91d94a14130 --- /dev/null +++ b/hummingbot/connector/derivative/aevo_perpetual/aevo_perpetual_api_order_book_data_source.py @@ -0,0 +1,140 @@ +import asyncio +from typing import Any, Dict, List, Optional +from hummingbot.core.data_type.order_book_message import OrderBookMessage +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.connector.derivative.aevo_perpetual import aevo_perpetual_constants as CONSTANTS +from hummingbot.connector.derivative.aevo_perpetual import aevo_perpetual_utils as utils + +from hummingbot.core.api_throttler.async_throttler import AsyncThrottler +from hummingbot.connector.derivative.aevo_perpetual.aevo_perpetual_auth import AevoPerpetualAuth +from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource + +class AevoPerpetualAPIOrderBookDataSource(OrderBookTrackerDataSource): + def __init__(self, + trading_pairs: List[str], + domain: str = "aevo", + api_factory: Optional[Any] = None, + throttler: Optional[AsyncThrottler] = None, + time_synchronizer: Optional[Any] = None): + super().__init__(trading_pairs) + self._domain = domain + self._throttler = throttler + self._api_factory = api_factory + self._time_synchronizer = time_synchronizer + + async def get_last_traded_prices(self, trading_pairs: List[str], domain: Optional[str] = None) -> Dict[str, float]: + return await self._get_last_traded_prices(trading_pairs) + + async def _get_last_traded_prices(self, trading_pairs: List[str]) -> Dict[str, float]: + res = await self._api_factory.call_rest( + method="GET", + url=f"{CONSTANTS.AEVO_BASE_URL}{CONSTANTS.TICKER_PATH_URL}" + ) + # Aevo returns list of tickers. Map 'instrument_name' to price. + # Example response: [{"instrument_name": "ETH-PERP", "mark_price": "2000.5", ...}, ...] + results = {} + for market in res: + name = market.get("instrument_name", "") # e.g., ETH-PERP + hb_name = utils.convert_to_hb_symbol(name) + if "mark_price" in market: + results[hb_name] = float(market["mark_price"]) + return results + + async def get_new_order_book(self, trading_pair: str) -> OrderBook: + # Aevo Orderbook Endpoint: /order_book?instrument_name=... + exchange_symbol = utils.convert_to_exchange_symbol(trading_pair) + params = {"instrument_name": exchange_symbol} + snapshot = await self._api_factory.call_rest( + method="GET", + url=f"{CONSTANTS.AEVO_BASE_URL}{CONSTANTS.SNAPSHOT_PATH_URL}", + params=params + ) + # Snapshot structure: {"bids": [[price, size], ...], "asks": ...} + # Convert to OrderBookMessage or OrderBook object + # Note: Hummingbot expects specific mapping, usually handled by message parser. + # For now, we return the raw snapshot or an OrderBook object depending on base class calc. + # Check base class: OrderBookTrackerDataSource usually returns OrderBookMessage from snapshot. + from hummingbot.core.data_type.order_book_message import OrderBookMessage, OrderBookMessageType + + timestamp = snapshot.get("timestamp", self._time_synchronizer.time() * 1e9) + return OrderBookMessage( + OrderBookMessageType.SNAPSHOT, + { + "trading_pair": trading_pair, + "update_id": int(timestamp), + "bids": snapshot.get("bids", []), + "asks": snapshot.get("asks", []) + }, + timestamp=timestamp * 1e-9 + ) + + + async def listen_for_subscriptions(self): + ws = None + while True: + try: + ws = await self._api_factory.get_ws_connection(CONSTANTS.AEVO_WS_URL) + await ws.connect() + + # Subscribe to channels + for pair in self._trading_pairs: + subscribe_request = { + "op": "subscribe", + "data": [ + f"{CONSTANTS.WS_TOPIC_ORDERBOOK}:{pair}", + f"{CONSTANTS.WS_TOPIC_TRADES}:{pair}" + ] + } + await ws.send_json(subscribe_request) + + async for msg in ws.iter_messages(): + if msg.data: + data = msg.json() + channel = data.get("channel") + + if channel and channel.startswith(CONSTANTS.WS_TOPIC_ORDERBOOK): + # Parse Order Book Snapshot/Update + # Aevo sends full snapshots or updates. Assuming snapshot for simplicity or parsing both via same logic if format aligns + payload = data.get("data", {}) + if payload.get("type") == "snapshot": + order_book_message = OrderBookMessage( + OrderBookMessageType.SNAPSHOT, + { + "trading_pair": channel.split(":")[-1], + "update_id": int(payload.get("timestamp", self._time_synchronizer.time() * 1e9)), + "bids": payload.get("bids", []), + "asks": payload.get("asks", []) + }, + timestamp=payload.get("timestamp", self._time_synchronizer.time() * 1e9) * 1e-9 + ) + self._message_queue.put_nowait(order_book_message) + + elif channel and channel.startswith(CONSTANTS.WS_TOPIC_TRADES): + # Parse Trades + payload = data.get("data", []) + # Payload might be a list of trades + for trade in payload: + trade_msg = OrderBookMessage( + OrderBookMessageType.TRADE, + { + "trading_pair": channel.split(":")[-1], + "trade_type": 1.0 if trade.get("side", "").lower() == "buy" else 2.0, + "trade_id": trade.get("trade_id"), + "update_id": int(trade.get("timestamp", 0)), + "price": trade.get("price"), + "amount": trade.get("amount") + }, + timestamp=trade.get("timestamp", self._time_synchronizer.time() * 1e9) * 1e-9 + ) + self._message_queue.put_nowait(trade_msg) + + except asyncio.CancelledError: + raise + except Exception as e: + # Log error and reconnect + self.logger().error(f"WS Error: {e}", exc_info=True) + await asyncio.sleep(5) + finally: + if ws: + await ws.disconnect() + diff --git a/hummingbot/connector/derivative/aevo_perpetual/aevo_perpetual_auth.py b/hummingbot/connector/derivative/aevo_perpetual/aevo_perpetual_auth.py new file mode 100644 index 00000000000..0e869cbfa45 --- /dev/null +++ b/hummingbot/connector/derivative/aevo_perpetual/aevo_perpetual_auth.py @@ -0,0 +1,80 @@ +import hmac +import hashlib +import time +import json +from typing import Dict, Any, Optional +from hummingbot.connector.time_synchronizer import TimeSynchronizer +from hummingbot.core.web_assistant.auth import AuthBase +from hummingbot.core.web_assistant.connections.data_types import RESTRequest, WSRequest + +class AevoPerpetualAuth(AuthBase): + def __init__(self, api_key: str, api_secret: str, time_provider: TimeSynchronizer): + self.api_key = api_key + self.api_secret = api_secret + self.time_provider = time_provider + + async def rest_authenticate(self, request: RESTRequest) -> RESTRequest: + headers = {} + if request.headers: + headers.update(request.headers) + + # Precision is Key! Aevo expects nanoseconds. + # Ensure we multiply by 1e9 and cast to int before string. + timestamp = str(int(self.time_provider.time() * 1e9)) + signature = self._generate_signature(timestamp, request.method, request.url, request.data) + + headers.update({ + "AEVO-ACCESS-KEY": self.api_key, + "AEVO-ACCESS-SIG": signature, + "AEVO-ACCESS-TIMESTAMP": timestamp, + }) + request.headers = headers + return request + + async def ws_authenticate(self, request: WSRequest) -> WSRequest: + return request # Websocket auth often handled differently, checking docs + + def _generate_signature(self, timestamp: str, method: str, url: str, data: Optional[Dict[str, Any]]) -> str: + # Aevo specific signature generation (check docs for exact format) + # Typically: HMAC-SHA256(secret, timestamp + method + path + body) + # Aevo expects: timestamp + method + path + body + # URL parsing to extract path + query + path = url + if "https://" in url: + path = "/" + url.split("https://")[-1].split("/", 1)[-1] + elif "http://" in url: + path = "/" + url.split("http://")[-1].split("/", 1)[-1] + + payload = f"{timestamp}{method.upper()}{path}" + if data: + payload += json.dumps(data, separators=(',', ':')) + + return hmac.new( + self.api_secret.encode("utf-8"), + payload.encode("utf-8"), + hashlib.sha256 + ).hexdigest() + + def get_ws_auth_payload(self) -> Dict[str, Any]: + # WebSocket Auth (Standard Aevo Pattern) + timestamp = str(int(self.time_provider.time() * 1e9)) + + # Signing value often differs for WS. + # Checking similar exchanges, often it's just timestamp or specific string. + # For Aevo, let's assume it signs the timestamp similar to REST but without method/url. + # If docs say otherwise, we adjust. + # Payload for sig: timestamp + signature = hmac.new( + self.api_secret.encode("utf-8"), + timestamp.encode("utf-8"), + hashlib.sha256 + ).hexdigest() + + return { + "op": "auth", + "data": { + "key": self.api_key, + "sig": signature, + "timestamp": timestamp + } + } diff --git a/hummingbot/connector/derivative/aevo_perpetual/aevo_perpetual_constants.py b/hummingbot/connector/derivative/aevo_perpetual/aevo_perpetual_constants.py new file mode 100644 index 00000000000..ab2d4d37a2f --- /dev/null +++ b/hummingbot/connector/derivative/aevo_perpetual/aevo_perpetual_constants.py @@ -0,0 +1,29 @@ +from hummingbot.core.api_throttler.data_types import RateLimit + +AEVO_BASE_URL = "https://api.aevo.xyz" +AEVO_WS_URL = "wss://ws.aevo.xyz" + +# Public Endpoints +SNAPSHOT_PATH_URL = "/order_book" +TICKER_PATH_URL = "/markets" +INSTRUMENT_PATH_URL = "/markets" +TRADES_PATH_URL = "/trades" + +# Private Endpoints +ORDER_PATH_URL = "/orders" +ACCOUNT_PATH_URL = "/account" +POSITIONS_PATH_URL = "/positions" + +# Websocket Topics +WS_TOPIC_ORDERBOOK = "orderbook" +WS_TOPIC_TRADES = "trades" +WS_TOPIC_TICKER = "ticker" + +# Rate Limits +RATE_LIMITS = [ + RateLimit(limit_id=SNAPSHOT_PATH_URL, limit=100, time_interval=10), + RateLimit(limit_id=TICKER_PATH_URL, limit=100, time_interval=10), + RateLimit(limit_id=TRADES_PATH_URL, limit=100, time_interval=10), + RateLimit(limit_id=ORDER_PATH_URL, limit=50, time_interval=10), + RateLimit(limit_id=ACCOUNT_PATH_URL, limit=50, time_interval=10), +] diff --git a/hummingbot/connector/derivative/aevo_perpetual/aevo_perpetual_derivative.py b/hummingbot/connector/derivative/aevo_perpetual/aevo_perpetual_derivative.py new file mode 100644 index 00000000000..65bea12fd25 --- /dev/null +++ b/hummingbot/connector/derivative/aevo_perpetual/aevo_perpetual_derivative.py @@ -0,0 +1,130 @@ +from typing import Tuple, Any, Dict, Optional, List +from hummingbot.connector.derivative.derivative_base import DerivativeBase +from hummingbot.connector.derivative.aevo_perpetual import aevo_perpetual_constants as CONSTANTS +from hummingbot.connector.derivative.aevo_perpetual.aevo_perpetual_api_order_book_data_source import AevoPerpetualAPIOrderBookDataSource +from hummingbot.connector.derivative.aevo_perpetual.aevo_perpetual_user_stream_data_source import AevoPerpetualUserStreamDataSource +from hummingbot.connector.derivative.aevo_perpetual import aevo_perpetual_utils as utils +from hummingbot.connector.derivative.aevo_perpetual.aevo_perpetual_auth import AevoPerpetualAuth +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory +from hummingbot.core.web_assistant.connections.data_types import RESTMethod +from hummingbot.core.data_type.common import OrderType +import asyncio + + +class AevoPerpetualDerivative(DerivativeBase): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._domain = "aevo" + self._auth: AevoPerpetualAuth = None + self._web_assistants_factory: WebAssistantsFactory = None + + @property + def name(self) -> str: + return "aevo_perpetual" + + @property + def supported_order_types(self) -> List[OrderType]: + return [OrderType.LIMIT, OrderType.MARKET] + + def _create_web_assistants_factory(self) -> WebAssistantsFactory: + return WebAssistantsFactory( + throttler=self._throttler, + auth=self._auth + ) + + async def _make_trading_rules_request(self) -> Any: + return await self._api_factory.call_rest( + method="GET", + url=f"{CONSTANTS.AEVO_BASE_URL}{CONSTANTS.INSTRUMENT_PATH_URL}" + ) + + async def _make_trading_pairs_request(self) -> Any: + return await self._api_factory.call_rest( + method="GET", + url=f"{CONSTANTS.AEVO_BASE_URL}{CONSTANTS.INSTRUMENT_PATH_URL}" + ) + + @property + def authenticator(self): + return self._auth + + @property + def rate_limits_rules(self): + return CONSTANTS.RATE_LIMITS + + async def start_network(self): + await self._stop_network() + self._stop_network_task = asyncio.create_task(self._start_network()) + + def _create_order_book_data_source(self): + return AevoPerpetualAPIOrderBookDataSource( + trading_pairs=self._trading_pairs, + domain=self._domain, + api_factory=self._web_assistants_factory, + throttler=self._throttler, + time_synchronizer=self._time_synchronizer) + + def _create_user_stream_data_source(self): + return AevoPerpetualUserStreamDataSource( + auth=self._auth, + trading_pairs=self._trading_pairs, + api_factory=self._web_assistants_factory, + domain=self._domain) + + async def _start_network(self): + self._order_book_tracker.start() + self._user_stream_tracker.start() + self._status_polling_task = asyncio.create_task(self._status_polling_loop()) + + async def _stop_network(self): + self._order_book_tracker.stop() + self._user_stream_tracker.stop() + if self._status_polling_task is not None: + self._status_polling_task.cancel() + + + async def _place_order(self, + order_id: str, + trading_pair: str, + amount: float, + trade_type: str, + order_type: str, + price: float, + **kwargs) -> Tuple[str, float]: + exchange_symbol = utils.convert_to_exchange_symbol(trading_pair) + params = { + "instrument_name": exchange_symbol, + "is_buy": trade_type.upper() == "BUY", + "limit_price": str(price), + "quantity": str(amount), + "post_only": kwargs.get("post_only", False), + "reduce_only": kwargs.get("reduce_only", False), + "time_in_force": kwargs.get("time_in_force", "GTC"), + # "client_order_id": order_id + # NOTE: client_order_id support in Aevo docs is sparse. + # Keeping it commented out until verified with live keys. + } + + # Determine endpoint based on order type if needed, or just standard /orders + response = await self._api_factory.call_rest( + method="POST", + url=f"{CONSTANTS.AEVO_BASE_URL}{CONSTANTS.ORDER_PATH_URL}", + data=params, + is_auth_required=True + ) + + # Parse response to get exchange order ID and timestamp + exchange_order_id = str(response.get("order_id", order_id)) + transact_time = float(response.get("timestamp", self._time_synchronizer.time() * 1e9)) * 1e-9 + + return exchange_order_id, transact_time + + async def _cancel_order(self, order_id: str, trading_pair: str, timestamp: float) -> Any: + # Aevo Cancel: DELETE /orders/{order_id} + response = await self._api_factory.call_rest( + method="DELETE", + url=f"{CONSTANTS.AEVO_BASE_URL}{CONSTANTS.ORDER_PATH_URL}/{order_id}", + is_auth_required=True + ) + return response + diff --git a/hummingbot/connector/derivative/aevo_perpetual/aevo_perpetual_user_stream_data_source.py b/hummingbot/connector/derivative/aevo_perpetual/aevo_perpetual_user_stream_data_source.py new file mode 100644 index 00000000000..e77d530bb51 --- /dev/null +++ b/hummingbot/connector/derivative/aevo_perpetual/aevo_perpetual_user_stream_data_source.py @@ -0,0 +1,54 @@ +import asyncio +from typing import List, Optional +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory +from hummingbot.connector.derivative.aevo_perpetual import aevo_perpetual_constants as CONSTANTS + +class AevoPerpetualUserStreamDataSource(UserStreamTrackerDataSource): + def __init__(self, + auth, + trading_pairs: List[str], + api_factory: Optional[WebAssistantsFactory] = None, + domain: str = "aevo"): + super().__init__() + self._auth = auth + self._trading_pairs = trading_pairs + self._api_factory = api_factory + self._domain = domain + + async def _listen_to_user_messages(self, output: asyncio.Queue): + ws = None + while True: + try: + # Use api_factory to get authenticated WS connection if supported, or manual auth + ws = await self._api_factory.get_ws_connection(CONSTANTS.AEVO_WS_URL) + await ws.connect() + + # Authenticate + auth_payload = self._auth.get_ws_auth_payload() + await ws.send_json(auth_payload) + + # Subscribe to private channels + channels = [ + "orders", + "fills", + "positions", + "account" + ] + subscribe_request = { + "op": "subscribe", + "data": channels + } + await ws.send_json(subscribe_request) + + async for msg in ws.iter_messages(): + if msg.data: + output.put_nowait(msg) + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().error(f"User Stream WS Error: {e}", exc_info=True) + await asyncio.sleep(5) + finally: + if ws: + await ws.disconnect() diff --git a/hummingbot/connector/derivative/aevo_perpetual/aevo_perpetual_utils.py b/hummingbot/connector/derivative/aevo_perpetual/aevo_perpetual_utils.py new file mode 100644 index 00000000000..1b50d575ee7 --- /dev/null +++ b/hummingbot/connector/derivative/aevo_perpetual/aevo_perpetual_utils.py @@ -0,0 +1,39 @@ +from typing import Any, Dict +from hummingbot.core.data_type.trade_fee import TradeFeeSchema +from hummingbot.client.config.config_methods import using_exchange +from hummingbot.client.config.config_var import ConfigVar + +CENTRALIZED = True +EXAMPLE_PAIR = "ETH-USD" +DEFAULT_FEES = [0.02, 0.04] # Maker, Taker +KEYS = { + "aevo_perpetual_api_key": ConfigVar( + key="aevo_perpetual_api_key", + prompt="Enter your Aevo API key: ", + required_if=using_exchange("aevo_perpetual"), + is_secure=True, + is_connect_key=True), + "aevo_perpetual_api_secret": ConfigVar( + key="aevo_perpetual_api_secret", + prompt="Enter your Aevo API secret: ", + required_if=using_exchange("aevo_perpetual"), + is_secure=True, + is_connect_key=True), +} + +from hummingbot.core.utils.tracking_nonce import get_tracking_nonce + +def get_new_client_order_id(is_buy: bool, trading_pair: str) -> str: + side = "B" if is_buy else "S" + return f"{side}-{trading_pair}-{get_tracking_nonce()}" + +def convert_to_exchange_symbol(hb_symbol: str) -> str: + # ETH-USD -> ETH-PERP + return hb_symbol.replace("-USD", "-PERP") + +def convert_to_hb_symbol(exchange_symbol: str) -> str: + # ETH-PERP -> ETH-USD + return exchange_symbol.replace("-PERP", "-USD") + +def is_exchange_information_valid(exchange_info: Dict[str, Any]) -> bool: + return True diff --git a/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml b/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml index a55e06a5201..d5caeec9019 100644 --- a/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml +++ b/hummingbot/templates/conf_fee_overrides_TEMPLATE.yml @@ -24,6 +24,12 @@ binance_buy_percent_fee_deducted_from_returns: # True # List of supported Exchanges for which the user's conf/conf_fee_override.yml # will work. This file currently needs to be in sync with hummingbot list of # supported exchanges +aevo_perpetual_buy_percent_fee_deducted_from_returns: +aevo_perpetual_maker_fixed_fees: +aevo_perpetual_maker_percent_fee: +aevo_perpetual_percent_fee_token: +aevo_perpetual_taker_fixed_fees: +aevo_perpetual_taker_percent_fee: ascend_ex_buy_percent_fee_deducted_from_returns: ascend_ex_maker_fixed_fees: ascend_ex_maker_percent_fee: diff --git a/setup/environment.yml b/setup/environment.yml index 15a2d5e93cd..f77d52fbcd4 100644 --- a/setup/environment.yml +++ b/setup/environment.yml @@ -26,7 +26,7 @@ dependencies: - bidict>=0.22.1 - bip-utils - cachetools>=5.3.1 - - commlib-py>=0.11 + - commlib-py==0.11.5 - cryptography>=41.0.2 - eth-account>=0.13.0 # - injective-py==1.12.* diff --git a/setup/environment_dydx.yml b/setup/environment_dydx.yml index e0d341e3849..56828c867a7 100644 --- a/setup/environment_dydx.yml +++ b/setup/environment_dydx.yml @@ -27,7 +27,7 @@ dependencies: - bidict>=0.22.1 - bip-utils - cachetools>=5.3.1 - - commlib-py>=0.11 + - commlib-py==0.11.5 - cryptography>=41.0.2 - dydxprotocol-v4-proto-py - eth-account >=0.13.0 diff --git a/test/hummingbot/connector/derivative/aevo_perpetual/mock_utils.py b/test/hummingbot/connector/derivative/aevo_perpetual/mock_utils.py new file mode 100644 index 00000000000..e5113f4a691 --- /dev/null +++ b/test/hummingbot/connector/derivative/aevo_perpetual/mock_utils.py @@ -0,0 +1,127 @@ +import sys +from types import ModuleType +from unittest.mock import MagicMock + +# Define Mock Classes +class MockAuthBase: + pass + +class MockRESTRequest: + def __init__(self, method, url, data=None, headers=None): + self.method = method + self.url = url + self.data = data + self.headers = headers or {} + +class MockWSRequest: + pass + +class MockWSResponse: + pass + +class MockRESTResponse: + pass + +class MockRESTMethod: + GET = "GET" + POST = "POST" + DELETE = "DELETE" + +class MockOrderBookMessageType: + SNAPSHOT = 1 + TRADE = 2 + +class MockTaskLog: + pass + +class MockRateLimit: + def __init__(self, limit_id, limit, time_interval): + pass + +class MockHexBytes: + pass + +mock_hexbytes_module = ModuleType("hexbytes") +mock_hexbytes_module.HexBytes = MockHexBytes + +# Create Mock Modules +mock_auth_module = ModuleType("hummingbot.core.web_assistant.auth") +mock_auth_module.AuthBase = MockAuthBase + +mock_data_types_module = ModuleType("hummingbot.core.web_assistant.connections.data_types") +mock_data_types_module.RESTRequest = MockRESTRequest +mock_data_types_module.WSRequest = MockWSRequest +mock_data_types_module.WSResponse = MockWSResponse +mock_data_types_module.RESTResponse = MockRESTResponse +mock_data_types_module.RESTMethod = MockRESTMethod + +mock_throttler_types_module = ModuleType("hummingbot.core.api_throttler.data_types") +mock_throttler_types_module.TaskLog = MockTaskLog +mock_throttler_types_module.RateLimit = MockRateLimit + +# Mock pydantic +mock_pydantic_module = ModuleType("pydantic") +class MockSecretStr: + def __init__(self, value): + self._value = value + def get_secret_value(self): + return self._value +mock_pydantic_module.SecretStr = MockSecretStr +sys.modules["pydantic"] = mock_pydantic_module + +# Mock pydantic_core +moved_pydantic_core_here = True # Just a marker to ensure placement + +# Mock pydantic_core +mock_pydantic_core_module = ModuleType("pydantic_core") +class MockCoreSchema: + class CoreSchema: + pass +mock_pydantic_core_module.core_schema = MockCoreSchema +sys.modules["pydantic_core"] = mock_pydantic_core_module + +# Inject Mocks +sys.modules["hummingbot.core.web_assistant.connections.data_types"] = mock_data_types_module +sys.modules["hummingbot.core.api_throttler.data_types"] = mock_throttler_types_module +sys.modules["hexbytes"] = mock_hexbytes_module + +# Mock DerivativeBase +mock_derivative_base_module = ModuleType("hummingbot.connector.derivative.derivative_base") +class MockDerivativeBase: + def __init__(self, **kwargs): + pass + def start(self): pass + def stop(self): pass + +mock_derivative_base_module.DerivativeBase = MockDerivativeBase +sys.modules["hummingbot.connector.derivative.derivative_base"] = mock_derivative_base_module + +# Mock OrderBook modules +mock_ob_module = ModuleType("hummingbot.core.data_type.order_book") +class MockOrderBook: + pass +mock_ob_module.OrderBook = MockOrderBook +sys.modules["hummingbot.core.data_type.order_book"] = mock_ob_module + +mock_ob_message_module = ModuleType("hummingbot.core.data_type.order_book_message") +class MockOrderBookMessage: + def __init__(self, *args, **kwargs): pass +class MockOrderBookMessageType: + SNAPSHOT = 1 + TRADE = 2 +mock_ob_message_module.OrderBookMessage = MockOrderBookMessage +mock_ob_message_module.OrderBookMessageType = MockOrderBookMessageType +sys.modules["hummingbot.core.data_type.order_book_message"] = mock_ob_message_module + +mock_ob_ds_module = ModuleType("hummingbot.core.data_type.order_book_tracker_data_source") +class MockOrderBookTrackerDataSource: + def __init__(self, trading_pairs): self._trading_pairs = trading_pairs +mock_ob_ds_module.OrderBookTrackerDataSource = MockOrderBookTrackerDataSource +sys.modules["hummingbot.core.data_type.order_book_tracker_data_source"] = mock_ob_ds_module + +# Mock Throttler +mock_throttler_module = ModuleType("hummingbot.core.api_throttler.async_throttler") +class MockAsyncThrottler: + pass +mock_throttler_module.AsyncThrottler = MockAsyncThrottler +sys.modules["hummingbot.core.api_throttler.async_throttler"] = mock_throttler_module diff --git a/test/hummingbot/connector/derivative/aevo_perpetual/test_aevo_perpetual_api_order_book_data_source.py b/test/hummingbot/connector/derivative/aevo_perpetual/test_aevo_perpetual_api_order_book_data_source.py new file mode 100644 index 00000000000..0cb8365496f --- /dev/null +++ b/test/hummingbot/connector/derivative/aevo_perpetual/test_aevo_perpetual_api_order_book_data_source.py @@ -0,0 +1,158 @@ +import unittest +import asyncio +import json +from unittest.mock import MagicMock, AsyncMock, patch +import sys +from types import ModuleType + +# --- MOCKING DEPENDENCIES START --- +import test.hummingbot.connector.derivative.aevo_perpetual.mock_utils +from test.hummingbot.connector.derivative.aevo_perpetual.mock_utils import MockOrderBookMessageType +from unittest.mock import MagicMock + +# Additional specific mocks for OrderBook +mock_ob_msg_module = ModuleType("hummingbot.core.data_type.order_book_message") +mock_ob_tracker_module = ModuleType("hummingbot.core.data_type.order_book_tracker_data_source") +mock_throttler_module = ModuleType("hummingbot.core.api_throttler.async_throttler") +mock_ob_module = ModuleType("hummingbot.core.data_type.order_book") + +class MockOrderBook: + pass + +class MockOrderBookMessage: + def __init__(self, msg_type, content, timestamp=0): + self.type = msg_type + self.content = content + self.timestamp = timestamp + +class MockOrderBookMessageType: + SNAPSHOT = 1 + TRADE = 2 + +class MockOrderBookTrackerDataSource: + def __init__(self, trading_pairs): + self._trading_pairs = trading_pairs + +mock_ob_msg_module.OrderBookMessage = MockOrderBookMessage +mock_ob_msg_module.OrderBookMessageType = MockOrderBookMessageType +mock_ob_tracker_module.OrderBookTrackerDataSource = MockOrderBookTrackerDataSource +mock_throttler_module.AsyncThrottler = MagicMock +mock_ob_module.OrderBook = MockOrderBook + +sys.modules["hummingbot.core.data_type.order_book_message"] = mock_ob_msg_module +sys.modules["hummingbot.core.data_type.order_book_tracker_data_source"] = mock_ob_tracker_module +sys.modules["hummingbot.core.api_throttler.async_throttler"] = mock_throttler_module +sys.modules["hummingbot.core.data_type.order_book"] = mock_ob_module +# --- MOCKING END --- + +from hummingbot.connector.derivative.aevo_perpetual.aevo_perpetual_api_order_book_data_source import AevoPerpetualAPIOrderBookDataSource +from hummingbot.connector.derivative.aevo_perpetual import aevo_perpetual_constants as CONSTANTS + +class AevoDataSourceTest(unittest.TestCase): + def setUp(self): + self.api_factory = MagicMock() + self.throttler = MagicMock() + self.time_synchronizer = MagicMock() + self.time_synchronizer.time.return_value = 1600000000.0 + + self.data_source = AevoPerpetualAPIOrderBookDataSource( + trading_pairs=["ETH-PERP"], + domain="aevo", + api_factory=self.api_factory, + throttler=self.throttler, + time_synchronizer=self.time_synchronizer + ) + self.data_source.logger = MagicMock() + self.data_source._message_queue = asyncio.Queue() + + def test_parse_order_book_snapshot(self): + # Mock WS connection + mock_ws = AsyncMock() + self.api_factory.get_ws_connection.return_value = mock_ws + + # Simulate a snapshot message + # Payload structure based on implementation: {"data": {"type": "snapshot", ...}, "channel": "orderbook:ETH-PERP"} + msg_content = { + "channel": f"{CONSTANTS.WS_TOPIC_ORDERBOOK}:ETH-PERP", + "data": { + "type": "snapshot", + "timestamp": 1600000000000000000, + "bids": [["2000", "1"]], + "asks": [["2001", "1"]] + } + } + + # Mock iter_messages to yield this message then simulate cancel/stop + async def iter_messages(): + mock_msg = MagicMock() + mock_msg.data = True + mock_msg.json.return_value = msg_content + yield mock_msg + # Break loop by raising CancelledError or just stopping if loop logic handles it + # The loop is 'while True', so we need to raise CancelledError to exit cleanly in test + raise asyncio.CancelledError + + mock_ws.iter_messages = iter_messages + mock_ws.connect = AsyncMock() + mock_ws.send_json = AsyncMock() + + # Run listener + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(self.data_source.listen_for_subscriptions()) + loop.close() + except asyncio.CancelledError: + pass + + # Verify message queue has the parsed message + self.assertFalse(self.data_source._message_queue.empty()) + msg = self.data_source._message_queue.get_nowait() + + self.assertEqual(msg.type, MockOrderBookMessageType.SNAPSHOT) + self.assertEqual(msg.content["trading_pair"], "ETH-PERP") + self.assertEqual(len(msg.content["bids"]), 1) + self.assertEqual(msg.content["bids"][0][0], "2000") + + def test_parse_trades(self): + mock_ws = AsyncMock() + self.api_factory.get_ws_connection.return_value = mock_ws + + msg_content = { + "channel": f"{CONSTANTS.WS_TOPIC_TRADES}:ETH-PERP", + "data": [ + { + "trade_id": "123", + "price": "2000.5", + "amount": "0.1", + "side": "buy", + "timestamp": 1600000000000000000 + } + ] + } + + async def iter_messages(): + mock_msg = MagicMock() + mock_msg.data = True + mock_msg.json.return_value = msg_content + yield mock_msg + raise asyncio.CancelledError + + mock_ws.iter_messages = iter_messages + mock_ws.connect = AsyncMock() + + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(self.data_source.listen_for_subscriptions()) + loop.close() + except asyncio.CancelledError: + pass + + self.assertFalse(self.data_source._message_queue.empty()) + msg = self.data_source._message_queue.get_nowait() + + self.assertEqual(msg.type, MockOrderBookMessageType.TRADE) + self.assertEqual(msg.content["trading_pair"], "ETH-PERP") + self.assertEqual(msg.content["trade_id"], "123") + self.assertEqual(msg.content["price"], "2000.5") diff --git a/test/hummingbot/connector/derivative/aevo_perpetual/test_aevo_perpetual_auth.py b/test/hummingbot/connector/derivative/aevo_perpetual/test_aevo_perpetual_auth.py new file mode 100644 index 00000000000..1f7e32b3aa0 --- /dev/null +++ b/test/hummingbot/connector/derivative/aevo_perpetual/test_aevo_perpetual_auth.py @@ -0,0 +1,73 @@ +import unittest +import sys +from unittest.mock import MagicMock, patch +from types import ModuleType + +# --- MOCKING DEPENDENCIES START --- +import test.hummingbot.connector.derivative.aevo_perpetual.mock_utils as mock_utils +from test.hummingbot.connector.derivative.aevo_perpetual.mock_utils import ( + MockRESTRequest, + MockWSRequest, + MockWSResponse, + MockRESTResponse, + MockAuthBase +) +# --- MOCKING DEPENDENCIES END --- + +# Now safe to import the class under test (it will use the mocks above) +import asyncio +# We need to ensure we can import the auth module. +# Since we are mocking dependencies, we can just import the file directly or via module path if PYTHONPATH is set. +# Assuming PYTHONPATH covers the root 'hummingbot' directory. +from hummingbot.connector.derivative.aevo_perpetual.aevo_perpetual_auth import AevoPerpetualAuth + +class AevoPerpetualAuthTest(unittest.TestCase): + def setUp(self): + self.api_key = "test_key" + self.api_secret = "test_secret" + self.mock_time_provider = MagicMock() + self.mock_time_provider.time.return_value = 1234567.890 + # Expected timestamp: 1234567890000000 (1e9 check) + self.auth = AevoPerpetualAuth(self.api_key, self.api_secret, self.mock_time_provider) + + def test_rest_authenticate(self): + params = {"foo": "bar"} + # Create a MockRESTRequest (which replaces the real one) + request = MockRESTRequest( + method="GET", + url="https://api.aevo.xyz/test", + data=params, + ) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + authenticated_request = loop.run_until_complete(self.auth.rest_authenticate(request)) + loop.close() + + headers = authenticated_request.headers + self.assertIn("AEVO-ACCESS-KEY", headers) + self.assertEqual(headers["AEVO-ACCESS-KEY"], self.api_key) + self.assertIn("AEVO-ACCESS-TIMESTAMP", headers) + # 1234567.890 * 1e9 = 1234567890000000 + self.assertEqual(headers["AEVO-ACCESS-TIMESTAMP"], "1234567890000000") + self.assertIn("AEVO-ACCESS-SIG", headers) + + # Verify signature existence + self.assertTrue(len(headers["AEVO-ACCESS-SIG"]) > 0) + + # Verify HMAC correctness manually + # Payload: timestamp + Method + path + body + # Note: In our implementation, url is passed as is. Aevo might expect path only? + # Let's assume implementation uses full URL for now as per code. + # Payload = "1234567890000000GEThttps://api.aevo.xyz/test{\"foo\":\"bar\"}" + # We can implement a parallel check here if we want strict verification. + + def test_generate_signature(self): + timestamp = "1234567890" + method = "GET" + url = "/test" + data = {"foo": "bar"} + + signature = self.auth._generate_signature(timestamp, method, url, data) + self.assertIsInstance(signature, str) + self.assertEqual(len(signature), 64) # SHA256 hex digest length diff --git a/test/hummingbot/connector/derivative/aevo_perpetual/test_aevo_perpetual_derivative.py b/test/hummingbot/connector/derivative/aevo_perpetual/test_aevo_perpetual_derivative.py new file mode 100644 index 00000000000..f27a034bc1f --- /dev/null +++ b/test/hummingbot/connector/derivative/aevo_perpetual/test_aevo_perpetual_derivative.py @@ -0,0 +1,15 @@ +import unittest +from unittest.mock import MagicMock +import test.hummingbot.connector.derivative.aevo_perpetual.mock_utils as mock_utils +from hummingbot.connector.derivative.aevo_perpetual.aevo_perpetual_derivative import AevoPerpetualDerivative + +class AevoPerpetualDerivativeTest(unittest.TestCase): + def test_instantiation(self): + # Mock dependencies + aevo = AevoPerpetualDerivative( + aevo_perpetual_api_key="test_key", + aevo_perpetual_api_secret="test_secret", + trading_pairs=["ETH-USD"], + trading_required=True + ) + self.assertIsInstance(aevo, AevoPerpetualDerivative) diff --git a/test/hummingbot/connector/derivative/aevo_perpetual/test_aevo_perpetual_user_stream.py b/test/hummingbot/connector/derivative/aevo_perpetual/test_aevo_perpetual_user_stream.py new file mode 100644 index 00000000000..cb8ea2a8f05 --- /dev/null +++ b/test/hummingbot/connector/derivative/aevo_perpetual/test_aevo_perpetual_user_stream.py @@ -0,0 +1,61 @@ +import unittest +from unittest.mock import AsyncMock, patch, MagicMock +import asyncio +from typing import Optional +import test.hummingbot.connector.derivative.aevo_perpetual.mock_utils # Ensure mocks are loaded +from hummingbot.connector.derivative.aevo_perpetual.aevo_perpetual_user_stream_data_source import AevoPerpetualUserStreamDataSource +from hummingbot.connector.derivative.aevo_perpetual import aevo_perpetual_constants as CONSTANTS + +class TestAevoPerpetualUserStreamDataSource(unittest.TestCase): + def setUp(self): + self.mock_auth = MagicMock() # Use MagicMock for synchronous methods + self.mock_auth.get_ws_auth_payload.return_value = {"op": "auth", "data": "mock_auth_data"} + self.trading_pairs = ["ETH-USD"] + self.mock_api_factory = AsyncMock() + self.mock_ws_connection = AsyncMock() + self.mock_api_factory.get_ws_connection.return_value = self.mock_ws_connection + self.user_stream = AevoPerpetualUserStreamDataSource( + auth=self.mock_auth, + trading_pairs=self.trading_pairs, + api_factory=self.mock_api_factory + ) + self.user_stream.logger = MagicMock() + + @patch("hummingbot.connector.derivative.aevo_perpetual.aevo_perpetual_user_stream_data_source.AevoPerpetualUserStreamDataSource._sleep") + def test_listen_to_user_messages_authenticates_and_subscribes(self, mock_sleep): + # Setup mock behavior + self.mock_ws_connection.connect = AsyncMock() + self.mock_ws_connection.send_json = AsyncMock() + # Mock iter_messages to return one message then raise CancelledError to stop loop + async def mock_iter_messages(): + yield AsyncMock(data="{\"channel\": \"orders\"}") + raise asyncio.CancelledError() + + self.mock_ws_connection.iter_messages = mock_iter_messages + + output_queue = asyncio.Queue() + + # Run the method + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete( + self.user_stream._listen_to_user_messages(output_queue) + ) + loop.close() + except asyncio.CancelledError: + pass + + # Verify Auth was called + self.mock_auth.get_ws_auth_payload.assert_called_once() + # Verify Auth message sent + print(f"DEBUG calls: {self.mock_ws_connection.send_json.call_args_list}") + self.mock_ws_connection.send_json.assert_any_call({"op": "auth", "data": "mock_auth_data"}) + + # Verify Subscription message sent + expected_subscription = { + "op": "subscribe", + "data": ["orders", "fills", "positions", "account"] + } + self.mock_ws_connection.send_json.assert_any_call(expected_subscription) +