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
+102
View File
@@ -9,6 +9,7 @@ without changing the surface.
from __future__ import annotations
import asyncio
import sys
from datetime import UTC, datetime
from pathlib import Path
@@ -18,7 +19,18 @@ from rich.console import Console
from rich.table import Table
from cerbero_bite import __version__
from cerbero_bite.clients import HttpToolClient, McpError
from cerbero_bite.clients.deribit import DeribitClient
from cerbero_bite.clients.hyperliquid import HyperliquidClient
from cerbero_bite.clients.macro import MacroClient
from cerbero_bite.clients.portfolio import PortfolioClient
from cerbero_bite.clients.sentiment import SentimentClient
from cerbero_bite.config.loader import compute_config_hash, load_strategy
from cerbero_bite.config.mcp_endpoints import (
DEFAULT_ENDPOINTS,
load_endpoints,
load_token,
)
from cerbero_bite.logging import configure as configure_logging
from cerbero_bite.logging import get_logger
from cerbero_bite.safety.audit_log import AuditChainError, AuditLog
@@ -225,6 +237,96 @@ def kill_switch_status(db: Path) -> None:
)
@main.command()
@click.option(
"--token-file",
type=click.Path(dir_okay=False, path_type=Path),
default=None,
help="Path to the bearer token file (default: secrets/core_token).",
)
@click.option(
"--timeout",
type=float,
default=4.0,
show_default=True,
help="Per-service timeout in seconds for the ping call.",
)
def ping(token_file: Path | None, timeout: float) -> None:
"""Print health status for every MCP service Cerbero Bite uses."""
try:
token = load_token(path=token_file)
except (FileNotFoundError, ValueError) as exc:
console.print(f"[red]token error[/red]: {exc}")
sys.exit(1)
endpoints = load_endpoints()
rows = asyncio.run(_ping_all(endpoints, token=token, timeout=timeout))
table = Table(title="MCP services")
table.add_column("service")
table.add_column("url")
table.add_column("status")
table.add_column("detail")
for service, url, status, detail in rows:
colour = {"ok": "green", "fail": "red", "skipped": "yellow"}.get(status, "white")
table.add_row(service, url, f"[{colour}]{status.upper()}[/{colour}]", detail)
console.print(table)
async def _ping_one(
*,
service: str,
url: str,
token: str,
timeout: float,
) -> tuple[str, str]:
"""Return ``(status, detail)`` for one service health check."""
http = HttpToolClient(
service=service,
base_url=url,
token=token,
retry_max=1,
timeout_s=timeout,
)
try:
if service == "deribit":
info = await DeribitClient(http).environment_info()
return "ok", f"environment={info.environment}"
if service == "macro":
await MacroClient(http).get_calendar(days=1, importance_min="high")
return "ok", "calendar reachable"
if service == "sentiment":
await SentimentClient(http).funding_cross_median_annualized("ETH")
return "ok", "funding reachable"
if service == "hyperliquid":
await HyperliquidClient(http).funding_rate_annualized("ETH")
return "ok", "ETH-PERP reachable"
if service == "portfolio":
await PortfolioClient(http).total_equity_eur()
return "ok", "portfolio reachable"
if service == "telegram":
# Notify-only: no read tool. Skip without hitting the bot.
return "skipped", "notify-only client (no health probe)"
return "skipped", "no probe defined" # pragma: no cover
except McpError as exc:
return "fail", f"{type(exc).__name__}: {exc}"
except Exception as exc: # surface any unexpected error for the operator
return "fail", f"{type(exc).__name__}: {exc}"
async def _ping_all(
endpoints: object, *, token: str, timeout: float
) -> list[tuple[str, str, str, str]]:
rows: list[tuple[str, str, str, str]] = []
for service in DEFAULT_ENDPOINTS:
url = endpoints.for_service(service) # type: ignore[attr-defined]
status, detail = await _ping_one(
service=service, url=url, token=token, timeout=timeout
)
rows.append((service, url, status, detail))
return rows
@main.command()
def gui() -> None:
"""Launch the Streamlit dashboard."""
+28
View File
@@ -0,0 +1,28 @@
"""Async wrappers over the Cerbero MCP HTTP services.
Every concrete client extends :class:`HttpToolClient` and exposes a
typed surface that returns the Pydantic records consumed by the
``core/`` algorithms.
"""
from cerbero_bite.clients._base import HttpToolClient
from cerbero_bite.clients._exceptions import (
McpAuthError,
McpDataAnomalyError,
McpError,
McpNotFoundError,
McpServerError,
McpTimeoutError,
McpToolError,
)
__all__ = [
"HttpToolClient",
"McpAuthError",
"McpDataAnomalyError",
"McpError",
"McpNotFoundError",
"McpServerError",
"McpTimeoutError",
"McpToolError",
]
+228
View File
@@ -0,0 +1,228 @@
"""HTTP tool client common to every MCP wrapper.
Each MCP service exposes ``POST <base_url>/tools/<tool_name>`` with a
JSON body and a ``Bearer <core_token>`` header. ``HttpToolClient`` is a
thin wrapper around :class:`httpx.AsyncClient` that:
* Adds the auth header.
* Applies the project-wide timeout (default 8 s, see
``docs/10-config-spec.md`` ``mcp.call_timeout_s``).
* Retries the call on transient failures with exponential backoff
(1 s, 5 s, 30 s) — at most 3 attempts in total.
* Maps HTTP errors and ``state == "error"`` envelopes into the typed
exceptions in :mod:`cerbero_bite.clients._exceptions`.
The wrapper does *not* hold a long-lived ``AsyncClient`` by default:
each call opens and closes its own connection so a transient DNS issue
on one MCP server does not corrupt connection pooling for the others.
A shared pool can still be passed in via ``transport`` / ``client``
when the orchestrator wants connection reuse.
"""
from __future__ import annotations
import json
import logging
from collections.abc import Awaitable, Callable
from typing import Any
import httpx
from tenacity import (
AsyncRetrying,
RetryError,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from cerbero_bite.clients._exceptions import (
McpAuthError,
McpError,
McpNotFoundError,
McpServerError,
McpTimeoutError,
McpToolError,
)
__all__ = ["HttpToolClient"]
_log = logging.getLogger("cerbero_bite.clients")
_RETRYABLE: tuple[type[BaseException], ...] = (
McpTimeoutError,
McpServerError,
)
class HttpToolClient:
"""Async client for ``POST <base>/tools/<tool>`` style MCP services.
Args:
service: short service identifier (``"deribit"``, ``"macro"`` …).
base_url: e.g. ``"http://mcp-deribit:9011"``. Trailing slash
is stripped.
token: bearer token for the ``Authorization`` header.
timeout_s: per-request timeout, default 8 seconds.
retry_max: max number of attempts (1 = no retry).
retry_base_delay: base delay for exponential backoff.
sleep: hook for tests to skip real waits.
"""
def __init__(
self,
*,
service: str,
base_url: str,
token: str,
timeout_s: float = 8.0,
retry_max: int = 3,
retry_base_delay: float = 1.0,
sleep: Callable[[int | float], Awaitable[None] | None] | None = None,
) -> None:
self._service = service
self._base_url = base_url.rstrip("/")
self._token = token
self._timeout = httpx.Timeout(timeout_s)
self._retry_max = max(1, retry_max)
self._retry_base_delay = retry_base_delay
self._sleep = sleep
@property
def service(self) -> str:
return self._service
@property
def base_url(self) -> str:
return self._base_url
async def call(
self,
tool: str,
body: dict[str, Any] | None = None,
*,
client: httpx.AsyncClient | None = None,
) -> Any:
"""Invoke ``tool`` with ``body`` and return the parsed JSON response.
Returns whatever shape the server replies with (typically ``dict``,
sometimes ``list``). The wrapper checks ``state == "error"`` only on
``dict`` responses; list/scalar responses are passed through unchanged.
"""
url = f"{self._base_url}/tools/{tool}"
headers = {
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json",
}
payload = body or {}
async def _attempt() -> Any:
return await self._do_request(
url=url,
headers=headers,
payload=payload,
tool=tool,
client=client,
)
if self._retry_max <= 1:
return await _attempt()
retry_kwargs: dict[str, Any] = {
"stop": stop_after_attempt(self._retry_max),
"wait": wait_exponential(multiplier=self._retry_base_delay, min=1, max=30),
"retry": retry_if_exception_type(_RETRYABLE),
"reraise": True,
}
if self._sleep is not None:
retry_kwargs["sleep"] = self._sleep
retrier = AsyncRetrying(**retry_kwargs)
try:
async for attempt in retrier:
with attempt:
return await _attempt()
except RetryError as exc: # pragma: no cover — reraise=True covers it
raise exc.last_attempt.exception() or McpError(
"retry exhausted", service=self._service, tool=tool
) from exc
# mypy needs an explicit fall-through — retry never falls out of the loop
raise McpError(
"unreachable retry loop exit", service=self._service, tool=tool
) # pragma: no cover
async def _do_request(
self,
*,
url: str,
headers: dict[str, str],
payload: dict[str, Any],
tool: str,
client: httpx.AsyncClient | None,
) -> Any:
request_client = client or httpx.AsyncClient(timeout=self._timeout)
owned = client is None
try:
try:
response = await request_client.post(url, json=payload, headers=headers)
except httpx.TimeoutException as exc:
raise McpTimeoutError(
f"timeout calling {self._service}.{tool}",
service=self._service,
tool=tool,
) from exc
except httpx.HTTPError as exc:
raise McpServerError(
f"HTTP error calling {self._service}.{tool}: {exc}",
service=self._service,
tool=tool,
) from exc
self._raise_for_status(response, tool=tool)
try:
data: Any = response.json()
except json.JSONDecodeError as exc:
raise McpServerError(
f"{self._service}.{tool}: response is not JSON",
service=self._service,
tool=tool,
) from exc
if isinstance(data, dict) and data.get("state") == "error":
raise McpToolError(
f"{self._service}.{tool} returned error: "
f"{data.get('error', 'unknown')}",
service=self._service,
tool=tool,
payload=data,
)
return data
finally:
if owned:
await request_client.aclose()
def _raise_for_status(self, response: httpx.Response, *, tool: str) -> None:
status = response.status_code
if 200 <= status < 300:
return
if status in (401, 403):
raise McpAuthError(
f"{self._service}.{tool} authentication failed (HTTP {status})",
service=self._service,
tool=tool,
)
if status == 404:
raise McpNotFoundError(
f"{self._service}.{tool} not found (HTTP 404)",
service=self._service,
tool=tool,
)
# 4xx other than auth/404 → tool error from server side; do not retry.
# 5xx → server fault, retry-eligible.
message = (
f"{self._service}.{tool} HTTP {status}: "
f"{(response.text or '')[:200]!r}"
)
if 500 <= status < 600:
raise McpServerError(message, service=self._service, tool=tool)
raise McpToolError(message, service=self._service, tool=tool)
+73
View File
@@ -0,0 +1,73 @@
"""Typed exceptions raised by the MCP client wrappers.
Every wrapper translates HTTP status codes and JSON error envelopes into
one of these classes so the orchestrator can decide its degradation
strategy without inspecting raw HTTP traces.
"""
from __future__ import annotations
__all__ = [
"McpAuthError",
"McpDataAnomalyError",
"McpError",
"McpNotFoundError",
"McpServerError",
"McpTimeoutError",
"McpToolError",
]
class McpError(Exception):
"""Base class for every MCP-side failure surfaced to the engine."""
def __init__(
self,
message: str,
*,
service: str | None = None,
tool: str | None = None,
) -> None:
super().__init__(message)
self.service = service
self.tool = tool
class McpTimeoutError(McpError):
"""The MCP service did not respond within the configured timeout."""
class McpAuthError(McpError):
"""The bearer token was rejected (HTTP 401/403)."""
class McpNotFoundError(McpError):
"""Endpoint missing on the server (HTTP 404). Indicates schema drift."""
class McpServerError(McpError):
"""5xx or unexpected HTTP error returned by the MCP service."""
class McpToolError(McpError):
"""The tool returned a structured error envelope (e.g. ``{state: "error"}``)."""
def __init__(
self,
message: str,
*,
service: str | None = None,
tool: str | None = None,
payload: dict[str, object] | None = None,
) -> None:
super().__init__(message, service=service, tool=tool)
self.payload = payload or {}
class McpDataAnomalyError(McpError):
"""The response shape was valid but the values are nonsensical.
Example: every option in the chain has ``mark_iv == 7%`` (the
Deribit testnet placeholder), or every bid is zero. The orchestrator
skips the cycle and alerts.
"""
+336
View File
@@ -0,0 +1,336 @@
"""Wrapper around ``mcp-deribit``.
Exposes the read tools Cerbero Bite needs to evaluate entry/exit and
the ``place_combo_order`` write path that submits the credit spread
atomically. Everything is converted to ``Decimal`` at the boundary so
the ``core/`` algorithms stay in their preferred numeric domain.
"""
from __future__ import annotations
import re
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict
from cerbero_bite.clients._base import HttpToolClient
from cerbero_bite.clients._exceptions import McpDataAnomalyError
from cerbero_bite.core.types import PutOrCall
__all__ = [
"ComboLegOrder",
"ComboOrderResult",
"DeribitClient",
"DeribitEnvironment",
"InstrumentMeta",
]
_INSTRUMENT_RE = re.compile(
r"^(?P<asset>[A-Z]+)-"
r"(?P<expiry>\d{1,2}[A-Z]{3}\d{2})-"
r"(?P<strike>\d+)-"
r"(?P<type>[PC])$"
)
class DeribitEnvironment(BaseModel):
"""Result of the ``environment_info`` tool."""
model_config = ConfigDict(frozen=True, extra="ignore")
exchange: str
environment: Literal["testnet", "mainnet"]
source: str
env_value: str | None
base_url: str
max_leverage: int | None = None
class InstrumentMeta(BaseModel):
"""Static metadata of a Deribit option instrument."""
model_config = ConfigDict(frozen=True, extra="ignore")
name: str
strike: Decimal
expiry: datetime
option_type: PutOrCall
open_interest: Decimal | None
tick_size: Decimal | None
min_trade_amount: Decimal | None
class ComboLegOrder(BaseModel):
"""One leg of a combo order request."""
model_config = ConfigDict(frozen=True, extra="forbid")
instrument_name: str
direction: Literal["buy", "sell"]
ratio: int = 1
class ComboOrderResult(BaseModel):
"""Outcome of a ``place_combo_order`` invocation."""
model_config = ConfigDict(frozen=True, extra="ignore")
combo_instrument: str
order_id: str | None
state: str
average_price_eth: Decimal | None
filled_amount: Decimal | None
raw: dict[str, Any]
def _parse_instrument(name: str) -> tuple[Decimal, datetime, PutOrCall]:
"""Return ``(strike, expiry, option_type)`` parsed from a Deribit instrument."""
match = _INSTRUMENT_RE.match(name)
if not match:
raise McpDataAnomalyError(
f"deribit instrument name '{name}' does not match expected pattern"
)
expiry = datetime.strptime(match.group("expiry"), "%d%b%y").replace(
hour=8, minute=0, second=0, tzinfo=UTC
)
return Decimal(match.group("strike")), expiry, match.group("type") # type: ignore[return-value]
def _to_decimal(value: Any) -> Decimal | None:
if value is None:
return None
return Decimal(str(value))
class DeribitClient:
SERVICE = "deribit"
def __init__(self, http: HttpToolClient) -> None:
if http.service != self.SERVICE:
raise ValueError(
f"DeribitClient requires service '{self.SERVICE}', got '{http.service}'"
)
self._http = http
# ------------------------------------------------------------------
# Environment / health
# ------------------------------------------------------------------
async def environment_info(self) -> DeribitEnvironment:
raw = await self._http.call("environment_info", {})
return DeribitEnvironment(**raw)
# ------------------------------------------------------------------
# Market data
# ------------------------------------------------------------------
async def index_price_eth(self) -> Decimal:
"""Return the ETH spot proxy used by combo selection.
Deribit does not expose the index price as its own MCP tool, so
we use the ``ETH-PERPETUAL`` mark price as a proxy. On testnet
this is good enough; on mainnet we will swap in a dedicated
index call when the server exposes it.
"""
raw = await self._http.call(
"get_ticker", {"instrument_name": "ETH-PERPETUAL"}
)
mark = raw.get("mark_price")
if mark is None:
raise McpDataAnomalyError(
"deribit ETH-PERPETUAL mark_price missing",
service=self.SERVICE,
tool="get_ticker",
)
return Decimal(str(mark))
async def latest_dvol(
self,
*,
currency: str = "ETH",
now: datetime | None = None,
) -> Decimal:
"""Return the latest DVOL value for ``currency``."""
when = (now or datetime.now(UTC)).astimezone(UTC)
body = {
"currency": currency,
"start_date": (when.date()).isoformat(),
"end_date": when.date().isoformat(),
"resolution": "1D",
}
raw = await self._http.call("get_dvol", body)
latest = raw.get("latest")
if latest is None:
candles = raw.get("candles") or []
if not candles:
raise McpDataAnomalyError(
"deribit DVOL response has neither 'latest' nor 'candles'",
service=self.SERVICE,
tool="get_dvol",
)
tail = candles[-1]
latest = tail.get("close") if isinstance(tail, dict) else None
if latest is None:
raise McpDataAnomalyError(
"deribit DVOL last candle missing 'close'",
service=self.SERVICE,
tool="get_dvol",
)
return Decimal(str(latest))
async def options_chain(
self,
*,
currency: str = "ETH",
expiry_from: datetime | None = None,
expiry_to: datetime | None = None,
min_open_interest: int | None = None,
limit: int = 500,
) -> list[InstrumentMeta]:
"""Return option instruments matching the filters as typed metadata."""
body: dict[str, Any] = {"currency": currency, "kind": "option", "limit": limit}
if expiry_from is not None:
body["expiry_from"] = expiry_from.date().isoformat()
if expiry_to is not None:
body["expiry_to"] = expiry_to.date().isoformat()
if min_open_interest is not None:
body["min_open_interest"] = min_open_interest
raw = await self._http.call("get_instruments", body)
instruments = raw.get("instruments") or []
out: list[InstrumentMeta] = []
for entry in instruments:
if not isinstance(entry, dict):
continue
name = entry.get("name")
if not isinstance(name, str):
continue
try:
strike, expiry, option_type = _parse_instrument(name)
except McpDataAnomalyError:
continue
out.append(
InstrumentMeta(
name=name,
strike=strike,
expiry=expiry,
option_type=option_type,
open_interest=_to_decimal(entry.get("open_interest")),
tick_size=_to_decimal(entry.get("tick_size")),
min_trade_amount=_to_decimal(entry.get("min_trade_amount")),
)
)
return out
async def get_tickers(self, instrument_names: list[str]) -> list[dict[str, Any]]:
"""Fetch full ticker data for up to 20 instruments at once."""
if not instrument_names:
return []
if len(instrument_names) > 20:
raise ValueError("get_tickers: max 20 instruments per call")
raw = await self._http.call(
"get_ticker_batch", {"instrument_names": instrument_names}
)
if isinstance(raw, dict) and raw.get("error"):
raise McpDataAnomalyError(
f"deribit get_ticker_batch error: {raw['error']}",
service=self.SERVICE,
tool="get_ticker_batch",
)
return list(raw.get("tickers") or [])
async def orderbook_depth_top3(self, instrument_name: str) -> int:
"""Sum of size on the top-3 bid + top-3 ask levels."""
raw = await self._http.call(
"get_orderbook", {"instrument_name": instrument_name, "depth": 3}
)
bids = raw.get("bids") or []
asks = raw.get("asks") or []
def _sum(rows: list[Any]) -> Decimal:
total = Decimal("0")
for row in rows[:3]:
if isinstance(row, list | tuple) and len(row) >= 2:
total += Decimal(str(row[1]))
elif isinstance(row, dict):
size = row.get("amount") or row.get("size") or 0
total += Decimal(str(size))
return total
return int(_sum(bids) + _sum(asks))
async def get_account_summary(self, currency: str = "USDC") -> dict[str, Any]:
result: Any = await self._http.call(
"get_account_summary", {"currency": currency}
)
return result if isinstance(result, dict) else {}
async def get_positions(self, currency: str = "USDC") -> list[dict[str, Any]]:
raw = await self._http.call("get_positions", {"currency": currency})
if isinstance(raw, list):
return raw
# Server may also wrap the list under a key — defensive only.
return list(raw.get("positions") or []) # pragma: no cover
# ------------------------------------------------------------------
# Execution
# ------------------------------------------------------------------
async def place_combo_order(
self,
*,
legs: list[ComboLegOrder],
side: Literal["buy", "sell"],
n_contracts: int,
limit_price_eth: Decimal | None = None,
order_type: Literal["limit", "market"] = "limit",
label: str | None = None,
) -> ComboOrderResult:
"""Submit a combo order atomically."""
if len(legs) < 2:
raise ValueError("place_combo_order requires at least 2 legs")
if n_contracts <= 0:
raise ValueError("place_combo_order: n_contracts must be > 0")
if order_type == "limit" and limit_price_eth is None:
raise ValueError("place_combo_order: limit price required for type=limit")
body: dict[str, Any] = {
"legs": [leg.model_dump() for leg in legs],
"side": side,
"amount": n_contracts,
"type": order_type,
}
if limit_price_eth is not None:
body["price"] = float(limit_price_eth)
if label is not None:
body["label"] = label
raw = await self._http.call("place_combo_order", body)
if not isinstance(raw, dict):
raise McpDataAnomalyError(
"place_combo_order: server returned non-object",
service=self.SERVICE,
tool="place_combo_order",
)
combo_instrument = raw.get("combo_instrument")
if not isinstance(combo_instrument, str):
raise McpDataAnomalyError(
"place_combo_order: missing 'combo_instrument' in response",
service=self.SERVICE,
tool="place_combo_order",
)
return ComboOrderResult(
combo_instrument=combo_instrument,
order_id=raw.get("order_id"),
state=str(raw.get("state") or "unknown"),
average_price_eth=_to_decimal(raw.get("average_price")),
filled_amount=_to_decimal(raw.get("filled_amount")),
raw=raw,
)
async def cancel_order(self, order_id: str) -> dict[str, Any]:
result: Any = await self._http.call("cancel_order", {"order_id": order_id})
return result if isinstance(result, dict) else {}
+49
View File
@@ -0,0 +1,49 @@
"""Wrapper around ``mcp-hyperliquid``.
Cerbero Bite consumes a single tool: ``get_funding_rate`` for ETH-PERP,
used by entry filter §2.6 of ``docs/01-strategy-rules.md`` (cap on the
absolute annualised funding rate).
"""
from __future__ import annotations
from decimal import Decimal
from cerbero_bite.clients._base import HttpToolClient
from cerbero_bite.clients._exceptions import McpDataAnomalyError
__all__ = ["HOURLY_FUNDING_PERIODS_PER_YEAR", "HyperliquidClient"]
HOURLY_FUNDING_PERIODS_PER_YEAR = 24 * 365 # = 8760
class HyperliquidClient:
SERVICE = "hyperliquid"
def __init__(self, http: HttpToolClient) -> None:
if http.service != self.SERVICE:
raise ValueError(
f"HyperliquidClient requires service '{self.SERVICE}', got '{http.service}'"
)
self._http = http
async def funding_rate_annualized(self, asset: str) -> Decimal:
"""Return the latest funding rate of ``asset`` as an annualised fraction."""
raw = await self._http.call(
"get_funding_rate", {"instrument": asset.upper()}
)
if raw.get("error"):
raise McpDataAnomalyError(
f"hyperliquid get_funding_rate error: {raw['error']}",
service=self.SERVICE,
tool="get_funding_rate",
)
rate = raw.get("current_funding_rate")
if rate is None:
raise McpDataAnomalyError(
"hyperliquid response missing 'current_funding_rate'",
service=self.SERVICE,
tool="get_funding_rate",
)
return Decimal(str(rate)) * Decimal(HOURLY_FUNDING_PERIODS_PER_YEAR)
+115
View File
@@ -0,0 +1,115 @@
"""Wrapper around ``mcp-macro`` (``docs/04-mcp-integration.md``).
Exposes a single use case relevant to Cerbero Bite: how many days
separate the current moment from the next high-severity macro event in
the requested window. The orchestrator feeds the result straight into
``entry_validator.EntryContext.next_macro_event_in_days``.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from pydantic import BaseModel, ConfigDict
from cerbero_bite.clients._base import HttpToolClient
__all__ = ["MacroClient", "MacroEvent"]
class MacroEvent(BaseModel):
"""One row of the macro calendar."""
model_config = ConfigDict(frozen=True, extra="ignore")
name: str
country_code: str
importance: str
datetime_utc: datetime | None
class MacroClient:
"""High-level wrapper that returns typed macro events."""
SERVICE = "macro"
def __init__(self, http: HttpToolClient) -> None:
if http.service != self.SERVICE:
raise ValueError(
f"MacroClient requires service '{self.SERVICE}', got '{http.service}'"
)
self._http = http
async def get_calendar(
self,
*,
days: int,
country_filter: list[str] | None = None,
importance_min: str | None = None,
) -> list[MacroEvent]:
"""Return the events in the next ``days`` matching the filters."""
body: dict[str, Any] = {"days": days}
if country_filter is not None:
body["country_filter"] = country_filter
if importance_min is not None:
body["importance_min"] = importance_min
raw = await self._http.call("get_macro_calendar", body)
events = raw.get("events") or []
out: list[MacroEvent] = []
for entry in events:
if not isinstance(entry, dict):
continue
out.append(
MacroEvent(
name=str(entry.get("name") or entry.get("event") or ""),
country_code=str(entry.get("country_code") or ""),
importance=str(entry.get("importance") or "medium"),
datetime_utc=_parse_dt(entry.get("datetime_utc"))
or _parse_dt(entry.get("date")),
)
)
return out
async def next_high_severity_within(
self,
*,
days: int,
countries: list[str] | None = None,
now: datetime | None = None,
) -> int | None:
"""Days until the first high-severity event within ``days``, else None.
``now`` is taken from the parameter (default: now in UTC) so the
decision is reproducible in tests; the result is rounded up to
whole days because the strategy filter compares with DTE in
days.
"""
events = await self.get_calendar(
days=days,
country_filter=countries,
importance_min="high",
)
reference = (now or datetime.now(UTC)).astimezone(UTC)
deltas: list[int] = []
for event in events:
if event.datetime_utc is None:
continue
delta = event.datetime_utc - reference
seconds = delta.total_seconds()
if seconds < 0:
continue
deltas.append(int(seconds // 86400))
return min(deltas) if deltas else None
def _parse_dt(value: Any) -> datetime | None:
if not isinstance(value, str) or not value:
return None
try:
out = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if out.tzinfo is None:
out = out.replace(tzinfo=UTC)
return out
+92
View File
@@ -0,0 +1,92 @@
"""Wrapper around ``mcp-portfolio``.
Cerbero Bite uses two pieces of information from this service:
* total portfolio value (EUR) — fed to the sizing engine after FX
conversion to USD;
* exposure of a specific asset as percentage of the total portfolio —
used by entry filter §2.7 (``eth_holdings_pct_max``).
The portfolio service stores everything in EUR. The orchestrator is
responsible for the EUR→USD conversion using a live FX rate.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
from cerbero_bite.clients._base import HttpToolClient
from cerbero_bite.clients._exceptions import McpDataAnomalyError
__all__ = ["PortfolioClient"]
class PortfolioClient:
SERVICE = "portfolio"
def __init__(self, http: HttpToolClient) -> None:
if http.service != self.SERVICE:
raise ValueError(
f"PortfolioClient requires service '{self.SERVICE}', got '{http.service}'"
)
self._http = http
async def total_equity_eur(self) -> Decimal:
"""Return the aggregate portfolio value in EUR."""
raw = await self._http.call(
"get_total_portfolio_value", {"currency": "EUR"}
)
if not isinstance(raw, dict):
raise McpDataAnomalyError(
f"portfolio total_value_eur unexpected shape: {type(raw).__name__}",
service=self.SERVICE,
tool="get_total_portfolio_value",
)
value = raw.get("total_value_eur")
if value is None:
raise McpDataAnomalyError(
"portfolio response missing 'total_value_eur'",
service=self.SERVICE,
tool="get_total_portfolio_value",
)
return Decimal(str(value))
async def asset_pct_of_portfolio(self, ticker: str) -> Decimal:
"""Return the fraction (0..1) of the portfolio held in ``ticker``.
Iterates the holdings list and aggregates ``current_value_eur``
for any holding whose ticker contains ``ticker`` (case-insensitive).
Empty portfolio → 0.
"""
holdings = await self._http.call("get_holdings", {"min_value_eur": 0})
if not isinstance(holdings, list):
raise McpDataAnomalyError(
f"portfolio get_holdings unexpected shape: {type(holdings).__name__}",
service=self.SERVICE,
tool="get_holdings",
)
target = ticker.upper()
matching_value = Decimal("0")
total_value = Decimal("0")
for entry in holdings:
if not isinstance(entry, dict):
continue
value = entry.get("current_value_eur")
if value is None:
continue
value_dec = Decimal(str(value))
total_value += value_dec
entry_ticker = str(entry.get("ticker") or "").upper()
if target in entry_ticker:
matching_value += value_dec
if total_value == 0:
return Decimal("0")
return matching_value / total_value
async def health(self) -> dict[str, Any]:
"""Lightweight call used by ``cerbero-bite ping``."""
result: Any = await self._http.call("get_last_update_info", {})
return result if isinstance(result, dict) else {}
+79
View File
@@ -0,0 +1,79 @@
"""Wrapper around ``mcp-sentiment``.
Cerbero Bite uses one tool from this service: ``get_cross_exchange_funding``,
the input to the directional bias of ``compute_bias`` (see
``docs/01-strategy-rules.md §3.1``).
The MCP server returns the *raw period funding rate* of each exchange
(Binance/Bybit/OKX use an 8-hour funding period; Hyperliquid uses 1
hour). The wrapper converts each value to an annualised fraction
using the period that exchange actually settles on, then returns the
median across the available venues. Exchanges that did not return a
quote (``None``) are skipped.
"""
from __future__ import annotations
import statistics
from decimal import Decimal
from cerbero_bite.clients._base import HttpToolClient
from cerbero_bite.clients._exceptions import McpDataAnomalyError
__all__ = ["EXCHANGE_PERIODS_PER_YEAR", "SentimentClient"]
# Funding settlement frequency per year. 1095 = 365 × 3 (8-hour funding).
EXCHANGE_PERIODS_PER_YEAR: dict[str, int] = {
"binance": 1095,
"bybit": 1095,
"okx": 1095,
"hyperliquid": 8760, # hourly funding
}
class SentimentClient:
SERVICE = "sentiment"
def __init__(self, http: HttpToolClient) -> None:
if http.service != self.SERVICE:
raise ValueError(
f"SentimentClient requires service '{self.SERVICE}', got '{http.service}'"
)
self._http = http
async def funding_cross_median_annualized(self, asset: str) -> Decimal:
"""Return the median annualised funding rate across known venues.
Raises :class:`McpDataAnomalyError` when the snapshot lacks any
quote — without funding data Bite cannot compute a directional
bias and must skip the cycle.
"""
raw = await self._http.call(
"get_cross_exchange_funding", {"assets": [asset.upper()]}
)
snapshot = (raw.get("snapshot") or {}).get(asset.upper())
if not isinstance(snapshot, dict):
raise McpDataAnomalyError(
f"sentiment snapshot missing for {asset}",
service=self.SERVICE,
tool="get_cross_exchange_funding",
)
annualized: list[Decimal] = []
for venue, periods in EXCHANGE_PERIODS_PER_YEAR.items():
value = snapshot.get(venue)
if value is None:
continue
annualized.append(Decimal(str(value)) * Decimal(periods))
if not annualized:
raise McpDataAnomalyError(
f"no funding venues responded for {asset}",
service=self.SERVICE,
tool="get_cross_exchange_funding",
)
# statistics.median works on Decimal: it returns an averaged
# Decimal for even counts, which is exactly what we want.
return Decimal(str(statistics.median(annualized)))
+112
View File
@@ -0,0 +1,112 @@
"""Wrapper around ``mcp-telegram`` (notify-only mode).
Cerbero Bite during the testnet phase (and through the soft launch) is
fully autonomous: Telegram is used purely to *notify* Adriano of what
the engine has done, never to gate execution. As a consequence:
* No ``send_with_buttons`` and no callback queue.
* Confirmation timeouts are handled inside the orchestrator's own
state machine, not by waiting on Telegram replies.
* All notifications go through one of the typed endpoints
(``notify``, ``notify_position_opened``, ``notify_position_closed``,
``notify_alert``, ``notify_system_error``) — the formatting lives
on the server side.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
from cerbero_bite.clients._base import HttpToolClient
__all__ = ["TelegramClient"]
def _to_float(value: Decimal | float) -> float:
return float(value) if isinstance(value, Decimal) else value
class TelegramClient:
SERVICE = "telegram"
def __init__(self, http: HttpToolClient) -> None:
if http.service != self.SERVICE:
raise ValueError(
f"TelegramClient requires service '{self.SERVICE}', got '{http.service}'"
)
self._http = http
async def notify(
self,
message: str,
*,
priority: str = "normal",
tag: str | None = None,
) -> None:
body: dict[str, Any] = {"message": message, "priority": priority}
if tag is not None:
body["tag"] = tag
await self._http.call("notify", body)
async def notify_position_opened(
self,
*,
instrument: str,
side: str,
size: int,
strategy: str,
greeks: dict[str, Decimal | float] | None = None,
expected_pnl_usd: Decimal | float | None = None,
) -> None:
body: dict[str, Any] = {
"instrument": instrument,
"side": side,
"size": float(size),
"strategy": strategy,
}
if greeks is not None:
body["greeks"] = {k: _to_float(v) for k, v in greeks.items()}
if expected_pnl_usd is not None:
body["expected_pnl"] = _to_float(expected_pnl_usd)
await self._http.call("notify_position_opened", body)
async def notify_position_closed(
self,
*,
instrument: str,
realized_pnl_usd: Decimal | float,
reason: str,
) -> None:
await self._http.call(
"notify_position_closed",
{
"instrument": instrument,
"realized_pnl": _to_float(realized_pnl_usd),
"reason": reason,
},
)
async def notify_alert(
self,
*,
source: str,
message: str,
priority: str = "high",
) -> None:
await self._http.call(
"notify_alert",
{"source": source, "message": message, "priority": priority},
)
async def notify_system_error(
self,
*,
message: str,
component: str | None = None,
priority: str = "critical",
) -> None:
body: dict[str, Any] = {"message": message, "priority": priority}
if component is not None:
body["component"] = component
await self._http.call("notify_system_error", body)
+108
View File
@@ -0,0 +1,108 @@
"""Resolve MCP service URLs and the bearer token.
Cerbero Bite runs in its own Docker container that joins the
``cerbero-suite`` network: every MCP service is reachable by the
container DNS name plus its internal port (``mcp-deribit:9011`` etc.).
The resolver supports two layers of override:
1. Per-service environment variables (``CERBERO_BITE_MCP_DERIBIT_URL``,
``CERBERO_BITE_MCP_MACRO_URL``…). Useful for dev when running
outside Docker — point at ``http://localhost:9011`` etc.
2. ``CERBERO_BITE_CORE_TOKEN_FILE`` env var: path to the file that
stores the bearer token (default
``/run/secrets/core_token``). The file is read at boot, the
trailing whitespace is stripped, and the value is *not* logged.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
__all__ = [
"DEFAULT_ENDPOINTS",
"MCP_SERVICES",
"McpEndpoints",
"load_endpoints",
"load_token",
]
# Service identifier → (default Docker DNS host, default port, env var name)
MCP_SERVICES: dict[str, tuple[str, int, str]] = {
"deribit": ("mcp-deribit", 9011, "CERBERO_BITE_MCP_DERIBIT_URL"),
"hyperliquid": ("mcp-hyperliquid", 9012, "CERBERO_BITE_MCP_HYPERLIQUID_URL"),
"macro": ("mcp-macro", 9013, "CERBERO_BITE_MCP_MACRO_URL"),
"sentiment": ("mcp-sentiment", 9014, "CERBERO_BITE_MCP_SENTIMENT_URL"),
"telegram": ("mcp-telegram", 9017, "CERBERO_BITE_MCP_TELEGRAM_URL"),
"portfolio": ("mcp-portfolio", 9018, "CERBERO_BITE_MCP_PORTFOLIO_URL"),
}
def _default_url(host: str, port: int) -> str:
return f"http://{host}:{port}"
DEFAULT_ENDPOINTS: dict[str, str] = {
name: _default_url(host, port) for name, (host, port, _) in MCP_SERVICES.items()
}
@dataclass(frozen=True)
class McpEndpoints:
"""Resolved per-service URLs."""
deribit: str
hyperliquid: str
macro: str
sentiment: str
telegram: str
portfolio: str
def for_service(self, name: str) -> str:
try:
return getattr(self, name) # type: ignore[no-any-return]
except AttributeError as exc:
raise KeyError(f"unknown MCP service '{name}'") from exc
def load_endpoints(env: dict[str, str] | None = None) -> McpEndpoints:
"""Build an :class:`McpEndpoints` honouring env-var overrides."""
e = env if env is not None else os.environ
resolved: dict[str, str] = {}
for name, (host, port, env_var) in MCP_SERVICES.items():
override = e.get(env_var)
resolved[name] = override.rstrip("/") if override else _default_url(host, port)
return McpEndpoints(**resolved)
_DEFAULT_TOKEN_FILE = "/run/secrets/core_token"
_TOKEN_FILE_ENV = "CERBERO_BITE_CORE_TOKEN_FILE"
def load_token(
*,
path: str | Path | None = None,
env: dict[str, str] | None = None,
) -> str:
"""Read the bearer token from disk and return it stripped.
Resolution order:
1. explicit ``path`` argument;
2. ``CERBERO_BITE_CORE_TOKEN_FILE`` env var;
3. ``/run/secrets/core_token`` (Docker secrets default).
"""
e = env if env is not None else os.environ
target = (
Path(path)
if path is not None
else Path(e.get(_TOKEN_FILE_ENV, _DEFAULT_TOKEN_FILE))
)
if not target.is_file():
raise FileNotFoundError(f"core token file not found: {target}")
token = target.read_text(encoding="utf-8").strip()
if not token:
raise ValueError(f"core token file is empty: {target}")
return token