Files

915 lines
29 KiB
Python

"""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"