Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ce97fff
Merge pull request #7832 from hummingbot/staging
nikspz Nov 3, 2025
588537c
Merge pull request #7871 from hummingbot/development
nikspz Dec 9, 2025
23a05dc
Update version from dev-2.11.0 to 2.11.0
rapcmia Dec 9, 2025
89d10af
Update version number in setup.py
rapcmia Dec 9, 2025
0eeb342
Merge pull request #7877 from hummingbot/development
rapcmia Dec 10, 2025
30e284c
Merge pull request #7880 from hummingbot/staging
rapcmia Dec 15, 2025
37be805
fix/commlib-bug
nikspz Dec 26, 2025
275781e
Update environment_dydx.yml
nikspz Dec 26, 2025
d1b1275
feat: implement Aevo perpetual connector
SBALAVIGNESH123 Dec 27, 2025
fa9dc2c
feat: implement aevo perpetual connector with tests
SBALAVIGNESH123 Dec 28, 2025
95d55cd
Merge remote-tracking branch 'upstream/development' into feat/aevo-co…
SBALAVIGNESH123 Dec 28, 2025
a55a7d0
fix: replace print with logger and refine comments
SBALAVIGNESH123 Dec 28, 2025
728a7d3
fix: expose connector class in __init__.py
SBALAVIGNESH123 Dec 28, 2025
39117b5
fix: use path only for auth signature per Aevo specs
SBALAVIGNESH123 Dec 28, 2025
7ad6ae7
add >= instead of == to commlib-py 0.11.5 environment.yml
nikspz Dec 29, 2025
147fe65
add >= instead of == to environment_dydx.yml
nikspz Dec 29, 2025
b184ae0
revert to == environment.yml
nikspz Dec 29, 2025
7dc6fe2
revert
nikspz Dec 29, 2025
0c15a22
Merge pull request #7897 from hummingbot/fix/commlib-bug
cardosofede Dec 29, 2025
e72eb4a
feat: implement Aevo Perpetual connector core logic (Auth, OrderBook,…
SBALAVIGNESH123 Dec 30, 2025
ddc18f8
Fix unit tests: asyncio loops and logger mocks
SBALAVIGNESH123 Dec 30, 2025
1ff5f40
Fix bug: Use side field for trade type detection
SBALAVIGNESH123 Dec 30, 2025
332e02f
Merge branch 'development' into feat/aevo-connector
SBALAVIGNESH123 Dec 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions hummingbot/connector/derivative/aevo_perpetual/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .aevo_perpetual_derivative import AevoPerpetualDerivative

__all__ = [
"AevoPerpetualDerivative",
]
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional time_synchronizer accessed without null check

The _time_synchronizer parameter is declared as Optional[Any] = None in __init__, but the code accesses self._time_synchronizer.time() at multiple locations without null checks. When the API response lacks a timestamp and _time_synchronizer is None, this causes an AttributeError crash. The fallback mechanism using .get(..., self._time_synchronizer.time() * 1e9) evaluates the default value before checking if the key exists, so the None access occurs even when a timestamp is present.

Additional Locations (2)

Fix in Cursor Fix in Web

return OrderBookMessage(
OrderBookMessageType.SNAPSHOT,
{
"trading_pair": trading_pair,
"update_id": int(timestamp),
"bids": snapshot.get("bids", []),
"asks": snapshot.get("asks", [])
},
timestamp=timestamp * 1e-9
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Method returns OrderBookMessage but declares OrderBook return type

The get_new_order_book method signature declares a return type of OrderBook but the implementation returns an OrderBookMessage instance at line 60. Callers expecting an OrderBook object will receive an incompatible OrderBookMessage type, causing method calls and attribute access to fail at runtime. Other connectors in the codebase that implement get_new_order_book correctly return OrderBook objects.

Fix in Cursor Fix in Web



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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WebSocket subscriptions missing symbol format conversion

The WebSocket subscription uses pair from self._trading_pairs directly without converting to exchange format (e.g., subscribing with "ETH-USD" instead of "ETH-PERP"). REST endpoints like get_new_order_book correctly call utils.convert_to_exchange_symbol() before making requests. Additionally, when parsing WebSocket responses, channel.split(":")[-1] extracts the trading pair in exchange format but never converts it back to HB format using utils.convert_to_hb_symbol(). This inconsistency causes trading pair mismatches between WebSocket data and the rest of the system.

Additional Locations (2)

Fix in Cursor Fix in Web


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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling put_nowait on dict instead of Queue

The code calls self._message_queue.put_nowait() directly, but _message_queue inherited from OrderBookTrackerDataSource is a Dict[str, asyncio.Queue], not a Queue. Other connectors correctly use self._message_queue[self._snapshot_messages_queue_key].put_nowait() or similar keyed access. This will raise an AttributeError at runtime.

Additional Locations (1)

Fix in Cursor Fix in Web


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()

Original file line number Diff line number Diff line change
@@ -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=(',', ':'))
Comment thread
cursor[bot] marked this conversation as resolved.

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
}
}
Original file line number Diff line number Diff line change
@@ -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),
]
Original file line number Diff line number Diff line change
@@ -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())
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Network task not cancelled during stop causes resource leak

The start_network method creates a task stored in _stop_network_task, but the _stop_network method only cancels _status_polling_task and never cancels _stop_network_task. When start_network is called multiple times (which calls _stop_network first), the previous _start_network task becomes orphaned and continues running. This causes resource leaks and potentially duplicate trackers/polling loops running concurrently. The variable naming also appears wrong - it stores a start task in a stop-named variable.

Additional Locations (1)

Fix in Cursor Fix in Web


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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Order type parameter ignored in place order method

The _place_order method accepts an order_type parameter but completely ignores it. The connector claims to support both OrderType.LIMIT and OrderType.MARKET in supported_order_types, but the implementation always sends limit_price in the request params regardless of order type. Other perpetual connectors (like Binance, Bitmart) correctly use order_type to set the type field and conditionally include price only for limit orders. Market orders placed through this connector would either fail or be incorrectly submitted as limit orders.

Fix in Cursor Fix in Web


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

Loading