diff --git a/docker-compose.yml b/docker-compose.yml index 7b2feba..5eb0981 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,8 +44,12 @@ services: CERBERO_BITE_MCP_HYPERLIQUID_URL: http://mcp-hyperliquid:9012 CERBERO_BITE_MCP_MACRO_URL: http://mcp-macro:9013 CERBERO_BITE_MCP_SENTIMENT_URL: http://mcp-sentiment:9014 - CERBERO_BITE_MCP_TELEGRAM_URL: http://mcp-telegram:9017 - CERBERO_BITE_MCP_PORTFOLIO_URL: http://mcp-portfolio:9018 + # Telegram and Portfolio are no longer shared MCP services. The + # bot now calls the Telegram Bot API directly and aggregates + # portfolio in-process from Deribit + Hyperliquid + Macro. + # Set the two env vars below to enable Telegram notifications. + # CERBERO_BITE_TELEGRAM_BOT_TOKEN: ... + # CERBERO_BITE_TELEGRAM_CHAT_ID: ... volumes: - bite-data:/app/data healthcheck: diff --git a/docs/02-architecture.md b/docs/02-architecture.md index 4d0faf4..8567fe3 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -75,7 +75,7 @@ Adriano gli eventi post-fact (entry placed, exit filled, alert). | Format/lint | `ruff` | Standard del progetto | | Dependency manager | `uv` | Coerente con `Cerbero_mcp` | | Client MCP | `httpx.AsyncClient` long-lived (pooling) + `tenacity` per retry | HTTP REST diretto, non SDK `mcp` | -| Notifiche | MCP `cerbero-telegram` (notify-only) | Riusa il canale esistente | +| Notifiche | Bot API Telegram in-process (notify-only) | Token e chat-id da env, no-op se non configurati | | GUI | `streamlit` ≥ 1.40 + `plotly` (Fase 4.5) | Dashboard locale, processo separato | ## Layout cartelle diff --git a/docs/04-mcp-integration.md b/docs/04-mcp-integration.md index 52bcd82..1101daa 100644 --- a/docs/04-mcp-integration.md +++ b/docs/04-mcp-integration.md @@ -1,10 +1,18 @@ # 04 — MCP Integration -Cerbero Bite consuma sei servizi MCP HTTP della suite (`Cerbero_mcp`). -Non utilizza l'SDK Python `mcp`: ogni server espone gli endpoint REST -`POST /tools/` con autenticazione Bearer, e Cerbero -Bite vi si collega tramite `httpx.AsyncClient` long-lived -(`clients/_base.py`). +Cerbero Bite consuma quattro servizi MCP HTTP della suite (`Cerbero_mcp`): +`cerbero-deribit`, `cerbero-hyperliquid`, `cerbero-macro`, +`cerbero-sentiment`. Non utilizza l'SDK Python `mcp`: ogni server +espone gli endpoint REST `POST /tools/` con +autenticazione Bearer, e Cerbero Bite vi si collega tramite +`httpx.AsyncClient` long-lived (`clients/_base.py`). + +Telegram e Portfolio, in passato esposti come servizi MCP condivisi, +sono stati rimossi dal layer MCP e gestiti **in-process** da ogni bot +della suite: il client Telegram chiama direttamente la Bot API +pubblica e l'aggregatore di portafoglio compone equity ed esposizioni +dai client di scambio (Deribit + Hyperliquid) convertendo in EUR +attraverso `cerbero-macro.get_asset_price("EURUSD")`. ## Configurazione di connessione @@ -20,8 +28,17 @@ d'ambiente dedicata, utile in sviluppo: | Hyperliquid | `CERBERO_BITE_MCP_HYPERLIQUID_URL` | `http://mcp-hyperliquid:9012` | | Macro | `CERBERO_BITE_MCP_MACRO_URL` | `http://mcp-macro:9013` | | Sentiment | `CERBERO_BITE_MCP_SENTIMENT_URL` | `http://mcp-sentiment:9014` | -| Telegram | `CERBERO_BITE_MCP_TELEGRAM_URL` | `http://mcp-telegram:9017` | -| Portfolio | `CERBERO_BITE_MCP_PORTFOLIO_URL` | `http://mcp-portfolio:9018` | + +Telegram (notify-only) viene configurato direttamente via due +variabili d'ambiente, lette al boot dal client in-process: + +| Variabile | Uso | +|---|---| +| `CERBERO_BITE_TELEGRAM_BOT_TOKEN` | Token del bot fornito da BotFather | +| `CERBERO_BITE_TELEGRAM_CHAT_ID` | Identificativo della chat o del gruppo destinatario | + +Quando una delle due manca, il client Telegram entra in modalità +**disabled** e ogni `notify_*` diventa un no-op a livello di DEBUG. Il bearer token per le chiamate è il token con capability `core` letto da `secrets/core.token` (path configurabile via @@ -100,22 +117,35 @@ Cerbero Bite è deterministico e non interpreta testi liberi. | Tool | Uso | |---|---| | `get_macro_calendar(days, country_filter, importance_min)` | Filtro entry §2.5: zero eventi `high` in `country_filter` (default `["US","EU"]`) entro la finestra DTE | +| `get_asset_price(ticker="EURUSD")` | Tasso di cambio EUR/USD usato dall'aggregatore di portafoglio per convertire l'equity USD degli scambi in EUR | -### `cerbero-portfolio` +## Componenti in-process -| Tool | Uso | +### Portfolio aggregator (`clients/portfolio.py`) + +Il client `PortfolioClient` non chiama più un servizio MCP dedicato; +compone i dati dei due exchange usati dal bot e applica il cambio +EUR/USD letto da `cerbero-macro`. + +| Metodo | Comportamento | |---|---| -| `get_total_portfolio_value(currency="EUR")` | Capitale di base per il sizing engine, dopo conversione in USD | -| `get_holdings()` | Aggregazione manuale di `current_value_eur` per i ticker che contengono `"ETH"`, usata dal filtro §2.7 (`eth_holdings_pct_max`) | +| `total_equity_eur()` | Somma `equity` USD di Deribit (USDC) e Hyperliquid, divide per `EURUSD` per ottenere il capitale in EUR consumato dal sizing engine | +| `asset_pct_of_portfolio(ticker)` | Somma il notional USD assoluto delle posizioni aperte su entrambi gli scambi il cui `instrument`/`coin` contiene `ticker`, e lo divide per l'equity totale USD. Usato dal filtro §2.7 (`eth_holdings_pct_max`) | -### `cerbero-telegram` +**Nota di scope**: la vista è la *slice* del singolo bot. Holdings su +exchange esterni, in cold storage, o gestiti da altri bot della suite +non vengono contati. Il filtro §2.7 va quindi inteso come cap +per-bot, non come cap suite-wide. -Cerbero Bite usa Telegram in modalità **notify-only**: nessuna conferma -manuale, nessun callback. L'engine apre e chiude le posizioni -automaticamente quando le regole sono soddisfatte; Telegram viene -informato post-fact. +### Telegram client (`clients/telegram.py`) -| Tool | Uso | +Cerbero Bite usa Telegram in modalità **notify-only**: nessuna +conferma manuale, nessun callback. L'engine apre e chiude le +posizioni automaticamente quando le regole sono soddisfatte; il +client invia il messaggio al `chat_id` configurato chiamando +direttamente `https://api.telegram.org/bot/sendMessage`. + +| Metodo | Uso | |---|---| | `notify(message, priority, tag)` | Alert MEDIUM o messaggi informativi | | `notify_position_opened(instrument, side, size, strategy, greeks, expected_pnl)` | Notifica di entry placed | @@ -123,16 +153,20 @@ informato post-fact. | `notify_alert(source, message, priority)` | Alert HIGH (kill switch) | | `notify_system_error(message, component, priority)` | Alert CRITICAL | +Quando le credenziali env non sono configurate, il client è in +modalità disabled e ogni invio diventa un no-op silente: il ciclo +decisionale non viene bloccato. + ## Errori e degradation -| Server fuori uso | Comportamento | +| Componente fuori uso | Comportamento | |---|---| | `cerbero-deribit` | **Hard fail**: senza dati di mercato e canale di esecuzione il ciclo viene saltato; in monitor le posizioni esistenti restano nello stato corrente, alert HIGH e kill switch | | `cerbero-hyperliquid` | Skip del filtro funding §2.6 con warning; il ciclo prosegue se le altre condizioni sono soddisfatte | | `cerbero-sentiment` | Bias §3.1 cade su `no_entry` per default (senza funding cross il bias non può fissare la direzione) | -| `cerbero-macro` | Hard fail per il filtro §2.5; senza calendar non si apre | -| `cerbero-portfolio` | Skip dei filtri §2.7 con warning; il sizing usa l'ultimo capitale noto da SQLite | -| `cerbero-telegram` | Skip notifiche post-fact; il ciclo decisionale non viene bloccato (l'engine non aspetta risposte) | +| `cerbero-macro` | Hard fail per il filtro §2.5 e per la conversione EUR/USD del portfolio aggregator; senza calendar/FX non si apre | +| Portfolio aggregator (deribit o hyperliquid down) | I metodi di `PortfolioClient` propagano l'eccezione dell'exchange sottostante; il sizing engine si comporta come per un guasto MCP del livello inferiore | +| Telegram client | Errore HTTP o `ok=false` dalla Bot API → `TelegramError` propagata dal chiamante. In modalità disabled (env mancanti) tutti i `notify_*` sono no-op silenti e il ciclo decisionale prosegue | I trigger HIGH e CRITICAL armano il kill switch e propagano un alert in audit chain. diff --git a/docs/06-operational-flow.md b/docs/06-operational-flow.md index f11ff2a..23bf251 100644 --- a/docs/06-operational-flow.md +++ b/docs/06-operational-flow.md @@ -140,7 +140,7 @@ Trigger: ogni 5 minuti. - macro.get_macro_calendar(days=1) - sentiment.get_cross_exchange_funding (no asset filter) - hyperliquid.get_funding_rate("ETH") - - portfolio.get_total_portfolio_value + - portfolio: skip (componente in-process, copertura indiretta dai probe deribit/hyperliquid/macro) - telegram: skip (notify-only, no probe non invasivo) 2. SQLite read-write probe (transazione fittizia) 3. Lock file ancora valido diff --git a/docs/07-risk-controls.md b/docs/07-risk-controls.md index 116debf..071add0 100644 --- a/docs/07-risk-controls.md +++ b/docs/07-risk-controls.md @@ -34,7 +34,7 @@ infrastrutturali o decisioni umane fuori posto. | Causa | Auto-arm | Implementato | Note | |---|---|---|---| | MCP `cerbero-deribit` non risponde per 3 health check consecutivi | Sì | `runtime/health_check.py` | Severity HIGH | -| MCP `cerbero-macro` / `cerbero-portfolio` / `cerbero-hyperliquid` / `cerbero-sentiment` non risponde per 3 health check consecutivi | Sì | `runtime/health_check.py` | Severity HIGH | +| MCP `cerbero-macro` / `cerbero-hyperliquid` / `cerbero-sentiment` non risponde per 3 health check consecutivi | Sì | `runtime/health_check.py` | Severity HIGH | | `mcp-deribit.environment_info.environment` ≠ `strategy.execution.environment` | Sì | `runtime/orchestrator.boot` + health check | Severity CRITICAL al boot, HIGH a runtime | | Mismatch tra il tail del file `data/audit.log` e `system_state.last_audit_hash` (truncation o tampering) | Sì | `runtime/orchestrator._verify_audit_anchor` | Severity CRITICAL al boot | | Stato SQLite incoerente con il broker (recovery non risolutivo) | Sì | `runtime/recovery.py` | Severity CRITICAL al boot | diff --git a/src/cerbero_bite/cli.py b/src/cerbero_bite/cli.py index 9adc8c7..7c12a18 100644 --- a/src/cerbero_bite/cli.py +++ b/src/cerbero_bite/cli.py @@ -26,7 +26,6 @@ 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 ( @@ -560,12 +559,6 @@ async def _ping_one( 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}" diff --git a/src/cerbero_bite/clients/hyperliquid.py b/src/cerbero_bite/clients/hyperliquid.py index 93a3192..d5afb63 100644 --- a/src/cerbero_bite/clients/hyperliquid.py +++ b/src/cerbero_bite/clients/hyperliquid.py @@ -1,13 +1,17 @@ """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). +Cerbero Bite consumes: + +* ``get_funding_rate`` — entry filter §2.6 cap on absolute annualised + funding rate (``docs/01-strategy-rules.md``). +* ``get_account_summary`` and ``get_positions`` — feed the in-process + portfolio aggregator (equity + ETH/BTC exposure on the perp side). """ 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 @@ -47,3 +51,19 @@ class HyperliquidClient: tool="get_funding_rate", ) return Decimal(str(rate)) * Decimal(HOURLY_FUNDING_PERIODS_PER_YEAR) + + async def get_account_summary(self) -> dict[str, Any]: + """Account equity and balances (USD).""" + raw: Any = await self._http.call("get_account_summary", {}) + return raw if isinstance(raw, dict) else {} + + async def get_positions(self) -> list[dict[str, Any]]: + """Open perp positions (list of dicts).""" + raw: Any = await self._http.call("get_positions", {}) + if isinstance(raw, list): + return raw + if isinstance(raw, dict): + inner = raw.get("positions") + if isinstance(inner, list): + return inner + return [] diff --git a/src/cerbero_bite/clients/macro.py b/src/cerbero_bite/clients/macro.py index 4d4a944..d1a9d5a 100644 --- a/src/cerbero_bite/clients/macro.py +++ b/src/cerbero_bite/clients/macro.py @@ -9,11 +9,13 @@ the requested window. The orchestrator feeds the result straight into from __future__ import annotations from datetime import UTC, datetime +from decimal import Decimal from typing import Any from pydantic import BaseModel, ConfigDict from cerbero_bite.clients._base import HttpToolClient +from cerbero_bite.clients._exceptions import McpDataAnomalyError __all__ = ["MacroClient", "MacroEvent"] @@ -71,6 +73,34 @@ class MacroClient: ) return out + async def get_asset_price(self, ticker: str) -> Decimal: + """Return the latest cross-asset price for ``ticker`` (e.g. ``EURUSD``).""" + raw = await self._http.call("get_asset_price", {"ticker": ticker}) + if not isinstance(raw, dict): + raise McpDataAnomalyError( + f"macro get_asset_price unexpected shape: {type(raw).__name__}", + service=self.SERVICE, + tool="get_asset_price", + ) + if raw.get("error"): + raise McpDataAnomalyError( + f"macro get_asset_price error for {ticker}: {raw['error']}", + service=self.SERVICE, + tool="get_asset_price", + ) + price = raw.get("price") + if price is None: + raise McpDataAnomalyError( + f"macro get_asset_price missing 'price' for {ticker}", + service=self.SERVICE, + tool="get_asset_price", + ) + return Decimal(str(price)) + + async def eur_usd_rate(self) -> Decimal: + """Return EUR→USD spot rate (i.e. ``EURUSD`` price).""" + return await self.get_asset_price("EURUSD") + async def next_high_severity_within( self, *, diff --git a/src/cerbero_bite/clients/portfolio.py b/src/cerbero_bite/clients/portfolio.py index 04b64cd..9863528 100644 --- a/src/cerbero_bite/clients/portfolio.py +++ b/src/cerbero_bite/clients/portfolio.py @@ -1,92 +1,157 @@ -"""Wrapper around ``mcp-portfolio``. +"""In-process portfolio aggregator. -Cerbero Bite uses two pieces of information from this service: +Each Cerbero Suite bot now manages its own portfolio view: instead of +calling a shared ``mcp-portfolio`` service, this client composes the +account summaries and open positions from the exchanges the bot +actually uses (Deribit options + Hyperliquid perps) and converts them +to EUR via the macro 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``). +Two values are exposed: -The portfolio service stores everything in EUR. The orchestrator is -responsible for the EUR→USD conversion using a live FX rate. +* :py:meth:`total_equity_eur` — sum of USDC equity on Deribit and USD + equity on Hyperliquid, converted to EUR using the live ``EURUSD`` + rate from ``mcp-macro``. +* :py:meth:`asset_pct_of_portfolio` — fraction (0..1) of total USD + equity exposed to a specific ticker via open positions on the two + exchanges. Used by entry filter §2.7 (``eth_holdings_pct_max``). + +**Scope note**: this is the bot's own slice. Holdings on other +exchanges, in cold storage, or held by other bots in the suite are +*not* counted. The §2.7 limit is therefore a per-bot cap, not a +suite-wide one. """ from __future__ import annotations +import asyncio +from collections.abc import Iterable from decimal import Decimal -from typing import Any +from typing import Any, cast -from cerbero_bite.clients._base import HttpToolClient from cerbero_bite.clients._exceptions import McpDataAnomalyError +from cerbero_bite.clients.deribit import DeribitClient +from cerbero_bite.clients.hyperliquid import HyperliquidClient +from cerbero_bite.clients.macro import MacroClient __all__ = ["PortfolioClient"] -class PortfolioClient: - SERVICE = "portfolio" +def _decimal_or_zero(value: Any) -> Decimal: + if value is None: + return Decimal(0) + try: + return Decimal(str(value)) + except (ValueError, ArithmeticError): + return Decimal(0) - 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 + +def _position_notional_usd(pos: dict[str, Any]) -> Decimal: + """Best-effort USD notional of an open position. + + Prefers an explicit ``notional_usd`` / ``size_usd`` / ``value_usd`` + field. Falls back to ``|size × mark_price|`` (or ``index_price`` if + mark is missing). Returns 0 on malformed entries. + """ + for key in ("notional_usd", "size_usd", "value_usd", "position_value"): + v = pos.get(key) + if v is not None: + return abs(_decimal_or_zero(v)) + size = _decimal_or_zero(pos.get("size") or pos.get("szi")) + mark = _decimal_or_zero( + pos.get("mark_price") + or pos.get("entry_price") + or pos.get("index_price") + ) + return abs(size * mark) + + +def _instrument_label(pos: dict[str, Any]) -> str: + for key in ("instrument_name", "instrument", "symbol", "coin", "asset"): + v = pos.get(key) + if v is not None: + return str(v).upper() + return "" + + +class PortfolioClient: + """Aggregates equity + asset exposure across the bot's exchange accounts.""" + + def __init__( + self, + *, + deribit: DeribitClient, + hyperliquid: HyperliquidClient, + macro: MacroClient, + ) -> None: + self._deribit = deribit + self._hyperliquid = hyperliquid + self._macro = macro + + async def _equity_usd_components(self) -> tuple[Decimal, Decimal]: + """Concurrent fetch of (deribit_equity_usd, hyperliquid_equity_usd).""" + deribit_summary, hl_summary = await asyncio.gather( + self._deribit.get_account_summary(currency="USDC"), + self._hyperliquid.get_account_summary(), + ) + deribit_eq = _decimal_or_zero(deribit_summary.get("equity")) + hl_eq = _decimal_or_zero(hl_summary.get("equity")) + return deribit_eq, hl_eq + + async def total_equity_usd(self) -> Decimal: + """Sum equity USD across the bot's exchange accounts.""" + deribit_eq, hl_eq = await self._equity_usd_components() + return deribit_eq + hl_eq 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): + """Return aggregate bot equity in EUR. + + Concurrent: account summaries × FX. Raises + :class:`McpDataAnomalyError` if the FX rate is non-positive. + """ + components_t = asyncio.create_task(self._equity_usd_components()) + fx_t = asyncio.create_task(self._macro.eur_usd_rate()) + await asyncio.gather(components_t, fx_t) + deribit_eq, hl_eq = components_t.result() + fx = fx_t.result() + if fx <= 0: raise McpDataAnomalyError( - f"portfolio total_value_eur unexpected shape: {type(raw).__name__}", - service=self.SERVICE, - tool="get_total_portfolio_value", + f"non-positive EURUSD rate: {fx}", + service="macro", + tool="get_asset_price", ) - 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)) + usd_total = deribit_eq + hl_eq + return usd_total / fx async def asset_pct_of_portfolio(self, ticker: str) -> Decimal: - """Return the fraction (0..1) of the portfolio held in ``ticker``. + """Fraction of bot equity (USD) exposed to ``ticker``. - Iterates the holdings list and aggregates ``current_value_eur`` - for any holding whose ticker contains ``ticker`` (case-insensitive). - Empty portfolio → 0. + Sums absolute USD notional of open positions whose instrument + label contains ``ticker`` (case-insensitive) on Deribit and + Hyperliquid, divided by the bot's total USD equity. Returns 0 + when there is no equity or no exposure. """ - 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 + deribit_pos_t = asyncio.create_task( + self._deribit.get_positions(currency="USDC") + ) + hl_pos_t = asyncio.create_task(self._hyperliquid.get_positions()) + equity_t = asyncio.create_task(self._equity_usd_components()) + await asyncio.gather(deribit_pos_t, hl_pos_t, equity_t) - if total_value == 0: - return Decimal("0") - return matching_value / total_value + exposure_usd = Decimal(0) + for raw_pos in cast(Iterable[Any], deribit_pos_t.result()): + if not isinstance(raw_pos, dict): + continue + if target in _instrument_label(raw_pos): + exposure_usd += _position_notional_usd(raw_pos) + for raw_pos in cast(Iterable[Any], hl_pos_t.result()): + if not isinstance(raw_pos, dict): + continue + if target in _instrument_label(raw_pos): + exposure_usd += _position_notional_usd(raw_pos) - 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 {} + deribit_eq, hl_eq = equity_t.result() + total_eq = deribit_eq + hl_eq + if total_eq <= 0: + return Decimal(0) + return exposure_usd / total_eq diff --git a/src/cerbero_bite/clients/telegram.py b/src/cerbero_bite/clients/telegram.py index cbce524..bc87ce4 100644 --- a/src/cerbero_bite/clients/telegram.py +++ b/src/cerbero_bite/clients/telegram.py @@ -1,41 +1,115 @@ -"""Wrapper around ``mcp-telegram`` (notify-only mode). +"""Direct Telegram Bot API client (notify-only). -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: +Cerbero Bite is fully autonomous: Telegram is used solely to *notify* +the operator of what the engine has done — there is no inbound queue +and no confirmation logic. -* 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. +Credentials are read from the environment: + +* ``CERBERO_BITE_TELEGRAM_BOT_TOKEN`` — bot token from BotFather. +* ``CERBERO_BITE_TELEGRAM_CHAT_ID`` — destination chat id. + +If either is missing the client runs in **disabled** mode: every +``notify_*`` becomes a no-op logged at DEBUG. This keeps unconfigured +deployments and the test environment harmless. """ from __future__ import annotations +import logging +import os from decimal import Decimal from typing import Any -from cerbero_bite.clients._base import HttpToolClient +import httpx -__all__ = ["TelegramClient"] +__all__ = [ + "TELEGRAM_BOT_TOKEN_ENV", + "TELEGRAM_CHAT_ID_ENV", + "TelegramClient", + "TelegramError", + "load_telegram_credentials", +] -def _to_float(value: Decimal | float) -> float: - return float(value) if isinstance(value, Decimal) else value +TELEGRAM_BOT_TOKEN_ENV = "CERBERO_BITE_TELEGRAM_BOT_TOKEN" +TELEGRAM_CHAT_ID_ENV = "CERBERO_BITE_TELEGRAM_CHAT_ID" + +_log = logging.getLogger("cerbero_bite.clients.telegram") + + +class TelegramError(RuntimeError): + """Raised when the Telegram Bot API rejects a sendMessage call.""" + + +def _to_float(value: Decimal | float | int) -> float: + return float(value) + + +def load_telegram_credentials( + env: dict[str, str] | None = None, +) -> tuple[str | None, str | None]: + """Return ``(bot_token, chat_id)`` from env. Empty strings → ``None``.""" + e = env if env is not None else os.environ + token = (e.get(TELEGRAM_BOT_TOKEN_ENV) or "").strip() or None + chat = (e.get(TELEGRAM_CHAT_ID_ENV) or "").strip() or None + return token, chat class TelegramClient: - SERVICE = "telegram" + """Notify-only client over the public Telegram Bot API.""" - def __init__(self, http: HttpToolClient) -> None: - if http.service != self.SERVICE: - raise ValueError( - f"TelegramClient requires service '{self.SERVICE}', got '{http.service}'" + BASE_URL = "https://api.telegram.org" + + def __init__( + self, + *, + bot_token: str | None, + chat_id: str | None, + http_client: httpx.AsyncClient | None = None, + timeout_s: float = 5.0, + parse_mode: str = "HTML", + ) -> None: + self._token = (bot_token or "").strip() or None + self._chat_id = (str(chat_id).strip() if chat_id is not None else "") or None + self._client = http_client + self._timeout = timeout_s + self._parse_mode = parse_mode + + @property + def enabled(self) -> bool: + return self._token is not None and self._chat_id is not None + + async def _send(self, text: str) -> None: + if not self.enabled: + _log.debug("telegram disabled, dropping message: %s", text[:120]) + return + url = f"{self.BASE_URL}/bot{self._token}/sendMessage" + payload: dict[str, Any] = { + "chat_id": self._chat_id, + "text": text, + "parse_mode": self._parse_mode, + "disable_web_page_preview": True, + } + client = self._client + owns = client is None + if client is None: + client = httpx.AsyncClient(timeout=self._timeout) + try: + resp = await client.post(url, json=payload, timeout=self._timeout) + finally: + if owns: + await client.aclose() + if resp.status_code != 200: + raise TelegramError( + f"telegram HTTP {resp.status_code}: {resp.text[:200]}" ) - self._http = http + data = resp.json() + if not isinstance(data, dict) or not data.get("ok", False): + desc = ( + data.get("description", "?") if isinstance(data, dict) else str(data) + ) + raise TelegramError(f"telegram api error: {desc}") async def notify( self, @@ -44,10 +118,10 @@ class TelegramClient: 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) + prefix = f"[{priority.upper()}]" + if tag: + prefix = f"{prefix}[{tag}]" + await self._send(f"{prefix} {message}") async def notify_position_opened( self, @@ -59,17 +133,19 @@ class TelegramClient: 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()} + lines = [ + "POSITION OPENED", + f"instrument: {instrument}", + f"side: {side} | size: {size} | strategy: {strategy}", + ] + if greeks: + joined = ", ".join( + f"{k}={_to_float(v):+.4f}" for k, v in greeks.items() + ) + lines.append(f"greeks: {joined}") if expected_pnl_usd is not None: - body["expected_pnl"] = _to_float(expected_pnl_usd) - await self._http.call("notify_position_opened", body) + lines.append(f"expected pnl: ${_to_float(expected_pnl_usd):+.2f}") + await self._send("\n".join(lines)) async def notify_position_closed( self, @@ -78,13 +154,12 @@ class TelegramClient: 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, - }, + pnl = _to_float(realized_pnl_usd) + await self._send( + "POSITION CLOSED\n" + f"instrument: {instrument}\n" + f"realized pnl: ${pnl:+.2f}\n" + f"reason: {reason}" ) async def notify_alert( @@ -94,9 +169,10 @@ class TelegramClient: message: str, priority: str = "high", ) -> None: - await self._http.call( - "notify_alert", - {"source": source, "message": message, "priority": priority}, + await self._send( + f"ALERT [{priority.upper()}]\n" + f"source: {source}\n" + f"{message}" ) async def notify_system_error( @@ -106,7 +182,8 @@ class TelegramClient: 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) + text = f"SYSTEM ERROR [{priority.upper()}]\n" + if component: + text += f"component: {component}\n" + text += message + await self._send(text) diff --git a/src/cerbero_bite/config/mcp_endpoints.py b/src/cerbero_bite/config/mcp_endpoints.py index 397e222..0712ac4 100644 --- a/src/cerbero_bite/config/mcp_endpoints.py +++ b/src/cerbero_bite/config/mcp_endpoints.py @@ -31,13 +31,15 @@ __all__ = [ # Service identifier → (default Docker DNS host, default port, env var name) +# +# Telegram and Portfolio used to be shared MCP services; both are now +# in-process per bot (Telegram → public Bot API, Portfolio → aggregator +# over Deribit + Hyperliquid + Macro). They are no longer listed here. 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"), } @@ -58,8 +60,6 @@ class McpEndpoints: hyperliquid: str macro: str sentiment: str - telegram: str - portfolio: str def for_service(self, name: str) -> str: try: diff --git a/src/cerbero_bite/runtime/alert_manager.py b/src/cerbero_bite/runtime/alert_manager.py index 66c9e96..1bc6abc 100644 --- a/src/cerbero_bite/runtime/alert_manager.py +++ b/src/cerbero_bite/runtime/alert_manager.py @@ -71,8 +71,11 @@ class AlertManager: return if severity == Severity.MEDIUM: + # The TelegramClient already prefixes [PRIORITY][tag] in the + # rendered text, so we pass the raw message and let the + # client compose the final form. await self._telegram.notify( - f"[{source}] {message}", priority="high", tag=source + message, priority="high", tag=source ) return diff --git a/src/cerbero_bite/runtime/dependencies.py b/src/cerbero_bite/runtime/dependencies.py index dbc59d1..9e02e78 100644 --- a/src/cerbero_bite/runtime/dependencies.py +++ b/src/cerbero_bite/runtime/dependencies.py @@ -22,7 +22,7 @@ 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.clients.telegram import TelegramClient +from cerbero_bite.clients.telegram import TelegramClient, load_telegram_credentials from cerbero_bite.config.mcp_endpoints import McpEndpoints from cerbero_bite.config.schema import StrategyConfig from cerbero_bite.runtime.alert_manager import AlertManager @@ -145,11 +145,25 @@ def build_runtime( client=http_client, ) - telegram = TelegramClient(_client("telegram")) + bot_token, chat_id = load_telegram_credentials() + telegram = TelegramClient( + bot_token=bot_token, + chat_id=chat_id, + http_client=http_client, + timeout_s=timeout_s, + ) alert_manager = AlertManager( telegram=telegram, audit_log=audit_log, kill_switch=kill_switch ) + deribit = DeribitClient(_client("deribit")) + macro = MacroClient(_client("macro")) + sentiment = SentimentClient(_client("sentiment")) + hyperliquid = HyperliquidClient(_client("hyperliquid")) + portfolio = PortfolioClient( + deribit=deribit, hyperliquid=hyperliquid, macro=macro + ) + return RuntimeContext( cfg=cfg, db_path=db_path, @@ -158,11 +172,11 @@ def build_runtime( audit_log=audit_log, kill_switch=kill_switch, alert_manager=alert_manager, - deribit=DeribitClient(_client("deribit")), - macro=MacroClient(_client("macro")), - sentiment=SentimentClient(_client("sentiment")), - hyperliquid=HyperliquidClient(_client("hyperliquid")), - portfolio=PortfolioClient(_client("portfolio")), + deribit=deribit, + macro=macro, + sentiment=sentiment, + hyperliquid=hyperliquid, + portfolio=portfolio, telegram=telegram, http_client=http_client, clock=clk, diff --git a/src/cerbero_bite/runtime/health_check.py b/src/cerbero_bite/runtime/health_check.py index dae44a7..6cd2d66 100644 --- a/src/cerbero_bite/runtime/health_check.py +++ b/src/cerbero_bite/runtime/health_check.py @@ -66,7 +66,6 @@ class HealthCheck: _probe("macro", self._ctx.macro.get_calendar(days=1)), _probe("sentiment", self._probe_sentiment()), _probe("hyperliquid", self._ctx.hyperliquid.funding_rate_annualized("ETH")), - _probe("portfolio", self._ctx.portfolio.total_equity_eur()), ) # SQLite health: lightweight transaction. diff --git a/tests/integration/test_audit_anchor.py b/tests/integration/test_audit_anchor.py index a7580ab..a23f23b 100644 --- a/tests/integration/test_audit_anchor.py +++ b/tests/integration/test_audit_anchor.py @@ -71,11 +71,6 @@ def _wire_boot_dependencies(httpx_mock: HTTPXMock) -> None: json={"asset": "ETH", "current_funding_rate": 0.0001}, is_reusable=True, ) - httpx_mock.add_response( - url="http://mcp-portfolio:9018/tools/get_total_portfolio_value", - json={"total_value_eur": 1000.0}, - is_reusable=True, - ) @pytest.mark.asyncio @@ -115,11 +110,5 @@ async def test_boot_detects_audit_truncation( orch = _build(tmp_path) _wire_boot_dependencies(httpx_mock) - httpx_mock.add_response( - url="http://mcp-telegram:9017/tools/notify_system_error", - json={"ok": True}, - is_reusable=True, - ) - await orch.boot() assert orch.context.kill_switch.is_armed() is True diff --git a/tests/integration/test_entry_cycle.py b/tests/integration/test_entry_cycle.py index 7a9b48d..a89f829 100644 --- a/tests/integration/test_entry_cycle.py +++ b/tests/integration/test_entry_cycle.py @@ -154,18 +154,39 @@ def _wire_market_snapshot( json={"events": macro_events or []}, is_reusable=True, ) + # In-process portfolio aggregator: wire the underlying exchange and + # macro endpoints so total_equity_eur and asset_pct_of_portfolio + # produce the requested ``portfolio_eur`` and ``eth_pct``. + # FX rate fixed at 1.0 → EUR amount equals USD amount in tests. portfolio_eur_f = float(portfolio_eur) httpx_mock.add_response( - url="http://mcp-portfolio:9018/tools/get_holdings", + url="http://mcp-macro:9013/tools/get_asset_price", + json={"ticker": "EURUSD", "price": 1.0}, + is_reusable=True, + ) + httpx_mock.add_response( + url="http://mcp-deribit:9011/tools/get_account_summary", + json={"equity": portfolio_eur_f, "currency": "USDC"}, + is_reusable=True, + ) + httpx_mock.add_response( + url="http://mcp-deribit:9011/tools/get_positions", json=[ - {"ticker": "AAPL", "current_value_eur": portfolio_eur_f * (1 - eth_pct)}, - {"ticker": "ETH-USD", "current_value_eur": portfolio_eur_f * eth_pct}, + { + "instrument_name": "ETH-15MAY26-2475-P", + "notional_usd": portfolio_eur_f * eth_pct, + } ], is_reusable=True, ) httpx_mock.add_response( - url="http://mcp-portfolio:9018/tools/get_total_portfolio_value", - json={"total_value_eur": portfolio_eur_f}, + url="http://mcp-hyperliquid:9012/tools/get_account_summary", + json={"equity": 0.0}, + is_reusable=True, + ) + httpx_mock.add_response( + url="http://mcp-hyperliquid:9012/tools/get_positions", + json=[], is_reusable=True, ) @@ -262,11 +283,12 @@ def _wire_combo_order( def _wire_telegram_notify_position_opened(httpx_mock: HTTPXMock) -> None: - httpx_mock.add_response( - url="http://mcp-telegram:9017/tools/notify_position_opened", - json={"ok": True}, - is_reusable=True, - ) + """No-op: Telegram is now an in-process client with disabled mode in tests. + + Kept for call-site compatibility; the function used to register an MCP + notify mock but post-refactor there is no HTTP endpoint to mock when + the bot has no Telegram credentials configured. + """ # --------------------------------------------------------------------------- @@ -355,11 +377,6 @@ async def test_below_capital_minimum_returns_no_entry( now: datetime, httpx_mock: HTTPXMock, ) -> None: - httpx_mock.add_response( - url="http://mcp-telegram:9017/tools/notify", - json={"ok": True}, - is_reusable=True, - ) # 500 EUR × 1.075 = 537 USD < 720 cfg minimum _wire_market_snapshot(httpx_mock, portfolio_eur=500.0) ctx = _ctx(cfg, runtime_paths, now) @@ -377,11 +394,6 @@ async def test_macro_event_within_dte_blocks_entry( now: datetime, httpx_mock: HTTPXMock, ) -> None: - httpx_mock.add_response( - url="http://mcp-telegram:9017/tools/notify", - json={"ok": True}, - is_reusable=True, - ) macro_events = [ { "name": "FOMC", @@ -406,11 +418,6 @@ async def test_no_bias_returns_no_entry( now: datetime, httpx_mock: HTTPXMock, ) -> None: - httpx_mock.add_response( - url="http://mcp-telegram:9017/tools/notify", - json={"ok": True}, - is_reusable=True, - ) # Funding cross neutral (=0) and DVOL 40 → no IC, no directional; # entry validates clean otherwise. _wire_market_snapshot( @@ -507,11 +514,6 @@ async def test_broker_reject_marks_position_cancelled( }, is_reusable=True, ) - httpx_mock.add_response( - url="http://mcp-telegram:9017/tools/notify_alert", - json={"ok": True}, - is_reusable=True, - ) bull_cfg = golden_config( entry=type(cfg.entry)( **{**cfg.entry.model_dump(), "trend_bull_threshold_pct": Decimal("0")} diff --git a/tests/integration/test_health_check.py b/tests/integration/test_health_check.py index a4b545d..9641ceb 100644 --- a/tests/integration/test_health_check.py +++ b/tests/integration/test_health_check.py @@ -60,11 +60,6 @@ def _wire_all_ok(httpx_mock: HTTPXMock) -> None: json={"asset": "ETH", "current_funding_rate": 0.0001}, is_reusable=True, ) - httpx_mock.add_response( - url="http://mcp-portfolio:9018/tools/get_total_portfolio_value", - json={"total_value_eur": 1000.0}, - is_reusable=True, - ) @pytest.mark.asyncio @@ -112,11 +107,6 @@ async def test_environment_mismatch_counts_as_failure( json={"asset": "ETH", "current_funding_rate": 0.0001}, is_reusable=True, ) - httpx_mock.add_response( - url="http://mcp-portfolio:9018/tools/get_total_portfolio_value", - json={"total_value_eur": 1000.0}, - is_reusable=True, - ) res = await hc.run() assert res.state == "degraded" assert any("environment mismatch" in r for _s, r in res.failures) @@ -149,17 +139,6 @@ async def test_three_consecutive_failures_arm_kill_switch( json={"asset": "ETH", "current_funding_rate": 0.0001}, is_reusable=True, ) - httpx_mock.add_response( - url="http://mcp-portfolio:9018/tools/get_total_portfolio_value", - json={"total_value_eur": 1000.0}, - is_reusable=True, - ) - httpx_mock.add_response( - url="http://mcp-telegram:9017/tools/notify_alert", - json={"ok": True}, - is_reusable=True, - ) - for _ in range(2): await hc.run() assert ctx.kill_switch.is_armed() is False @@ -197,11 +176,6 @@ async def test_recovered_run_resets_counter( json={"asset": "ETH", "current_funding_rate": 0.0001}, is_reusable=True, ) - httpx_mock.add_response( - url="http://mcp-portfolio:9018/tools/get_total_portfolio_value", - json={"total_value_eur": 1000.0}, - is_reusable=True, - ) res = await hc.run() assert res.state == "degraded" assert res.consecutive_failures == 1 diff --git a/tests/integration/test_monitor_cycle.py b/tests/integration/test_monitor_cycle.py index 3ffe4c3..f3b7981 100644 --- a/tests/integration/test_monitor_cycle.py +++ b/tests/integration/test_monitor_cycle.py @@ -231,11 +231,6 @@ async def test_monitor_closes_position_on_profit_take( }, is_reusable=True, ) - httpx_mock.add_response( - url="http://mcp-telegram:9017/tools/notify_position_closed", - json={"ok": True}, - is_reusable=True, - ) res = await run_monitor_cycle(ctx, now=now) assert len(res.outcomes) == 1 @@ -296,11 +291,6 @@ async def test_monitor_uses_dvol_history_for_return_4h( }, is_reusable=True, ) - httpx_mock.add_response( - url="http://mcp-telegram:9017/tools/notify_position_closed", - json={"ok": True}, - is_reusable=True, - ) res = await run_monitor_cycle(ctx, now=now) assert res.outcomes[0].action == "CLOSE_AVERSE" diff --git a/tests/integration/test_orchestrator.py b/tests/integration/test_orchestrator.py index e82b97f..ae71c55 100644 --- a/tests/integration/test_orchestrator.py +++ b/tests/integration/test_orchestrator.py @@ -56,11 +56,6 @@ def _wire_health_probes(httpx_mock: HTTPXMock) -> None: json={"asset": "ETH", "current_funding_rate": 0.0001}, is_reusable=True, ) - httpx_mock.add_response( - url="http://mcp-portfolio:9018/tools/get_total_portfolio_value", - json={"total_value_eur": 1000.0}, - is_reusable=True, - ) def _build_orch(tmp_path: Path, *, expected: str = "testnet") -> Orchestrator: @@ -110,12 +105,6 @@ async def test_boot_arms_kill_switch_on_environment_mismatch( json=[], is_reusable=True, ) - httpx_mock.add_response( - url="http://mcp-telegram:9017/tools/notify_system_error", - json={"ok": True}, - is_reusable=True, - ) - orch = _build_orch(tmp_path, expected="testnet") await orch.boot() assert orch.context.kill_switch.is_armed() is True diff --git a/tests/integration/test_recovery.py b/tests/integration/test_recovery.py index cb172b5..19bece6 100644 --- a/tests/integration/test_recovery.py +++ b/tests/integration/test_recovery.py @@ -115,11 +115,6 @@ async def test_recovery_cancels_awaiting_fill_when_broker_lacks_legs( url="http://mcp-deribit:9011/tools/get_positions", json=[], ) - httpx_mock.add_response( - url="http://mcp-telegram:9017/tools/notify_system_error", - json={"ok": True}, - is_reusable=True, - ) await recover_state(ctx, now=_now()) @@ -154,11 +149,6 @@ async def test_recovery_alerts_on_open_position_missing_on_broker( url="http://mcp-deribit:9011/tools/get_positions", json=[], ) - httpx_mock.add_response( - url="http://mcp-telegram:9017/tools/notify_system_error", - json={"ok": True}, - is_reusable=True, - ) await recover_state(ctx, now=_now()) assert ctx.kill_switch.is_armed() is True diff --git a/tests/unit/test_alert_manager.py b/tests/unit/test_alert_manager.py index 97510a8..f75cefd 100644 --- a/tests/unit/test_alert_manager.py +++ b/tests/unit/test_alert_manager.py @@ -9,13 +9,14 @@ from pathlib import Path import pytest from pytest_httpx import HTTPXMock -from cerbero_bite.clients._base import HttpToolClient from cerbero_bite.clients.telegram import TelegramClient from cerbero_bite.runtime.alert_manager import AlertManager, Severity from cerbero_bite.safety import AuditLog, iter_entries from cerbero_bite.safety.kill_switch import KillSwitch from cerbero_bite.state import Repository, connect, run_migrations, transaction +SEND_URL = "https://api.telegram.org/botTOK/sendMessage" + def _make_alert_manager(tmp_path: Path) -> tuple[AlertManager, Path, Path, KillSwitch]: db_path = tmp_path / "state.sqlite" @@ -39,14 +40,7 @@ def _make_alert_manager(tmp_path: Path) -> tuple[AlertManager, Path, Path, KillS audit_log=audit, clock=lambda: next(times), ) - telegram = TelegramClient( - HttpToolClient( - service="telegram", - base_url="http://mcp-telegram:9017", - token="t", - retry_max=1, - ) - ) + telegram = TelegramClient(bot_token="TOK", chat_id="42") return AlertManager(telegram=telegram, audit_log=audit, kill_switch=ks), audit_path, db_path, ks @@ -65,17 +59,13 @@ async def test_low_emits_audit_only(tmp_path: Path, httpx_mock: HTTPXMock) -> No @pytest.mark.asyncio async def test_medium_calls_telegram_notify(tmp_path: Path, httpx_mock: HTTPXMock) -> None: - httpx_mock.add_response( - url="http://mcp-telegram:9017/tools/notify", json={"ok": True} - ) + httpx_mock.add_response(url=SEND_URL, json={"ok": True}) am, audit_path, _, ks = _make_alert_manager(tmp_path) await am.medium(source="entry_cycle", message="snapshot delayed") requests = httpx_mock.get_requests() assert len(requests) == 1 body = json.loads(requests[0].read()) - assert body["message"] == "[entry_cycle] snapshot delayed" - assert body["priority"] == "high" - assert body["tag"] == "entry_cycle" + assert body["text"] == "[HIGH][entry_cycle] snapshot delayed" assert ks.is_armed() is False assert any(e.payload["severity"] == "medium" for e in iter_entries(audit_path)) @@ -84,17 +74,13 @@ async def test_medium_calls_telegram_notify(tmp_path: Path, httpx_mock: HTTPXMoc async def test_high_arms_kill_switch_and_calls_notify_alert( tmp_path: Path, httpx_mock: HTTPXMock ) -> None: - httpx_mock.add_response( - url="http://mcp-telegram:9017/tools/notify_alert", json={"ok": True} - ) + httpx_mock.add_response(url=SEND_URL, json={"ok": True}) am, _, _, ks = _make_alert_manager(tmp_path) await am.high(source="health", message="3 consecutive MCP failures") body = json.loads(httpx_mock.get_request().read()) - assert body == { - "source": "health", - "message": "3 consecutive MCP failures", - "priority": "high", - } + text = body["text"] + assert "ALERT [HIGH]" in text + assert "health" in text and "3 consecutive MCP failures" in text assert ks.is_armed() is True @@ -102,9 +88,7 @@ async def test_high_arms_kill_switch_and_calls_notify_alert( async def test_critical_arms_kill_switch_and_calls_notify_system_error( tmp_path: Path, httpx_mock: HTTPXMock ) -> None: - httpx_mock.add_response( - url="http://mcp-telegram:9017/tools/notify_system_error", json={"ok": True} - ) + httpx_mock.add_response(url=SEND_URL, json={"ok": True}) am, _, _, ks = _make_alert_manager(tmp_path) await am.critical( source="audit_chain", @@ -112,8 +96,9 @@ async def test_critical_arms_kill_switch_and_calls_notify_system_error( component="safety.audit_log", ) body = json.loads(httpx_mock.get_request().read()) - assert body["component"] == "safety.audit_log" - assert body["priority"] == "critical" + text = body["text"] + assert "SYSTEM ERROR [CRITICAL]" in text + assert "safety.audit_log" in text assert ks.is_armed() is True @@ -121,9 +106,7 @@ async def test_critical_arms_kill_switch_and_calls_notify_system_error( async def test_critical_when_already_armed_is_idempotent( tmp_path: Path, httpx_mock: HTTPXMock ) -> None: - httpx_mock.add_response( - url="http://mcp-telegram:9017/tools/notify_system_error", json={"ok": True} - ) + httpx_mock.add_response(url=SEND_URL, json={"ok": True}) am, _, _, ks = _make_alert_manager(tmp_path) ks.arm(reason="prior", source="manual") assert ks.is_armed() is True diff --git a/tests/unit/test_cli_ping.py b/tests/unit/test_cli_ping.py index a74438d..b3cb1c7 100644 --- a/tests/unit/test_cli_ping.py +++ b/tests/unit/test_cli_ping.py @@ -49,10 +49,6 @@ def test_ping_reports_each_service( 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"] @@ -62,10 +58,10 @@ def test_ping_reports_each_service( 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 + # Telegram and Portfolio are no longer MCP services and are not + # listed by the ping command. + assert "portfolio" not in result.output + assert "OK" in result.output def test_ping_reports_failure_when_service_unreachable( @@ -90,10 +86,6 @@ def test_ping_reports_failure_when_service_unreachable( 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"] diff --git a/tests/unit/test_clients_portfolio.py b/tests/unit/test_clients_portfolio.py index 50c3742..b344e09 100644 --- a/tests/unit/test_clients_portfolio.py +++ b/tests/unit/test_clients_portfolio.py @@ -1,95 +1,240 @@ -"""Tests for PortfolioClient.""" +"""Tests for in-process PortfolioClient (composes deribit + hyperliquid + macro).""" from __future__ import annotations from decimal import Decimal +from typing import Any 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 +# --------------------------------------------------------------------------- +# Test doubles +# --------------------------------------------------------------------------- -def _client() -> PortfolioClient: - http = HttpToolClient( - service="portfolio", - base_url="http://mcp-portfolio:9018", - token="t", - retry_max=1, + +class _FakeDeribit: + SERVICE = "deribit" + + def __init__( + self, + *, + equity_usd: Decimal | float = Decimal("0"), + positions: list[dict[str, Any]] | None = None, + ) -> None: + self._equity = Decimal(str(equity_usd)) + self._positions = positions or [] + + async def get_account_summary(self, currency: str = "USDC") -> dict[str, Any]: + assert currency == "USDC" + return {"equity": float(self._equity), "currency": "USDC"} + + async def get_positions(self, currency: str = "USDC") -> list[dict[str, Any]]: + assert currency == "USDC" + return list(self._positions) + + +class _FakeHyperliquid: + SERVICE = "hyperliquid" + + def __init__( + self, + *, + equity_usd: Decimal | float = Decimal("0"), + positions: list[dict[str, Any]] | None = None, + ) -> None: + self._equity = Decimal(str(equity_usd)) + self._positions = positions or [] + + async def get_account_summary(self) -> dict[str, Any]: + return {"equity": float(self._equity)} + + async def get_positions(self) -> list[dict[str, Any]]: + return list(self._positions) + + +class _FakeMacro: + SERVICE = "macro" + + def __init__(self, *, eur_usd: Decimal | float | None = Decimal("1.10")) -> None: + self._eur_usd = eur_usd + + async def eur_usd_rate(self) -> Decimal: + if self._eur_usd is None: + raise McpDataAnomalyError( + "missing", service="macro", tool="get_asset_price" + ) + return Decimal(str(self._eur_usd)) + + +def _make( + *, + deribit_eq: Decimal | float = 0, + hl_eq: Decimal | float = 0, + deribit_pos: list[dict[str, Any]] | None = None, + hl_pos: list[dict[str, Any]] | None = None, + eur_usd: Decimal | float | None = Decimal("1.10"), +) -> PortfolioClient: + return PortfolioClient( + deribit=_FakeDeribit(equity_usd=deribit_eq, positions=deribit_pos), + hyperliquid=_FakeHyperliquid(equity_usd=hl_eq, positions=hl_pos), + macro=_FakeMacro(eur_usd=eur_usd), ) - return PortfolioClient(http) + + +# --------------------------------------------------------------------------- +# total_equity_usd / total_equity_eur +# --------------------------------------------------------------------------- @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") +async def test_total_equity_usd_sums_both_exchanges() -> None: + p = _make(deribit_eq="1500.50", hl_eq="982.50") + assert await p.total_equity_usd() == Decimal("2483.00") @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() +async def test_total_equity_eur_converts_with_fx() -> None: + p = _make(deribit_eq="1100", hl_eq="0", eur_usd="1.10") + # 1100 USD / 1.10 = 1000 EUR + assert await p.total_equity_eur() == Decimal("1000") @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() +async def test_total_equity_eur_zero_when_no_balance() -> None: + p = _make(deribit_eq=0, hl_eq=0, eur_usd="1.20") + assert await p.total_equity_eur() == Decimal("0") @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}, +async def test_total_equity_eur_raises_on_non_positive_fx() -> None: + p = _make(deribit_eq="100", hl_eq="0", eur_usd="0") + with pytest.raises(McpDataAnomalyError, match="non-positive EURUSD"): + await p.total_equity_eur() + + +@pytest.mark.asyncio +async def test_total_equity_eur_propagates_macro_anomaly() -> None: + p = _make(deribit_eq="100", hl_eq="0", eur_usd=None) + with pytest.raises(McpDataAnomalyError): + await p.total_equity_eur() + + +# --------------------------------------------------------------------------- +# asset_pct_of_portfolio +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_asset_pct_aggregates_eth_across_both_exchanges() -> None: + p = _make( + deribit_eq="5000", + hl_eq="5000", + deribit_pos=[ + { + "instrument_name": "ETH-15MAY26-2475-P", + "size": 10, + "mark_price": 100, + }, + # BTC position should be ignored when asking for ETH + { + "instrument_name": "BTC-PERPETUAL", + "size": 1, + "mark_price": 75000, + }, + ], + hl_pos=[ + {"coin": "ETH", "notional_usd": 1000}, ], ) - pct = await _client().asset_pct_of_portfolio("ETH") - # 4000 / 10000 = 0.4 - assert pct == Decimal("0.4") + # ETH exposure: 10×100 (deribit) + 1000 (hl) = 2000 + # total equity: 10000 + pct = await p.asset_pct_of_portfolio("ETH") + assert pct == Decimal("0.2") @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") +async def test_asset_pct_returns_zero_when_no_positions() -> None: + p = _make(deribit_eq="1000", hl_eq="0") + assert await p.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}, - ] +async def test_asset_pct_returns_zero_when_no_equity() -> None: + p = _make( + deribit_eq=0, + hl_eq=0, + deribit_pos=[ + {"instrument_name": "ETH-PERP", "notional_usd": 100}, + ], ) - assert await _client().asset_pct_of_portfolio("ETH") == Decimal("0") + assert await p.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 +async def test_asset_pct_uses_explicit_notional_when_present() -> None: + p = _make( + deribit_eq="1000", + hl_eq=0, + deribit_pos=[ + # explicit notional_usd takes precedence over size×mark + { + "instrument_name": "ETH-XYZ", + "notional_usd": 250, + "size": 999, + "mark_price": 999, + }, + ], ) - with pytest.raises(ValueError, match="requires service 'portfolio'"): - PortfolioClient(bad) + assert await p.asset_pct_of_portfolio("ETH") == Decimal("0.25") + + +@pytest.mark.asyncio +async def test_asset_pct_falls_back_to_size_times_mark() -> None: + p = _make( + deribit_eq="1000", + hl_eq=0, + deribit_pos=[ + {"instrument_name": "ETH-XYZ", "size": 5, "mark_price": 40}, + ], + ) + # 5×40 / 1000 = 0.2 + assert await p.asset_pct_of_portfolio("ETH") == Decimal("0.2") + + +@pytest.mark.asyncio +async def test_asset_pct_takes_absolute_value_for_short_positions() -> None: + p = _make( + deribit_eq="1000", + hl_eq=0, + hl_pos=[{"coin": "ETH", "size": -10, "mark_price": 50}], + ) + # |-10×50| / 1000 = 0.5 + assert await p.asset_pct_of_portfolio("ETH") == Decimal("0.5") + + +@pytest.mark.asyncio +async def test_asset_pct_case_insensitive_match() -> None: + p = _make( + deribit_eq="1000", + hl_eq=0, + deribit_pos=[ + {"instrument_name": "eth-perpetual", "notional_usd": 300}, + ], + ) + assert await p.asset_pct_of_portfolio("eth") == Decimal("0.3") + + +@pytest.mark.asyncio +async def test_asset_pct_skips_non_dict_entries() -> None: + p = _make( + deribit_eq="1000", + hl_eq=0, + deribit_pos=[ + "not a dict", # type: ignore[list-item] + {"instrument_name": "ETH", "notional_usd": 100}, + ], + ) + assert await p.asset_pct_of_portfolio("ETH") == Decimal("0.1") diff --git a/tests/unit/test_clients_telegram.py b/tests/unit/test_clients_telegram.py index edb9c90..625a644 100644 --- a/tests/unit/test_clients_telegram.py +++ b/tests/unit/test_clients_telegram.py @@ -1,25 +1,27 @@ -"""Tests for TelegramClient (notify-only mode).""" +"""Tests for in-process TelegramClient (Bot API, notify-only).""" from __future__ import annotations import json from decimal import Decimal +import httpx import pytest from pytest_httpx import HTTPXMock -from cerbero_bite.clients._base import HttpToolClient -from cerbero_bite.clients.telegram import TelegramClient +from cerbero_bite.clients.telegram import ( + TelegramClient, + TelegramError, + load_telegram_credentials, +) + +SEND_URL = "https://api.telegram.org/botTOK/sendMessage" -def _client() -> TelegramClient: - http = HttpToolClient( - service="telegram", - base_url="http://mcp-telegram:9017", - token="t", - retry_max=1, - ) - return TelegramClient(http) +def _client(**kw) -> TelegramClient: + defaults = {"bot_token": "TOK", "chat_id": "42"} + defaults.update(kw) + return TelegramClient(**defaults) def _request_body(httpx_mock: HTTPXMock) -> dict: @@ -28,34 +30,66 @@ def _request_body(httpx_mock: HTTPXMock) -> dict: return json.loads(request.read()) +# --------------------------------------------------------------------------- +# enabled / disabled +# --------------------------------------------------------------------------- + + +def test_enabled_when_both_token_and_chat_id_present() -> None: + assert _client().enabled is True + + +def test_disabled_when_token_missing() -> None: + c = TelegramClient(bot_token=None, chat_id="42") + assert c.enabled is False + + +def test_disabled_when_chat_id_missing() -> None: + c = TelegramClient(bot_token="TOK", chat_id=None) + assert c.enabled is False + + +def test_disabled_when_token_blank() -> None: + c = TelegramClient(bot_token=" ", chat_id="42") + assert c.enabled is False + + @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}, - ) +async def test_disabled_notify_is_noop(httpx_mock: HTTPXMock) -> None: + c = TelegramClient(bot_token=None, chat_id=None) + await c.notify("hello") + assert httpx_mock.get_requests() == [] + + +# --------------------------------------------------------------------------- +# notify formatting +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_notify_sends_with_priority_and_tag(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(url=SEND_URL, json={"ok": True, "result": {}}) await _client().notify("hello", priority="high", tag="entry") body = _request_body(httpx_mock) - assert body == {"message": "hello", "priority": "high", "tag": "entry"} + assert body["chat_id"] == "42" + assert body["parse_mode"] == "HTML" + assert body["text"] == "[HIGH][entry] hello" + assert body["disable_web_page_preview"] is True @pytest.mark.asyncio async def test_notify_default_priority_normal(httpx_mock: HTTPXMock) -> None: - httpx_mock.add_response(json={"ok": True}) + httpx_mock.add_response(url=SEND_URL, json={"ok": True}) await _client().notify("plain") body = _request_body(httpx_mock) - assert body["priority"] == "normal" - assert "tag" not in body + assert body["text"] == "[NORMAL] plain" @pytest.mark.asyncio -async def test_notify_position_opened_serialises_decimals( +async def test_notify_position_opened_formats_decimals( httpx_mock: HTTPXMock, ) -> None: - httpx_mock.add_response( - url="http://mcp-telegram:9017/tools/notify_position_opened", - json={"ok": True}, - ) + httpx_mock.add_response(url=SEND_URL, json={"ok": True}) await _client().notify_position_opened( instrument="ETH-15MAY26-2475-P", side="SELL", @@ -64,59 +98,139 @@ async def test_notify_position_opened_serialises_decimals( 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 + text = _request_body(httpx_mock)["text"] + assert "POSITION OPENED" in text + assert "ETH-15MAY26-2475-P" in text + assert "SELL" in text and "size: 2" in text and "bull_put" in text + assert "delta=-0.0400" in text and "vega=+0.2000" in text + assert "$+45.00" in text + + +@pytest.mark.asyncio +async def test_notify_position_opened_without_greeks(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(url=SEND_URL, json={"ok": True}) + await _client().notify_position_opened( + instrument="BTC-PERPETUAL", side="BUY", size=1, strategy="hedge" + ) + text = _request_body(httpx_mock)["text"] + assert "greeks" not in text + assert "expected pnl" not in text @pytest.mark.asyncio async def test_notify_position_closed(httpx_mock: HTTPXMock) -> None: - httpx_mock.add_response(json={"ok": True}) + httpx_mock.add_response(url=SEND_URL, 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", - } + text = _request_body(httpx_mock)["text"] + assert "POSITION CLOSED" in text + assert "ETH-15MAY26-2475-P_2350-P" in text + assert "$+32.50" in text + assert "CLOSE_PROFIT" in text + + +@pytest.mark.asyncio +async def test_notify_position_closed_negative_pnl(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(url=SEND_URL, json={"ok": True}) + await _client().notify_position_closed( + instrument="X", realized_pnl_usd=Decimal("-12.5"), reason="STOP" + ) + text = _request_body(httpx_mock)["text"] + assert "$-12.50" in text @pytest.mark.asyncio async def test_notify_alert(httpx_mock: HTTPXMock) -> None: - httpx_mock.add_response(json={"ok": True}) + httpx_mock.add_response(url=SEND_URL, 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", - } + text = _request_body(httpx_mock)["text"] + assert "ALERT [CRITICAL]" in text + assert "kill_switch" in text and "armed manually" in text @pytest.mark.asyncio async def test_notify_system_error(httpx_mock: HTTPXMock) -> None: - httpx_mock.add_response(json={"ok": True}) + httpx_mock.add_response(url=SEND_URL, json={"ok": True}) await _client().notify_system_error( - message="deribit feed anomaly", - component="clients.deribit", + 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" + text = _request_body(httpx_mock)["text"] + assert "SYSTEM ERROR [CRITICAL]" in text + assert "deribit feed anomaly" in text + assert "clients.deribit" in text -def test_telegram_client_rejects_wrong_service() -> None: - bad = HttpToolClient( - service="macro", base_url="http://x:1", token="t", retry_max=1 +@pytest.mark.asyncio +async def test_notify_system_error_without_component(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(url=SEND_URL, json={"ok": True}) + await _client().notify_system_error(message="boom") + text = _request_body(httpx_mock)["text"] + assert "component" not in text + + +# --------------------------------------------------------------------------- +# error paths +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_http_non_200_raises(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(url=SEND_URL, status_code=500, text="upstream") + with pytest.raises(TelegramError, match="HTTP 500"): + await _client().notify("x") + + +@pytest.mark.asyncio +async def test_api_ok_false_raises(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url=SEND_URL, json={"ok": False, "description": "chat not found"} ) - with pytest.raises(ValueError, match="requires service 'telegram'"): - TelegramClient(bad) + with pytest.raises(TelegramError, match="chat not found"): + await _client().notify("x") + + +# --------------------------------------------------------------------------- +# shared httpx client +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_uses_shared_http_client(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(url=SEND_URL, json={"ok": True}) + shared = httpx.AsyncClient() + try: + c = _client(http_client=shared) + await c.notify("x") + finally: + await shared.aclose() + assert len(httpx_mock.get_requests()) == 1 + + +# --------------------------------------------------------------------------- +# env-var loader +# --------------------------------------------------------------------------- + + +def test_load_credentials_returns_none_when_unset() -> None: + assert load_telegram_credentials(env={}) == (None, None) + + +def test_load_credentials_strips_whitespace() -> None: + env = { + "CERBERO_BITE_TELEGRAM_BOT_TOKEN": " abc ", + "CERBERO_BITE_TELEGRAM_CHAT_ID": " -100 ", + } + assert load_telegram_credentials(env=env) == ("abc", "-100") + + +def test_load_credentials_treats_empty_as_none() -> None: + env = { + "CERBERO_BITE_TELEGRAM_BOT_TOKEN": "", + "CERBERO_BITE_TELEGRAM_CHAT_ID": " ", + } + assert load_telegram_credentials(env=env) == (None, None) diff --git a/tests/unit/test_mcp_endpoints.py b/tests/unit/test_mcp_endpoints.py index 43c66ff..76a1b27 100644 --- a/tests/unit/test_mcp_endpoints.py +++ b/tests/unit/test_mcp_endpoints.py @@ -16,7 +16,7 @@ from cerbero_bite.config.mcp_endpoints import ( def test_defaults_match_known_docker_dns() -> None: assert DEFAULT_ENDPOINTS["deribit"] == "http://mcp-deribit:9011" - assert DEFAULT_ENDPOINTS["telegram"] == "http://mcp-telegram:9017" + assert DEFAULT_ENDPOINTS["sentiment"] == "http://mcp-sentiment:9014" def test_load_endpoints_uses_defaults_when_env_empty() -> None: @@ -72,5 +72,7 @@ def test_load_token_raises_when_file_empty(tmp_path: Path) -> None: def test_mcp_services_table_is_complete() -> None: - expected = {"deribit", "hyperliquid", "macro", "sentiment", "telegram", "portfolio"} + # Telegram and Portfolio are now in-process and must NOT be listed + # as shared MCP services. + expected = {"deribit", "hyperliquid", "macro", "sentiment"} assert set(MCP_SERVICES) == expected diff --git a/tests/unit/test_runtime_dependencies.py b/tests/unit/test_runtime_dependencies.py index e070fd8..8572795 100644 --- a/tests/unit/test_runtime_dependencies.py +++ b/tests/unit/test_runtime_dependencies.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import UTC, datetime from pathlib import Path +from cerbero_bite.clients.portfolio import PortfolioClient from cerbero_bite.config import golden_config from cerbero_bite.config.mcp_endpoints import load_endpoints from cerbero_bite.runtime import build_runtime @@ -51,5 +52,8 @@ def test_build_runtime_clients_pinned_to_endpoints(tmp_path: Path) -> None: assert ctx.macro.SERVICE == "macro" assert ctx.sentiment.SERVICE == "sentiment" assert ctx.hyperliquid.SERVICE == "hyperliquid" - assert ctx.portfolio.SERVICE == "portfolio" - assert ctx.telegram.SERVICE == "telegram" + # Portfolio is now an in-process aggregator over deribit/hyperliquid/macro; + # it has no SERVICE attribute. Telegram is also in-process and disabled + # when env vars are unset. + assert isinstance(ctx.portfolio, PortfolioClient) + assert ctx.telegram.enabled is False