refactor: telegram + portfolio in-process (drop shared MCP)

Each bot now manages its own notification + portfolio aggregation:

* TelegramClient calls the public Bot API directly via httpx, reading
  CERBERO_BITE_TELEGRAM_BOT_TOKEN / CERBERO_BITE_TELEGRAM_CHAT_ID from
  env. No credentials → silent disabled mode.
* PortfolioClient composes DeribitClient + HyperliquidClient + the new
  MacroClient.get_asset_price/eur_usd_rate to expose equity (EUR) and
  per-asset exposure as the bot's own slice (no cross-bot view).
* mcp-telegram and mcp-portfolio removed from MCP_SERVICES / McpEndpoints
  and the cerbero-bite ping CLI; health_check no longer probes portfolio.

Docs (02/04/06/07) and docker-compose updated to reflect the new
architecture.

353/353 tests pass; ruff clean; mypy src clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 00:31:20 +02:00
parent 067f74bc89
commit abf5a140e2
26 changed files with 836 additions and 423 deletions
+6 -2
View File
@@ -44,8 +44,12 @@ services:
CERBERO_BITE_MCP_HYPERLIQUID_URL: http://mcp-hyperliquid:9012 CERBERO_BITE_MCP_HYPERLIQUID_URL: http://mcp-hyperliquid:9012
CERBERO_BITE_MCP_MACRO_URL: http://mcp-macro:9013 CERBERO_BITE_MCP_MACRO_URL: http://mcp-macro:9013
CERBERO_BITE_MCP_SENTIMENT_URL: http://mcp-sentiment:9014 CERBERO_BITE_MCP_SENTIMENT_URL: http://mcp-sentiment:9014
CERBERO_BITE_MCP_TELEGRAM_URL: http://mcp-telegram:9017 # Telegram and Portfolio are no longer shared MCP services. The
CERBERO_BITE_MCP_PORTFOLIO_URL: http://mcp-portfolio:9018 # 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: volumes:
- bite-data:/app/data - bite-data:/app/data
healthcheck: healthcheck:
+1 -1
View File
@@ -75,7 +75,7 @@ Adriano gli eventi post-fact (entry placed, exit filled, alert).
| Format/lint | `ruff` | Standard del progetto | | Format/lint | `ruff` | Standard del progetto |
| Dependency manager | `uv` | Coerente con `Cerbero_mcp` | | Dependency manager | `uv` | Coerente con `Cerbero_mcp` |
| Client MCP | `httpx.AsyncClient` long-lived (pooling) + `tenacity` per retry | HTTP REST diretto, non SDK `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 | | GUI | `streamlit` ≥ 1.40 + `plotly` (Fase 4.5) | Dashboard locale, processo separato |
## Layout cartelle ## Layout cartelle
+55 -21
View File
@@ -1,10 +1,18 @@
# 04 — MCP Integration # 04 — MCP Integration
Cerbero Bite consuma sei servizi MCP HTTP della suite (`Cerbero_mcp`). Cerbero Bite consuma quattro servizi MCP HTTP della suite (`Cerbero_mcp`):
Non utilizza l'SDK Python `mcp`: ogni server espone gli endpoint REST `cerbero-deribit`, `cerbero-hyperliquid`, `cerbero-macro`,
`POST <base_url>/tools/<tool_name>` con autenticazione Bearer, e Cerbero `cerbero-sentiment`. Non utilizza l'SDK Python `mcp`: ogni server
Bite vi si collega tramite `httpx.AsyncClient` long-lived espone gli endpoint REST `POST <base_url>/tools/<tool_name>` con
(`clients/_base.py`). 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 ## Configurazione di connessione
@@ -20,8 +28,17 @@ d'ambiente dedicata, utile in sviluppo:
| Hyperliquid | `CERBERO_BITE_MCP_HYPERLIQUID_URL` | `http://mcp-hyperliquid:9012` | | Hyperliquid | `CERBERO_BITE_MCP_HYPERLIQUID_URL` | `http://mcp-hyperliquid:9012` |
| Macro | `CERBERO_BITE_MCP_MACRO_URL` | `http://mcp-macro:9013` | | Macro | `CERBERO_BITE_MCP_MACRO_URL` | `http://mcp-macro:9013` |
| Sentiment | `CERBERO_BITE_MCP_SENTIMENT_URL` | `http://mcp-sentiment:9014` | | 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 Il bearer token per le chiamate è il token con capability `core` letto
da `secrets/core.token` (path configurabile via da `secrets/core.token` (path configurabile via
@@ -100,22 +117,35 @@ Cerbero Bite è deterministico e non interpreta testi liberi.
| Tool | Uso | | 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_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 | | `total_equity_eur()` | Somma `equity` USD di Deribit (USDC) e Hyperliquid, divide per `EURUSD` per ottenere il capitale in EUR consumato dal sizing engine |
| `get_holdings()` | Aggregazione manuale di `current_value_eur` per i ticker che contengono `"ETH"`, usata dal filtro §2.7 (`eth_holdings_pct_max`) | | `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 ### Telegram client (`clients/telegram.py`)
manuale, nessun callback. L'engine apre e chiude le posizioni
automaticamente quando le regole sono soddisfatte; Telegram viene
informato post-fact.
| 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<TOKEN>/sendMessage`.
| Metodo | Uso |
|---|---| |---|---|
| `notify(message, priority, tag)` | Alert MEDIUM o messaggi informativi | | `notify(message, priority, tag)` | Alert MEDIUM o messaggi informativi |
| `notify_position_opened(instrument, side, size, strategy, greeks, expected_pnl)` | Notifica di entry placed | | `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_alert(source, message, priority)` | Alert HIGH (kill switch) |
| `notify_system_error(message, component, priority)` | Alert CRITICAL | | `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 ## 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-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-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-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-macro` | Hard fail per il filtro §2.5 e per la conversione EUR/USD del portfolio aggregator; senza calendar/FX non si apre |
| `cerbero-portfolio` | Skip dei filtri §2.7 con warning; il sizing usa l'ultimo capitale noto da SQLite | | 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 |
| `cerbero-telegram` | Skip notifiche post-fact; il ciclo decisionale non viene bloccato (l'engine non aspetta risposte) | | 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 I trigger HIGH e CRITICAL armano il kill switch e propagano un alert
in audit chain. in audit chain.
+1 -1
View File
@@ -140,7 +140,7 @@ Trigger: ogni 5 minuti.
- macro.get_macro_calendar(days=1) - macro.get_macro_calendar(days=1)
- sentiment.get_cross_exchange_funding (no asset filter) - sentiment.get_cross_exchange_funding (no asset filter)
- hyperliquid.get_funding_rate("ETH") - 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) - telegram: skip (notify-only, no probe non invasivo)
2. SQLite read-write probe (transazione fittizia) 2. SQLite read-write probe (transazione fittizia)
3. Lock file ancora valido 3. Lock file ancora valido
+1 -1
View File
@@ -34,7 +34,7 @@ infrastrutturali o decisioni umane fuori posto.
| Causa | Auto-arm | Implementato | Note | | Causa | Auto-arm | Implementato | Note |
|---|---|---|---| |---|---|---|---|
| MCP `cerbero-deribit` non risponde per 3 health check consecutivi | Sì | `runtime/health_check.py` | Severity HIGH | | 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 | | `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 | | 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 | | Stato SQLite incoerente con il broker (recovery non risolutivo) | Sì | `runtime/recovery.py` | Severity CRITICAL al boot |
-7
View File
@@ -26,7 +26,6 @@ from cerbero_bite.clients import HttpToolClient, McpError
from cerbero_bite.clients.deribit import DeribitClient from cerbero_bite.clients.deribit import DeribitClient
from cerbero_bite.clients.hyperliquid import HyperliquidClient from cerbero_bite.clients.hyperliquid import HyperliquidClient
from cerbero_bite.clients.macro import MacroClient 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.sentiment import SentimentClient
from cerbero_bite.config.loader import compute_config_hash, load_strategy from cerbero_bite.config.loader import compute_config_hash, load_strategy
from cerbero_bite.config.mcp_endpoints import ( from cerbero_bite.config.mcp_endpoints import (
@@ -560,12 +559,6 @@ async def _ping_one(
if service == "hyperliquid": if service == "hyperliquid":
await HyperliquidClient(http).funding_rate_annualized("ETH") await HyperliquidClient(http).funding_rate_annualized("ETH")
return "ok", "ETH-PERP reachable" 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 return "skipped", "no probe defined" # pragma: no cover
except McpError as exc: except McpError as exc:
return "fail", f"{type(exc).__name__}: {exc}" return "fail", f"{type(exc).__name__}: {exc}"
+23 -3
View File
@@ -1,13 +1,17 @@
"""Wrapper around ``mcp-hyperliquid``. """Wrapper around ``mcp-hyperliquid``.
Cerbero Bite consumes a single tool: ``get_funding_rate`` for ETH-PERP, Cerbero Bite consumes:
used by entry filter §2.6 of ``docs/01-strategy-rules.md`` (cap on the
absolute annualised funding rate). * ``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 __future__ import annotations
from decimal import Decimal from decimal import Decimal
from typing import Any
from cerbero_bite.clients._base import HttpToolClient from cerbero_bite.clients._base import HttpToolClient
from cerbero_bite.clients._exceptions import McpDataAnomalyError from cerbero_bite.clients._exceptions import McpDataAnomalyError
@@ -47,3 +51,19 @@ class HyperliquidClient:
tool="get_funding_rate", tool="get_funding_rate",
) )
return Decimal(str(rate)) * Decimal(HOURLY_FUNDING_PERIODS_PER_YEAR) 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 []
+30
View File
@@ -9,11 +9,13 @@ the requested window. The orchestrator feeds the result straight into
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from decimal import Decimal
from typing import Any from typing import Any
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from cerbero_bite.clients._base import HttpToolClient from cerbero_bite.clients._base import HttpToolClient
from cerbero_bite.clients._exceptions import McpDataAnomalyError
__all__ = ["MacroClient", "MacroEvent"] __all__ = ["MacroClient", "MacroEvent"]
@@ -71,6 +73,34 @@ class MacroClient:
) )
return out 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( async def next_high_severity_within(
self, self,
*, *,
+130 -65
View File
@@ -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 Two values are exposed:
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 * :py:meth:`total_equity_eur` — sum of USDC equity on Deribit and USD
responsible for the EUR→USD conversion using a live FX rate. 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 from __future__ import annotations
import asyncio
from collections.abc import Iterable
from decimal import Decimal 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._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"] __all__ = ["PortfolioClient"]
class PortfolioClient: def _decimal_or_zero(value: Any) -> Decimal:
SERVICE = "portfolio" 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: def _position_notional_usd(pos: dict[str, Any]) -> Decimal:
raise ValueError( """Best-effort USD notional of an open position.
f"PortfolioClient requires service '{self.SERVICE}', got '{http.service}'"
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")
) )
self._http = http 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: async def total_equity_eur(self) -> Decimal:
"""Return the aggregate portfolio value in EUR.""" """Return aggregate bot equity in EUR.
raw = await self._http.call(
"get_total_portfolio_value", {"currency": "EUR"} Concurrent: account summaries × FX. Raises
) :class:`McpDataAnomalyError` if the FX rate is non-positive.
if not isinstance(raw, dict): """
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( raise McpDataAnomalyError(
f"portfolio total_value_eur unexpected shape: {type(raw).__name__}", f"non-positive EURUSD rate: {fx}",
service=self.SERVICE, service="macro",
tool="get_total_portfolio_value", tool="get_asset_price",
) )
value = raw.get("total_value_eur") usd_total = deribit_eq + hl_eq
if value is None: return usd_total / fx
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: 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`` Sums absolute USD notional of open positions whose instrument
for any holding whose ticker contains ``ticker`` (case-insensitive). label contains ``ticker`` (case-insensitive) on Deribit and
Empty portfolio → 0. 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() target = ticker.upper()
matching_value = Decimal("0") deribit_pos_t = asyncio.create_task(
total_value = Decimal("0") self._deribit.get_positions(currency="USDC")
for entry in holdings: )
if not isinstance(entry, dict): hl_pos_t = asyncio.create_task(self._hyperliquid.get_positions())
continue equity_t = asyncio.create_task(self._equity_usd_components())
value = entry.get("current_value_eur") await asyncio.gather(deribit_pos_t, hl_pos_t, equity_t)
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: exposure_usd = Decimal(0)
return Decimal("0") for raw_pos in cast(Iterable[Any], deribit_pos_t.result()):
return matching_value / total_value 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]: deribit_eq, hl_eq = equity_t.result()
"""Lightweight call used by ``cerbero-bite ping``.""" total_eq = deribit_eq + hl_eq
result: Any = await self._http.call("get_last_update_info", {}) if total_eq <= 0:
return result if isinstance(result, dict) else {} return Decimal(0)
return exposure_usd / total_eq
+126 -49
View File
@@ -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 Cerbero Bite is fully autonomous: Telegram is used solely to *notify*
fully autonomous: Telegram is used purely to *notify* Adriano of what the operator of what the engine has done — there is no inbound queue
the engine has done, never to gate execution. As a consequence: and no confirmation logic.
* No ``send_with_buttons`` and no callback queue. Credentials are read from the environment:
* Confirmation timeouts are handled inside the orchestrator's own
state machine, not by waiting on Telegram replies. * ``CERBERO_BITE_TELEGRAM_BOT_TOKEN`` — bot token from BotFather.
* All notifications go through one of the typed endpoints * ``CERBERO_BITE_TELEGRAM_CHAT_ID`` — destination chat id.
(``notify``, ``notify_position_opened``, ``notify_position_closed``,
``notify_alert``, ``notify_system_error``) — the formatting lives If either is missing the client runs in **disabled** mode: every
on the server side. ``notify_*`` becomes a no-op logged at DEBUG. This keeps unconfigured
deployments and the test environment harmless.
""" """
from __future__ import annotations from __future__ import annotations
import logging
import os
from decimal import Decimal from decimal import Decimal
from typing import Any 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: TELEGRAM_BOT_TOKEN_ENV = "CERBERO_BITE_TELEGRAM_BOT_TOKEN"
return float(value) if isinstance(value, Decimal) else value 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: class TelegramClient:
SERVICE = "telegram" """Notify-only client over the public Telegram Bot API."""
def __init__(self, http: HttpToolClient) -> None: BASE_URL = "https://api.telegram.org"
if http.service != self.SERVICE:
raise ValueError( def __init__(
f"TelegramClient requires service '{self.SERVICE}', got '{http.service}'" 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( async def notify(
self, self,
@@ -44,10 +118,10 @@ class TelegramClient:
priority: str = "normal", priority: str = "normal",
tag: str | None = None, tag: str | None = None,
) -> None: ) -> None:
body: dict[str, Any] = {"message": message, "priority": priority} prefix = f"[{priority.upper()}]"
if tag is not None: if tag:
body["tag"] = tag prefix = f"{prefix}[{tag}]"
await self._http.call("notify", body) await self._send(f"{prefix} {message}")
async def notify_position_opened( async def notify_position_opened(
self, self,
@@ -59,17 +133,19 @@ class TelegramClient:
greeks: dict[str, Decimal | float] | None = None, greeks: dict[str, Decimal | float] | None = None,
expected_pnl_usd: Decimal | float | None = None, expected_pnl_usd: Decimal | float | None = None,
) -> None: ) -> None:
body: dict[str, Any] = { lines = [
"instrument": instrument, "<b>POSITION OPENED</b>",
"side": side, f"instrument: <code>{instrument}</code>",
"size": float(size), f"side: {side} | size: {size} | strategy: {strategy}",
"strategy": strategy, ]
} if greeks:
if greeks is not None: joined = ", ".join(
body["greeks"] = {k: _to_float(v) for k, v in greeks.items()} f"{k}={_to_float(v):+.4f}" for k, v in greeks.items()
)
lines.append(f"greeks: {joined}")
if expected_pnl_usd is not None: if expected_pnl_usd is not None:
body["expected_pnl"] = _to_float(expected_pnl_usd) lines.append(f"expected pnl: ${_to_float(expected_pnl_usd):+.2f}")
await self._http.call("notify_position_opened", body) await self._send("\n".join(lines))
async def notify_position_closed( async def notify_position_closed(
self, self,
@@ -78,13 +154,12 @@ class TelegramClient:
realized_pnl_usd: Decimal | float, realized_pnl_usd: Decimal | float,
reason: str, reason: str,
) -> None: ) -> None:
await self._http.call( pnl = _to_float(realized_pnl_usd)
"notify_position_closed", await self._send(
{ "<b>POSITION CLOSED</b>\n"
"instrument": instrument, f"instrument: <code>{instrument}</code>\n"
"realized_pnl": _to_float(realized_pnl_usd), f"realized pnl: ${pnl:+.2f}\n"
"reason": reason, f"reason: {reason}"
},
) )
async def notify_alert( async def notify_alert(
@@ -94,9 +169,10 @@ class TelegramClient:
message: str, message: str,
priority: str = "high", priority: str = "high",
) -> None: ) -> None:
await self._http.call( await self._send(
"notify_alert", f"<b>ALERT [{priority.upper()}]</b>\n"
{"source": source, "message": message, "priority": priority}, f"source: {source}\n"
f"{message}"
) )
async def notify_system_error( async def notify_system_error(
@@ -106,7 +182,8 @@ class TelegramClient:
component: str | None = None, component: str | None = None,
priority: str = "critical", priority: str = "critical",
) -> None: ) -> None:
body: dict[str, Any] = {"message": message, "priority": priority} text = f"<b>SYSTEM ERROR [{priority.upper()}]</b>\n"
if component is not None: if component:
body["component"] = component text += f"component: {component}\n"
await self._http.call("notify_system_error", body) text += message
await self._send(text)
+4 -4
View File
@@ -31,13 +31,15 @@ __all__ = [
# Service identifier → (default Docker DNS host, default port, env var name) # 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]] = { MCP_SERVICES: dict[str, tuple[str, int, str]] = {
"deribit": ("mcp-deribit", 9011, "CERBERO_BITE_MCP_DERIBIT_URL"), "deribit": ("mcp-deribit", 9011, "CERBERO_BITE_MCP_DERIBIT_URL"),
"hyperliquid": ("mcp-hyperliquid", 9012, "CERBERO_BITE_MCP_HYPERLIQUID_URL"), "hyperliquid": ("mcp-hyperliquid", 9012, "CERBERO_BITE_MCP_HYPERLIQUID_URL"),
"macro": ("mcp-macro", 9013, "CERBERO_BITE_MCP_MACRO_URL"), "macro": ("mcp-macro", 9013, "CERBERO_BITE_MCP_MACRO_URL"),
"sentiment": ("mcp-sentiment", 9014, "CERBERO_BITE_MCP_SENTIMENT_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 hyperliquid: str
macro: str macro: str
sentiment: str sentiment: str
telegram: str
portfolio: str
def for_service(self, name: str) -> str: def for_service(self, name: str) -> str:
try: try:
+4 -1
View File
@@ -71,8 +71,11 @@ class AlertManager:
return return
if severity == Severity.MEDIUM: 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( await self._telegram.notify(
f"[{source}] {message}", priority="high", tag=source message, priority="high", tag=source
) )
return return
+21 -7
View File
@@ -22,7 +22,7 @@ from cerbero_bite.clients.hyperliquid import HyperliquidClient
from cerbero_bite.clients.macro import MacroClient from cerbero_bite.clients.macro import MacroClient
from cerbero_bite.clients.portfolio import PortfolioClient from cerbero_bite.clients.portfolio import PortfolioClient
from cerbero_bite.clients.sentiment import SentimentClient 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.mcp_endpoints import McpEndpoints
from cerbero_bite.config.schema import StrategyConfig from cerbero_bite.config.schema import StrategyConfig
from cerbero_bite.runtime.alert_manager import AlertManager from cerbero_bite.runtime.alert_manager import AlertManager
@@ -145,11 +145,25 @@ def build_runtime(
client=http_client, 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( alert_manager = AlertManager(
telegram=telegram, audit_log=audit_log, kill_switch=kill_switch 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( return RuntimeContext(
cfg=cfg, cfg=cfg,
db_path=db_path, db_path=db_path,
@@ -158,11 +172,11 @@ def build_runtime(
audit_log=audit_log, audit_log=audit_log,
kill_switch=kill_switch, kill_switch=kill_switch,
alert_manager=alert_manager, alert_manager=alert_manager,
deribit=DeribitClient(_client("deribit")), deribit=deribit,
macro=MacroClient(_client("macro")), macro=macro,
sentiment=SentimentClient(_client("sentiment")), sentiment=sentiment,
hyperliquid=HyperliquidClient(_client("hyperliquid")), hyperliquid=hyperliquid,
portfolio=PortfolioClient(_client("portfolio")), portfolio=portfolio,
telegram=telegram, telegram=telegram,
http_client=http_client, http_client=http_client,
clock=clk, clock=clk,
-1
View File
@@ -66,7 +66,6 @@ class HealthCheck:
_probe("macro", self._ctx.macro.get_calendar(days=1)), _probe("macro", self._ctx.macro.get_calendar(days=1)),
_probe("sentiment", self._probe_sentiment()), _probe("sentiment", self._probe_sentiment()),
_probe("hyperliquid", self._ctx.hyperliquid.funding_rate_annualized("ETH")), _probe("hyperliquid", self._ctx.hyperliquid.funding_rate_annualized("ETH")),
_probe("portfolio", self._ctx.portfolio.total_equity_eur()),
) )
# SQLite health: lightweight transaction. # SQLite health: lightweight transaction.
-11
View File
@@ -71,11 +71,6 @@ def _wire_boot_dependencies(httpx_mock: HTTPXMock) -> None:
json={"asset": "ETH", "current_funding_rate": 0.0001}, json={"asset": "ETH", "current_funding_rate": 0.0001},
is_reusable=True, 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 @pytest.mark.asyncio
@@ -115,11 +110,5 @@ async def test_boot_detects_audit_truncation(
orch = _build(tmp_path) orch = _build(tmp_path)
_wire_boot_dependencies(httpx_mock) _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() await orch.boot()
assert orch.context.kill_switch.is_armed() is True assert orch.context.kill_switch.is_armed() is True
+32 -30
View File
@@ -154,18 +154,39 @@ def _wire_market_snapshot(
json={"events": macro_events or []}, json={"events": macro_events or []},
is_reusable=True, 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) portfolio_eur_f = float(portfolio_eur)
httpx_mock.add_response( 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=[ 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, is_reusable=True,
) )
httpx_mock.add_response( httpx_mock.add_response(
url="http://mcp-portfolio:9018/tools/get_total_portfolio_value", url="http://mcp-hyperliquid:9012/tools/get_account_summary",
json={"total_value_eur": portfolio_eur_f}, json={"equity": 0.0},
is_reusable=True,
)
httpx_mock.add_response(
url="http://mcp-hyperliquid:9012/tools/get_positions",
json=[],
is_reusable=True, is_reusable=True,
) )
@@ -262,11 +283,12 @@ def _wire_combo_order(
def _wire_telegram_notify_position_opened(httpx_mock: HTTPXMock) -> None: def _wire_telegram_notify_position_opened(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response( """No-op: Telegram is now an in-process client with disabled mode in tests.
url="http://mcp-telegram:9017/tools/notify_position_opened",
json={"ok": True}, Kept for call-site compatibility; the function used to register an MCP
is_reusable=True, 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, now: datetime,
httpx_mock: HTTPXMock, httpx_mock: HTTPXMock,
) -> None: ) -> 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 # 500 EUR × 1.075 = 537 USD < 720 cfg minimum
_wire_market_snapshot(httpx_mock, portfolio_eur=500.0) _wire_market_snapshot(httpx_mock, portfolio_eur=500.0)
ctx = _ctx(cfg, runtime_paths, now) ctx = _ctx(cfg, runtime_paths, now)
@@ -377,11 +394,6 @@ async def test_macro_event_within_dte_blocks_entry(
now: datetime, now: datetime,
httpx_mock: HTTPXMock, httpx_mock: HTTPXMock,
) -> None: ) -> None:
httpx_mock.add_response(
url="http://mcp-telegram:9017/tools/notify",
json={"ok": True},
is_reusable=True,
)
macro_events = [ macro_events = [
{ {
"name": "FOMC", "name": "FOMC",
@@ -406,11 +418,6 @@ async def test_no_bias_returns_no_entry(
now: datetime, now: datetime,
httpx_mock: HTTPXMock, httpx_mock: HTTPXMock,
) -> None: ) -> 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; # Funding cross neutral (=0) and DVOL 40 → no IC, no directional;
# entry validates clean otherwise. # entry validates clean otherwise.
_wire_market_snapshot( _wire_market_snapshot(
@@ -507,11 +514,6 @@ async def test_broker_reject_marks_position_cancelled(
}, },
is_reusable=True, 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( bull_cfg = golden_config(
entry=type(cfg.entry)( entry=type(cfg.entry)(
**{**cfg.entry.model_dump(), "trend_bull_threshold_pct": Decimal("0")} **{**cfg.entry.model_dump(), "trend_bull_threshold_pct": Decimal("0")}
-26
View File
@@ -60,11 +60,6 @@ def _wire_all_ok(httpx_mock: HTTPXMock) -> None:
json={"asset": "ETH", "current_funding_rate": 0.0001}, json={"asset": "ETH", "current_funding_rate": 0.0001},
is_reusable=True, 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 @pytest.mark.asyncio
@@ -112,11 +107,6 @@ async def test_environment_mismatch_counts_as_failure(
json={"asset": "ETH", "current_funding_rate": 0.0001}, json={"asset": "ETH", "current_funding_rate": 0.0001},
is_reusable=True, 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() res = await hc.run()
assert res.state == "degraded" assert res.state == "degraded"
assert any("environment mismatch" in r for _s, r in res.failures) 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}, json={"asset": "ETH", "current_funding_rate": 0.0001},
is_reusable=True, 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): for _ in range(2):
await hc.run() await hc.run()
assert ctx.kill_switch.is_armed() is False 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}, json={"asset": "ETH", "current_funding_rate": 0.0001},
is_reusable=True, 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() res = await hc.run()
assert res.state == "degraded" assert res.state == "degraded"
assert res.consecutive_failures == 1 assert res.consecutive_failures == 1
-10
View File
@@ -231,11 +231,6 @@ async def test_monitor_closes_position_on_profit_take(
}, },
is_reusable=True, 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) res = await run_monitor_cycle(ctx, now=now)
assert len(res.outcomes) == 1 assert len(res.outcomes) == 1
@@ -296,11 +291,6 @@ async def test_monitor_uses_dvol_history_for_return_4h(
}, },
is_reusable=True, 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) res = await run_monitor_cycle(ctx, now=now)
assert res.outcomes[0].action == "CLOSE_AVERSE" assert res.outcomes[0].action == "CLOSE_AVERSE"
-11
View File
@@ -56,11 +56,6 @@ def _wire_health_probes(httpx_mock: HTTPXMock) -> None:
json={"asset": "ETH", "current_funding_rate": 0.0001}, json={"asset": "ETH", "current_funding_rate": 0.0001},
is_reusable=True, 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: 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=[], json=[],
is_reusable=True, 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") orch = _build_orch(tmp_path, expected="testnet")
await orch.boot() await orch.boot()
assert orch.context.kill_switch.is_armed() is True assert orch.context.kill_switch.is_armed() is True
-10
View File
@@ -115,11 +115,6 @@ async def test_recovery_cancels_awaiting_fill_when_broker_lacks_legs(
url="http://mcp-deribit:9011/tools/get_positions", url="http://mcp-deribit:9011/tools/get_positions",
json=[], 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()) 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", url="http://mcp-deribit:9011/tools/get_positions",
json=[], 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()) await recover_state(ctx, now=_now())
assert ctx.kill_switch.is_armed() is True assert ctx.kill_switch.is_armed() is True
+14 -31
View File
@@ -9,13 +9,14 @@ from pathlib import Path
import pytest import pytest
from pytest_httpx import HTTPXMock 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
from cerbero_bite.runtime.alert_manager import AlertManager, Severity from cerbero_bite.runtime.alert_manager import AlertManager, Severity
from cerbero_bite.safety import AuditLog, iter_entries from cerbero_bite.safety import AuditLog, iter_entries
from cerbero_bite.safety.kill_switch import KillSwitch from cerbero_bite.safety.kill_switch import KillSwitch
from cerbero_bite.state import Repository, connect, run_migrations, transaction 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]: def _make_alert_manager(tmp_path: Path) -> tuple[AlertManager, Path, Path, KillSwitch]:
db_path = tmp_path / "state.sqlite" 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, audit_log=audit,
clock=lambda: next(times), clock=lambda: next(times),
) )
telegram = TelegramClient( telegram = TelegramClient(bot_token="TOK", chat_id="42")
HttpToolClient(
service="telegram",
base_url="http://mcp-telegram:9017",
token="t",
retry_max=1,
)
)
return AlertManager(telegram=telegram, audit_log=audit, kill_switch=ks), audit_path, db_path, ks 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 @pytest.mark.asyncio
async def test_medium_calls_telegram_notify(tmp_path: Path, httpx_mock: HTTPXMock) -> None: async def test_medium_calls_telegram_notify(tmp_path: Path, httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response( httpx_mock.add_response(url=SEND_URL, json={"ok": True})
url="http://mcp-telegram:9017/tools/notify", json={"ok": True}
)
am, audit_path, _, ks = _make_alert_manager(tmp_path) am, audit_path, _, ks = _make_alert_manager(tmp_path)
await am.medium(source="entry_cycle", message="snapshot delayed") await am.medium(source="entry_cycle", message="snapshot delayed")
requests = httpx_mock.get_requests() requests = httpx_mock.get_requests()
assert len(requests) == 1 assert len(requests) == 1
body = json.loads(requests[0].read()) body = json.loads(requests[0].read())
assert body["message"] == "[entry_cycle] snapshot delayed" assert body["text"] == "[HIGH][entry_cycle] snapshot delayed"
assert body["priority"] == "high"
assert body["tag"] == "entry_cycle"
assert ks.is_armed() is False assert ks.is_armed() is False
assert any(e.payload["severity"] == "medium" for e in iter_entries(audit_path)) 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( async def test_high_arms_kill_switch_and_calls_notify_alert(
tmp_path: Path, httpx_mock: HTTPXMock tmp_path: Path, httpx_mock: HTTPXMock
) -> None: ) -> None:
httpx_mock.add_response( httpx_mock.add_response(url=SEND_URL, json={"ok": True})
url="http://mcp-telegram:9017/tools/notify_alert", json={"ok": True}
)
am, _, _, ks = _make_alert_manager(tmp_path) am, _, _, ks = _make_alert_manager(tmp_path)
await am.high(source="health", message="3 consecutive MCP failures") await am.high(source="health", message="3 consecutive MCP failures")
body = json.loads(httpx_mock.get_request().read()) body = json.loads(httpx_mock.get_request().read())
assert body == { text = body["text"]
"source": "health", assert "ALERT [HIGH]" in text
"message": "3 consecutive MCP failures", assert "health" in text and "3 consecutive MCP failures" in text
"priority": "high",
}
assert ks.is_armed() is True 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( async def test_critical_arms_kill_switch_and_calls_notify_system_error(
tmp_path: Path, httpx_mock: HTTPXMock tmp_path: Path, httpx_mock: HTTPXMock
) -> None: ) -> None:
httpx_mock.add_response( httpx_mock.add_response(url=SEND_URL, json={"ok": True})
url="http://mcp-telegram:9017/tools/notify_system_error", json={"ok": True}
)
am, _, _, ks = _make_alert_manager(tmp_path) am, _, _, ks = _make_alert_manager(tmp_path)
await am.critical( await am.critical(
source="audit_chain", source="audit_chain",
@@ -112,8 +96,9 @@ async def test_critical_arms_kill_switch_and_calls_notify_system_error(
component="safety.audit_log", component="safety.audit_log",
) )
body = json.loads(httpx_mock.get_request().read()) body = json.loads(httpx_mock.get_request().read())
assert body["component"] == "safety.audit_log" text = body["text"]
assert body["priority"] == "critical" assert "SYSTEM ERROR [CRITICAL]" in text
assert "safety.audit_log" in text
assert ks.is_armed() is True 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( async def test_critical_when_already_armed_is_idempotent(
tmp_path: Path, httpx_mock: HTTPXMock tmp_path: Path, httpx_mock: HTTPXMock
) -> None: ) -> None:
httpx_mock.add_response( httpx_mock.add_response(url=SEND_URL, json={"ok": True})
url="http://mcp-telegram:9017/tools/notify_system_error", json={"ok": True}
)
am, _, _, ks = _make_alert_manager(tmp_path) am, _, _, ks = _make_alert_manager(tmp_path)
ks.arm(reason="prior", source="manual") ks.arm(reason="prior", source="manual")
assert ks.is_armed() is True assert ks.is_armed() is True
+4 -12
View File
@@ -49,10 +49,6 @@ def test_ping_reports_each_service(
url="http://mcp-sentiment:9014/tools/get_cross_exchange_funding", url="http://mcp-sentiment:9014/tools/get_cross_exchange_funding",
json={"snapshot": {"ETH": {"binance": 0.0001}}}, 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( result = CliRunner().invoke(
cli_main, ["ping", "--token-file", str(token_file), "--timeout", "1.0"] 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 "hyperliquid" in result.output
assert "macro" in result.output assert "macro" in result.output
assert "sentiment" in result.output assert "sentiment" in result.output
assert "portfolio" in result.output # Telegram and Portfolio are no longer MCP services and are not
assert "telegram" in result.output # listed even if skipped # listed by the ping command.
# at least 5 OK statuses assert "portfolio" not in result.output
assert result.output.count("OK") >= 5 assert "OK" in result.output
def test_ping_reports_failure_when_service_unreachable( 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", url="http://mcp-sentiment:9014/tools/get_cross_exchange_funding",
json={"snapshot": {"ETH": {"binance": 0.0001}}}, 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( result = CliRunner().invoke(
cli_main, ["ping", "--token-file", str(token_file), "--timeout", "1.0"] cli_main, ["ping", "--token-file", str(token_file), "--timeout", "1.0"]
+203 -58
View File
@@ -1,95 +1,240 @@
"""Tests for PortfolioClient.""" """Tests for in-process PortfolioClient (composes deribit + hyperliquid + macro)."""
from __future__ import annotations from __future__ import annotations
from decimal import Decimal from decimal import Decimal
from typing import Any
import pytest 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._exceptions import McpDataAnomalyError
from cerbero_bite.clients.portfolio import PortfolioClient from cerbero_bite.clients.portfolio import PortfolioClient
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
def _client() -> PortfolioClient:
http = HttpToolClient( class _FakeDeribit:
service="portfolio", SERVICE = "deribit"
base_url="http://mcp-portfolio:9018",
token="t", def __init__(
retry_max=1, 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 PortfolioClient(http) return Decimal(str(self._eur_usd))
@pytest.mark.asyncio def _make(
async def test_total_equity_eur(httpx_mock: HTTPXMock) -> None: *,
httpx_mock.add_response( deribit_eq: Decimal | float = 0,
url="http://mcp-portfolio:9018/tools/get_total_portfolio_value", hl_eq: Decimal | float = 0,
json={"total_value_eur": 12345.67}, 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),
) )
out = await _client().total_equity_eur()
assert out == Decimal("12345.67")
# ---------------------------------------------------------------------------
# total_equity_usd / total_equity_eur
# ---------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_total_equity_anomaly_when_missing(httpx_mock: HTTPXMock) -> None: async def test_total_equity_usd_sums_both_exchanges() -> None:
httpx_mock.add_response(json={}) p = _make(deribit_eq="1500.50", hl_eq="982.50")
with pytest.raises(McpDataAnomalyError, match="total_value_eur"): assert await p.total_equity_usd() == Decimal("2483.00")
await _client().total_equity_eur()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_total_equity_anomaly_on_unexpected_shape(httpx_mock: HTTPXMock) -> None: async def test_total_equity_eur_converts_with_fx() -> None:
httpx_mock.add_response(json=[1, 2, 3]) p = _make(deribit_eq="1100", hl_eq="0", eur_usd="1.10")
with pytest.raises(McpDataAnomalyError, match="unexpected shape"): # 1100 USD / 1.10 = 1000 EUR
await _client().total_equity_eur() assert await p.total_equity_eur() == Decimal("1000")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_asset_pct_aggregates_matching_tickers(httpx_mock: HTTPXMock) -> None: async def test_total_equity_eur_zero_when_no_balance() -> None:
httpx_mock.add_response( p = _make(deribit_eq=0, hl_eq=0, eur_usd="1.20")
url="http://mcp-portfolio:9018/tools/get_holdings", assert await p.total_equity_eur() == Decimal("0")
json=[
{"ticker": "ETH-USD", "current_value_eur": 3000.0},
{"ticker": "ETHE", "current_value_eur": 1000.0}, # ETH ticker variant @pytest.mark.asyncio
{"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") # ETH exposure: 10×100 (deribit) + 1000 (hl) = 2000
# 4000 / 10000 = 0.4 # total equity: 10000
assert pct == Decimal("0.4") pct = await p.asset_pct_of_portfolio("ETH")
assert pct == Decimal("0.2")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_asset_pct_returns_zero_for_empty_portfolio( async def test_asset_pct_returns_zero_when_no_positions() -> None:
httpx_mock: HTTPXMock, p = _make(deribit_eq="1000", hl_eq="0")
) -> None: assert await p.asset_pct_of_portfolio("ETH") == Decimal("0")
httpx_mock.add_response(json=[])
assert await _client().asset_pct_of_portfolio("ETH") == Decimal("0")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_asset_pct_skips_entries_without_value(httpx_mock: HTTPXMock) -> None: async def test_asset_pct_returns_zero_when_no_equity() -> None:
httpx_mock.add_response( p = _make(
json=[ deribit_eq=0,
{"ticker": "ETH", "current_value_eur": None}, hl_eq=0,
{"ticker": "AAPL", "current_value_eur": 1000.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 @pytest.mark.asyncio
async def test_asset_pct_anomaly_when_response_not_list(httpx_mock: HTTPXMock) -> None: async def test_asset_pct_uses_explicit_notional_when_present() -> None:
httpx_mock.add_response(json={"holdings": []}) p = _make(
with pytest.raises(McpDataAnomalyError, match="unexpected shape"): deribit_eq="1000",
await _client().asset_pct_of_portfolio("ETH") hl_eq=0,
deribit_pos=[
# explicit notional_usd takes precedence over size×mark
def test_portfolio_client_rejects_wrong_service() -> None: {
bad = HttpToolClient( "instrument_name": "ETH-XYZ",
service="macro", base_url="http://x:1", token="t", retry_max=1 "notional_usd": 250,
"size": 999,
"mark_price": 999,
},
],
) )
with pytest.raises(ValueError, match="requires service 'portfolio'"): assert await p.asset_pct_of_portfolio("ETH") == Decimal("0.25")
PortfolioClient(bad)
@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")
+171 -57
View File
@@ -1,25 +1,27 @@
"""Tests for TelegramClient (notify-only mode).""" """Tests for in-process TelegramClient (Bot API, notify-only)."""
from __future__ import annotations from __future__ import annotations
import json import json
from decimal import Decimal from decimal import Decimal
import httpx
import pytest import pytest
from pytest_httpx import HTTPXMock from pytest_httpx import HTTPXMock
from cerbero_bite.clients._base import HttpToolClient from cerbero_bite.clients.telegram import (
from cerbero_bite.clients.telegram import TelegramClient TelegramClient,
TelegramError,
load_telegram_credentials,
def _client() -> TelegramClient:
http = HttpToolClient(
service="telegram",
base_url="http://mcp-telegram:9017",
token="t",
retry_max=1,
) )
return TelegramClient(http)
SEND_URL = "https://api.telegram.org/botTOK/sendMessage"
def _client(**kw) -> TelegramClient:
defaults = {"bot_token": "TOK", "chat_id": "42"}
defaults.update(kw)
return TelegramClient(**defaults)
def _request_body(httpx_mock: HTTPXMock) -> dict: def _request_body(httpx_mock: HTTPXMock) -> dict:
@@ -28,34 +30,66 @@ def _request_body(httpx_mock: HTTPXMock) -> dict:
return json.loads(request.read()) 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 @pytest.mark.asyncio
async def test_notify_sends_message_with_priority(httpx_mock: HTTPXMock) -> None: async def test_disabled_notify_is_noop(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response( c = TelegramClient(bot_token=None, chat_id=None)
url="http://mcp-telegram:9017/tools/notify", await c.notify("hello")
json={"ok": True}, 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") await _client().notify("hello", priority="high", tag="entry")
body = _request_body(httpx_mock) 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 @pytest.mark.asyncio
async def test_notify_default_priority_normal(httpx_mock: HTTPXMock) -> None: 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") await _client().notify("plain")
body = _request_body(httpx_mock) body = _request_body(httpx_mock)
assert body["priority"] == "normal" assert body["text"] == "[NORMAL] plain"
assert "tag" not in body
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_notify_position_opened_serialises_decimals( async def test_notify_position_opened_formats_decimals(
httpx_mock: HTTPXMock, httpx_mock: HTTPXMock,
) -> None: ) -> None:
httpx_mock.add_response( httpx_mock.add_response(url=SEND_URL, json={"ok": True})
url="http://mcp-telegram:9017/tools/notify_position_opened",
json={"ok": True},
)
await _client().notify_position_opened( await _client().notify_position_opened(
instrument="ETH-15MAY26-2475-P", instrument="ETH-15MAY26-2475-P",
side="SELL", side="SELL",
@@ -64,59 +98,139 @@ async def test_notify_position_opened_serialises_decimals(
greeks={"delta": Decimal("-0.04"), "vega": Decimal("0.20")}, greeks={"delta": Decimal("-0.04"), "vega": Decimal("0.20")},
expected_pnl_usd=Decimal("45.00"), expected_pnl_usd=Decimal("45.00"),
) )
body = _request_body(httpx_mock) text = _request_body(httpx_mock)["text"]
assert body["instrument"] == "ETH-15MAY26-2475-P" assert "POSITION OPENED" in text
assert body["greeks"] == {"delta": -0.04, "vega": 0.20} assert "ETH-15MAY26-2475-P" in text
assert body["expected_pnl"] == 45.0 assert "SELL" in text and "size: 2" in text and "bull_put" in text
assert body["size"] == 2.0 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 @pytest.mark.asyncio
async def test_notify_position_closed(httpx_mock: HTTPXMock) -> None: 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( await _client().notify_position_closed(
instrument="ETH-15MAY26-2475-P_2350-P", instrument="ETH-15MAY26-2475-P_2350-P",
realized_pnl_usd=Decimal("32.50"), realized_pnl_usd=Decimal("32.50"),
reason="CLOSE_PROFIT", reason="CLOSE_PROFIT",
) )
body = _request_body(httpx_mock) text = _request_body(httpx_mock)["text"]
assert body == { assert "POSITION CLOSED" in text
"instrument": "ETH-15MAY26-2475-P_2350-P", assert "ETH-15MAY26-2475-P_2350-P" in text
"realized_pnl": 32.5, assert "$+32.50" in text
"reason": "CLOSE_PROFIT", 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 @pytest.mark.asyncio
async def test_notify_alert(httpx_mock: HTTPXMock) -> None: 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( await _client().notify_alert(
source="kill_switch", message="armed manually", priority="critical" source="kill_switch", message="armed manually", priority="critical"
) )
body = _request_body(httpx_mock) text = _request_body(httpx_mock)["text"]
assert body == { assert "ALERT [CRITICAL]" in text
"source": "kill_switch", assert "kill_switch" in text and "armed manually" in text
"message": "armed manually",
"priority": "critical",
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_notify_system_error(httpx_mock: HTTPXMock) -> None: 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( await _client().notify_system_error(
message="deribit feed anomaly", message="deribit feed anomaly", component="clients.deribit"
component="clients.deribit",
) )
body = _request_body(httpx_mock) text = _request_body(httpx_mock)["text"]
assert body["message"] == "deribit feed anomaly" assert "SYSTEM ERROR [CRITICAL]" in text
assert body["component"] == "clients.deribit" assert "deribit feed anomaly" in text
assert body["priority"] == "critical" assert "clients.deribit" in text
def test_telegram_client_rejects_wrong_service() -> None: @pytest.mark.asyncio
bad = HttpToolClient( async def test_notify_system_error_without_component(httpx_mock: HTTPXMock) -> None:
service="macro", base_url="http://x:1", token="t", retry_max=1 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'"): with pytest.raises(TelegramError, match="chat not found"):
TelegramClient(bad) 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)
+4 -2
View File
@@ -16,7 +16,7 @@ from cerbero_bite.config.mcp_endpoints import (
def test_defaults_match_known_docker_dns() -> None: def test_defaults_match_known_docker_dns() -> None:
assert DEFAULT_ENDPOINTS["deribit"] == "http://mcp-deribit:9011" 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: 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: 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 assert set(MCP_SERVICES) == expected
+6 -2
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from cerbero_bite.clients.portfolio import PortfolioClient
from cerbero_bite.config import golden_config from cerbero_bite.config import golden_config
from cerbero_bite.config.mcp_endpoints import load_endpoints from cerbero_bite.config.mcp_endpoints import load_endpoints
from cerbero_bite.runtime import build_runtime 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.macro.SERVICE == "macro"
assert ctx.sentiment.SERVICE == "sentiment" assert ctx.sentiment.SERVICE == "sentiment"
assert ctx.hyperliquid.SERVICE == "hyperliquid" assert ctx.hyperliquid.SERVICE == "hyperliquid"
assert ctx.portfolio.SERVICE == "portfolio" # Portfolio is now an in-process aggregator over deribit/hyperliquid/macro;
assert ctx.telegram.SERVICE == "telegram" # 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