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:
@@ -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."""
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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 {}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 {}
|
||||
@@ -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)))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user