"""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 ( BASE_TESTNET, BybitClient, _f, _i, ) from pytest_httpx import HTTPXMock # ── helpers / costanti ──────────────────────────────────────── def _url(path: str) -> str: return BASE_TESTNET + path def _last_request_json(httpx_mock: HTTPXMock) -> dict: req = httpx_mock.get_requests()[-1] return json.loads(req.content.decode("utf-8")) # ── 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") assert t["symbol"] == "BTCUSDT" assert t["last_price"] == 60000.0 assert t["mark_price"] == 60010.0 assert t["bid"] == 59995.0 assert t["ask"] == 60005.0 assert t["volume_24h"] == 1500.5 assert t["funding_rate"] == 0.0001 assert t["open_interest"] == 50000.0 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"} 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"} 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) 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 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, ) assert len(out["candles"]) == 2 c0 = out["candles"][0] assert c0["timestamp"] == 1700000000000 assert c0["open"] == 60000.0 assert c0["high"] == 60500.0 assert c0["low"] == 59500.0 assert c0["close"] == 60200.0 assert c0["volume"] == 100.0 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", ] for i in range(35) ] 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", indicators=["rsi", "atr", "macd", "adx"], interval="60", ) assert "rsi" in out and out["rsi"] is not None assert "atr" in out and out["atr"] is not None assert "macd" in out and out["macd"]["macd"] is not None assert "adx" in out and out["adx"]["adx"] is not None 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 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 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 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") assert len(out["instruments"]) == 1 inst = out["instruments"][0] assert inst["symbol"] == "BTCUSDT" assert inst["tick_size"] == 0.1 assert inst["qty_step"] == 0.001 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") assert len(out["options"]) == 2 assert out["options"][0]["type"] == "Call" # ── 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") assert len(out) == 1 p = out[0] assert p["symbol"] == "BTCUSDT" assert p["side"] == "Buy" 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 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() assert out["equity"] == 10000.0 assert out["available_balance"] == 9000.0 assert out["unrealized_pnl"] == 200.0 assert len(out["coins"]) == 1 assert out["coins"][0]["coin"] == "USDT" 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) assert len(out) == 1 assert out[0]["symbol"] == "BTCUSDT" assert out[0]["size"] == 0.01 assert out[0]["price"] == 60000.0 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") assert len(out) == 1 assert out[0]["order_id"] == "o1" assert out[0]["price"] == 59000.0 # ── 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 assert out["perp_price"] == 60120.0 assert out["basis_abs"] == 120.0 assert round(out["basis_pct"], 3) == 0.2 # ── 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", ) assert out["order_id"] == "ord123" 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" 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", ) assert out.get("error") == "insufficient balance" assert out.get("code") == 10001 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" body = _last_request_json(httpx_mock) assert body["orderId"] == "ord1" assert body["qty"] == "0.02" assert "price" not in body 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, }, ] out = await client.place_combo_order(category="option", legs=legs) assert len(out["orders"]) == 2 assert out["orders"][0]["order_id"] == "ord-1" 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" assert "orderLinkId" in request[0] 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, }, ], ) assert out["error"] == "invalid leg" assert out["code"] == 10001 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", }, ], ) 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" 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" 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 ) 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" 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 ) body = _last_request_json(httpx_mock) assert body["takeProfit"] == "65000.0" assert out["status"] == "take_profit_set" 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" # 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" 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" 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" ) body = _last_request_json(httpx_mock) assert body["mode"] == 3 assert out["status"] == "mode_switched" 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" ) 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"