Phase 3: MCP HTTP clients + Dockerization

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>
This commit is contained in:
2026-04-27 23:36:30 +02:00
parent 263470786d
commit 466e63dc19
29 changed files with 2988 additions and 235 deletions
+341
View File
@@ -0,0 +1,341 @@
"""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)