# app/tests/unit/telegram/ui/test_currency_ui.py from __future__ import annotations from datetime import datetime, timezone from decimal import Decimal from decimal import Decimal from types import SimpleNamespace from typing import cast import pytest from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.models import ( BalanceSummary, ) from src.integrations.exchange.service import ExchangeService from src.market_data.acquisition.models.instrument import Instrument from src.market_data.acquisition.models.quote import Quote from src.telegram.ui.currency_ui import ( _resolve_asset_quote_instrument, estimate_balance_usd, get_asset_usd_rate, ) def _instrument( *, symbol: str, base_asset: str, quote_asset: str = "USD", status: str = "TRADING", market_type: str = "SPOT", ) -> Instrument: return Instrument( symbol=symbol, name=symbol, status=status, base_asset=base_asset, quote_asset=quote_asset, asset_type="CRYPTOCURRENCY", market_type=market_type, market_modes=("REGULAR",), order_types=("LIMIT", "MARKET"), base_asset_precision=8, quote_asset_precision=8, tick_size=Decimal("0.01"), tick_value=None, step_size=Decimal("0.0001"), min_qty=Decimal("0.0001"), max_qty=None, min_notional=Decimal("1"), country=None, sector=None, industry=None, trading_hours=None, ) def _service() -> ExchangeService: return object.__new__( ExchangeService ) def test_resolver_uses_get_instruments( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() instruments = ( _instrument( symbol="BTC/USD", base_asset="BTC", ), ) call_count = 0 def get_instruments() -> tuple[Instrument, ...]: nonlocal call_count call_count += 1 return instruments monkeypatch.setattr( service, "get_instruments", get_instruments, ) result = _resolve_asset_quote_instrument( service, "BTC", ) assert result is instruments[0] assert call_count == 1 def test_resolver_uses_canonical_instrument_reference( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() instrument = _instrument( symbol="BTC/USD", base_asset="BTC", ) calls = 0 def get_instruments() -> tuple[Instrument, ...]: nonlocal calls calls += 1 return ( instrument, ) monkeypatch.setattr( service, "get_instruments", get_instruments, ) result = _resolve_asset_quote_instrument( service, "BTC", ) assert result is instrument assert calls == 1 def test_resolver_prefers_usd_over_usdt( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() usd = _instrument( symbol="BTC/USD", base_asset="BTC", quote_asset="USD", market_type="LEVERAGE", ) usdt = _instrument( symbol="BTC/USDT", base_asset="BTC", quote_asset="USDT", market_type="SPOT", ) monkeypatch.setattr( service, "get_instruments", lambda: ( usdt, usd, ), ) result = _resolve_asset_quote_instrument( service, "BTC", ) assert result is usd def test_resolver_prefers_trading_status( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() active = _instrument( symbol="BTC/USD_ACTIVE", base_asset="BTC", status="TRADING", market_type="LEVERAGE", ) inactive = _instrument( symbol="BTC/USD_INACTIVE", base_asset="BTC", status="UNKNOWN", market_type="SPOT", ) monkeypatch.setattr( service, "get_instruments", lambda: ( inactive, active, ), ) result = _resolve_asset_quote_instrument( service, "BTC", ) assert result is active def test_resolver_prefers_spot_when_other_priority_is_equal( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() spot = _instrument( symbol="BTC/USD_SPOT", base_asset="BTC", market_type="SPOT", ) leverage = _instrument( symbol="BTC/USD_LEVERAGE", base_asset="BTC", market_type="LEVERAGE", ) monkeypatch.setattr( service, "get_instruments", lambda: ( leverage, spot, ), ) result = _resolve_asset_quote_instrument( service, "BTC", ) assert result is spot def test_resolver_ignores_other_base_assets( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() monkeypatch.setattr( service, "get_instruments", lambda: ( _instrument( symbol="ETH/USD", base_asset="ETH", ), ), ) result = _resolve_asset_quote_instrument( service, "BTC", ) assert result is None def test_resolver_ignores_unsupported_quote_assets( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() monkeypatch.setattr( service, "get_instruments", lambda: ( _instrument( symbol="BTC/EUR", base_asset="BTC", quote_asset="EUR", ), ), ) result = _resolve_asset_quote_instrument( service, "BTC", ) assert result is None def test_resolver_returns_none_when_get_instruments_fails( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() def raise_error() -> tuple[Instrument, ...]: raise ExchangeError( "exchangeInfo unavailable" ) monkeypatch.setattr( service, "get_instruments", raise_error, ) result = _resolve_asset_quote_instrument( service, "BTC", ) assert result is None @pytest.mark.parametrize( "currency", [ "USD", "usd", "USDT", "usdt", ], ) def test_usd_and_usdt_rates_are_one( currency: str, ) -> None: service = _service() cache: dict[str, float | None] = {} result = get_asset_usd_rate( service, currency, cache, ) assert result == 1.0 assert cache == {} def test_get_asset_usd_rate_uses_existing_price_cache( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() cache: dict[str, float | None] = { "BTC": 50_000.0, } def fail_if_called() -> tuple[Instrument, ...]: raise AssertionError( "Instrument lookup must not run on price cache hit." ) monkeypatch.setattr( service, "get_instruments", fail_if_called, ) result = get_asset_usd_rate( service, "BTC", cache, ) assert result == 50_000.0 def test_get_asset_usd_rate_loads_price_by_instrument_symbol( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() instrument = _instrument( symbol="BTC/USD", base_asset="BTC", ) requested_symbols: list[str] = [] monkeypatch.setattr( service, "get_instruments", lambda: ( instrument, ), ) def get_quote( symbol: str, *, runtime_key: str | None = None, ) -> Quote: del runtime_key requested_symbols.append(symbol) return Quote( symbol=symbol, last_price=Decimal("50000"), bid_price=Decimal("49999"), ask_price=Decimal("50001"), exchange_timestamp=None, received_at=datetime.now(timezone.utc), source="test", ) monkeypatch.setattr( service, "get_quote", get_quote, ) cache: dict[str, float | None] = {} result = get_asset_usd_rate( service, "BTC", cache, ) assert result == 50_000.0 assert requested_symbols == [ "BTC/USD", ] assert cache == { "BTC": 50_000.0, } def test_get_asset_usd_rate_caches_missing_instrument( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() monkeypatch.setattr( service, "get_instruments", lambda: (), ) cache: dict[str, float | None] = {} result = get_asset_usd_rate( service, "BTC", cache, ) assert result is None assert cache == { "BTC": None, } def test_get_asset_usd_rate_caches_price_error( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() monkeypatch.setattr( service, "get_instruments", lambda: ( _instrument( symbol="BTC/USD", base_asset="BTC", ), ), ) def raise_error( symbol: str, *, runtime_key: str | None = None, ) -> Quote: del symbol del runtime_key raise ExchangeError( "ticker unavailable" ) monkeypatch.setattr( service, "get_quote", raise_error, ) cache: dict[str, float | None] = {} result = get_asset_usd_rate( service, "BTC", cache, ) assert result is None assert cache == { "BTC": None, } def test_estimate_balance_usd_for_fiat_balance() -> None: service = _service() balance = BalanceSummary( currency="USD", available=100.0, locked=25.0, source="test", ) result = estimate_balance_usd( balance, service, {}, ) assert result == 125.0 def test_estimate_balance_usd_for_crypto_balance( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() monkeypatch.setattr( service, "get_instruments", lambda: ( _instrument( symbol="BTC/USD", base_asset="BTC", ), ), ) monkeypatch.setattr( service, "get_quote", lambda symbol, **_: Quote( symbol=symbol, last_price=Decimal("50000"), bid_price=Decimal("49999"), ask_price=Decimal("50001"), exchange_timestamp=None, received_at=datetime.now(timezone.utc), source="test", ), ) balance = BalanceSummary( currency="BTC", available=0.01, locked=0.005, source="test", ) result = estimate_balance_usd( balance, service, {}, ) assert result == pytest.approx( 750.0 ) def test_estimate_balance_usd_returns_none_for_zero_total() -> None: service = _service() balance = BalanceSummary( currency="BTC", available=0.0, locked=0.0, source="test", ) result = estimate_balance_usd( balance, service, {}, ) assert result is None def test_estimate_balance_usd_returns_none_when_rate_is_missing( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() monkeypatch.setattr( service, "get_instruments", lambda: (), ) balance = BalanceSummary( currency="BTC", available=1.0, locked=0.0, source="test", ) result = estimate_balance_usd( balance, service, {}, ) assert result is None