feat(V2): unified /mcp interface; retire Bybit/Alpaca to old/
Add a common cross-exchange interface (/mcp) over the integrated venues
(deribit, hyperliquid):
- get_instruments: uniform schema where each row carries its own
`exchange`, `fees` (maker/taker, live from Deribit, null where the
venue has no per-instrument schedule) and `history_start` (listing
date, live from Deribit creation_timestamp), plus type/tick_size and a
lossless `native` blob. Optional `exchange` filter; fan-out otherwise.
- get_historical: generalized to {exchange, instrument, interval,
start_date, end_date}, returning a single chosen venue's candles.
Consensus merge stays available on /mcp-cross.
New: routers/unified.py, exchanges/cross/instruments.py (normalizers),
UnifiedClient in cross/client.py, schemas in cross/tools.py. Deribit
get_instruments now also surfaces maker/taker_commission and
creation_timestamp (additive).
Retire Bybit and Alpaca from the API surface: move clients, routers,
settings classes and their tests under old/ (history preserved via
git mv); drop them from the builder, /mcp-cross dispatch and symbol_map.
Bybit remains a public funding/OI data source in sentiment (not the
trading client). IBKR is intentionally excluded from /mcp for now.
Docs: rewrite API_REFERENCE.md (remove Bybit/Alpaca, document /mcp,
clarify that data_timestamp is injected globally by middleware).
Tests: add unified-interface coverage; update cross/settings/builder/boot
tests for the reduced venue set. Fix a pre-existing flaky assertion in
the Hyperliquid signing test (r/s use eth_utils.to_hex like the official
SDK, so a leading zero byte yields <66 chars ~1/256 of the time).
323 passed, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from cerbero_mcp.exchanges.alpaca.client import AlpacaClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
"""AlpacaClient paper su httpx mock (gestito da pytest-httpx)."""
|
||||
c = AlpacaClient(api_key="test_key", secret_key="test_secret", paper=True)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
await c.aclose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client_live():
|
||||
c = AlpacaClient(api_key="test_key", secret_key="test_secret", paper=False)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
await c.aclose()
|
||||
@@ -0,0 +1,417 @@
|
||||
"""Test AlpacaClient httpx-based (V2.0.0).
|
||||
|
||||
Mockano gli endpoint REST tramite pytest-httpx. Coprono account/positions,
|
||||
ordini (place/cancel/limit-error), close position, clock, asset class
|
||||
invalida → ValueError.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from cerbero_mcp.exchanges.alpaca.client import AlpacaClient
|
||||
from pytest_httpx import HTTPXMock
|
||||
|
||||
PAPER = "https://paper-api.alpaca.markets"
|
||||
DATA = "https://data.alpaca.markets"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_paper_mode(client: AlpacaClient):
|
||||
assert client.paper is True
|
||||
assert client.base_url is None
|
||||
assert client._trading_base == PAPER
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_live_mode(client_live: AlpacaClient):
|
||||
assert client_live.paper is False
|
||||
assert client_live._trading_base == "https://api.alpaca.markets"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_base_url_override():
|
||||
c = AlpacaClient(
|
||||
api_key="k",
|
||||
secret_key="s",
|
||||
paper=True,
|
||||
base_url="https://alpaca-custom.example.com",
|
||||
)
|
||||
try:
|
||||
assert c.base_url == "https://alpaca-custom.example.com"
|
||||
assert c._trading_base == "https://alpaca-custom.example.com"
|
||||
# Data endpoint NON viene overridato
|
||||
assert c._data_base == DATA
|
||||
finally:
|
||||
await c.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_account(httpx_mock: HTTPXMock, client: AlpacaClient):
|
||||
httpx_mock.add_response(
|
||||
url=f"{PAPER}/v2/account",
|
||||
json={"id": "abc", "equity": "100000.00", "buying_power": "200000.00"},
|
||||
)
|
||||
result = await client.get_account()
|
||||
assert result["id"] == "abc"
|
||||
assert result["equity"] == "100000.00"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_account_sends_auth_headers(
|
||||
httpx_mock: HTTPXMock, client: AlpacaClient
|
||||
):
|
||||
httpx_mock.add_response(url=f"{PAPER}/v2/account", json={"id": "x"})
|
||||
await client.get_account()
|
||||
req = httpx_mock.get_requests()[0]
|
||||
assert req.headers["APCA-API-KEY-ID"] == "test_key"
|
||||
assert req.headers["APCA-API-SECRET-KEY"] == "test_secret"
|
||||
assert req.headers["Accept"] == "application/json"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_positions_returns_list(
|
||||
httpx_mock: HTTPXMock, client: AlpacaClient
|
||||
):
|
||||
httpx_mock.add_response(
|
||||
url=f"{PAPER}/v2/positions",
|
||||
json=[{"symbol": "AAPL", "qty": "10", "side": "long"}],
|
||||
)
|
||||
result = await client.get_positions()
|
||||
assert len(result) == 1
|
||||
assert result[0]["symbol"] == "AAPL"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_place_market_order_stocks(
|
||||
httpx_mock: HTTPXMock, client: AlpacaClient
|
||||
):
|
||||
httpx_mock.add_response(
|
||||
method="POST",
|
||||
url=f"{PAPER}/v2/orders",
|
||||
json={"id": "o123", "symbol": "AAPL", "status": "accepted"},
|
||||
)
|
||||
result = await client.place_order(
|
||||
symbol="AAPL", side="buy", qty=1, order_type="market", asset_class="stocks"
|
||||
)
|
||||
assert result["id"] == "o123"
|
||||
# body POST corretto
|
||||
req = httpx_mock.get_requests()[0]
|
||||
import json as _j
|
||||
|
||||
body = _j.loads(req.content)
|
||||
assert body["symbol"] == "AAPL"
|
||||
assert body["side"] == "buy"
|
||||
assert body["type"] == "market"
|
||||
assert body["qty"] == "1"
|
||||
assert body["time_in_force"] == "day"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_place_limit_order_requires_price(client: AlpacaClient):
|
||||
with pytest.raises(ValueError, match="limit_price"):
|
||||
await client.place_order(
|
||||
symbol="AAPL", side="buy", qty=1, order_type="limit"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_place_stop_order_requires_price(client: AlpacaClient):
|
||||
with pytest.raises(ValueError, match="stop_price"):
|
||||
await client.place_order(
|
||||
symbol="AAPL", side="buy", qty=1, order_type="stop"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_place_unsupported_order_type(client: AlpacaClient):
|
||||
with pytest.raises(ValueError, match="unsupported order_type"):
|
||||
await client.place_order(
|
||||
symbol="AAPL", side="buy", qty=1, order_type="trailing_stop"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_order(httpx_mock: HTTPXMock, client: AlpacaClient):
|
||||
# 204 No Content su success
|
||||
httpx_mock.add_response(
|
||||
method="DELETE",
|
||||
url=f"{PAPER}/v2/orders/o1",
|
||||
status_code=204,
|
||||
)
|
||||
result = await client.cancel_order("o1")
|
||||
assert result == {"order_id": "o1", "canceled": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_all_orders(httpx_mock: HTTPXMock, client: AlpacaClient):
|
||||
httpx_mock.add_response(
|
||||
method="DELETE",
|
||||
url=f"{PAPER}/v2/orders",
|
||||
json=[
|
||||
{"id": "a", "status": 200},
|
||||
{"id": "b", "status": 200},
|
||||
],
|
||||
)
|
||||
result = await client.cancel_all_orders()
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_position_no_options(
|
||||
httpx_mock: HTTPXMock, client: AlpacaClient
|
||||
):
|
||||
httpx_mock.add_response(
|
||||
method="DELETE",
|
||||
url=f"{PAPER}/v2/positions/AAPL",
|
||||
json={"id": "close-1", "symbol": "AAPL"},
|
||||
)
|
||||
result = await client.close_position("AAPL")
|
||||
assert result["id"] == "close-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_position_with_qty(
|
||||
httpx_mock: HTTPXMock, client: AlpacaClient
|
||||
):
|
||||
httpx_mock.add_response(
|
||||
method="DELETE",
|
||||
url=f"{PAPER}/v2/positions/AAPL?qty=5.0",
|
||||
json={"id": "close-2"},
|
||||
)
|
||||
result = await client.close_position("AAPL", qty=5.0)
|
||||
assert result["id"] == "close-2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_all_positions(
|
||||
httpx_mock: HTTPXMock, client: AlpacaClient
|
||||
):
|
||||
httpx_mock.add_response(
|
||||
method="DELETE",
|
||||
url=f"{PAPER}/v2/positions?cancel_orders=true",
|
||||
json=[{"symbol": "AAPL", "status": 200}],
|
||||
)
|
||||
result = await client.close_all_positions(cancel_orders=True)
|
||||
assert len(result) == 1
|
||||
assert result[0]["symbol"] == "AAPL"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_clock(httpx_mock: HTTPXMock, client: AlpacaClient):
|
||||
httpx_mock.add_response(
|
||||
url=f"{PAPER}/v2/clock",
|
||||
json={"is_open": True, "next_close": "2026-04-21T20:00:00Z"},
|
||||
)
|
||||
result = await client.get_clock()
|
||||
assert result["is_open"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_asset_class(client: AlpacaClient):
|
||||
with pytest.raises(ValueError, match="invalid asset_class"):
|
||||
await client.get_ticker("AAPL", asset_class="forex")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_ticker_stocks(httpx_mock: HTTPXMock, client: AlpacaClient):
|
||||
httpx_mock.add_response(
|
||||
url=f"{DATA}/v2/stocks/AAPL/trades/latest",
|
||||
json={
|
||||
"symbol": "AAPL",
|
||||
"trade": {"p": 175.50, "s": 100, "t": "2026-04-18T15:30:00Z"},
|
||||
},
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url=f"{DATA}/v2/stocks/AAPL/quotes/latest",
|
||||
json={
|
||||
"symbol": "AAPL",
|
||||
"quote": {
|
||||
"bp": 175.40,
|
||||
"ap": 175.55,
|
||||
"bs": 50,
|
||||
"as": 25,
|
||||
"t": "2026-04-18T15:30:00Z",
|
||||
},
|
||||
},
|
||||
)
|
||||
result = await client.get_ticker("AAPL", asset_class="stocks")
|
||||
assert result["asset_class"] == "stocks"
|
||||
assert result["last_price"] == 175.50
|
||||
assert result["bid"] == 175.40
|
||||
assert result["ask"] == 175.55
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_bars_stocks(httpx_mock: HTTPXMock, client: AlpacaClient):
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(rf"^{DATA}/v2/stocks/bars\?.*"),
|
||||
json={
|
||||
"bars": {
|
||||
"AAPL": [
|
||||
{
|
||||
"t": "2026-04-17T00:00:00Z",
|
||||
"o": 170.0,
|
||||
"h": 176.0,
|
||||
"l": 169.5,
|
||||
"c": 175.0,
|
||||
"v": 1000000,
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
result = await client.get_bars(
|
||||
symbol="AAPL",
|
||||
asset_class="stocks",
|
||||
interval="1d",
|
||||
start="2026-04-17T00:00:00",
|
||||
end="2026-04-18T00:00:00",
|
||||
limit=10,
|
||||
)
|
||||
assert result["symbol"] == "AAPL"
|
||||
assert result["interval"] == "1d"
|
||||
assert len(result["candles"]) == 1
|
||||
assert result["candles"][0]["close"] == 175.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_bars_unsupported_timeframe(client: AlpacaClient):
|
||||
with pytest.raises(ValueError, match="unsupported timeframe"):
|
||||
await client.get_bars(
|
||||
symbol="AAPL",
|
||||
asset_class="stocks",
|
||||
interval="3min",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_bars_invalid_asset_class(client: AlpacaClient):
|
||||
with pytest.raises(ValueError, match="invalid asset_class"):
|
||||
await client.get_bars(symbol="AAPL", asset_class="forex")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_assets(httpx_mock: HTTPXMock, client: AlpacaClient):
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(rf"^{PAPER}/v2/assets\?.*"),
|
||||
json=[
|
||||
{"symbol": "AAPL", "tradable": True, "class": "us_equity"},
|
||||
{"symbol": "GOOG", "tradable": True, "class": "us_equity"},
|
||||
],
|
||||
)
|
||||
result = await client.get_assets(asset_class="stocks", status="active")
|
||||
assert len(result) == 2
|
||||
assert result[0]["symbol"] == "AAPL"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_assets_invalid_class(client: AlpacaClient):
|
||||
with pytest.raises(ValueError, match="invalid asset_class"):
|
||||
await client.get_assets(asset_class="forex")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_open_orders(httpx_mock: HTTPXMock, client: AlpacaClient):
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(rf"^{PAPER}/v2/orders\?.*"),
|
||||
json=[{"id": "o1", "status": "open", "symbol": "AAPL"}],
|
||||
)
|
||||
result = await client.get_open_orders(limit=10)
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "o1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_amend_order(httpx_mock: HTTPXMock, client: AlpacaClient):
|
||||
httpx_mock.add_response(
|
||||
method="PATCH",
|
||||
url=f"{PAPER}/v2/orders/o1",
|
||||
json={"id": "o1", "qty": "5", "limit_price": "180.0"},
|
||||
)
|
||||
result = await client.amend_order(
|
||||
"o1", qty=5, limit_price=180.0, tif="gtc"
|
||||
)
|
||||
assert result["id"] == "o1"
|
||||
req = httpx_mock.get_requests()[0]
|
||||
import json as _j
|
||||
|
||||
body = _j.loads(req.content)
|
||||
assert body["qty"] == "5"
|
||||
assert body["limit_price"] == "180.0"
|
||||
assert body["time_in_force"] == "gtc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_calendar(httpx_mock: HTTPXMock, client: AlpacaClient):
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(rf"^{PAPER}/v2/calendar.*"),
|
||||
json=[{"date": "2026-04-20", "open": "09:30", "close": "16:00"}],
|
||||
)
|
||||
result = await client.get_calendar(start="2026-04-20", end="2026-04-20")
|
||||
assert len(result) == 1
|
||||
assert result[0]["date"] == "2026-04-20"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_calendar_no_filters(
|
||||
httpx_mock: HTTPXMock, client: AlpacaClient
|
||||
):
|
||||
httpx_mock.add_response(
|
||||
url=f"{PAPER}/v2/calendar",
|
||||
json=[{"date": "2026-04-20"}],
|
||||
)
|
||||
result = await client.get_calendar()
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_snapshot(httpx_mock: HTTPXMock, client: AlpacaClient):
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(rf"^{DATA}/v2/stocks/snapshots\?.*"),
|
||||
json={
|
||||
"AAPL": {
|
||||
"latestTrade": {"p": 175.0},
|
||||
"latestQuote": {"bp": 174.9, "ap": 175.1},
|
||||
}
|
||||
},
|
||||
)
|
||||
result = await client.get_snapshot("AAPL")
|
||||
assert result["latestTrade"]["p"] == 175.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_option_chain(httpx_mock: HTTPXMock, client: AlpacaClient):
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(rf"^{DATA}/v1beta1/options/snapshots/AAPL.*"),
|
||||
json={
|
||||
"snapshots": {
|
||||
"AAPL250620C00200000": {
|
||||
"latestQuote": {"bp": 1.20, "ap": 1.30}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
result = await client.get_option_chain("AAPL", expiry="2026-06-20")
|
||||
assert result["underlying"] == "AAPL"
|
||||
assert result["expiry"] == "2026-06-20"
|
||||
assert "AAPL250620C00200000" in result["contracts"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_activities(httpx_mock: HTTPXMock, client: AlpacaClient):
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(rf"^{PAPER}/v2/account/activities.*"),
|
||||
json=[
|
||||
{"id": "1", "activity_type": "FILL"},
|
||||
{"id": "2", "activity_type": "TRANS"},
|
||||
],
|
||||
)
|
||||
result = await client.get_activities(limit=10)
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aclose_idempotent(client: AlpacaClient):
|
||||
await client.aclose()
|
||||
await client.aclose() # nessun raise
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from cerbero_mcp.exchanges.alpaca.leverage_cap import enforce_leverage, get_max_leverage
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def test_get_max_leverage_returns_creds_value():
|
||||
creds = {"max_leverage": 4}
|
||||
assert get_max_leverage(creds) == 4
|
||||
|
||||
|
||||
def test_get_max_leverage_default_when_missing():
|
||||
"""Default 1 (cash) se il secret non ha max_leverage."""
|
||||
assert get_max_leverage({}) == 1
|
||||
|
||||
|
||||
def test_enforce_leverage_pass_at_cap_one():
|
||||
"""Alpaca cash account: cap 1, leverage 1 OK."""
|
||||
creds = {"max_leverage": 1}
|
||||
enforce_leverage(1, creds=creds, exchange="alpaca") # no raise
|
||||
|
||||
|
||||
def test_enforce_leverage_reject_over_cap_one():
|
||||
creds = {"max_leverage": 1}
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
enforce_leverage(2, creds=creds, exchange="alpaca")
|
||||
assert exc.value.status_code == 403
|
||||
assert exc.value.detail["error"] == "LEVERAGE_CAP_EXCEEDED"
|
||||
assert exc.value.detail["exchange"] == "alpaca"
|
||||
assert exc.value.detail["requested"] == 2
|
||||
assert exc.value.detail["max"] == 1
|
||||
|
||||
|
||||
def test_enforce_leverage_reject_when_below_one():
|
||||
creds = {"max_leverage": 1}
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
enforce_leverage(0, creds=creds, exchange="alpaca")
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_enforce_leverage_default_when_none():
|
||||
"""Se requested è None, applica il cap come default."""
|
||||
creds = {"max_leverage": 1}
|
||||
result = enforce_leverage(None, creds=creds, exchange="alpaca")
|
||||
assert result == 1
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from cerbero_mcp.exchanges.bybit.client import BybitClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""BybitClient con base_url testnet e AsyncClient interno.
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,914 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from cerbero_mcp.exchanges.bybit.leverage_cap import enforce_leverage, get_max_leverage
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def test_get_max_leverage_returns_creds_value():
|
||||
creds = {"max_leverage": 5}
|
||||
assert get_max_leverage(creds) == 5
|
||||
|
||||
|
||||
def test_get_max_leverage_default_when_missing():
|
||||
"""Default 1 (cash) se il secret non ha max_leverage."""
|
||||
assert get_max_leverage({}) == 1
|
||||
|
||||
|
||||
def test_enforce_leverage_pass_under_cap():
|
||||
creds = {"max_leverage": 3}
|
||||
enforce_leverage(2, creds=creds, exchange="bybit") # no raise
|
||||
|
||||
|
||||
def test_enforce_leverage_pass_at_cap():
|
||||
creds = {"max_leverage": 3}
|
||||
enforce_leverage(3, creds=creds, exchange="bybit") # no raise
|
||||
|
||||
|
||||
def test_enforce_leverage_reject_over_cap():
|
||||
creds = {"max_leverage": 3}
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
enforce_leverage(10, creds=creds, exchange="bybit")
|
||||
assert exc.value.status_code == 403
|
||||
assert exc.value.detail["error"] == "LEVERAGE_CAP_EXCEEDED"
|
||||
assert exc.value.detail["exchange"] == "bybit"
|
||||
assert exc.value.detail["requested"] == 10
|
||||
assert exc.value.detail["max"] == 3
|
||||
|
||||
|
||||
def test_enforce_leverage_reject_when_below_one():
|
||||
creds = {"max_leverage": 3}
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
enforce_leverage(0, creds=creds, exchange="bybit")
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_enforce_leverage_default_when_none():
|
||||
"""Se requested è None, applica il cap come default."""
|
||||
creds = {"max_leverage": 3}
|
||||
result = enforce_leverage(None, creds=creds, exchange="bybit")
|
||||
assert result == 3
|
||||
Reference in New Issue
Block a user