296 lines
8.1 KiB
Python
296 lines
8.1 KiB
Python
# app/tests/unit/integrations/exchange/test_service_quote.py
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from decimal import Decimal
|
|
from types import SimpleNamespace
|
|
from typing import cast
|
|
|
|
import pytest
|
|
|
|
import src.integrations.exchange.service as service_module
|
|
from src.core.config import Settings
|
|
from src.integrations.exchange.exceptions import ExchangeError
|
|
from src.integrations.exchange.market_cache import MarketPriceCache
|
|
from src.integrations.exchange.service import ExchangeService
|
|
from src.market_data.acquisition.models.quote import Quote
|
|
from src.storage.quote_store import InMemoryQuoteStore
|
|
|
|
|
|
def _service(*, exchange_enabled: bool = True) -> ExchangeService:
|
|
service = object.__new__(ExchangeService)
|
|
service.settings = cast(
|
|
Settings,
|
|
SimpleNamespace(
|
|
exchange_enabled=exchange_enabled,
|
|
default_symbol="BTC/USD_LEVERAGE",
|
|
),
|
|
)
|
|
return service
|
|
|
|
|
|
def _quote(*, age_seconds: float = 0.0) -> Quote:
|
|
return Quote(
|
|
symbol="BTC/USD_LEVERAGE",
|
|
last_price=Decimal("100"),
|
|
bid_price=Decimal("99"),
|
|
ask_price=Decimal("101"),
|
|
exchange_timestamp=None,
|
|
received_at=(
|
|
datetime.now(timezone.utc)
|
|
- timedelta(seconds=age_seconds)
|
|
),
|
|
source="test",
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_store():
|
|
original = MarketPriceCache._store
|
|
MarketPriceCache._store = InMemoryQuoteStore()
|
|
yield
|
|
MarketPriceCache._store = original
|
|
|
|
|
|
def test_get_quote_returns_fresh_cached_identity(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
quote = _quote()
|
|
MarketPriceCache.set_quote(quote, runtime_key="auto")
|
|
monkeypatch.setattr(
|
|
service,
|
|
"validate_symbol",
|
|
lambda _: SimpleNamespace(
|
|
is_valid=True,
|
|
normalized_symbol=quote.symbol,
|
|
message="",
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
service,
|
|
"_get_fresh_quote",
|
|
lambda _: (_ for _ in ()).throw(AssertionError("REST not expected")),
|
|
)
|
|
|
|
assert service.get_quote(quote.symbol, runtime_key="auto") is quote
|
|
|
|
|
|
def test_get_quote_refreshes_stale_quote(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
stale = _quote(age_seconds=10)
|
|
fresh = _quote()
|
|
MarketPriceCache.set_quote(stale, runtime_key="auto")
|
|
monkeypatch.setattr(
|
|
service,
|
|
"validate_symbol",
|
|
lambda _: SimpleNamespace(
|
|
is_valid=True,
|
|
normalized_symbol=fresh.symbol,
|
|
message="",
|
|
),
|
|
)
|
|
monkeypatch.setattr(service, "_get_fresh_quote", lambda _: fresh)
|
|
|
|
result = service.get_quote(fresh.symbol, runtime_key="auto")
|
|
|
|
assert result is fresh
|
|
assert MarketPriceCache.get_quote(fresh.symbol, runtime_key="auto") is fresh
|
|
|
|
|
|
def test_get_quote_wraps_acquisition_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
original = RuntimeError("unavailable")
|
|
monkeypatch.setattr(
|
|
service,
|
|
"_load_quote_via_acquisition",
|
|
lambda _: (_ for _ in ()).throw(original),
|
|
)
|
|
monkeypatch.setattr(service, "_log_exchange_error", lambda **_: None)
|
|
|
|
with pytest.raises(ExchangeError) as exc_info:
|
|
service._get_fresh_quote("BTC/USD_LEVERAGE")
|
|
|
|
assert exc_info.value.__cause__ is original
|
|
|
|
|
|
def test_refresh_quote_cache_forces_fresh_quote_and_preserves_identity(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
cached = _quote()
|
|
fresh = _quote()
|
|
MarketPriceCache.set_quote(cached, runtime_key="auto")
|
|
requested_symbols: list[str] = []
|
|
|
|
monkeypatch.setattr(
|
|
service,
|
|
"validate_symbol",
|
|
lambda symbol: SimpleNamespace(
|
|
is_valid=True,
|
|
normalized_symbol=(requested_symbols.append(symbol) or fresh.symbol),
|
|
message="",
|
|
),
|
|
)
|
|
monkeypatch.setattr(service, "_get_fresh_quote", lambda _: fresh)
|
|
|
|
result = service.refresh_quote_cache(
|
|
" btc/usd_leverage ",
|
|
runtime_key="auto",
|
|
)
|
|
|
|
assert requested_symbols == [" btc/usd_leverage "]
|
|
assert result is fresh
|
|
assert result is not cached
|
|
assert MarketPriceCache.get_quote(fresh.symbol, runtime_key="auto") is fresh
|
|
|
|
|
|
def test_refresh_quote_cache_uses_default_runtime_key(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
fresh = _quote()
|
|
monkeypatch.setattr(
|
|
service,
|
|
"validate_symbol",
|
|
lambda _: SimpleNamespace(
|
|
is_valid=True,
|
|
normalized_symbol=fresh.symbol,
|
|
message="",
|
|
),
|
|
)
|
|
monkeypatch.setattr(service, "_get_fresh_quote", lambda _: fresh)
|
|
|
|
result = service.refresh_quote_cache(fresh.symbol)
|
|
|
|
assert result is fresh
|
|
assert MarketPriceCache.get_quote(fresh.symbol, runtime_key="auto") is fresh
|
|
|
|
|
|
def test_refresh_quote_cache_normalizes_runtime_key(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
fresh = _quote()
|
|
monkeypatch.setattr(
|
|
service,
|
|
"validate_symbol",
|
|
lambda _: SimpleNamespace(
|
|
is_valid=True,
|
|
normalized_symbol=fresh.symbol,
|
|
message="",
|
|
),
|
|
)
|
|
monkeypatch.setattr(service, "_get_fresh_quote", lambda _: fresh)
|
|
|
|
result = service.refresh_quote_cache(
|
|
fresh.symbol,
|
|
runtime_key=" Debug_Auto ",
|
|
)
|
|
|
|
assert result is fresh
|
|
assert (
|
|
MarketPriceCache.get_quote(
|
|
fresh.symbol,
|
|
runtime_key="debug_auto",
|
|
)
|
|
is fresh
|
|
)
|
|
|
|
|
|
def test_refresh_quote_cache_rejects_invalid_symbol(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
monkeypatch.setattr(
|
|
service,
|
|
"validate_symbol",
|
|
lambda _: SimpleNamespace(
|
|
is_valid=False,
|
|
normalized_symbol="UNKNOWN/USD",
|
|
message="Символ не найден.",
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
service,
|
|
"_get_fresh_quote",
|
|
lambda _: (_ for _ in ()).throw(
|
|
AssertionError("Fresh Quote must not be requested.")
|
|
),
|
|
)
|
|
|
|
with pytest.raises(ExchangeError, match="Символ не найден"):
|
|
service.refresh_quote_cache("UNKNOWN/USD", runtime_key="auto")
|
|
|
|
|
|
def test_refresh_quote_cache_does_not_update_cache_on_acquisition_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service()
|
|
cached = _quote()
|
|
original = RuntimeError("unavailable")
|
|
MarketPriceCache.set_quote(cached, runtime_key="auto")
|
|
monkeypatch.setattr(
|
|
service,
|
|
"validate_symbol",
|
|
lambda _: SimpleNamespace(
|
|
is_valid=True,
|
|
normalized_symbol=cached.symbol,
|
|
message="",
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
service,
|
|
"_load_quote_via_acquisition",
|
|
lambda _: (_ for _ in ()).throw(original),
|
|
)
|
|
monkeypatch.setattr(service, "_log_exchange_error", lambda **_: None)
|
|
|
|
with pytest.raises(ExchangeError) as exc_info:
|
|
service.refresh_quote_cache(cached.symbol, runtime_key="auto")
|
|
|
|
assert exc_info.value.__cause__ is original
|
|
assert MarketPriceCache.get_quote(cached.symbol, runtime_key="auto") is cached
|
|
|
|
|
|
def test_refresh_quote_cache_uses_and_stores_mock_quote(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
service = _service(exchange_enabled=False)
|
|
mock = _quote()
|
|
monkeypatch.setattr(service_module, "mock_quote", lambda _: mock)
|
|
monkeypatch.setattr(
|
|
service,
|
|
"validate_symbol",
|
|
lambda _: (_ for _ in ()).throw(
|
|
AssertionError("Validation is not expected in mock mode.")
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
service,
|
|
"_get_fresh_quote",
|
|
lambda _: (_ for _ in ()).throw(
|
|
AssertionError("REST is not expected in mock mode.")
|
|
),
|
|
)
|
|
|
|
result = service.refresh_quote_cache(
|
|
"BTC/USD_LEVERAGE",
|
|
runtime_key="debug_auto",
|
|
)
|
|
|
|
assert result is mock
|
|
assert (
|
|
MarketPriceCache.get_quote(
|
|
mock.symbol,
|
|
runtime_key="debug_auto",
|
|
)
|
|
is mock
|
|
)
|
|
|