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