# app/tests/unit/integrations/exchange/test_service_instruments.py from __future__ import annotations from collections.abc import Iterator from decimal import Decimal from types import SimpleNamespace from typing import cast import pytest from src.core.config import Settings from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.service import ExchangeService from src.market_data.acquisition.exceptions import ( InstrumentReferenceTransportError, ) from src.market_data.acquisition.models.instrument import Instrument from src.storage.instrument_store import InMemoryInstrumentStore _SOURCE_NAME = "dzengi" def _settings( *, exchange_enabled: bool = True, ) -> Settings: return cast( Settings, SimpleNamespace( exchange_enabled=exchange_enabled, exchange_name="dzengi", ), ) def _service( *, exchange_enabled: bool = True, ) -> ExchangeService: service = object.__new__(ExchangeService) service.settings = _settings( exchange_enabled=exchange_enabled, ) return service def _instrument( *, symbol: str = "BTC/USD_LEVERAGE", name: str = "BTC/USD", base_asset: str = "BTC", ) -> Instrument: return Instrument( symbol=symbol, name=name, status="TRADING", base_asset=base_asset, quote_asset="USD", asset_type="CRYPTOCURRENCY", market_type="LEVERAGE", market_modes=("REGULAR",), order_types=("LIMIT", "MARKET", "STOP"), base_asset_precision=4, quote_asset_precision=4, tick_size=Decimal("0.05"), tick_value=Decimal("3878.86"), step_size=Decimal("0.0001"), min_qty=Decimal("0.0001"), max_qty=Decimal("1000"), min_notional=Decimal("1"), country=None, sector=None, industry=None, trading_hours=None, ) @pytest.fixture(autouse=True) def reset_instrument_storage() -> Iterator[None]: original_store = ExchangeService._instrument_store ExchangeService._instrument_store = InMemoryInstrumentStore() yield ExchangeService._instrument_store.clear() ExchangeService._instrument_store = original_store def test_get_instruments_returns_empty_tuple_when_exchange_disabled( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service( exchange_enabled=False, ) def fail_if_called() -> tuple[Instrument, ...]: raise AssertionError( "Acquisition must not run when exchange is disabled." ) monkeypatch.setattr( service, "_load_instruments_via_acquisition", fail_if_called, ) result = service.get_instruments() assert result == () assert isinstance(result, tuple) def test_exchange_disabled_does_not_read_existing_store( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service( exchange_enabled=False, ) instruments = ( _instrument(), ) ExchangeService._instrument_store.set( _SOURCE_NAME, instruments, ) def fail_get( source_name: str, ) -> tuple[Instrument, ...] | None: del source_name raise AssertionError( "Instrument Store must not be read " "when exchange is disabled." ) monkeypatch.setattr( ExchangeService._instrument_store, "get", fail_get, ) assert service.get_instruments() == () def test_store_hit_returns_same_tuple_object( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() instruments = ( _instrument(), ) ExchangeService._instrument_store.set( _SOURCE_NAME, instruments, ) def fail_if_called() -> tuple[Instrument, ...]: raise AssertionError( "Acquisition must not run on store hit." ) monkeypatch.setattr( service, "_load_instruments_via_acquisition", fail_if_called, ) result = service.get_instruments() assert result is instruments def test_store_miss_runs_acquisition_once( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() instruments = ( _instrument(), ) call_count = 0 def load_instruments() -> tuple[Instrument, ...]: nonlocal call_count call_count += 1 return instruments monkeypatch.setattr( service, "_load_instruments_via_acquisition", load_instruments, ) result = service.get_instruments() assert result is instruments assert call_count == 1 def test_loaded_instruments_are_saved_in_store( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() instruments = ( _instrument(), ) monkeypatch.setattr( service, "_load_instruments_via_acquisition", lambda: instruments, ) result = service.get_instruments() stored = ExchangeService._instrument_store.get( _SOURCE_NAME ) assert result is instruments assert stored is instruments def test_second_get_instruments_call_uses_store( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() instruments = ( _instrument(), ) call_count = 0 def load_instruments() -> tuple[Instrument, ...]: nonlocal call_count call_count += 1 return instruments monkeypatch.setattr( service, "_load_instruments_via_acquisition", load_instruments, ) first = service.get_instruments() second = service.get_instruments() assert first is instruments assert second is instruments assert call_count == 1 def test_empty_tuple_in_store_is_valid_cache_hit( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() ExchangeService._instrument_store.set( _SOURCE_NAME, (), ) def fail_if_called() -> tuple[Instrument, ...]: raise AssertionError( "Stored empty tuple must be treated as cache hit." ) monkeypatch.setattr( service, "_load_instruments_via_acquisition", fail_if_called, ) result = service.get_instruments() assert result == () def test_multiple_service_instances_share_instrument_store( monkeypatch: pytest.MonkeyPatch, ) -> None: first_service = _service() second_service = _service() instruments = ( _instrument(), ) call_count = 0 def load_instruments() -> tuple[Instrument, ...]: nonlocal call_count call_count += 1 return instruments monkeypatch.setattr( first_service, "_load_instruments_via_acquisition", load_instruments, ) monkeypatch.setattr( second_service, "_load_instruments_via_acquisition", load_instruments, ) first = first_service.get_instruments() second = second_service.get_instruments() assert first is instruments assert second is instruments assert call_count == 1 def test_acquisition_error_is_wrapped_in_exchange_error( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() original_error = InstrumentReferenceTransportError( "Network error." ) def raise_error() -> tuple[Instrument, ...]: raise original_error monkeypatch.setattr( service, "_load_instruments_via_acquisition", raise_error, ) monkeypatch.setattr( service, "_log_exchange_error", lambda **_: None, ) with pytest.raises( ExchangeError, match=r"Network error", ) as exc_info: service.get_instruments() assert exc_info.value.__cause__ is original_error def test_acquisition_error_is_logged_as_exchange_info( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() original_error = InstrumentReferenceTransportError( "Network error." ) logged_calls: list[dict[str, object]] = [] def raise_error() -> tuple[Instrument, ...]: raise original_error def record_error( *, endpoint: str, exc: Exception, symbol: str | None = None, extra_payload: dict[str, object] | None = None, ) -> None: logged_calls.append( { "endpoint": endpoint, "exc": exc, "symbol": symbol, "extra_payload": extra_payload, } ) monkeypatch.setattr( service, "_load_instruments_via_acquisition", raise_error, ) monkeypatch.setattr( service, "_log_exchange_error", record_error, ) with pytest.raises(ExchangeError): service.get_instruments() assert logged_calls == [ { "endpoint": "exchangeInfo", "exc": original_error, "symbol": None, "extra_payload": None, } ] def test_acquisition_error_does_not_fill_store( monkeypatch: pytest.MonkeyPatch, ) -> None: service = _service() def raise_error() -> tuple[Instrument, ...]: raise InstrumentReferenceTransportError( "Network error." ) monkeypatch.setattr( service, "_load_instruments_via_acquisition", raise_error, ) monkeypatch.setattr( service, "_log_exchange_error", lambda **_: None, ) with pytest.raises(ExchangeError): service.get_instruments() assert ExchangeService._instrument_store.get( _SOURCE_NAME ) is None def test_exchange_service_has_no_legacy_projection_cache() -> None: assert not hasattr( ExchangeService, "_exchange_symbols_projection_cache", )