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:
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user