466e63dc19
Wrapper async tipizzati sui sei servizi MCP HTTP che Cerbero Bite consuma in autonomia. 277 test pass, copertura clients 93%, mypy strict pulito, ruff clean. Base layer: - clients/_base.py: HttpToolClient con httpx + tenacity (retry esponenziale 3x, timeout 8s, mapping HTTP→eccezioni tipizzate). - clients/_exceptions.py: McpAuthError, McpServerError, McpToolError, McpDataAnomalyError, McpNotFoundError, McpTimeoutError. - config/mcp_endpoints.py: risoluzione URL via Docker DNS (mcp-deribit:9011, ...) con override per servizio via env var; caricamento bearer token da secrets/core.token o CERBERO_BITE_CORE_TOKEN_FILE. Wrapper: - clients/macro.py: next_high_severity_within() per filtro entry §2.5. - clients/sentiment.py: funding_cross_median_annualized() con annualizzazione per period nativo per exchange (Binance/Bybit/OKX 1095, Hyperliquid 8760). - clients/hyperliquid.py: funding_rate_annualized() per filtro §2.6. - clients/portfolio.py: total_equity_eur(), asset_pct_of_portfolio() per sizing engine + filtro §2.7. - clients/telegram.py: notify-only (no callback queue, no conferme — Bite auto-execute). - clients/deribit.py: environment_info, index_price_eth, latest_dvol, options_chain, get_tickers, orderbook_depth_top3, get_account_summary, get_positions, place_combo_order (combo atomico), cancel_order. CLI: - cerbero-bite ping: health-check parallelo di tutti gli MCP con tabella rich (OK/FAIL/SKIPPED). Docker: - Dockerfile multi-stage Python 3.13 + uv, user non-root. - docker-compose.yml con rete external "cerbero-suite", secret core_token montato a /run/secrets/core_token, env per ogni MCP. - secrets/README.md documenta il setup del token. Documentazione di intervento: - docs/12-mcp-deribit-changes.md: spec delle modifiche apportate al server mcp-deribit (place_combo_order + override testnet via DERIBIT_TESTNET). Dipendenze: - aggiunto pytest-httpx per i test HTTP. - rimosso mcp>=1.0 (non usiamo l'SDK MCP, parliamo via HTTP REST). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
342 lines
11 KiB
Python
342 lines
11 KiB
Python
"""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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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)
|