"""Tests for SentimentClient.""" from __future__ import annotations from decimal import Decimal 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.sentiment import ( EXCHANGE_PERIODS_PER_YEAR, SentimentClient, ) def _client() -> SentimentClient: http = HttpToolClient( service="sentiment", base_url="http://mcp-sentiment:9014", token="t", retry_max=1, ) return SentimentClient(http) @pytest.mark.asyncio async def test_median_annualises_per_exchange_period(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="http://mcp-sentiment:9014/tools/get_cross_exchange_funding", json={ "snapshot": { "ETH": { "binance": 0.0001, "bybit": 0.0002, "okx": 0.00015, "hyperliquid": 0.00002, } } }, ) median = await _client().funding_cross_median_annualized("eth") # Annualised values (Decimal): # binance: 0.0001 * 1095 = 0.1095 # bybit: 0.0002 * 1095 = 0.2190 # okx: 0.00015 * 1095 = 0.16425 # hyperliquid:0.00002 * 8760 = 0.17520 # sorted: 0.1095, 0.16425, 0.17520, 0.2190 → median = (0.16425+0.17520)/2 = 0.169725 assert median == Decimal("0.169725") @pytest.mark.asyncio async def test_median_skips_missing_venues(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( json={ "snapshot": { "ETH": { "binance": 0.0001, "bybit": None, "okx": None, "hyperliquid": None, } } } ) median = await _client().funding_cross_median_annualized("ETH") assert median == Decimal("0.0001") * Decimal("1095") @pytest.mark.asyncio async def test_anomaly_when_snapshot_missing(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(json={"snapshot": {}}) with pytest.raises(McpDataAnomalyError, match="snapshot missing"): await _client().funding_cross_median_annualized("ETH") @pytest.mark.asyncio async def test_anomaly_when_no_venue_responded(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( json={ "snapshot": { "ETH": { "binance": None, "bybit": None, "okx": None, "hyperliquid": None, } } } ) with pytest.raises(McpDataAnomalyError, match="no funding venues"): await _client().funding_cross_median_annualized("ETH") def test_periods_table_covers_documented_venues() -> None: assert set(EXCHANGE_PERIODS_PER_YEAR) == { "binance", "bybit", "okx", "hyperliquid" } @pytest.mark.asyncio async def test_liquidation_heatmap_parses_high_risk(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="http://mcp-sentiment:9014/tools/get_liquidation_heatmap", json={ "asset": "ETH", "avg_funding_rate": 0.00012, "oi_delta_pct_4h": 6.5, "oi_delta_pct_24h": 8.2, "long_squeeze_risk": "high", "short_squeeze_risk": "low", }, ) out = await _client().liquidation_heatmap("eth") assert out.asset == "ETH" assert out.avg_funding_rate == Decimal("0.00012") assert out.long_squeeze_risk == "high" assert out.has_high_squeeze_risk is True @pytest.mark.asyncio async def test_liquidation_heatmap_unknown_risk_levels_default_to_low( httpx_mock: HTTPXMock, ) -> None: httpx_mock.add_response( json={ "asset": "ETH", "long_squeeze_risk": "extreme", "short_squeeze_risk": None, } ) out = await _client().liquidation_heatmap("ETH") assert out.long_squeeze_risk == "low" assert out.short_squeeze_risk == "low" assert out.has_high_squeeze_risk is False def test_sentiment_client_rejects_wrong_service() -> None: bad = HttpToolClient( service="macro", base_url="http://x:1", token="t", retry_max=1, ) with pytest.raises(ValueError, match="requires service 'sentiment'"): SentimentClient(bad)