453 lines
9.7 KiB
Python
453 lines
9.7 KiB
Python
# 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",
|
|
) |