build 039: complete Quotes Feed migration foundation

This commit is contained in:
2026-07-14 09:58:16 +03:00
parent 26deb861bc
commit 7b62873832
443 changed files with 80452 additions and 1335 deletions

View 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",
)