build 039: complete Quotes Feed migration foundation
This commit is contained in:
453
app/tests/unit/integrations/exchange/test_service_instruments.py
Normal file
453
app/tests/unit/integrations/exchange/test_service_instruments.py
Normal file
@@ -0,0 +1,453 @@
|
||||
# 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",
|
||||
)
|
||||
Reference in New Issue
Block a user