From 6097dde4e42eac7a9ceefb542a5a737302b28bb1 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 1 May 2026 01:35:26 +0200 Subject: [PATCH] =?UTF-8?q?refactor(V2):=20bybit=20client=20da=20pybit=20a?= =?UTF-8?q?=20httpx=20puro=20(parit=C3=A0=20V1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cerbero_mcp/exchanges/bybit/client.py | 634 ++++++++---- tests/unit/exchanges/bybit/conftest.py | 13 +- tests/unit/exchanges/bybit/test_client.py | 1070 ++++++++++++++------- tests/unit/test_exchanges_builder.py | 52 +- 4 files changed, 1149 insertions(+), 620 deletions(-) diff --git a/src/cerbero_mcp/exchanges/bybit/client.py b/src/cerbero_mcp/exchanges/bybit/client.py index f6c958d..11c7b7a 100644 --- a/src/cerbero_mcp/exchanges/bybit/client.py +++ b/src/cerbero_mcp/exchanges/bybit/client.py @@ -1,13 +1,33 @@ +"""Bybit V5 REST API client (httpx puro, no SDK). + +Implementazione diretta su `httpx.AsyncClient` per i tool Cerbero MCP V2. +Mantiene parità di interfaccia con la versione precedente basata su +`pybit.unified_trading.HTTP` per non rompere `tools.py` né i router. + +Auth Bybit V5: + Header X-BAPI-SIGN = HMAC_SHA256(secret, + timestamp + api_key + recv_window + (body_json | querystring)) +""" from __future__ import annotations -import asyncio +import hashlib +import hmac +import json +import time +import uuid from typing import Any +from urllib.parse import urlencode -from pybit.unified_trading import HTTP +import httpx from cerbero_mcp.common import indicators as ind from cerbero_mcp.common import microstructure as micro +BASE_MAINNET = "https://api.bybit.com" +BASE_TESTNET = "https://api-testnet.bybit.com" +DEFAULT_RECV_WINDOW = "5000" +DEFAULT_TIMEOUT = 15.0 + def _f(v: Any) -> float | None: try: @@ -23,37 +43,143 @@ def _i(v: Any) -> int | None: return None +class BybitAPIError(RuntimeError): + """Errore di trasporto Bybit V5 (non gestito a livello envelope).""" + + class BybitClient: + """Async REST client per Bybit V5 (linear/inverse/spot/option).""" + def __init__( self, api_key: str, api_secret: str, testnet: bool = True, - http: Any | None = None, + http: httpx.AsyncClient | None = None, base_url: str | None = None, ) -> None: self.api_key = api_key self.api_secret = api_secret self.testnet = testnet - # pybit HTTP non accetta `endpoint` come kwarg (vedi _V5HTTPManager.__init__: - # solo `domain`/`tld`/`testnet`). Override URL applicato post-init - # sovrascrivendo l'attributo `endpoint` dell'istanza HTTP. - self.base_url = base_url - if http is None: - http = HTTP( - api_key=api_key, - api_secret=api_secret, - testnet=testnet, - ) - if base_url: - http.endpoint = base_url - self._http = http + self.base_url = base_url or (BASE_TESTNET if testnet else BASE_MAINNET) + self.recv_window = DEFAULT_RECV_WINDOW + # `http` injection è usato dai test per montare un AsyncClient con + # `httpx.MockTransport`. In produzione creiamo un client dedicato. + self._owns_http = http is None + self._http: httpx.AsyncClient = http or httpx.AsyncClient( + timeout=DEFAULT_TIMEOUT + ) - async def _run(self, fn, /, **kwargs): - return await asyncio.to_thread(fn, **kwargs) + async def aclose(self) -> None: + """Chiude l'AsyncClient httpx se di nostra proprietà.""" + if self._owns_http: + await self._http.aclose() + + # ── auth helpers ─────────────────────────────────────────── + + def _timestamp_ms(self) -> str: + return str(int(time.time() * 1000)) + + def _sign(self, timestamp: str, payload: str) -> str: + msg = timestamp + self.api_key + self.recv_window + payload + return hmac.new( + self.api_secret.encode("utf-8"), + msg.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + def _signed_headers(self, payload: str) -> dict[str, str]: + ts = self._timestamp_ms() + sig = self._sign(ts, payload) + return { + "X-BAPI-API-KEY": self.api_key, + "X-BAPI-TIMESTAMP": ts, + "X-BAPI-RECV-WINDOW": self.recv_window, + "X-BAPI-SIGN": sig, + "Content-Type": "application/json", + } @staticmethod - def _parse_ticker(row: dict) -> dict: + def _clean_params(params: dict[str, Any] | None) -> dict[str, Any]: + if not params: + return {} + return {k: v for k, v in params.items() if v is not None} + + @staticmethod + def _querystring(params: dict[str, Any]) -> str: + # Bybit accetta querystring nell'ordine in cui viene serializzata la + # request. Per la signature usiamo lo stesso urlencode (ordine + # inserzione dict). In Python 3.7+ dict mantiene insertion order: + # mantenere coerenza tra signature payload e URL effettivo. + return urlencode(params) + + # ── request primitives ───────────────────────────────────── + + async def _request_public( + self, + method: str, + path: str, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + clean = self._clean_params(params) + url = self.base_url + path + resp = await self._http.request( + method, url, params=clean if clean else None + ) + return self._parse_response(resp) + + async def _request_signed( + self, + method: str, + path: str, + params: dict[str, Any] | None = None, + body: dict[str, Any] | None = None, + ) -> dict[str, Any]: + url = self.base_url + path + method = method.upper() + if method == "GET": + clean = self._clean_params(params) + qs = self._querystring(clean) + headers = self._signed_headers(qs) + resp = await self._http.request( + method, url, params=clean if clean else None, headers=headers + ) + else: + payload_body = body or {} + body_json = json.dumps(payload_body, separators=(",", ":")) + headers = self._signed_headers(body_json) + resp = await self._http.request( + method, url, content=body_json, headers=headers + ) + return self._parse_response(resp) + + @staticmethod + def _parse_response(resp: httpx.Response) -> dict[str, Any]: + try: + data = resp.json() + except Exception as e: # pragma: no cover - difficilmente raggiungibile + raise BybitAPIError( + f"invalid JSON from Bybit (status={resp.status_code}): {resp.text[:200]}" + ) from e + if resp.status_code >= 500: + raise BybitAPIError( + f"bybit server error {resp.status_code}: " + f"{data.get('retMsg', resp.text[:200])}" + ) + if not isinstance(data, dict): + raise BybitAPIError(f"unexpected payload type: {type(data).__name__}") + return data + + def _envelope(self, resp: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: + code = resp.get("retCode", 0) + if code != 0: + return {"error": resp.get("retMsg", "bybit_error"), "code": code} + return payload + + # ── parsers shared ───────────────────────────────────────── + + @staticmethod + def _parse_ticker(row: dict[str, Any]) -> dict[str, Any]: return { "symbol": row.get("symbol"), "last_price": _f(row.get("lastPrice")), @@ -66,9 +192,13 @@ class BybitClient: "open_interest": _f(row.get("openInterest")), } + # ── market data (public) ─────────────────────────────────── + async def get_ticker(self, symbol: str, category: str = "linear") -> dict: - resp = await self._run( - self._http.get_tickers, category=category, symbol=symbol + resp = await self._request_public( + "GET", + "/v5/market/tickers", + params={"category": category, "symbol": symbol}, ) rows = (resp.get("result") or {}).get("list") or [] if not rows: @@ -86,8 +216,10 @@ class BybitClient: async def get_orderbook( self, symbol: str, category: str = "linear", limit: int = 50 ) -> dict: - resp = await self._run( - self._http.get_orderbook, category=category, symbol=symbol, limit=limit + resp = await self._request_public( + "GET", + "/v5/market/orderbook", + params={"category": category, "symbol": symbol, "limit": limit}, ) r = resp.get("result") or {} return { @@ -106,17 +238,17 @@ class BybitClient: end: int | None = None, limit: int = 1000, ) -> dict: - kwargs = dict( - category=category, - symbol=symbol, - interval=interval, - limit=limit, - ) + params: dict[str, Any] = { + "category": category, + "symbol": symbol, + "interval": interval, + "limit": limit, + } if start is not None: - kwargs["start"] = start + params["start"] = start if end is not None: - kwargs["end"] = end - resp = await self._run(self._http.get_kline, **kwargs) + params["end"] = end + resp = await self._request_public("GET", "/v5/market/kline", params=params) rows = (resp.get("result") or {}).get("list") or [] rows_sorted = sorted(rows, key=lambda r: int(r[0])) candles = [ @@ -168,8 +300,10 @@ class BybitClient: return out async def get_funding_rate(self, symbol: str, category: str = "linear") -> dict: - resp = await self._run( - self._http.get_tickers, category=category, symbol=symbol + resp = await self._request_public( + "GET", + "/v5/market/tickers", + params={"category": category, "symbol": symbol}, ) rows = (resp.get("result") or {}).get("list") or [] if not rows: @@ -184,9 +318,10 @@ class BybitClient: async def get_funding_history( self, symbol: str, category: str = "linear", limit: int = 100 ) -> dict: - resp = await self._run( - self._http.get_funding_rate_history, - category=category, symbol=symbol, limit=limit, + resp = await self._request_public( + "GET", + "/v5/market/funding/history", + params={"category": category, "symbol": symbol, "limit": limit}, ) rows = (resp.get("result") or {}).get("list") or [] hist = [ @@ -205,9 +340,15 @@ class BybitClient: interval: str = "5min", limit: int = 288, ) -> dict: - resp = await self._run( - self._http.get_open_interest, - category=category, symbol=symbol, intervalTime=interval, limit=limit, + resp = await self._request_public( + "GET", + "/v5/market/open-interest", + params={ + "category": category, + "symbol": symbol, + "intervalTime": interval, + "limit": limit, + }, ) rows = (resp.get("result") or {}).get("list") or [] points = [ @@ -226,71 +367,88 @@ class BybitClient: "points": points, } - async def get_instruments(self, category: str = "linear", symbol: str | None = None) -> dict: - kwargs: dict[str, Any] = {"category": category} + async def get_instruments( + self, category: str = "linear", symbol: str | None = None + ) -> dict: + params: dict[str, Any] = {"category": category} if symbol: - kwargs["symbol"] = symbol - resp = await self._run(self._http.get_instruments_info, **kwargs) + params["symbol"] = symbol + resp = await self._request_public( + "GET", "/v5/market/instruments-info", params=params + ) rows = (resp.get("result") or {}).get("list") or [] instruments = [] for r in rows: pf = r.get("priceFilter") or {} lf = r.get("lotSizeFilter") or {} - instruments.append({ - "symbol": r.get("symbol"), - "status": r.get("status"), - "base_coin": r.get("baseCoin"), - "quote_coin": r.get("quoteCoin"), - "tick_size": _f(pf.get("tickSize")), - "qty_step": _f(lf.get("qtyStep")), - "min_qty": _f(lf.get("minOrderQty")), - }) + instruments.append( + { + "symbol": r.get("symbol"), + "status": r.get("status"), + "base_coin": r.get("baseCoin"), + "quote_coin": r.get("quoteCoin"), + "tick_size": _f(pf.get("tickSize")), + "qty_step": _f(lf.get("qtyStep")), + "min_qty": _f(lf.get("minOrderQty")), + } + ) return {"category": category, "instruments": instruments} async def get_option_chain(self, base_coin: str, expiry: str | None = None) -> dict: - kwargs: dict[str, Any] = {"category": "option", "baseCoin": base_coin.upper()} - resp = await self._run(self._http.get_instruments_info, **kwargs) + resp = await self._request_public( + "GET", + "/v5/market/instruments-info", + params={"category": "option", "baseCoin": base_coin.upper()}, + ) rows = (resp.get("result") or {}).get("list") or [] options = [] for r in rows: delivery = r.get("deliveryTime") if expiry and expiry not in r.get("symbol", ""): continue - options.append({ - "symbol": r.get("symbol"), - "base_coin": r.get("baseCoin"), - "settle_coin": r.get("settleCoin"), - "type": r.get("optionsType"), - "launch_time": int(r.get("launchTime", 0)), - "delivery_time": int(delivery) if delivery else None, - }) + options.append( + { + "symbol": r.get("symbol"), + "base_coin": r.get("baseCoin"), + "settle_coin": r.get("settleCoin"), + "type": r.get("optionsType"), + "launch_time": int(r.get("launchTime", 0)), + "delivery_time": int(delivery) if delivery else None, + } + ) return {"base_coin": base_coin.upper(), "options": options} + # ── account / positions / orders (signed) ───────────────── + async def get_positions( self, category: str = "linear", settle_coin: str = "USDT" ) -> list[dict]: - kwargs: dict[str, Any] = {"category": category} + params: dict[str, Any] = {"category": category} if category in ("linear", "inverse"): - kwargs["settleCoin"] = settle_coin - resp = await self._run(self._http.get_positions, **kwargs) + params["settleCoin"] = settle_coin + resp = await self._request_signed("GET", "/v5/position/list", params=params) rows = (resp.get("result") or {}).get("list") or [] out = [] for r in rows: - out.append({ - "symbol": r.get("symbol"), - "side": r.get("side"), - "size": _f(r.get("size")), - "entry_price": _f(r.get("avgPrice")), - "unrealized_pnl": _f(r.get("unrealisedPnl")), - "leverage": _f(r.get("leverage")), - "liquidation_price": _f(r.get("liqPrice")), - "position_value": _f(r.get("positionValue")), - }) + out.append( + { + "symbol": r.get("symbol"), + "side": r.get("side"), + "size": _f(r.get("size")), + "entry_price": _f(r.get("avgPrice")), + "unrealized_pnl": _f(r.get("unrealisedPnl")), + "leverage": _f(r.get("leverage")), + "liquidation_price": _f(r.get("liqPrice")), + "position_value": _f(r.get("positionValue")), + } + ) return out async def get_account_summary(self, account_type: str = "UNIFIED") -> dict: - resp = await self._run( - self._http.get_wallet_balance, accountType=account_type + resp = await self._request_signed( + "GET", + "/v5/account/wallet-balance", + params={"accountType": account_type}, ) rows = (resp.get("result") or {}).get("list") or [] if not rows: @@ -298,11 +456,13 @@ class BybitClient: a = rows[0] coins = [] for c in a.get("coin") or []: - coins.append({ - "coin": c.get("coin"), - "wallet_balance": _f(c.get("walletBalance")), - "equity": _f(c.get("equity")), - }) + coins.append( + { + "coin": c.get("coin"), + "wallet_balance": _f(c.get("walletBalance")), + "equity": _f(c.get("equity")), + } + ) return { "account_type": a.get("accountType"), "equity": _f(a.get("totalEquity")), @@ -316,8 +476,10 @@ class BybitClient: async def get_trade_history( self, category: str = "linear", limit: int = 50 ) -> list[dict]: - resp = await self._run( - self._http.get_executions, category=category, limit=limit + resp = await self._request_signed( + "GET", + "/v5/execution/list", + params={"category": category, "limit": limit}, ) rows = (resp.get("result") or {}).get("list") or [] return [ @@ -339,12 +501,14 @@ class BybitClient: symbol: str | None = None, settle_coin: str = "USDT", ) -> list[dict]: - kwargs: dict[str, Any] = {"category": category} + params: dict[str, Any] = {"category": category} if category in ("linear", "inverse") and not symbol: - kwargs["settleCoin"] = settle_coin + params["settleCoin"] = settle_coin if symbol: - kwargs["symbol"] = symbol - resp = await self._run(self._http.get_open_orders, **kwargs) + params["symbol"] = symbol + resp = await self._request_signed( + "GET", "/v5/order/realtime", params=params + ) rows = (resp.get("result") or {}).get("list") or [] return [ { @@ -360,15 +524,20 @@ class BybitClient: for r in rows ] + # ── microstructure / basis ───────────────────────────────── + async def get_orderbook_imbalance( self, symbol: str, category: str = "linear", depth: int = 10, ) -> dict: - """Microstructure: bid/ask imbalance ratio + microprice + slope.""" - ob = await self.get_orderbook(symbol=symbol, category=category, limit=max(depth, 50)) - result = micro.orderbook_imbalance(ob.get("bids") or [], ob.get("asks") or [], depth=depth) + ob = await self.get_orderbook( + symbol=symbol, category=category, limit=max(depth, 50) + ) + result = micro.orderbook_imbalance( + ob.get("bids") or [], ob.get("asks") or [], depth=depth + ) return { "symbol": symbol, "category": category, @@ -378,9 +547,6 @@ class BybitClient: } async def get_basis_term_structure(self, asset: str) -> dict: - """Basis curve futures (dated) vs perp + spot. Filtra contratti future - BTCUSDT / ETHUSDT con scadenza, calcola annualized basis per ognuno. - """ import datetime as _dt asset = asset.upper() @@ -389,12 +555,13 @@ class BybitClient: sp = spot.get("last_price") pp = perp.get("last_price") - # Lista futures dated (linear/inverse) instr = await self.get_instruments(category="linear") - items = (instr.get("instruments") or []) + items = instr.get("instruments") or [] futures = [ - x for x in items - if x.get("symbol", "").startswith(f"{asset}-") or x.get("symbol", "").startswith(f"{asset}USDT-") + x + for x in items + if x.get("symbol", "").startswith(f"{asset}-") + or x.get("symbol", "").startswith(f"{asset}USDT-") ] rows: list[dict[str, Any]] = [] @@ -409,21 +576,25 @@ class BybitClient: days = max((int(expiry_ms) - now_ms) / 86_400_000, 1) basis_pct = 100.0 * (fp - sp) / sp annualized = basis_pct * 365.0 / days - rows.append({ - "symbol": f["symbol"], - "expiry_ms": int(expiry_ms), - "days_to_expiry": round(days, 2), - "future_price": fp, - "basis_pct": round(basis_pct, 4), - "annualized_basis_pct": round(annualized, 4), - }) + rows.append( + { + "symbol": f["symbol"], + "expiry_ms": int(expiry_ms), + "days_to_expiry": round(days, 2), + "future_price": fp, + "basis_pct": round(basis_pct, 4), + "annualized_basis_pct": round(annualized, 4), + } + ) rows.sort(key=lambda r: r["days_to_expiry"]) return { "asset": asset, "spot_price": sp, "perp_price": pp, - "perp_basis_pct": round(100.0 * (pp - sp) / sp, 4) if (sp and pp) else None, + "perp_basis_pct": round(100.0 * (pp - sp) / sp, 4) + if (sp and pp) + else None, "term_structure": rows, "data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(), } @@ -449,11 +620,7 @@ class BybitClient: "funding_rate": perp.get("funding_rate"), } - def _envelope(self, resp: dict, payload: dict) -> dict: - code = resp.get("retCode", 0) - if code != 0: - return {"error": resp.get("retMsg", "bybit_error"), "code": code} - return payload + # ── trading (signed, write) ──────────────────────────────── async def place_order( self, @@ -467,7 +634,7 @@ class BybitClient: reduce_only: bool = False, position_idx: int | None = None, ) -> dict: - kwargs: dict[str, Any] = { + body: dict[str, Any] = { "category": category, "symbol": symbol, "side": side, @@ -477,38 +644,34 @@ class BybitClient: "reduceOnly": reduce_only, } if price is not None: - kwargs["price"] = str(price) + body["price"] = str(price) if position_idx is not None: - kwargs["positionIdx"] = position_idx + body["positionIdx"] = position_idx if category == "option": - import uuid - kwargs["orderLinkId"] = f"cerbero-{uuid.uuid4().hex[:16]}" - resp = await self._run(self._http.place_order, **kwargs) + body["orderLinkId"] = f"cerbero-{uuid.uuid4().hex[:16]}" + resp = await self._request_signed("POST", "/v5/order/create", body=body) r = resp.get("result") or {} - return self._envelope(resp, { - "order_id": r.get("orderId"), - "order_link_id": r.get("orderLinkId"), - "status": "submitted", - }) + return self._envelope( + resp, + { + "order_id": r.get("orderId"), + "order_link_id": r.get("orderLinkId"), + "status": "submitted", + }, + ) async def place_combo_order( self, category: str, legs: list[dict[str, Any]], ) -> dict: - """Atomic multi-leg via /v5/order/create-batch (Bybit option only). - - Bybit supporta batch_order solo su category='option'. Per perp/linear - usare loop di place_order (non atomic). - - legs: [{symbol, side, qty, order_type, price?, tif?, reduce_only?}]. - """ if category != "option": - raise ValueError("place_combo_order: Bybit batch_order è disponibile solo su category='option'") + raise ValueError( + "place_combo_order: Bybit batch_order è disponibile solo su category='option'" + ) if len(legs) < 2: raise ValueError("combo requires at least 2 legs") - import uuid request: list[dict[str, Any]] = [] for leg in legs: entry: dict[str, Any] = { @@ -524,7 +687,10 @@ class BybitClient: entry["price"] = str(leg["price"]) request.append(entry) - resp = await self._run(self._http.place_batch_order, category=category, request=request) + body = {"category": category, "request": request} + resp = await self._request_signed( + "POST", "/v5/order/create-batch", body=body + ) result_list = (resp.get("result") or {}).get("list") or [] orders = [ { @@ -544,80 +710,112 @@ class BybitClient: new_qty: float | None = None, new_price: float | None = None, ) -> dict: - kwargs: dict[str, Any] = { + body: dict[str, Any] = { "category": category, "symbol": symbol, "orderId": order_id, } if new_qty is not None: - kwargs["qty"] = str(new_qty) + body["qty"] = str(new_qty) if new_price is not None: - kwargs["price"] = str(new_price) - resp = await self._run(self._http.amend_order, **kwargs) + body["price"] = str(new_price) + resp = await self._request_signed("POST", "/v5/order/amend", body=body) r = resp.get("result") or {} - return self._envelope(resp, { - "order_id": r.get("orderId", order_id), - "status": "amended", - }) - - async def cancel_order( - self, category: str, symbol: str, order_id: str - ) -> dict: - resp = await self._run( - self._http.cancel_order, - category=category, symbol=symbol, orderId=order_id, + return self._envelope( + resp, + { + "order_id": r.get("orderId", order_id), + "status": "amended", + }, ) + + async def cancel_order(self, category: str, symbol: str, order_id: str) -> dict: + body = {"category": category, "symbol": symbol, "orderId": order_id} + resp = await self._request_signed("POST", "/v5/order/cancel", body=body) r = resp.get("result") or {} - return self._envelope(resp, { - "order_id": r.get("orderId", order_id), - "status": "cancelled", - }) + return self._envelope( + resp, + { + "order_id": r.get("orderId", order_id), + "status": "cancelled", + }, + ) async def cancel_all_orders( self, category: str, symbol: str | None = None ) -> dict: - kwargs: dict[str, Any] = {"category": category} + body: dict[str, Any] = {"category": category} if symbol: - kwargs["symbol"] = symbol - resp = await self._run(self._http.cancel_all_orders, **kwargs) + body["symbol"] = symbol + resp = await self._request_signed( + "POST", "/v5/order/cancel-all", body=body + ) r = resp.get("result") or {} ids = [x.get("orderId") for x in (r.get("list") or [])] - return self._envelope(resp, { - "cancelled_ids": ids, - "count": len(ids), - }) + return self._envelope( + resp, + { + "cancelled_ids": ids, + "count": len(ids), + }, + ) async def set_stop_loss( - self, category: str, symbol: str, stop_loss: float, + self, + category: str, + symbol: str, + stop_loss: float, position_idx: int = 0, ) -> dict: - resp = await self._run( - self._http.set_trading_stop, - category=category, symbol=symbol, - stopLoss=str(stop_loss), positionIdx=position_idx, + body = { + "category": category, + "symbol": symbol, + "stopLoss": str(stop_loss), + "positionIdx": position_idx, + } + resp = await self._request_signed( + "POST", "/v5/position/trading-stop", body=body + ) + return self._envelope( + resp, + { + "symbol": symbol, + "stop_loss": stop_loss, + "status": "stop_loss_set", + }, ) - return self._envelope(resp, { - "symbol": symbol, "stop_loss": stop_loss, - "status": "stop_loss_set", - }) async def set_take_profit( - self, category: str, symbol: str, take_profit: float, + self, + category: str, + symbol: str, + take_profit: float, position_idx: int = 0, ) -> dict: - resp = await self._run( - self._http.set_trading_stop, - category=category, symbol=symbol, - takeProfit=str(take_profit), positionIdx=position_idx, + body = { + "category": category, + "symbol": symbol, + "takeProfit": str(take_profit), + "positionIdx": position_idx, + } + resp = await self._request_signed( + "POST", "/v5/position/trading-stop", body=body + ) + return self._envelope( + resp, + { + "symbol": symbol, + "take_profit": take_profit, + "status": "take_profit_set", + }, ) - return self._envelope(resp, { - "symbol": symbol, "take_profit": take_profit, - "status": "take_profit_set", - }) async def close_position(self, category: str, symbol: str) -> dict: positions = await self.get_positions(category=category) - target = next((p for p in positions if p["symbol"] == symbol and (p["size"] or 0) > 0), None) + target = next( + (p for p in positions if p["symbol"] == symbol and (p["size"] or 0) > 0), + None, + ) if not target: return {"error": "no_open_position", "symbol": symbol} close_side = "Sell" if target["side"] == "Buy" else "Buy" @@ -634,28 +832,44 @@ class BybitClient: async def set_leverage( self, category: str, symbol: str, leverage: int ) -> dict: - resp = await self._run( - self._http.set_leverage, - category=category, symbol=symbol, - buyLeverage=str(leverage), sellLeverage=str(leverage), + body = { + "category": category, + "symbol": symbol, + "buyLeverage": str(leverage), + "sellLeverage": str(leverage), + } + resp = await self._request_signed( + "POST", "/v5/position/set-leverage", body=body + ) + return self._envelope( + resp, + { + "symbol": symbol, + "leverage": leverage, + "status": "leverage_set", + }, ) - return self._envelope(resp, { - "symbol": symbol, "leverage": leverage, - "status": "leverage_set", - }) async def switch_position_mode( self, category: str, symbol: str, mode: str ) -> dict: mode_code = 3 if mode.lower() == "hedge" else 0 - resp = await self._run( - self._http.switch_position_mode, - category=category, symbol=symbol, mode=mode_code, + body = { + "category": category, + "symbol": symbol, + "mode": mode_code, + } + resp = await self._request_signed( + "POST", "/v5/position/switch-mode", body=body + ) + return self._envelope( + resp, + { + "symbol": symbol, + "mode": mode, + "status": "mode_switched", + }, ) - return self._envelope(resp, { - "symbol": symbol, "mode": mode, - "status": "mode_switched", - }) async def transfer_asset( self, @@ -664,19 +878,23 @@ class BybitClient: from_type: str, to_type: str, ) -> dict: - import uuid - resp = await self._run( - self._http.create_internal_transfer, - transferId=str(uuid.uuid4()), - coin=coin, - amount=str(amount), - fromAccountType=from_type, - toAccountType=to_type, + body = { + "transferId": str(uuid.uuid4()), + "coin": coin, + "amount": str(amount), + "fromAccountType": from_type, + "toAccountType": to_type, + } + resp = await self._request_signed( + "POST", "/v5/asset/transfer/inter-transfer", body=body ) r = resp.get("result") or {} - return self._envelope(resp, { - "transfer_id": r.get("transferId"), - "coin": coin, - "amount": amount, - "status": "submitted", - }) + return self._envelope( + resp, + { + "transfer_id": r.get("transferId"), + "coin": coin, + "amount": amount, + "status": "submitted", + }, + ) diff --git a/tests/unit/exchanges/bybit/conftest.py b/tests/unit/exchanges/bybit/conftest.py index 51508cc..cfbdfbd 100644 --- a/tests/unit/exchanges/bybit/conftest.py +++ b/tests/unit/exchanges/bybit/conftest.py @@ -1,21 +1,18 @@ from __future__ import annotations -from unittest.mock import MagicMock - import pytest from cerbero_mcp.exchanges.bybit.client import BybitClient @pytest.fixture -def mock_http(): - return MagicMock(name="pybit_HTTP") +def client(): + """BybitClient con base_url testnet e AsyncClient interno. - -@pytest.fixture -def client(mock_http): + pytest-httpx intercetta le chiamate dell'AsyncClient httpx creato dal + costruttore (auto-mock), quindi non serve injection esplicita. + """ return BybitClient( api_key="test_key", api_secret="test_secret", testnet=True, - http=mock_http, ) diff --git a/tests/unit/exchanges/bybit/test_client.py b/tests/unit/exchanges/bybit/test_client.py index 7d5099a..8814d12 100644 --- a/tests/unit/exchanges/bybit/test_client.py +++ b/tests/unit/exchanges/bybit/test_client.py @@ -1,48 +1,108 @@ +"""Test del client Bybit V5 (httpx puro) con pytest-httpx. + +Tutte le chiamate HTTP vengono intercettate via `httpx_mock`. Le request +non hanno bisogno di un AsyncClient iniettato: BybitClient costruisce +internamente `httpx.AsyncClient`, e pytest-httpx applica un MockTransport +globale. +""" from __future__ import annotations +import json + +import httpx import pytest -from cerbero_mcp.exchanges.bybit.client import BybitClient +from cerbero_mcp.exchanges.bybit.client import ( + BASE_TESTNET, + BybitClient, + _f, + _i, +) +from pytest_httpx import HTTPXMock + +# ── helpers / costanti ──────────────────────────────────────── -def test_client_init_stores_attrs(client, mock_http): - assert client.testnet is True - assert client._http is mock_http +def _url(path: str) -> str: + return BASE_TESTNET + path -def test_client_init_default_http(monkeypatch): - created = {} - - class FakeHTTP: - def __init__(self, **kwargs): - created.update(kwargs) - - monkeypatch.setattr("cerbero_mcp.exchanges.bybit.client.HTTP", FakeHTTP) - BybitClient(api_key="k", api_secret="s", testnet=False) - assert created["api_key"] == "k" - assert created["api_secret"] == "s" - assert created["testnet"] is False +def _last_request_json(httpx_mock: HTTPXMock) -> dict: + req = httpx_mock.get_requests()[-1] + return json.loads(req.content.decode("utf-8")) -@pytest.mark.asyncio -async def test_get_ticker(client, mock_http): - mock_http.get_tickers.return_value = { - "retCode": 0, - "result": { - "list": [{ - "symbol": "BTCUSDT", - "lastPrice": "60000", - "markPrice": "60010", - "bid1Price": "59995", - "ask1Price": "60005", - "volume24h": "1500.5", - "turnover24h": "90000000", - "fundingRate": "0.0001", - "openInterest": "50000", - }] +# ── init / helpers basics ───────────────────────────────────── + + +def test_client_init_stores_attrs(): + c = BybitClient(api_key="k", api_secret="s", testnet=True) + assert c.testnet is True + assert c.base_url == BASE_TESTNET + assert c.api_key == "k" + + +def test_client_init_mainnet_base_url(): + c = BybitClient(api_key="k", api_secret="s", testnet=False) + assert c.testnet is False + assert c.base_url == "https://api.bybit.com" + + +def test_client_init_custom_base_url(): + c = BybitClient( + api_key="k", api_secret="s", testnet=True, base_url="https://example.test" + ) + assert c.base_url == "https://example.test" + + +def test_client_init_injected_http_not_owned(): + http = httpx.AsyncClient() + c = BybitClient(api_key="k", api_secret="s", http=http) + assert c._http is http + assert c._owns_http is False + + +def test_parse_helpers(): + assert _f("1.5") == 1.5 + assert _f("") is None + assert _f(None) is None + assert _i("42") == 42 + assert _i("") is None + assert _i(None) is None + + +async def test_aclose_owned_client_closes(): + c = BybitClient(api_key="k", api_secret="s", testnet=True) + await c.aclose() + # An aclosed client cannot dispatch requests + assert c._http.is_closed is True + + +# ── market data (public) ────────────────────────────────────── + + +async def test_get_ticker(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/market/tickers?category=linear&symbol=BTCUSDT"), + json={ + "retCode": 0, + "result": { + "list": [ + { + "symbol": "BTCUSDT", + "lastPrice": "60000", + "markPrice": "60010", + "bid1Price": "59995", + "ask1Price": "60005", + "volume24h": "1500.5", + "turnover24h": "90000000", + "fundingRate": "0.0001", + "openInterest": "50000", + } + ] + }, }, - } + ) t = await client.get_ticker("BTCUSDT", category="linear") - mock_http.get_tickers.assert_called_once_with(category="linear", symbol="BTCUSDT") assert t["symbol"] == "BTCUSDT" assert t["last_price"] == 60000.0 assert t["mark_price"] == 60010.0 @@ -53,77 +113,99 @@ async def test_get_ticker(client, mock_http): assert t["open_interest"] == 50000.0 -@pytest.mark.asyncio -async def test_get_ticker_batch(client, mock_http): - def side_effect(**kwargs): - symbol = kwargs["symbol"] - return {"retCode": 0, "result": {"list": [{ - "symbol": symbol, "lastPrice": "1", "markPrice": "1", - "bid1Price": "1", "ask1Price": "1", "volume24h": "0", - "turnover24h": "0", "fundingRate": "0", "openInterest": "0", - }]}} - mock_http.get_tickers.side_effect = side_effect +async def test_get_ticker_batch(client, httpx_mock: HTTPXMock): + def _row(sym: str) -> dict: + return { + "symbol": sym, + "lastPrice": "1", + "markPrice": "1", + "bid1Price": "1", + "ask1Price": "1", + "volume24h": "0", + "turnover24h": "0", + "fundingRate": "0", + "openInterest": "0", + } + + httpx_mock.add_response( + url=_url("/v5/market/tickers?category=linear&symbol=BTCUSDT"), + json={"retCode": 0, "result": {"list": [_row("BTCUSDT")]}}, + ) + httpx_mock.add_response( + url=_url("/v5/market/tickers?category=linear&symbol=ETHUSDT"), + json={"retCode": 0, "result": {"list": [_row("ETHUSDT")]}}, + ) out = await client.get_ticker_batch(["BTCUSDT", "ETHUSDT"], category="linear") assert set(out.keys()) == {"BTCUSDT", "ETHUSDT"} - assert mock_http.get_tickers.call_count == 2 -@pytest.mark.asyncio -async def test_get_ticker_not_found(client, mock_http): - mock_http.get_tickers.return_value = {"retCode": 0, "result": {"list": []}} +async def test_get_ticker_not_found(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/market/tickers?category=linear&symbol=UNKNOWNUSDT"), + json={"retCode": 0, "result": {"list": []}}, + ) t = await client.get_ticker("UNKNOWNUSDT", category="linear") assert t == {"symbol": "UNKNOWNUSDT", "error": "not_found"} -def test_parse_helpers(): - from cerbero_mcp.exchanges.bybit.client import _f, _i - assert _f("1.5") == 1.5 - assert _f("") is None - assert _f(None) is None - assert _i("42") == 42 - assert _i("") is None - assert _i(None) is None - - -@pytest.mark.asyncio -async def test_get_orderbook(client, mock_http): - mock_http.get_orderbook.return_value = { - "retCode": 0, - "result": { - "s": "BTCUSDT", - "b": [["59990", "0.5"], ["59980", "1.0"]], - "a": [["60010", "0.3"], ["60020", "0.7"]], - "ts": 1700000000000, +async def test_get_orderbook(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/market/orderbook?category=linear&symbol=BTCUSDT&limit=25"), + json={ + "retCode": 0, + "result": { + "s": "BTCUSDT", + "b": [["59990", "0.5"], ["59980", "1.0"]], + "a": [["60010", "0.3"], ["60020", "0.7"]], + "ts": 1700000000000, + }, }, - } - ob = await client.get_orderbook("BTCUSDT", category="linear", limit=25) - mock_http.get_orderbook.assert_called_once_with( - category="linear", symbol="BTCUSDT", limit=25 ) + ob = await client.get_orderbook("BTCUSDT", category="linear", limit=25) assert ob["symbol"] == "BTCUSDT" assert ob["bids"] == [[59990.0, 0.5], [59980.0, 1.0]] assert ob["asks"] == [[60010.0, 0.3], [60020.0, 0.7]] assert ob["timestamp"] == 1700000000000 -@pytest.mark.asyncio -async def test_get_historical(client, mock_http): - mock_http.get_kline.return_value = { - "retCode": 0, - "result": { - "list": [ - ["1700000000000", "60000", "60500", "59500", "60200", "100", "6020000"], - ["1700003600000", "60200", "60700", "60000", "60400", "80", "4832000"], - ] +async def test_get_historical(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url( + "/v5/market/kline?category=linear&symbol=BTCUSDT&interval=60&limit=1000" + "&start=1700000000000&end=1700003600000" + ), + json={ + "retCode": 0, + "result": { + "list": [ + [ + "1700000000000", + "60000", + "60500", + "59500", + "60200", + "100", + "6020000", + ], + [ + "1700003600000", + "60200", + "60700", + "60000", + "60400", + "80", + "4832000", + ], + ] + }, }, - } - out = await client.get_historical( - "BTCUSDT", category="linear", interval="60", - start=1700000000000, end=1700003600000, ) - mock_http.get_kline.assert_called_once_with( - category="linear", symbol="BTCUSDT", interval="60", - start=1700000000000, end=1700003600000, limit=1000, + out = await client.get_historical( + "BTCUSDT", + category="linear", + interval="60", + start=1700000000000, + end=1700003600000, ) assert len(out["candles"]) == 2 c0 = out["candles"][0] @@ -135,18 +217,28 @@ async def test_get_historical(client, mock_http): assert c0["volume"] == 100.0 -@pytest.mark.asyncio -async def test_get_indicators(client, mock_http): +async def test_get_indicators(client, httpx_mock: HTTPXMock): rows = [ - [str(1700000000000 + i * 3600_000), - str(60000 + i * 10), str(60000 + i * 10 + 5), - str(60000 + i * 10 - 5), str(60000 + i * 10 + 2), - "100", "6000000"] + [ + str(1700000000000 + i * 3600_000), + str(60000 + i * 10), + str(60000 + i * 10 + 5), + str(60000 + i * 10 - 5), + str(60000 + i * 10 + 2), + "100", + "6000000", + ] for i in range(35) ] - mock_http.get_kline.return_value = {"retCode": 0, "result": {"list": rows}} + httpx_mock.add_response( + url=_url( + "/v5/market/kline?category=linear&symbol=BTCUSDT&interval=60&limit=1000" + ), + json={"retCode": 0, "result": {"list": rows}}, + ) out = await client.get_indicators( - "BTCUSDT", category="linear", + "BTCUSDT", + category="linear", indicators=["rsi", "atr", "macd", "adx"], interval="60", ) @@ -156,70 +248,109 @@ async def test_get_indicators(client, mock_http): assert "adx" in out and out["adx"]["adx"] is not None -@pytest.mark.asyncio -async def test_get_funding_rate(client, mock_http): - mock_http.get_tickers.return_value = { - "retCode": 0, - "result": {"list": [{ - "symbol": "BTCUSDT", "fundingRate": "0.0001", - "nextFundingTime": "1700003600000", - "lastPrice": "60000", "markPrice": "60000", - "bid1Price": "0", "ask1Price": "0", - "volume24h": "0", "turnover24h": "0", "openInterest": "0", - }]}, - } +async def test_get_funding_rate(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/market/tickers?category=linear&symbol=BTCUSDT"), + json={ + "retCode": 0, + "result": { + "list": [ + { + "symbol": "BTCUSDT", + "fundingRate": "0.0001", + "nextFundingTime": "1700003600000", + "lastPrice": "60000", + "markPrice": "60000", + "bid1Price": "0", + "ask1Price": "0", + "volume24h": "0", + "turnover24h": "0", + "openInterest": "0", + } + ] + }, + }, + ) out = await client.get_funding_rate("BTCUSDT", category="linear") assert out["symbol"] == "BTCUSDT" assert out["funding_rate"] == 0.0001 assert out["next_funding_time"] == 1700003600000 -@pytest.mark.asyncio -async def test_get_funding_history(client, mock_http): - mock_http.get_funding_rate_history.return_value = { - "retCode": 0, - "result": {"list": [ - {"symbol": "BTCUSDT", "fundingRate": "0.0001", "fundingRateTimestamp": "1700000000000"}, - {"symbol": "BTCUSDT", "fundingRate": "0.00008", "fundingRateTimestamp": "1699996400000"}, - ]}, - } - out = await client.get_funding_history("BTCUSDT", category="linear", limit=50) - mock_http.get_funding_rate_history.assert_called_once_with( - category="linear", symbol="BTCUSDT", limit=50 +async def test_get_funding_history(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url( + "/v5/market/funding/history?category=linear&symbol=BTCUSDT&limit=50" + ), + json={ + "retCode": 0, + "result": { + "list": [ + { + "symbol": "BTCUSDT", + "fundingRate": "0.0001", + "fundingRateTimestamp": "1700000000000", + }, + { + "symbol": "BTCUSDT", + "fundingRate": "0.00008", + "fundingRateTimestamp": "1699996400000", + }, + ] + }, + }, ) + out = await client.get_funding_history("BTCUSDT", category="linear", limit=50) assert len(out["history"]) == 2 assert out["history"][0]["rate"] == 0.0001 -@pytest.mark.asyncio -async def test_get_open_interest(client, mock_http): - mock_http.get_open_interest.return_value = { - "retCode": 0, - "result": {"list": [ - {"openInterest": "50000", "timestamp": "1700000000000"}, - {"openInterest": "49000", "timestamp": "1699996400000"}, - ]}, - } - out = await client.get_open_interest("BTCUSDT", category="linear", interval="5min", limit=100) - mock_http.get_open_interest.assert_called_once_with( - category="linear", symbol="BTCUSDT", intervalTime="5min", limit=100 +async def test_get_open_interest(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url( + "/v5/market/open-interest?category=linear&symbol=BTCUSDT" + "&intervalTime=5min&limit=100" + ), + json={ + "retCode": 0, + "result": { + "list": [ + {"openInterest": "50000", "timestamp": "1700000000000"}, + {"openInterest": "49000", "timestamp": "1699996400000"}, + ] + }, + }, + ) + out = await client.get_open_interest( + "BTCUSDT", category="linear", interval="5min", limit=100 ) assert len(out["points"]) == 2 assert out["current_oi"] == 50000.0 -@pytest.mark.asyncio -async def test_get_instruments(client, mock_http): - mock_http.get_instruments_info.return_value = { - "retCode": 0, - "result": {"list": [ - {"symbol": "BTCUSDT", "status": "Trading", "baseCoin": "BTC", - "quoteCoin": "USDT", "priceFilter": {"tickSize": "0.1"}, - "lotSizeFilter": {"qtyStep": "0.001", "minOrderQty": "0.001"}}, - ]}, - } +async def test_get_instruments(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/market/instruments-info?category=linear"), + json={ + "retCode": 0, + "result": { + "list": [ + { + "symbol": "BTCUSDT", + "status": "Trading", + "baseCoin": "BTC", + "quoteCoin": "USDT", + "priceFilter": {"tickSize": "0.1"}, + "lotSizeFilter": { + "qtyStep": "0.001", + "minOrderQty": "0.001", + }, + } + ] + }, + }, + ) out = await client.get_instruments(category="linear") - mock_http.get_instruments_info.assert_called_once_with(category="linear") assert len(out["instruments"]) == 1 inst = out["instruments"][0] assert inst["symbol"] == "BTCUSDT" @@ -227,37 +358,63 @@ async def test_get_instruments(client, mock_http): assert inst["qty_step"] == 0.001 -@pytest.mark.asyncio -async def test_get_option_chain(client, mock_http): - mock_http.get_instruments_info.return_value = { - "retCode": 0, - "result": {"list": [ - {"symbol": "BTC-30JUN25-50000-C", "baseCoin": "BTC", - "settleCoin": "USDC", "optionsType": "Call", - "launchTime": "1700000000000", "deliveryTime": "1719734400000"}, - {"symbol": "BTC-30JUN25-50000-P", "baseCoin": "BTC", - "settleCoin": "USDC", "optionsType": "Put", - "launchTime": "1700000000000", "deliveryTime": "1719734400000"}, - ]}, - } +async def test_get_option_chain(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/market/instruments-info?category=option&baseCoin=BTC"), + json={ + "retCode": 0, + "result": { + "list": [ + { + "symbol": "BTC-30JUN25-50000-C", + "baseCoin": "BTC", + "settleCoin": "USDC", + "optionsType": "Call", + "launchTime": "1700000000000", + "deliveryTime": "1719734400000", + }, + { + "symbol": "BTC-30JUN25-50000-P", + "baseCoin": "BTC", + "settleCoin": "USDC", + "optionsType": "Put", + "launchTime": "1700000000000", + "deliveryTime": "1719734400000", + }, + ] + }, + }, + ) out = await client.get_option_chain(base_coin="BTC") - mock_http.get_instruments_info.assert_called_once_with(category="option", baseCoin="BTC") assert len(out["options"]) == 2 assert out["options"][0]["type"] == "Call" -@pytest.mark.asyncio -async def test_get_positions(client, mock_http): - mock_http.get_positions.return_value = { - "retCode": 0, - "result": {"list": [ - {"symbol": "BTCUSDT", "side": "Buy", "size": "0.1", - "avgPrice": "60000", "unrealisedPnl": "50", - "leverage": "10", "liqPrice": "50000", "positionValue": "6000"}, - ]}, - } +# ── account / positions / orders (signed reads) ─────────────── + + +async def test_get_positions(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/position/list?category=linear&settleCoin=USDT"), + json={ + "retCode": 0, + "result": { + "list": [ + { + "symbol": "BTCUSDT", + "side": "Buy", + "size": "0.1", + "avgPrice": "60000", + "unrealisedPnl": "50", + "leverage": "10", + "liqPrice": "50000", + "positionValue": "6000", + } + ] + }, + }, + ) out = await client.get_positions(category="linear") - mock_http.get_positions.assert_called_once_with(category="linear", settleCoin="USDT") assert len(out) == 1 p = out[0] assert p["symbol"] == "BTCUSDT" @@ -265,26 +422,39 @@ async def test_get_positions(client, mock_http): assert p["size"] == 0.1 assert p["entry_price"] == 60000.0 assert p["liquidation_price"] == 50000.0 + # signed: header X-BAPI-API-KEY presente + req = httpx_mock.get_requests()[-1] + assert req.headers.get("X-BAPI-API-KEY") == "test_key" + assert "X-BAPI-SIGN" in req.headers -@pytest.mark.asyncio -async def test_get_account_summary(client, mock_http): - mock_http.get_wallet_balance.return_value = { - "retCode": 0, - "result": {"list": [{ - "accountType": "UNIFIED", - "totalEquity": "10000", - "totalWalletBalance": "9500", - "totalMarginBalance": "9800", - "totalAvailableBalance": "9000", - "totalPerpUPL": "200", - "coin": [ - {"coin": "USDT", "walletBalance": "9500", "equity": "9700"} - ], - }]}, - } +async def test_get_account_summary(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/account/wallet-balance?accountType=UNIFIED"), + json={ + "retCode": 0, + "result": { + "list": [ + { + "accountType": "UNIFIED", + "totalEquity": "10000", + "totalWalletBalance": "9500", + "totalMarginBalance": "9800", + "totalAvailableBalance": "9000", + "totalPerpUPL": "200", + "coin": [ + { + "coin": "USDT", + "walletBalance": "9500", + "equity": "9700", + } + ], + } + ] + }, + }, + ) out = await client.get_account_summary() - mock_http.get_wallet_balance.assert_called_once_with(accountType="UNIFIED") assert out["equity"] == 10000.0 assert out["available_balance"] == 9000.0 assert out["unrealized_pnl"] == 200.0 @@ -292,59 +462,106 @@ async def test_get_account_summary(client, mock_http): assert out["coins"][0]["coin"] == "USDT" -@pytest.mark.asyncio -async def test_get_trade_history(client, mock_http): - mock_http.get_executions.return_value = { - "retCode": 0, - "result": {"list": [ - {"symbol": "BTCUSDT", "side": "Buy", "execQty": "0.01", - "execPrice": "60000", "execFee": "0.1", - "execTime": "1700000000000", "orderId": "abc"}, - ]}, - } +async def test_get_trade_history(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/execution/list?category=linear&limit=50"), + json={ + "retCode": 0, + "result": { + "list": [ + { + "symbol": "BTCUSDT", + "side": "Buy", + "execQty": "0.01", + "execPrice": "60000", + "execFee": "0.1", + "execTime": "1700000000000", + "orderId": "abc", + } + ] + }, + }, + ) out = await client.get_trade_history(category="linear", limit=50) - mock_http.get_executions.assert_called_once_with(category="linear", limit=50) assert len(out) == 1 assert out[0]["symbol"] == "BTCUSDT" assert out[0]["size"] == 0.01 assert out[0]["price"] == 60000.0 -@pytest.mark.asyncio -async def test_get_open_orders(client, mock_http): - mock_http.get_open_orders.return_value = { - "retCode": 0, - "result": {"list": [ - {"symbol": "BTCUSDT", "orderId": "o1", "side": "Buy", - "qty": "0.1", "price": "59000", "orderType": "Limit", - "orderStatus": "New", "reduceOnly": False}, - ]}, - } +async def test_get_open_orders(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/order/realtime?category=linear&settleCoin=USDT"), + json={ + "retCode": 0, + "result": { + "list": [ + { + "symbol": "BTCUSDT", + "orderId": "o1", + "side": "Buy", + "qty": "0.1", + "price": "59000", + "orderType": "Limit", + "orderStatus": "New", + "reduceOnly": False, + } + ] + }, + }, + ) out = await client.get_open_orders(category="linear") - mock_http.get_open_orders.assert_called_once_with(category="linear", settleCoin="USDT") assert len(out) == 1 assert out[0]["order_id"] == "o1" assert out[0]["price"] == 59000.0 -@pytest.mark.asyncio -async def test_get_basis_spot_perp(client, mock_http): - def side(**kwargs): - if kwargs["category"] == "spot": - return {"retCode": 0, "result": {"list": [{ - "symbol": "BTCUSDT", "lastPrice": "60000", "markPrice": "60000", - "bid1Price": "59995", "ask1Price": "60005", - "volume24h": "0", "turnover24h": "0", - "fundingRate": "0", "openInterest": "0", - }]}} - else: - return {"retCode": 0, "result": {"list": [{ - "symbol": "BTCUSDT", "lastPrice": "60120", "markPrice": "60120", - "bid1Price": "60115", "ask1Price": "60125", - "volume24h": "0", "turnover24h": "0", - "fundingRate": "0.0001", "openInterest": "0", - }]}} - mock_http.get_tickers.side_effect = side +# ── basis / spreads ──────────────────────────────────────────── + + +async def test_get_basis_spot_perp(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/market/tickers?category=spot&symbol=BTCUSDT"), + json={ + "retCode": 0, + "result": { + "list": [ + { + "symbol": "BTCUSDT", + "lastPrice": "60000", + "markPrice": "60000", + "bid1Price": "59995", + "ask1Price": "60005", + "volume24h": "0", + "turnover24h": "0", + "fundingRate": "0", + "openInterest": "0", + } + ] + }, + }, + ) + httpx_mock.add_response( + url=_url("/v5/market/tickers?category=linear&symbol=BTCUSDT"), + json={ + "retCode": 0, + "result": { + "list": [ + { + "symbol": "BTCUSDT", + "lastPrice": "60120", + "markPrice": "60120", + "bid1Price": "60115", + "ask1Price": "60125", + "volume24h": "0", + "turnover24h": "0", + "fundingRate": "0.0001", + "openInterest": "0", + } + ] + }, + }, + ) out = await client.get_basis_spot_perp("BTC") assert out["asset"] == "BTC" assert out["spot_price"] == 60000.0 @@ -353,236 +570,345 @@ async def test_get_basis_spot_perp(client, mock_http): assert round(out["basis_pct"], 3) == 0.2 -@pytest.mark.asyncio -async def test_place_order_limit(client, mock_http): - mock_http.place_order.return_value = { - "retCode": 0, - "result": {"orderId": "ord123", "orderLinkId": ""}, - } +# ── trading writes ──────────────────────────────────────────── + + +async def test_place_order_limit(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/order/create"), + method="POST", + json={ + "retCode": 0, + "result": {"orderId": "ord123", "orderLinkId": ""}, + }, + ) out = await client.place_order( - category="linear", symbol="BTCUSDT", side="Buy", - qty=0.01, order_type="Limit", price=60000.0, tif="GTC", + category="linear", + symbol="BTCUSDT", + side="Buy", + qty=0.01, + order_type="Limit", + price=60000.0, + tif="GTC", ) assert out["order_id"] == "ord123" - kwargs = mock_http.place_order.call_args.kwargs - assert kwargs["category"] == "linear" - assert kwargs["symbol"] == "BTCUSDT" - assert kwargs["side"] == "Buy" - assert kwargs["qty"] == "0.01" - assert kwargs["orderType"] == "Limit" - assert kwargs["price"] == "60000.0" - assert kwargs["timeInForce"] == "GTC" + body = _last_request_json(httpx_mock) + assert body["category"] == "linear" + assert body["symbol"] == "BTCUSDT" + assert body["side"] == "Buy" + assert body["qty"] == "0.01" + assert body["orderType"] == "Limit" + assert body["price"] == "60000.0" + assert body["timeInForce"] == "GTC" -@pytest.mark.asyncio -async def test_place_order_error(client, mock_http): - mock_http.place_order.return_value = {"retCode": 10001, "retMsg": "insufficient balance"} +async def test_place_order_error(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/order/create"), + method="POST", + json={"retCode": 10001, "retMsg": "insufficient balance"}, + ) out = await client.place_order( - category="linear", symbol="BTCUSDT", side="Buy", qty=0.01, order_type="Market" + category="linear", + symbol="BTCUSDT", + side="Buy", + qty=0.01, + order_type="Market", ) assert out.get("error") == "insufficient balance" assert out.get("code") == 10001 -@pytest.mark.asyncio -async def test_amend_order(client, mock_http): - mock_http.amend_order.return_value = {"retCode": 0, "result": {"orderId": "ord1"}} +async def test_amend_order(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/order/amend"), + method="POST", + json={"retCode": 0, "result": {"orderId": "ord1"}}, + ) out = await client.amend_order( category="linear", symbol="BTCUSDT", order_id="ord1", new_qty=0.02 ) assert out["order_id"] == "ord1" - kwargs = mock_http.amend_order.call_args.kwargs - assert kwargs["orderId"] == "ord1" - assert kwargs["qty"] == "0.02" - assert "price" not in kwargs + body = _last_request_json(httpx_mock) + assert body["orderId"] == "ord1" + assert body["qty"] == "0.02" + assert "price" not in body -@pytest.mark.asyncio -async def test_place_order_option_adds_link_id(client, mock_http): - mock_http.place_order.return_value = { - "retCode": 0, - "result": {"orderId": "opt1", "orderLinkId": "cerbero-abc"}, - } - await client.place_order( - category="option", symbol="BTC-24APR26-96000-C-USDT", - side="Buy", qty=0.01, order_type="Limit", price=5.0, - ) - kwargs = mock_http.place_order.call_args.kwargs - assert "orderLinkId" in kwargs - assert kwargs["orderLinkId"].startswith("cerbero-") - - -@pytest.mark.asyncio -async def test_place_order_linear_no_link_id(client, mock_http): - mock_http.place_order.return_value = {"retCode": 0, "result": {"orderId": "x"}} - await client.place_order( - category="linear", symbol="BTCUSDT", side="Buy", qty=0.01, order_type="Market" - ) - kwargs = mock_http.place_order.call_args.kwargs - assert "orderLinkId" not in kwargs - - -@pytest.mark.asyncio -async def test_place_combo_order_batch_option(client, mock_http): - """Combo order via place_batch_order su category=option (atomic, 1 round-trip).""" - mock_http.place_batch_order.return_value = { - "retCode": 0, - "result": { - "list": [ - {"orderId": "ord-1", "orderLinkId": "cerbero-leg1"}, - {"orderId": "ord-2", "orderLinkId": "cerbero-leg2"}, - ] +async def test_place_order_option_adds_link_id(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/order/create"), + method="POST", + json={ + "retCode": 0, + "result": {"orderId": "opt1", "orderLinkId": "cerbero-abc"}, }, - } + ) + await client.place_order( + category="option", + symbol="BTC-24APR26-96000-C-USDT", + side="Buy", + qty=0.01, + order_type="Limit", + price=5.0, + ) + body = _last_request_json(httpx_mock) + assert "orderLinkId" in body + assert body["orderLinkId"].startswith("cerbero-") + + +async def test_place_order_linear_no_link_id(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/order/create"), + method="POST", + json={"retCode": 0, "result": {"orderId": "x"}}, + ) + await client.place_order( + category="linear", + symbol="BTCUSDT", + side="Buy", + qty=0.01, + order_type="Market", + ) + body = _last_request_json(httpx_mock) + assert "orderLinkId" not in body + + +async def test_place_combo_order_batch_option(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/order/create-batch"), + method="POST", + json={ + "retCode": 0, + "result": { + "list": [ + {"orderId": "ord-1", "orderLinkId": "cerbero-leg1"}, + {"orderId": "ord-2", "orderLinkId": "cerbero-leg2"}, + ] + }, + }, + ) legs = [ - {"symbol": "BTC-30APR26-75000-C-USDT", "side": "Buy", "qty": 0.01, "order_type": "Limit", "price": 5.0}, - {"symbol": "BTC-30APR26-80000-C-USDT", "side": "Sell", "qty": 0.01, "order_type": "Limit", "price": 3.0}, + { + "symbol": "BTC-30APR26-75000-C-USDT", + "side": "Buy", + "qty": 0.01, + "order_type": "Limit", + "price": 5.0, + }, + { + "symbol": "BTC-30APR26-80000-C-USDT", + "side": "Sell", + "qty": 0.01, + "order_type": "Limit", + "price": 3.0, + }, ] out = await client.place_combo_order(category="option", legs=legs) assert len(out["orders"]) == 2 assert out["orders"][0]["order_id"] == "ord-1" - kwargs = mock_http.place_batch_order.call_args.kwargs - assert kwargs["category"] == "option" - request = kwargs["request"] + body = _last_request_json(httpx_mock) + assert body["category"] == "option" + request = body["request"] assert len(request) == 2 assert request[0]["symbol"] == "BTC-30APR26-75000-C-USDT" assert request[0]["qty"] == "0.01" assert request[0]["orderType"] == "Limit" - # CER: orderLinkId obbligatorio per option assert "orderLinkId" in request[0] -@pytest.mark.asyncio -async def test_place_combo_order_error(client, mock_http): - mock_http.place_batch_order.return_value = {"retCode": 10001, "retMsg": "invalid leg"} +async def test_place_combo_order_error(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/order/create-batch"), + method="POST", + json={"retCode": 10001, "retMsg": "invalid leg"}, + ) out = await client.place_combo_order( category="option", legs=[ - {"symbol": "X", "side": "Buy", "qty": 1, "order_type": "Limit", "price": 1.0}, - {"symbol": "Y", "side": "Sell", "qty": 1, "order_type": "Limit", "price": 1.0}, + { + "symbol": "X", + "side": "Buy", + "qty": 1, + "order_type": "Limit", + "price": 1.0, + }, + { + "symbol": "Y", + "side": "Sell", + "qty": 1, + "order_type": "Limit", + "price": 1.0, + }, ], ) assert out["error"] == "invalid leg" assert out["code"] == 10001 -@pytest.mark.asyncio -async def test_place_combo_order_rejects_non_option(client, mock_http): - """Bybit batch_order è disponibile solo su option category.""" - import pytest as _pytest - with _pytest.raises(ValueError, match="option"): +async def test_place_combo_order_rejects_non_option(client): + with pytest.raises(ValueError, match="option"): await client.place_combo_order( category="linear", legs=[ - {"symbol": "BTCUSDT", "side": "Buy", "qty": 0.01, "order_type": "Market"}, - {"symbol": "ETHUSDT", "side": "Sell", "qty": 0.01, "order_type": "Market"}, + { + "symbol": "BTCUSDT", + "side": "Buy", + "qty": 0.01, + "order_type": "Market", + }, + { + "symbol": "ETHUSDT", + "side": "Sell", + "qty": 0.01, + "order_type": "Market", + }, ], ) -@pytest.mark.asyncio -async def test_cancel_order(client, mock_http): - mock_http.cancel_order.return_value = {"retCode": 0, "result": {"orderId": "ord1"}} - out = await client.cancel_order(category="linear", symbol="BTCUSDT", order_id="ord1") - mock_http.cancel_order.assert_called_once_with( - category="linear", symbol="BTCUSDT", orderId="ord1" +async def test_cancel_order(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/order/cancel"), + method="POST", + json={"retCode": 0, "result": {"orderId": "ord1"}}, ) + out = await client.cancel_order(category="linear", symbol="BTCUSDT", order_id="ord1") assert out["order_id"] == "ord1" assert out["status"] == "cancelled" + body = _last_request_json(httpx_mock) + assert body["category"] == "linear" + assert body["symbol"] == "BTCUSDT" + assert body["orderId"] == "ord1" -@pytest.mark.asyncio -async def test_cancel_all_orders(client, mock_http): - mock_http.cancel_all_orders.return_value = { - "retCode": 0, - "result": {"list": [{"orderId": "o1"}, {"orderId": "o2"}]}, - } - out = await client.cancel_all_orders(category="linear", symbol="BTCUSDT") - mock_http.cancel_all_orders.assert_called_once_with( - category="linear", symbol="BTCUSDT" +async def test_cancel_all_orders(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/order/cancel-all"), + method="POST", + json={ + "retCode": 0, + "result": {"list": [{"orderId": "o1"}, {"orderId": "o2"}]}, + }, ) + out = await client.cancel_all_orders(category="linear", symbol="BTCUSDT") assert out["cancelled_ids"] == ["o1", "o2"] + body = _last_request_json(httpx_mock) + assert body["category"] == "linear" + assert body["symbol"] == "BTCUSDT" -@pytest.mark.asyncio -async def test_set_stop_loss(client, mock_http): - mock_http.set_trading_stop.return_value = {"retCode": 0, "result": {}} +async def test_set_stop_loss(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/position/trading-stop"), + method="POST", + json={"retCode": 0, "result": {}}, + ) out = await client.set_stop_loss( category="linear", symbol="BTCUSDT", stop_loss=55000.0 ) - mock_http.set_trading_stop.assert_called_once() - kwargs = mock_http.set_trading_stop.call_args.kwargs - assert kwargs["category"] == "linear" - assert kwargs["symbol"] == "BTCUSDT" - assert kwargs["stopLoss"] == "55000.0" - assert kwargs.get("positionIdx", 0) == 0 + body = _last_request_json(httpx_mock) + assert body["category"] == "linear" + assert body["symbol"] == "BTCUSDT" + assert body["stopLoss"] == "55000.0" + assert body.get("positionIdx", 0) == 0 assert out["status"] == "stop_loss_set" -@pytest.mark.asyncio -async def test_set_take_profit(client, mock_http): - mock_http.set_trading_stop.return_value = {"retCode": 0, "result": {}} +async def test_set_take_profit(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/position/trading-stop"), + method="POST", + json={"retCode": 0, "result": {}}, + ) out = await client.set_take_profit( category="linear", symbol="BTCUSDT", take_profit=65000.0 ) - kwargs = mock_http.set_trading_stop.call_args.kwargs - assert kwargs["takeProfit"] == "65000.0" + body = _last_request_json(httpx_mock) + assert body["takeProfit"] == "65000.0" assert out["status"] == "take_profit_set" -@pytest.mark.asyncio -async def test_close_position(client, mock_http): - mock_http.get_positions.return_value = { - "retCode": 0, "result": {"list": [ - {"symbol": "BTCUSDT", "side": "Buy", "size": "0.1", - "avgPrice": "60000", "unrealisedPnl": "0", - "leverage": "10", "liqPrice": "0", "positionValue": "6000"}, - ]}, - } - mock_http.place_order.return_value = { - "retCode": 0, "result": {"orderId": "closeord", "orderLinkId": ""}, - } +async def test_close_position(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/position/list?category=linear&settleCoin=USDT"), + json={ + "retCode": 0, + "result": { + "list": [ + { + "symbol": "BTCUSDT", + "side": "Buy", + "size": "0.1", + "avgPrice": "60000", + "unrealisedPnl": "0", + "leverage": "10", + "liqPrice": "0", + "positionValue": "6000", + } + ] + }, + }, + ) + httpx_mock.add_response( + url=_url("/v5/order/create"), + method="POST", + json={ + "retCode": 0, + "result": {"orderId": "closeord", "orderLinkId": ""}, + }, + ) out = await client.close_position(category="linear", symbol="BTCUSDT") assert out["status"] == "submitted" - kwargs = mock_http.place_order.call_args.kwargs - assert kwargs["side"] == "Sell" - assert kwargs["qty"] == "0.1" - assert kwargs["reduceOnly"] is True - assert kwargs["orderType"] == "Market" + # the LAST request is the place_order body + body = _last_request_json(httpx_mock) + assert body["side"] == "Sell" + assert body["qty"] == "0.1" + assert body["reduceOnly"] is True + assert body["orderType"] == "Market" -@pytest.mark.asyncio -async def test_set_leverage(client, mock_http): - mock_http.set_leverage.return_value = {"retCode": 0, "result": {}} - out = await client.set_leverage(category="linear", symbol="BTCUSDT", leverage=5) - mock_http.set_leverage.assert_called_once_with( - category="linear", symbol="BTCUSDT", buyLeverage="5", sellLeverage="5" +async def test_set_leverage(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/position/set-leverage"), + method="POST", + json={"retCode": 0, "result": {}}, ) + out = await client.set_leverage(category="linear", symbol="BTCUSDT", leverage=5) + body = _last_request_json(httpx_mock) + assert body["category"] == "linear" + assert body["symbol"] == "BTCUSDT" + assert body["buyLeverage"] == "5" + assert body["sellLeverage"] == "5" assert out["status"] == "leverage_set" -@pytest.mark.asyncio -async def test_switch_position_mode(client, mock_http): - mock_http.switch_position_mode.return_value = {"retCode": 0, "result": {}} +async def test_switch_position_mode(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/position/switch-mode"), + method="POST", + json={"retCode": 0, "result": {}}, + ) out = await client.switch_position_mode( category="linear", symbol="BTCUSDT", mode="hedge" ) - kwargs = mock_http.switch_position_mode.call_args.kwargs - assert kwargs["mode"] == 3 + body = _last_request_json(httpx_mock) + assert body["mode"] == 3 assert out["status"] == "mode_switched" -@pytest.mark.asyncio -async def test_transfer_asset(client, mock_http): - mock_http.create_internal_transfer.return_value = { - "retCode": 0, "result": {"transferId": "tx123"}, - } +async def test_transfer_asset(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=_url("/v5/asset/transfer/inter-transfer"), + method="POST", + json={"retCode": 0, "result": {"transferId": "tx123"}}, + ) out = await client.transfer_asset( coin="USDT", amount=100.0, from_type="UNIFIED", to_type="FUND" ) - kwargs = mock_http.create_internal_transfer.call_args.kwargs - assert kwargs["coin"] == "USDT" - assert kwargs["amount"] == "100.0" - assert kwargs["fromAccountType"] == "UNIFIED" - assert kwargs["toAccountType"] == "FUND" + body = _last_request_json(httpx_mock) + assert body["coin"] == "USDT" + assert body["amount"] == "100.0" + assert body["fromAccountType"] == "UNIFIED" + assert body["toAccountType"] == "FUND" assert out["transfer_id"] == "tx123" diff --git a/tests/unit/test_exchanges_builder.py b/tests/unit/test_exchanges_builder.py index c53d95c..dfde514 100644 --- a/tests/unit/test_exchanges_builder.py +++ b/tests/unit/test_exchanges_builder.py @@ -29,15 +29,8 @@ async def test_build_client_bybit_returns_correct_env(monkeypatch): for k, v in _minimal_env().items(): monkeypatch.setenv(k, v) - # Stub pybit HTTP per evitare connessione reale durante __init__ - from cerbero_mcp.exchanges.bybit import client as bybit_client - - class _FakeHTTP: - def __init__(self, **kwargs): - self.kwargs = kwargs - - monkeypatch.setattr(bybit_client, "HTTP", _FakeHTTP) - + # BybitClient costruisce internamente httpx.AsyncClient: nessuna + # connessione reale finché non si invoca un metodo di rete. from cerbero_mcp.exchanges import build_client from cerbero_mcp.settings import Settings @@ -78,28 +71,22 @@ async def test_build_client_alpaca_returns_correct_env(monkeypatch): for k, v in _minimal_env().items(): monkeypatch.setenv(k, v) - # Stub alpaca SDK clients per evitare connessioni reali in __init__ - from cerbero_mcp.exchanges.alpaca import client as alpaca_client - - class _FakeSdk: - def __init__(self, **kwargs): - self.kwargs = kwargs - - monkeypatch.setattr(alpaca_client, "TradingClient", _FakeSdk) - monkeypatch.setattr(alpaca_client, "StockHistoricalDataClient", _FakeSdk) - monkeypatch.setattr(alpaca_client, "CryptoHistoricalDataClient", _FakeSdk) - monkeypatch.setattr(alpaca_client, "OptionHistoricalDataClient", _FakeSdk) - + # AlpacaClient (V2) usa httpx puro: il costruttore non apre connessioni + # reali (httpx.AsyncClient è lazy fino alla prima request), quindi nessuno + # stub SDK è necessario. from cerbero_mcp.exchanges import build_client from cerbero_mcp.settings import Settings s = Settings() c_test = await build_client(s, "alpaca", "testnet") c_live = await build_client(s, "alpaca", "mainnet") - - assert c_test is not c_live - assert c_test.paper is True - assert c_live.paper is False + try: + assert c_test is not c_live + assert c_test.paper is True + assert c_live.paper is False + finally: + await c_test.aclose() + await c_live.aclose() @pytest.mark.asyncio @@ -202,8 +189,8 @@ async def test_hyperliquid_url_from_env_overrides_default(monkeypatch): @pytest.mark.asyncio async def test_bybit_url_from_env_overrides_default(monkeypatch): - """Bybit: pybit non accetta `endpoint` come kwarg, ma setting di - `_http.endpoint` post-init rispecchia l'override.""" + """Bybit (httpx): override BYBIT_URL_TESTNET applica direttamente a + `self.base_url`, usato come base di ogni richiesta REST V5.""" from tests.unit.test_settings import _minimal_env env = _minimal_env(BYBIT_URL_TESTNET="https://bybit-custom.example.com") @@ -216,14 +203,12 @@ async def test_bybit_url_from_env_overrides_default(monkeypatch): s = Settings() c = await build_client(s, "bybit", "testnet") assert c.base_url == "https://bybit-custom.example.com" - # override applicato all'istanza pybit HTTP via attributo `endpoint` - assert getattr(c._http, "endpoint", None) == "https://bybit-custom.example.com" @pytest.mark.asyncio async def test_alpaca_url_from_env_overrides_default(monkeypatch): - """Alpaca: TradingClient supporta url_override per trading API. - Data clients (Stock/Crypto/Option) non supportano override sul costruttore.""" + """Alpaca V2 (httpx): `base_url` override applica al solo trading + endpoint; data endpoints (data.alpaca.markets) restano hardcoded.""" from tests.unit.test_settings import _minimal_env env = _minimal_env(ALPACA_URL_TESTNET="https://alpaca-custom.example.com") @@ -235,7 +220,10 @@ async def test_alpaca_url_from_env_overrides_default(monkeypatch): s = Settings() c = await build_client(s, "alpaca", "testnet") - assert c.base_url == "https://alpaca-custom.example.com" + try: + assert c.base_url == "https://alpaca-custom.example.com" + finally: + await c.aclose() @pytest.mark.asyncio