diff --git a/app/src/integrations/exchange/market_data_runner.py b/app/src/integrations/exchange/market_data_runner.py index 683e1d0..e0d42e1 100644 --- a/app/src/integrations/exchange/market_data_runner.py +++ b/app/src/integrations/exchange/market_data_runner.py @@ -395,7 +395,7 @@ class MarketDataRunner: ) -> None: try: await asyncio.to_thread( - ExchangeService().refresh_market_snapshot_cache, + ExchangeService().refresh_quote_cache, symbol, runtime_key=context.runtime_key, ) diff --git a/app/src/integrations/exchange/service.py b/app/src/integrations/exchange/service.py index 388d7cc..5389033 100644 --- a/app/src/integrations/exchange/service.py +++ b/app/src/integrations/exchange/service.py @@ -806,6 +806,33 @@ class ExchangeService: ) return quote + # Принудительно обновить Quote cache через свежий REST Quotes Feed. + def refresh_quote_cache( + self, + symbol: str | None = None, + *, + runtime_key: str | None = None, + ) -> Quote: + symbol_to_use = symbol or self.settings.default_symbol + normalized_runtime_key = self._runtime_key(runtime_key) + + if not self.settings.exchange_enabled: + quote = mock_quote(symbol_to_use) + else: + validation = self.validate_symbol(symbol_to_use) + if not validation.is_valid: + raise ExchangeError(validation.message) + + quote = self._get_fresh_quote( + validation.normalized_symbol + ) + + MarketPriceCache.set_quote( + quote, + runtime_key=normalized_runtime_key, + ) + return quote + # Получить snapshot, пригодный для execution layer. def get_execution_snapshot( self, diff --git a/app/tests/unit/integrations/exchange/test_market_data_runner.py b/app/tests/unit/integrations/exchange/test_market_data_runner.py index 2ff7bf3..fdd97dd 100644 --- a/app/tests/unit/integrations/exchange/test_market_data_runner.py +++ b/app/tests/unit/integrations/exchange/test_market_data_runner.py @@ -325,3 +325,100 @@ def test_run_websocket_raises_after_five_invalid_quotes( "BTC/USD_LEVERAGE", ) ) + + +def test_rest_fallback_once_refreshes_quote_cache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import asyncio + + from src.integrations.exchange.market_data_runner import MarketRuntimeContext + + calls: list[tuple[str, str | None]] = [] + + class Service: + def refresh_quote_cache( + self, + symbol: str, + *, + runtime_key: str | None = None, + ) -> None: + calls.append((symbol, runtime_key)) + + context = MarketRuntimeContext( + runtime_key="debug_auto", + task=None, + interval_seconds=1, + symbol_provider=lambda: "BTC/USD_LEVERAGE", + screen=None, + action="market_data", + runtime_label=None, + last_rest_state="UNAVAILABLE", + last_rest_error_key="previous-error", + ) + + monkeypatch.setattr(runner_module, "ExchangeService", Service) + monkeypatch.setattr( + MarketDataRunner, + "_can_log_runtime_event", + lambda _: False, + ) + + asyncio.run( + MarketDataRunner._rest_fallback_once( + context, + "BTC/USD_LEVERAGE", + ) + ) + + assert calls == [("BTC/USD_LEVERAGE", "debug_auto")] + assert context.last_rest_state == "AVAILABLE" + assert context.last_rest_error_key is None + + +def test_rest_fallback_once_preserves_unavailable_error_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import asyncio + + from src.integrations.exchange.market_data_runner import MarketRuntimeContext + + class Service: + def refresh_quote_cache( + self, + symbol: str, + *, + runtime_key: str | None = None, + ) -> None: + del symbol, runtime_key + raise RuntimeError("REST unavailable") + + context = MarketRuntimeContext( + runtime_key="auto", + task=None, + interval_seconds=1, + symbol_provider=lambda: "BTC/USD_LEVERAGE", + screen=None, + action="market_data", + runtime_label=None, + ) + + monkeypatch.setattr(runner_module, "ExchangeService", Service) + monkeypatch.setattr( + MarketDataRunner, + "_can_log_runtime_event", + lambda _: False, + ) + + asyncio.run( + MarketDataRunner._rest_fallback_once( + context, + "BTC/USD_LEVERAGE", + ) + ) + + assert context.last_rest_state == "UNAVAILABLE" + assert context.last_rest_error_key == ( + "BTC/USD_LEVERAGE:RuntimeError:REST unavailable" + ) + diff --git a/app/tests/unit/integrations/exchange/test_service_quote.py b/app/tests/unit/integrations/exchange/test_service_quote.py index 2f7be29..ad00149 100644 --- a/app/tests/unit/integrations/exchange/test_service_quote.py +++ b/app/tests/unit/integrations/exchange/test_service_quote.py @@ -9,6 +9,7 @@ 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 @@ -116,3 +117,179 @@ def test_get_quote_wraps_acquisition_error( 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 + ) +