"""Tests for DeribitClient.""" from __future__ import annotations import json from datetime import UTC, datetime from decimal import Decimal import pytest from pytest_httpx import HTTPXMock from cerbero_bite.clients._base import HttpToolClient from cerbero_bite.clients._exceptions import McpDataAnomalyError from cerbero_bite.clients.deribit import ( ComboLegOrder, DeribitClient, ) def _client() -> DeribitClient: http = HttpToolClient( service="deribit", base_url="http://mcp-deribit:9011", token="t", retry_max=1, ) return DeribitClient(http) def _request_body(httpx_mock: HTTPXMock, *, index: int = -1) -> dict: requests = httpx_mock.get_requests() assert requests, "expected at least one request" return json.loads(requests[index].read()) # --------------------------------------------------------------------------- # environment_info # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_environment_info_parses_payload(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="http://mcp-deribit:9011/tools/environment_info", json={ "exchange": "deribit", "environment": "testnet", "source": "env", "env_value": "true", "base_url": "https://test.deribit.com/api/v2", "max_leverage": 3, }, ) info = await _client().environment_info() assert info.environment == "testnet" assert info.source == "env" assert info.max_leverage == 3 # --------------------------------------------------------------------------- # index_price_eth # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_index_price_eth_uses_perpetual_mark(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="http://mcp-deribit:9011/tools/get_ticker", json={"instrument_name": "ETH-PERPETUAL", "mark_price": 3024.5, "bid": 3024.3}, ) out = await _client().index_price_eth() assert out == Decimal("3024.5") body = _request_body(httpx_mock) assert body == {"instrument_name": "ETH-PERPETUAL"} @pytest.mark.asyncio async def test_index_price_eth_anomaly_when_mark_missing( httpx_mock: HTTPXMock, ) -> None: httpx_mock.add_response(json={"instrument_name": "ETH-PERPETUAL"}) with pytest.raises(McpDataAnomalyError, match="mark_price"): await _client().index_price_eth() # --------------------------------------------------------------------------- # latest_dvol # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_latest_dvol_returns_latest_field(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="http://mcp-deribit:9011/tools/get_dvol", json={"currency": "ETH", "latest": 52.4, "candles": []}, ) out = await _client().latest_dvol(now=datetime(2026, 4, 27, 14, 0, tzinfo=UTC)) assert out == Decimal("52.4") @pytest.mark.asyncio async def test_latest_dvol_falls_back_to_candle_close(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( json={ "currency": "ETH", "candles": [ {"close": 50.0}, {"close": 51.7}, ], } ) out = await _client().latest_dvol(now=datetime(2026, 4, 27, 14, 0, tzinfo=UTC)) assert out == Decimal("51.7") @pytest.mark.asyncio async def test_latest_dvol_anomaly_when_empty(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(json={"currency": "ETH", "candles": []}) with pytest.raises(McpDataAnomalyError): await _client().latest_dvol(now=datetime(2026, 4, 27, 14, 0, tzinfo=UTC)) # --------------------------------------------------------------------------- # options_chain # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_options_chain_parses_instrument_names(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="http://mcp-deribit:9011/tools/get_instruments", json={ "instruments": [ { "name": "ETH-15MAY26-2475-P", "strike": 2475, "open_interest": 200, "tick_size": 0.0005, "min_trade_amount": 1, }, { "name": "ETH-15MAY26-2350-P", "strike": 2350, "open_interest": 150, }, {"name": "MALFORMED-INSTRUMENT"}, ] }, ) chain = await _client().options_chain(currency="ETH") names = [m.name for m in chain] assert names == ["ETH-15MAY26-2475-P", "ETH-15MAY26-2350-P"] assert chain[0].strike == Decimal("2475") assert chain[0].expiry == datetime(2026, 5, 15, 8, 0, tzinfo=UTC) assert chain[0].option_type == "P" assert chain[0].open_interest == Decimal("200") # --------------------------------------------------------------------------- # get_tickers / orderbook # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_get_tickers_returns_list(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="http://mcp-deribit:9011/tools/get_ticker_batch", json={"tickers": [{"instrument_name": "X"}], "errors": []}, ) tickers = await _client().get_tickers(["X"]) assert tickers == [{"instrument_name": "X"}] @pytest.mark.asyncio async def test_get_tickers_empty_short_circuits(httpx_mock: HTTPXMock) -> None: out = await _client().get_tickers([]) assert out == [] assert httpx_mock.get_requests() == [] @pytest.mark.asyncio async def test_get_tickers_rejects_more_than_twenty() -> None: with pytest.raises(ValueError, match="max 20"): await _client().get_tickers(["x"] * 21) @pytest.mark.asyncio async def test_get_tickers_anomaly_on_error_envelope( httpx_mock: HTTPXMock, ) -> None: httpx_mock.add_response(json={"error": "max 20 instruments per batch"}) with pytest.raises(McpDataAnomalyError): await _client().get_tickers(["x"]) @pytest.mark.asyncio async def test_orderbook_depth_top3_sums_levels(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="http://mcp-deribit:9011/tools/get_orderbook", json={ "bids": [[3000, 5], [2999, 7], [2998, 3], [2997, 100]], "asks": [[3001, 4], [3002, 6], [3003, 2]], }, ) depth = await _client().orderbook_depth_top3("ETH-15MAY26-2475-P") # bids 5+7+3=15; asks 4+6+2=12 → 27 assert depth == 27 # --------------------------------------------------------------------------- # place_combo_order # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_place_combo_order_submits_correct_body(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="http://mcp-deribit:9011/tools/place_combo_order", json={ "combo_instrument": "ETH-15MAY26-2475P_2350P", "order_id": "ord-1", "state": "open", "average_price": None, "filled_amount": 0, }, ) legs = [ ComboLegOrder(instrument_name="ETH-15MAY26-2475-P", direction="sell"), ComboLegOrder(instrument_name="ETH-15MAY26-2350-P", direction="buy"), ] res = await _client().place_combo_order( legs=legs, side="sell", n_contracts=2, limit_price_eth=Decimal("0.005"), label="weekly-open", ) body = _request_body(httpx_mock) assert body["side"] == "sell" assert body["amount"] == 2 assert body["price"] == 0.005 assert body["label"] == "weekly-open" assert body["legs"] == [ {"instrument_name": "ETH-15MAY26-2475-P", "direction": "sell", "ratio": 1}, {"instrument_name": "ETH-15MAY26-2350-P", "direction": "buy", "ratio": 1}, ] assert res.combo_instrument == "ETH-15MAY26-2475P_2350P" assert res.order_id == "ord-1" assert res.state == "open" @pytest.mark.asyncio async def test_place_combo_rejects_single_leg() -> None: with pytest.raises(ValueError, match="at least 2 legs"): await _client().place_combo_order( legs=[ComboLegOrder(instrument_name="X", direction="sell")], side="sell", n_contracts=1, limit_price_eth=Decimal("0.001"), ) @pytest.mark.asyncio async def test_place_combo_rejects_zero_contracts() -> None: legs = [ ComboLegOrder(instrument_name="A", direction="sell"), ComboLegOrder(instrument_name="B", direction="buy"), ] with pytest.raises(ValueError, match="n_contracts"): await _client().place_combo_order( legs=legs, side="sell", n_contracts=0, limit_price_eth=Decimal("0.001") ) @pytest.mark.asyncio async def test_place_combo_rejects_limit_without_price() -> None: legs = [ ComboLegOrder(instrument_name="A", direction="sell"), ComboLegOrder(instrument_name="B", direction="buy"), ] with pytest.raises(ValueError, match="limit price"): await _client().place_combo_order( legs=legs, side="sell", n_contracts=1, limit_price_eth=None ) @pytest.mark.asyncio async def test_place_combo_anomaly_when_combo_instrument_missing( httpx_mock: HTTPXMock, ) -> None: httpx_mock.add_response(json={"order_id": "x"}) legs = [ ComboLegOrder(instrument_name="A", direction="sell"), ComboLegOrder(instrument_name="B", direction="buy"), ] with pytest.raises(McpDataAnomalyError, match="combo_instrument"): await _client().place_combo_order( legs=legs, side="sell", n_contracts=1, limit_price_eth=Decimal("0.001") ) # --------------------------------------------------------------------------- # cancel + accounts # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_cancel_order_passes_id(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="http://mcp-deribit:9011/tools/cancel_order", json={"order_id": "ord-1", "state": "cancelled"}, ) out = await _client().cancel_order("ord-1") assert out["state"] == "cancelled" @pytest.mark.asyncio async def test_get_account_summary_passes_currency(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(json={"equity": 1000, "balance": 1000}) out = await _client().get_account_summary("USDC") assert out["equity"] == 1000 @pytest.mark.asyncio async def test_get_positions_returns_list(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(json=[{"instrument": "ETH-PERPETUAL", "size": 1}]) out = await _client().get_positions() assert out == [{"instrument": "ETH-PERPETUAL", "size": 1}] # --------------------------------------------------------------------------- # misc # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_dealer_gamma_profile_eth_parses_payload(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="http://mcp-deribit:9011/tools/get_dealer_gamma_profile", json={ "currency": "ETH", "spot_price": 3000.0, "by_strike": [], "total_net_dealer_gamma": 12345.6, "gamma_flip_level": 2950.5, "strikes_analyzed": 18, }, ) snap = await _client().dealer_gamma_profile_eth() assert snap.spot_price == Decimal("3000.0") assert snap.total_net_dealer_gamma == Decimal("12345.6") assert snap.gamma_flip_level == Decimal("2950.5") assert snap.strikes_analyzed == 18 @pytest.mark.asyncio async def test_dealer_gamma_profile_anomaly_when_total_missing( httpx_mock: HTTPXMock, ) -> None: httpx_mock.add_response(json={"spot_price": 3000.0}) with pytest.raises(McpDataAnomalyError, match="missing spot_price or total"): await _client().dealer_gamma_profile_eth() def test_deribit_client_rejects_wrong_service() -> None: bad = HttpToolClient( service="macro", base_url="http://x:1", token="t", retry_max=1 ) with pytest.raises(ValueError, match="requires service 'deribit'"): DeribitClient(bad)