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
+110
View File
@@ -0,0 +1,110 @@
"""End-to-end test for ``cerbero-bite ping``.
The CLI uses the production code paths, so we set up an HTTP mock that
matches every URL Bite is going to hit and assert the rendered output
contains the expected statuses.
"""
from __future__ import annotations
from pathlib import Path
from click.testing import CliRunner
from pytest_httpx import HTTPXMock
from cerbero_bite.cli import main as cli_main
def _seed_token(tmp_path: Path) -> Path:
target = tmp_path / "core_token"
target.write_text("super-secret\n", encoding="utf-8")
return target
def test_ping_reports_each_service(
tmp_path: Path, httpx_mock: HTTPXMock
) -> None:
token_file = _seed_token(tmp_path)
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,
},
)
httpx_mock.add_response(
url="http://mcp-hyperliquid:9012/tools/get_funding_rate",
json={"asset": "ETH", "current_funding_rate": 0.0001},
)
httpx_mock.add_response(
url="http://mcp-macro:9013/tools/get_macro_calendar",
json={"events": []},
)
httpx_mock.add_response(
url="http://mcp-sentiment:9014/tools/get_cross_exchange_funding",
json={"snapshot": {"ETH": {"binance": 0.0001}}},
)
httpx_mock.add_response(
url="http://mcp-portfolio:9018/tools/get_total_portfolio_value",
json={"total_value_eur": 5000.0},
)
result = CliRunner().invoke(
cli_main, ["ping", "--token-file", str(token_file), "--timeout", "1.0"]
)
assert result.exit_code == 0, result.output
assert "deribit" in result.output
assert "hyperliquid" in result.output
assert "macro" in result.output
assert "sentiment" in result.output
assert "portfolio" in result.output
assert "telegram" in result.output # listed even if skipped
# at least 5 OK statuses
assert result.output.count("OK") >= 5
def test_ping_reports_failure_when_service_unreachable(
tmp_path: Path, httpx_mock: HTTPXMock
) -> None:
token_file = _seed_token(tmp_path)
httpx_mock.add_response(
url="http://mcp-deribit:9011/tools/environment_info",
status_code=500,
text="boom",
)
# Provide successful stubs for the others to keep the call small.
httpx_mock.add_response(
url="http://mcp-hyperliquid:9012/tools/get_funding_rate",
json={"asset": "ETH", "current_funding_rate": 0.0001},
)
httpx_mock.add_response(
url="http://mcp-macro:9013/tools/get_macro_calendar",
json={"events": []},
)
httpx_mock.add_response(
url="http://mcp-sentiment:9014/tools/get_cross_exchange_funding",
json={"snapshot": {"ETH": {"binance": 0.0001}}},
)
httpx_mock.add_response(
url="http://mcp-portfolio:9018/tools/get_total_portfolio_value",
json={"total_value_eur": 0.0},
)
result = CliRunner().invoke(
cli_main, ["ping", "--token-file", str(token_file), "--timeout", "1.0"]
)
assert result.exit_code == 0
assert "FAIL" in result.output
def test_ping_token_missing_exits_nonzero(tmp_path: Path) -> None:
result = CliRunner().invoke(
cli_main, ["ping", "--token-file", str(tmp_path / "nope")]
)
assert result.exit_code == 1
assert "token error" in result.output
+168
View File
@@ -0,0 +1,168 @@
"""Tests for :class:`HttpToolClient`."""
from __future__ import annotations
import httpx
import pytest
from pytest_httpx import HTTPXMock
from cerbero_bite.clients._base import HttpToolClient
from cerbero_bite.clients._exceptions import (
McpAuthError,
McpNotFoundError,
McpServerError,
McpTimeoutError,
McpToolError,
)
def _make_client(**overrides: object) -> HttpToolClient:
base: dict[str, object] = {
"service": "test",
"base_url": "http://mcp-test:9000",
"token": "tok",
"retry_max": 1,
}
base.update(overrides)
return HttpToolClient(**base) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_call_returns_parsed_payload(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
url="http://mcp-test:9000/tools/get_thing",
json={"result": [1, 2, 3]},
)
client = _make_client()
out = await client.call("get_thing", {"x": 1})
assert out == {"result": [1, 2, 3]}
@pytest.mark.asyncio
async def test_call_attaches_bearer_token(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(json={"ok": True})
client = _make_client(token="abc123")
await client.call("any")
request = httpx_mock.get_request()
assert request is not None
assert request.headers["Authorization"] == "Bearer abc123"
assert request.headers["Content-Type"] == "application/json"
@pytest.mark.asyncio
async def test_call_raises_auth_error_on_401(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(status_code=401, json={"detail": "nope"})
client = _make_client()
with pytest.raises(McpAuthError):
await client.call("any")
@pytest.mark.asyncio
async def test_call_raises_auth_error_on_403(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(status_code=403, json={"detail": "forbidden"})
client = _make_client()
with pytest.raises(McpAuthError):
await client.call("any")
@pytest.mark.asyncio
async def test_call_raises_not_found_on_404(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(status_code=404, text="missing")
client = _make_client()
with pytest.raises(McpNotFoundError):
await client.call("any")
@pytest.mark.asyncio
async def test_call_raises_server_error_on_500(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(status_code=500, text="boom")
client = _make_client()
with pytest.raises(McpServerError):
await client.call("any")
@pytest.mark.asyncio
async def test_call_raises_tool_error_on_state_error_envelope(
httpx_mock: HTTPXMock,
) -> None:
httpx_mock.add_response(json={"state": "error", "error": "bad params"})
client = _make_client()
with pytest.raises(McpToolError) as info:
await client.call("any")
assert "bad params" in str(info.value)
assert info.value.payload == {"state": "error", "error": "bad params"}
@pytest.mark.asyncio
async def test_call_raises_tool_error_on_4xx_other(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(status_code=422, text="invalid")
client = _make_client()
with pytest.raises(McpToolError):
await client.call("any")
@pytest.mark.asyncio
async def test_call_raises_server_error_when_response_not_json(
httpx_mock: HTTPXMock,
) -> None:
httpx_mock.add_response(content=b"not-json", headers={"content-type": "text/plain"})
client = _make_client()
with pytest.raises(McpServerError, match="not JSON"):
await client.call("any")
@pytest.mark.asyncio
async def test_call_passes_through_list_response(httpx_mock: HTTPXMock) -> None:
"""Some MCP tools (e.g. portfolio.get_holdings) return a list."""
httpx_mock.add_response(json=[1, 2, 3])
client = _make_client()
out = await client.call("any")
assert out == [1, 2, 3]
@pytest.mark.asyncio
async def test_call_raises_timeout(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_exception(httpx.ReadTimeout("slow"))
client = _make_client()
with pytest.raises(McpTimeoutError):
await client.call("any")
@pytest.mark.asyncio
async def test_call_retries_on_server_error_then_succeeds(
httpx_mock: HTTPXMock,
) -> None:
httpx_mock.add_response(status_code=500, text="down")
httpx_mock.add_response(json={"ok": True})
sleeps: list[float] = []
async def _fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
client = _make_client(retry_max=3, sleep=_fake_sleep)
out = await client.call("any")
assert out == {"ok": True}
# one retry → at least one sleep recorded
assert sleeps, "expected the retrier to sleep before retry"
@pytest.mark.asyncio
async def test_call_does_not_retry_auth_error(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(status_code=401, json={"detail": "no"})
client = _make_client(retry_max=3)
with pytest.raises(McpAuthError):
await client.call("any")
# only one request made; no retry on auth
requests = httpx_mock.get_requests()
assert len(requests) == 1
@pytest.mark.asyncio
async def test_base_url_trailing_slash_is_stripped(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
url="http://mcp-test:9000/tools/foo",
json={"ok": True},
)
client = _make_client(base_url="http://mcp-test:9000///")
await client.call("foo")
+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)
+57
View File
@@ -0,0 +1,57 @@
"""Tests for HyperliquidClient."""
from __future__ import annotations
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.hyperliquid import (
HOURLY_FUNDING_PERIODS_PER_YEAR,
HyperliquidClient,
)
def _client() -> HyperliquidClient:
http = HttpToolClient(
service="hyperliquid",
base_url="http://mcp-hyperliquid:9012",
token="t",
retry_max=1,
)
return HyperliquidClient(http)
@pytest.mark.asyncio
async def test_funding_rate_annualized(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
url="http://mcp-hyperliquid:9012/tools/get_funding_rate",
json={"asset": "ETH", "current_funding_rate": 0.00005},
)
out = await _client().funding_rate_annualized("eth")
assert out == Decimal("0.00005") * Decimal(HOURLY_FUNDING_PERIODS_PER_YEAR)
@pytest.mark.asyncio
async def test_funding_rate_anomaly_on_error_envelope(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(json={"error": "Asset XYZ not found"})
with pytest.raises(McpDataAnomalyError, match="Asset XYZ"):
await _client().funding_rate_annualized("XYZ")
@pytest.mark.asyncio
async def test_funding_rate_anomaly_when_rate_missing(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(json={"asset": "ETH"})
with pytest.raises(McpDataAnomalyError, match="current_funding_rate"):
await _client().funding_rate_annualized("ETH")
def test_hyperliquid_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 'hyperliquid'"):
HyperliquidClient(bad)
+139
View File
@@ -0,0 +1,139 @@
"""Tests for MacroClient."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from pytest_httpx import HTTPXMock
from cerbero_bite.clients._base import HttpToolClient
from cerbero_bite.clients.macro import MacroClient
def _client() -> MacroClient:
http = HttpToolClient(
service="macro",
base_url="http://mcp-macro:9013",
token="t",
retry_max=1,
)
return MacroClient(http)
@pytest.mark.asyncio
async def test_get_calendar_parses_events(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
url="http://mcp-macro:9013/tools/get_macro_calendar",
json={
"events": [
{
"name": "FOMC Meeting",
"country_code": "US",
"importance": "high",
"datetime_utc": "2026-05-05T18:00:00+00:00",
},
{
"event": "ECB rate decision",
"country_code": "EU",
"importance": "high",
"datetime_utc": "2026-05-08T12:00:00Z",
},
]
},
)
events = await _client().get_calendar(days=18)
assert [e.name for e in events] == ["FOMC Meeting", "ECB rate decision"]
assert events[0].country_code == "US"
assert events[0].importance == "high"
assert events[0].datetime_utc == datetime(2026, 5, 5, 18, 0, tzinfo=UTC)
@pytest.mark.asyncio
async def test_get_calendar_skips_non_dict_entries(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(json={"events": ["not-a-dict", None]})
assert await _client().get_calendar(days=18) == []
@pytest.mark.asyncio
async def test_next_high_severity_returns_min_days(httpx_mock: HTTPXMock) -> None:
now = datetime(2026, 4, 27, 14, 0, tzinfo=UTC)
httpx_mock.add_response(
json={
"events": [
{
"name": "FOMC",
"country_code": "US",
"importance": "high",
"datetime_utc": (now + timedelta(days=10, hours=4)).isoformat(),
},
{
"name": "ECB",
"country_code": "EU",
"importance": "high",
"datetime_utc": (now + timedelta(days=3, hours=1)).isoformat(),
},
]
}
)
days = await _client().next_high_severity_within(
days=18, countries=["US", "EU"], now=now
)
assert days == 3
@pytest.mark.asyncio
async def test_next_high_severity_ignores_past_events(httpx_mock: HTTPXMock) -> None:
now = datetime(2026, 4, 27, 14, 0, tzinfo=UTC)
httpx_mock.add_response(
json={
"events": [
{
"name": "stale",
"country_code": "US",
"importance": "high",
"datetime_utc": (now - timedelta(days=2)).isoformat(),
},
]
}
)
assert (
await _client().next_high_severity_within(days=18, countries=None, now=now)
is None
)
@pytest.mark.asyncio
async def test_next_high_severity_no_events_returns_none(
httpx_mock: HTTPXMock,
) -> None:
httpx_mock.add_response(json={"events": []})
out = await _client().next_high_severity_within(
days=18, countries=["US"], now=datetime(2026, 4, 27, 14, 0, tzinfo=UTC)
)
assert out is None
@pytest.mark.asyncio
async def test_get_calendar_passes_filters(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(json={"events": []})
await _client().get_calendar(
days=18, country_filter=["US", "EU"], importance_min="high"
)
request = httpx_mock.get_request()
assert request is not None
body = request.read().decode()
assert '"days":18' in body
assert '"country_filter":["US","EU"]' in body
assert '"importance_min":"high"' in body
def test_macro_client_rejects_wrong_service() -> None:
bad = HttpToolClient(
service="deribit",
base_url="http://x:1",
token="t",
retry_max=1,
)
with pytest.raises(ValueError, match="requires service 'macro'"):
MacroClient(bad)
+95
View File
@@ -0,0 +1,95 @@
"""Tests for PortfolioClient."""
from __future__ import annotations
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.portfolio import PortfolioClient
def _client() -> PortfolioClient:
http = HttpToolClient(
service="portfolio",
base_url="http://mcp-portfolio:9018",
token="t",
retry_max=1,
)
return PortfolioClient(http)
@pytest.mark.asyncio
async def test_total_equity_eur(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
url="http://mcp-portfolio:9018/tools/get_total_portfolio_value",
json={"total_value_eur": 12345.67},
)
out = await _client().total_equity_eur()
assert out == Decimal("12345.67")
@pytest.mark.asyncio
async def test_total_equity_anomaly_when_missing(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(json={})
with pytest.raises(McpDataAnomalyError, match="total_value_eur"):
await _client().total_equity_eur()
@pytest.mark.asyncio
async def test_total_equity_anomaly_on_unexpected_shape(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(json=[1, 2, 3])
with pytest.raises(McpDataAnomalyError, match="unexpected shape"):
await _client().total_equity_eur()
@pytest.mark.asyncio
async def test_asset_pct_aggregates_matching_tickers(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
url="http://mcp-portfolio:9018/tools/get_holdings",
json=[
{"ticker": "ETH-USD", "current_value_eur": 3000.0},
{"ticker": "ETHE", "current_value_eur": 1000.0}, # ETH ticker variant
{"ticker": "AAPL", "current_value_eur": 6000.0},
],
)
pct = await _client().asset_pct_of_portfolio("ETH")
# 4000 / 10000 = 0.4
assert pct == Decimal("0.4")
@pytest.mark.asyncio
async def test_asset_pct_returns_zero_for_empty_portfolio(
httpx_mock: HTTPXMock,
) -> None:
httpx_mock.add_response(json=[])
assert await _client().asset_pct_of_portfolio("ETH") == Decimal("0")
@pytest.mark.asyncio
async def test_asset_pct_skips_entries_without_value(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
json=[
{"ticker": "ETH", "current_value_eur": None},
{"ticker": "AAPL", "current_value_eur": 1000.0},
]
)
assert await _client().asset_pct_of_portfolio("ETH") == Decimal("0")
@pytest.mark.asyncio
async def test_asset_pct_anomaly_when_response_not_list(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(json={"holdings": []})
with pytest.raises(McpDataAnomalyError, match="unexpected shape"):
await _client().asset_pct_of_portfolio("ETH")
def test_portfolio_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 'portfolio'"):
PortfolioClient(bad)
+110
View File
@@ -0,0 +1,110 @@
"""Tests for SentimentClient."""
from __future__ import annotations
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.sentiment import (
EXCHANGE_PERIODS_PER_YEAR,
SentimentClient,
)
def _client() -> SentimentClient:
http = HttpToolClient(
service="sentiment",
base_url="http://mcp-sentiment:9014",
token="t",
retry_max=1,
)
return SentimentClient(http)
@pytest.mark.asyncio
async def test_median_annualises_per_exchange_period(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
url="http://mcp-sentiment:9014/tools/get_cross_exchange_funding",
json={
"snapshot": {
"ETH": {
"binance": 0.0001,
"bybit": 0.0002,
"okx": 0.00015,
"hyperliquid": 0.00002,
}
}
},
)
median = await _client().funding_cross_median_annualized("eth")
# Annualised values (Decimal):
# binance: 0.0001 * 1095 = 0.1095
# bybit: 0.0002 * 1095 = 0.2190
# okx: 0.00015 * 1095 = 0.16425
# hyperliquid:0.00002 * 8760 = 0.17520
# sorted: 0.1095, 0.16425, 0.17520, 0.2190 → median = (0.16425+0.17520)/2 = 0.169725
assert median == Decimal("0.169725")
@pytest.mark.asyncio
async def test_median_skips_missing_venues(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
json={
"snapshot": {
"ETH": {
"binance": 0.0001,
"bybit": None,
"okx": None,
"hyperliquid": None,
}
}
}
)
median = await _client().funding_cross_median_annualized("ETH")
assert median == Decimal("0.0001") * Decimal("1095")
@pytest.mark.asyncio
async def test_anomaly_when_snapshot_missing(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(json={"snapshot": {}})
with pytest.raises(McpDataAnomalyError, match="snapshot missing"):
await _client().funding_cross_median_annualized("ETH")
@pytest.mark.asyncio
async def test_anomaly_when_no_venue_responded(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
json={
"snapshot": {
"ETH": {
"binance": None,
"bybit": None,
"okx": None,
"hyperliquid": None,
}
}
}
)
with pytest.raises(McpDataAnomalyError, match="no funding venues"):
await _client().funding_cross_median_annualized("ETH")
def test_periods_table_covers_documented_venues() -> None:
assert set(EXCHANGE_PERIODS_PER_YEAR) == {
"binance", "bybit", "okx", "hyperliquid"
}
def test_sentiment_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 'sentiment'"):
SentimentClient(bad)
+122
View File
@@ -0,0 +1,122 @@
"""Tests for TelegramClient (notify-only mode)."""
from __future__ import annotations
import json
from decimal import Decimal
import pytest
from pytest_httpx import HTTPXMock
from cerbero_bite.clients._base import HttpToolClient
from cerbero_bite.clients.telegram import TelegramClient
def _client() -> TelegramClient:
http = HttpToolClient(
service="telegram",
base_url="http://mcp-telegram:9017",
token="t",
retry_max=1,
)
return TelegramClient(http)
def _request_body(httpx_mock: HTTPXMock) -> dict:
request = httpx_mock.get_request()
assert request is not None
return json.loads(request.read())
@pytest.mark.asyncio
async def test_notify_sends_message_with_priority(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
url="http://mcp-telegram:9017/tools/notify",
json={"ok": True},
)
await _client().notify("hello", priority="high", tag="entry")
body = _request_body(httpx_mock)
assert body == {"message": "hello", "priority": "high", "tag": "entry"}
@pytest.mark.asyncio
async def test_notify_default_priority_normal(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(json={"ok": True})
await _client().notify("plain")
body = _request_body(httpx_mock)
assert body["priority"] == "normal"
assert "tag" not in body
@pytest.mark.asyncio
async def test_notify_position_opened_serialises_decimals(
httpx_mock: HTTPXMock,
) -> None:
httpx_mock.add_response(
url="http://mcp-telegram:9017/tools/notify_position_opened",
json={"ok": True},
)
await _client().notify_position_opened(
instrument="ETH-15MAY26-2475-P",
side="SELL",
size=2,
strategy="bull_put",
greeks={"delta": Decimal("-0.04"), "vega": Decimal("0.20")},
expected_pnl_usd=Decimal("45.00"),
)
body = _request_body(httpx_mock)
assert body["instrument"] == "ETH-15MAY26-2475-P"
assert body["greeks"] == {"delta": -0.04, "vega": 0.20}
assert body["expected_pnl"] == 45.0
assert body["size"] == 2.0
@pytest.mark.asyncio
async def test_notify_position_closed(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(json={"ok": True})
await _client().notify_position_closed(
instrument="ETH-15MAY26-2475-P_2350-P",
realized_pnl_usd=Decimal("32.50"),
reason="CLOSE_PROFIT",
)
body = _request_body(httpx_mock)
assert body == {
"instrument": "ETH-15MAY26-2475-P_2350-P",
"realized_pnl": 32.5,
"reason": "CLOSE_PROFIT",
}
@pytest.mark.asyncio
async def test_notify_alert(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(json={"ok": True})
await _client().notify_alert(
source="kill_switch", message="armed manually", priority="critical"
)
body = _request_body(httpx_mock)
assert body == {
"source": "kill_switch",
"message": "armed manually",
"priority": "critical",
}
@pytest.mark.asyncio
async def test_notify_system_error(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(json={"ok": True})
await _client().notify_system_error(
message="deribit feed anomaly",
component="clients.deribit",
)
body = _request_body(httpx_mock)
assert body["message"] == "deribit feed anomaly"
assert body["component"] == "clients.deribit"
assert body["priority"] == "critical"
def test_telegram_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 'telegram'"):
TelegramClient(bad)
+76
View File
@@ -0,0 +1,76 @@
"""Tests for the MCP endpoint and token resolver."""
from __future__ import annotations
from pathlib import Path
import pytest
from cerbero_bite.config.mcp_endpoints import (
DEFAULT_ENDPOINTS,
MCP_SERVICES,
load_endpoints,
load_token,
)
def test_defaults_match_known_docker_dns() -> None:
assert DEFAULT_ENDPOINTS["deribit"] == "http://mcp-deribit:9011"
assert DEFAULT_ENDPOINTS["telegram"] == "http://mcp-telegram:9017"
def test_load_endpoints_uses_defaults_when_env_empty() -> None:
endpoints = load_endpoints(env={})
for name, url in DEFAULT_ENDPOINTS.items():
assert endpoints.for_service(name) == url
def test_per_service_env_var_override() -> None:
endpoints = load_endpoints(
env={"CERBERO_BITE_MCP_DERIBIT_URL": "http://localhost:9911"}
)
assert endpoints.deribit == "http://localhost:9911"
assert endpoints.macro == DEFAULT_ENDPOINTS["macro"]
def test_per_service_env_var_strips_trailing_slash() -> None:
endpoints = load_endpoints(
env={"CERBERO_BITE_MCP_DERIBIT_URL": "http://localhost:9911///"}
)
assert endpoints.deribit == "http://localhost:9911"
def test_for_service_unknown_raises_key_error() -> None:
endpoints = load_endpoints(env={})
with pytest.raises(KeyError, match="unknown MCP service"):
endpoints.for_service("nope")
def test_load_token_uses_explicit_path(tmp_path: Path) -> None:
target = tmp_path / "core.token"
target.write_text("abcdef\n", encoding="utf-8")
assert load_token(path=target) == "abcdef"
def test_load_token_uses_env_var(tmp_path: Path) -> None:
target = tmp_path / "core.token"
target.write_text("xyz", encoding="utf-8")
token = load_token(env={"CERBERO_BITE_CORE_TOKEN_FILE": str(target)})
assert token == "xyz"
def test_load_token_raises_when_file_missing(tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError):
load_token(path=tmp_path / "missing")
def test_load_token_raises_when_file_empty(tmp_path: Path) -> None:
target = tmp_path / "empty"
target.write_text("", encoding="utf-8")
with pytest.raises(ValueError, match="empty"):
load_token(path=target)
def test_mcp_services_table_is_complete() -> None:
expected = {"deribit", "hyperliquid", "macro", "sentiment", "telegram", "portfolio"}
assert set(MCP_SERVICES) == expected