build 039: complete Quotes Feed migration foundation
This commit is contained in:
74
app/tests/unit/integrations/exchange/test_market_cache.py
Normal file
74
app/tests/unit/integrations/exchange/test_market_cache.py
Normal file
@@ -0,0 +1,74 @@
|
||||
# app/tests/unit/integrations/exchange/test_market_cache.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from src.integrations.exchange.market_cache import MarketPriceCache
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
from src.storage.quote_store import InMemoryQuoteStore, QuoteStoreProtocol
|
||||
|
||||
|
||||
def _quote(symbol: str = "BTC/USD_LEVERAGE") -> Quote:
|
||||
return Quote(
|
||||
symbol=symbol,
|
||||
last_price=Decimal("100"),
|
||||
bid_price=Decimal("99"),
|
||||
ask_price=Decimal("101"),
|
||||
exchange_timestamp=None,
|
||||
received_at=datetime.now(timezone.utc),
|
||||
source="test",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_market_price_cache() -> Iterator[None]:
|
||||
original_store = MarketPriceCache._store
|
||||
MarketPriceCache._store = InMemoryQuoteStore()
|
||||
yield
|
||||
MarketPriceCache._store = original_store
|
||||
|
||||
|
||||
def test_market_price_cache_uses_quote_store_protocol() -> None:
|
||||
assert isinstance(MarketPriceCache._store, QuoteStoreProtocol)
|
||||
|
||||
|
||||
def test_set_and_get_quote_preserve_identity() -> None:
|
||||
quote = _quote()
|
||||
MarketPriceCache.set_quote(quote, runtime_key="auto")
|
||||
assert MarketPriceCache.get_quote(quote.symbol, runtime_key="auto") is quote
|
||||
|
||||
|
||||
def test_symbol_and_runtime_key_are_normalized() -> None:
|
||||
quote = _quote("BTC/USD_LEVERAGE")
|
||||
MarketPriceCache.set_quote(quote, runtime_key=" AUTO ")
|
||||
assert MarketPriceCache.get_quote(" btc/usd_leverage ", runtime_key="auto") is quote
|
||||
|
||||
|
||||
def test_runtime_keys_are_isolated() -> None:
|
||||
auto = _quote()
|
||||
debug = Quote(
|
||||
symbol=auto.symbol, last_price=Decimal("200"), bid_price=Decimal("199"),
|
||||
ask_price=Decimal("201"), exchange_timestamp=None,
|
||||
received_at=datetime.now(timezone.utc), source="debug",
|
||||
)
|
||||
MarketPriceCache.set_quote(auto, runtime_key="auto")
|
||||
MarketPriceCache.set_quote(debug, runtime_key="debug_auto")
|
||||
assert MarketPriceCache.get_quote(auto.symbol, runtime_key="auto") is auto
|
||||
assert MarketPriceCache.get_quote(auto.symbol, runtime_key="debug_auto") is debug
|
||||
|
||||
|
||||
def test_clear_targeted_and_all() -> None:
|
||||
btc = _quote("BTC/USD_LEVERAGE")
|
||||
eth = _quote("ETH/USD_LEVERAGE")
|
||||
MarketPriceCache.set_quote(btc, runtime_key="auto")
|
||||
MarketPriceCache.set_quote(eth, runtime_key="auto")
|
||||
MarketPriceCache.clear(btc.symbol, runtime_key="auto")
|
||||
assert MarketPriceCache.get_quote(btc.symbol, runtime_key="auto") is None
|
||||
assert MarketPriceCache.get_quote(eth.symbol, runtime_key="auto") is eth
|
||||
MarketPriceCache.clear()
|
||||
assert MarketPriceCache.get_quote(eth.symbol, runtime_key="auto") is None
|
||||
327
app/tests/unit/integrations/exchange/test_market_data_runner.py
Normal file
327
app/tests/unit/integrations/exchange/test_market_data_runner.py
Normal file
@@ -0,0 +1,327 @@
|
||||
# app/tests/unit/integrations/exchange/test_market_data_runner.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import src.integrations.exchange.market_data_runner as runner_module
|
||||
from src.integrations.exchange.market_data_runner import MarketDataRunner
|
||||
|
||||
|
||||
class StubExchangeService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
validation: object | None = None,
|
||||
error: Exception | None = None,
|
||||
) -> None:
|
||||
self._validation = validation
|
||||
self._error = error
|
||||
self.requested_symbols: list[str] = []
|
||||
|
||||
def validate_symbol(
|
||||
self,
|
||||
symbol: str,
|
||||
) -> object:
|
||||
self.requested_symbols.append(symbol)
|
||||
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
|
||||
return self._validation
|
||||
|
||||
|
||||
def test_cache_symbol_uses_normalized_symbol_from_validation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
validation = SimpleNamespace(
|
||||
is_valid=True,
|
||||
normalized_symbol="BTC/USD_LEVERAGE",
|
||||
)
|
||||
service = StubExchangeService(
|
||||
validation=validation,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
runner_module,
|
||||
"ExchangeService",
|
||||
lambda: service,
|
||||
)
|
||||
|
||||
result = MarketDataRunner._cache_symbol(
|
||||
" btc/usd_leverage "
|
||||
)
|
||||
|
||||
assert result == "BTC/USD_LEVERAGE"
|
||||
assert service.requested_symbols == [
|
||||
" btc/usd_leverage ",
|
||||
]
|
||||
|
||||
|
||||
def test_cache_symbol_does_not_require_symbol_info(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
validation = SimpleNamespace(
|
||||
is_valid=True,
|
||||
normalized_symbol="ETH/USD_LEVERAGE",
|
||||
)
|
||||
service = StubExchangeService(
|
||||
validation=validation,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
runner_module,
|
||||
"ExchangeService",
|
||||
lambda: service,
|
||||
)
|
||||
|
||||
result = MarketDataRunner._cache_symbol(
|
||||
"ETH/USD_LEVERAGE"
|
||||
)
|
||||
|
||||
assert result == "ETH/USD_LEVERAGE"
|
||||
assert not hasattr(
|
||||
validation,
|
||||
"symbol_info",
|
||||
)
|
||||
|
||||
|
||||
def test_cache_symbol_returns_original_symbol_when_validation_is_invalid(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
validation = SimpleNamespace(
|
||||
is_valid=False,
|
||||
normalized_symbol="UNKNOWN/USD",
|
||||
)
|
||||
service = StubExchangeService(
|
||||
validation=validation,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
runner_module,
|
||||
"ExchangeService",
|
||||
lambda: service,
|
||||
)
|
||||
|
||||
result = MarketDataRunner._cache_symbol(
|
||||
"unknown/usd"
|
||||
)
|
||||
|
||||
assert result == "unknown/usd"
|
||||
assert service.requested_symbols == [
|
||||
"unknown/usd",
|
||||
]
|
||||
|
||||
|
||||
def test_cache_symbol_returns_original_symbol_when_validation_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = StubExchangeService(
|
||||
error=RuntimeError(
|
||||
"Instrument reference data unavailable."
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
runner_module,
|
||||
"ExchangeService",
|
||||
lambda: service,
|
||||
)
|
||||
|
||||
result = MarketDataRunner._cache_symbol(
|
||||
"BTC/USD"
|
||||
)
|
||||
|
||||
assert result == "BTC/USD"
|
||||
assert service.requested_symbols == [
|
||||
"BTC/USD",
|
||||
]
|
||||
|
||||
|
||||
def test_ws_symbol_uses_cache_symbol(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
def cache_symbol(
|
||||
symbol: str,
|
||||
) -> str:
|
||||
calls.append(symbol)
|
||||
return "BTC/USD_LEVERAGE"
|
||||
|
||||
monkeypatch.setattr(
|
||||
MarketDataRunner,
|
||||
"_cache_symbol",
|
||||
cache_symbol,
|
||||
)
|
||||
|
||||
result = MarketDataRunner._ws_symbol(
|
||||
"btc/usd_leverage"
|
||||
)
|
||||
|
||||
assert result == "BTC/USD_LEVERAGE"
|
||||
assert calls == [
|
||||
"btc/usd_leverage",
|
||||
]
|
||||
|
||||
def test_run_websocket_uses_canonical_quote_adapter(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from src.integrations.exchange.market_data_runner import MarketRuntimeContext
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
|
||||
payload = {"payload": {"symbol": "BTC/USD_LEVERAGE"}}
|
||||
quote = 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),
|
||||
source="dzengi",
|
||||
)
|
||||
mapped_documents: list[object] = []
|
||||
stored_quotes: list[tuple[Quote, str]] = []
|
||||
|
||||
class Client:
|
||||
async def stream_depth(
|
||||
self,
|
||||
symbol: str,
|
||||
*,
|
||||
interval_seconds: int,
|
||||
):
|
||||
assert symbol == "BTC/USD_LEVERAGE"
|
||||
assert interval_seconds == 1
|
||||
yield payload
|
||||
|
||||
class Adapter:
|
||||
def map_message(self, document: object) -> Quote:
|
||||
mapped_documents.append(document)
|
||||
return quote
|
||||
|
||||
class Cache:
|
||||
@classmethod
|
||||
def set_quote(
|
||||
cls,
|
||||
value: Quote,
|
||||
*,
|
||||
runtime_key: str = "default",
|
||||
) -> None:
|
||||
stored_quotes.append((value, runtime_key))
|
||||
|
||||
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(
|
||||
MarketDataRunner,
|
||||
"_cache_symbol",
|
||||
lambda symbol: "BTC/USD_LEVERAGE",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
MarketDataRunner,
|
||||
"_ws_symbol",
|
||||
lambda symbol: "BTC/USD_LEVERAGE",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
MarketDataRunner,
|
||||
"_can_log_runtime_event",
|
||||
lambda event_key: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
MarketDataRunner,
|
||||
"_log_ws_depth_debug",
|
||||
lambda **kwargs: None,
|
||||
)
|
||||
monkeypatch.setattr(runner_module, "ExchangeWebSocketClient", Client)
|
||||
monkeypatch.setattr(runner_module, "DzengiWebSocketQuoteAdapter", Adapter)
|
||||
monkeypatch.setattr(runner_module, "MarketPriceCache", Cache)
|
||||
monkeypatch.setattr(
|
||||
MarketDataRunner,
|
||||
"_extract_best_price",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("Legacy parser must not be called.")
|
||||
),
|
||||
)
|
||||
|
||||
asyncio.run(
|
||||
MarketDataRunner._run_websocket(
|
||||
context,
|
||||
"BTC/USD_LEVERAGE",
|
||||
)
|
||||
)
|
||||
|
||||
assert mapped_documents == [payload]
|
||||
assert stored_quotes == [(quote, "auto")]
|
||||
|
||||
|
||||
def test_run_websocket_raises_after_five_invalid_quotes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import asyncio
|
||||
|
||||
from src.integrations.exchange.market_data_runner import MarketRuntimeContext
|
||||
from src.market_data.acquisition.exceptions import QuoteSchemaError
|
||||
|
||||
class Client:
|
||||
async def stream_depth(
|
||||
self,
|
||||
symbol: str,
|
||||
*,
|
||||
interval_seconds: int,
|
||||
):
|
||||
del symbol, interval_seconds
|
||||
|
||||
for index in range(5):
|
||||
yield {"invalid": index}
|
||||
|
||||
class Adapter:
|
||||
def map_message(self, document: object) -> object:
|
||||
del document
|
||||
raise QuoteSchemaError("Invalid message.")
|
||||
|
||||
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(
|
||||
MarketDataRunner,
|
||||
"_cache_symbol",
|
||||
lambda symbol: "BTC/USD_LEVERAGE",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
MarketDataRunner,
|
||||
"_ws_symbol",
|
||||
lambda symbol: "BTC/USD_LEVERAGE",
|
||||
)
|
||||
monkeypatch.setattr(runner_module, "ExchangeWebSocketClient", Client)
|
||||
monkeypatch.setattr(runner_module, "DzengiWebSocketQuoteAdapter", Adapter)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="does not contain valid quotes",
|
||||
):
|
||||
asyncio.run(
|
||||
MarketDataRunner._run_websocket(
|
||||
context,
|
||||
"BTC/USD_LEVERAGE",
|
||||
)
|
||||
)
|
||||
450
app/tests/unit/integrations/exchange/test_market_stream.py
Normal file
450
app/tests/unit/integrations/exchange/test_market_stream.py
Normal file
@@ -0,0 +1,450 @@
|
||||
# app/tests/unit/integrations/exchange/test_market_stream.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import src.integrations.exchange.market_stream as stream_module
|
||||
from src.integrations.exchange.market_stream import start_market_stream
|
||||
|
||||
|
||||
class StopStream(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class StubExchangeService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
validation: object,
|
||||
) -> None:
|
||||
self._validation = validation
|
||||
self.requested_symbols: list[str] = []
|
||||
|
||||
def validate_symbol(
|
||||
self,
|
||||
symbol: str,
|
||||
) -> object:
|
||||
self.requested_symbols.append(symbol)
|
||||
|
||||
return self._validation
|
||||
|
||||
|
||||
class StubWebSocketClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
requested_symbols: list[str],
|
||||
) -> None:
|
||||
self._requested_symbols = requested_symbols
|
||||
|
||||
async def stream_depth(
|
||||
self,
|
||||
symbol: str,
|
||||
):
|
||||
self._requested_symbols.append(symbol)
|
||||
|
||||
raise StopStream
|
||||
|
||||
if False:
|
||||
yield {}
|
||||
|
||||
|
||||
async def _raise_stop_stream(
|
||||
delay: float,
|
||||
) -> None:
|
||||
del delay
|
||||
|
||||
raise StopStream
|
||||
|
||||
|
||||
def test_start_market_stream_uses_normalized_symbol_from_validation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
validation = SimpleNamespace(
|
||||
is_valid=True,
|
||||
normalized_symbol="BTC/USD_LEVERAGE",
|
||||
)
|
||||
|
||||
service = StubExchangeService(
|
||||
validation=validation,
|
||||
)
|
||||
|
||||
websocket_symbols: list[str] = []
|
||||
|
||||
settings = SimpleNamespace(
|
||||
exchange_enabled=True,
|
||||
default_symbol=" btc/usd_leverage ",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
stream_module,
|
||||
"load_settings",
|
||||
lambda: settings,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
stream_module,
|
||||
"JournalService",
|
||||
lambda: SimpleNamespace(
|
||||
log_info=lambda *args, **kwargs: None,
|
||||
log_warning=lambda *args, **kwargs: None,
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
stream_module,
|
||||
"ExchangeService",
|
||||
lambda: service,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
stream_module,
|
||||
"ExchangeWebSocketClient",
|
||||
lambda: StubWebSocketClient(
|
||||
requested_symbols=websocket_symbols,
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
stream_module.asyncio,
|
||||
"sleep",
|
||||
_raise_stop_stream,
|
||||
)
|
||||
|
||||
with pytest.raises(StopStream):
|
||||
asyncio.run(
|
||||
start_market_stream()
|
||||
)
|
||||
|
||||
assert service.requested_symbols == [
|
||||
" btc/usd_leverage ",
|
||||
]
|
||||
|
||||
assert websocket_symbols == [
|
||||
"BTC/USD_LEVERAGE",
|
||||
]
|
||||
|
||||
|
||||
def test_start_market_stream_does_not_require_symbol_info(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
validation = SimpleNamespace(
|
||||
is_valid=True,
|
||||
normalized_symbol="ETH/USD_LEVERAGE",
|
||||
)
|
||||
|
||||
service = StubExchangeService(
|
||||
validation=validation,
|
||||
)
|
||||
|
||||
websocket_symbols: list[str] = []
|
||||
|
||||
settings = SimpleNamespace(
|
||||
exchange_enabled=True,
|
||||
default_symbol="ETH/USD_LEVERAGE",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
stream_module,
|
||||
"load_settings",
|
||||
lambda: settings,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
stream_module,
|
||||
"JournalService",
|
||||
lambda: SimpleNamespace(
|
||||
log_info=lambda *args, **kwargs: None,
|
||||
log_warning=lambda *args, **kwargs: None,
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
stream_module,
|
||||
"ExchangeService",
|
||||
lambda: service,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
stream_module,
|
||||
"ExchangeWebSocketClient",
|
||||
lambda: StubWebSocketClient(
|
||||
requested_symbols=websocket_symbols,
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
stream_module.asyncio,
|
||||
"sleep",
|
||||
_raise_stop_stream,
|
||||
)
|
||||
|
||||
with pytest.raises(StopStream):
|
||||
asyncio.run(
|
||||
start_market_stream()
|
||||
)
|
||||
|
||||
assert websocket_symbols == [
|
||||
"ETH/USD_LEVERAGE",
|
||||
]
|
||||
|
||||
assert not hasattr(
|
||||
validation,
|
||||
"symbol_info",
|
||||
)
|
||||
|
||||
|
||||
def test_start_market_stream_skips_websocket_for_invalid_symbol(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
validation = SimpleNamespace(
|
||||
is_valid=False,
|
||||
normalized_symbol="UNKNOWN/USD",
|
||||
)
|
||||
|
||||
service = StubExchangeService(
|
||||
validation=validation,
|
||||
)
|
||||
|
||||
websocket_client_calls = 0
|
||||
|
||||
settings = SimpleNamespace(
|
||||
exchange_enabled=True,
|
||||
default_symbol="UNKNOWN/USD",
|
||||
)
|
||||
|
||||
def create_websocket_client() -> object:
|
||||
nonlocal websocket_client_calls
|
||||
|
||||
websocket_client_calls += 1
|
||||
|
||||
raise AssertionError(
|
||||
"WebSocket client must not be created "
|
||||
"for an invalid symbol."
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
stream_module,
|
||||
"load_settings",
|
||||
lambda: settings,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
stream_module,
|
||||
"JournalService",
|
||||
lambda: SimpleNamespace(
|
||||
log_info=lambda *args, **kwargs: None,
|
||||
log_warning=lambda *args, **kwargs: None,
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
stream_module,
|
||||
"ExchangeService",
|
||||
lambda: service,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
stream_module,
|
||||
"ExchangeWebSocketClient",
|
||||
create_websocket_client,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
stream_module.asyncio,
|
||||
"sleep",
|
||||
_raise_stop_stream,
|
||||
)
|
||||
|
||||
with pytest.raises(StopStream):
|
||||
asyncio.run(
|
||||
start_market_stream()
|
||||
)
|
||||
|
||||
assert service.requested_symbols == [
|
||||
"UNKNOWN/USD",
|
||||
]
|
||||
|
||||
assert websocket_client_calls == 0
|
||||
|
||||
|
||||
def test_start_market_stream_returns_when_exchange_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
settings = SimpleNamespace(
|
||||
exchange_enabled=False,
|
||||
default_symbol="BTC/USD",
|
||||
)
|
||||
|
||||
service_calls = 0
|
||||
|
||||
def create_service() -> object:
|
||||
nonlocal service_calls
|
||||
|
||||
service_calls += 1
|
||||
|
||||
raise AssertionError(
|
||||
"ExchangeService must not be created "
|
||||
"when exchange is disabled."
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
stream_module,
|
||||
"load_settings",
|
||||
lambda: settings,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
stream_module,
|
||||
"JournalService",
|
||||
lambda: SimpleNamespace(),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
stream_module,
|
||||
"ExchangeService",
|
||||
create_service,
|
||||
)
|
||||
|
||||
asyncio.run(
|
||||
start_market_stream()
|
||||
)
|
||||
|
||||
assert service_calls == 0
|
||||
|
||||
def test_start_market_stream_maps_message_to_quote(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
|
||||
validation = SimpleNamespace(
|
||||
is_valid=True,
|
||||
normalized_symbol="BTC/USD_LEVERAGE",
|
||||
)
|
||||
service = StubExchangeService(validation=validation)
|
||||
settings = SimpleNamespace(
|
||||
exchange_enabled=True,
|
||||
default_symbol="BTC/USD_LEVERAGE",
|
||||
)
|
||||
payload = {"payload": {"symbol": "BTC/USD_LEVERAGE"}}
|
||||
quote = 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),
|
||||
source="dzengi",
|
||||
)
|
||||
mapped_documents: list[object] = []
|
||||
stored_quotes: list[tuple[Quote, str]] = []
|
||||
|
||||
class Client:
|
||||
async def stream_depth(self, symbol: str):
|
||||
assert symbol == "BTC/USD_LEVERAGE"
|
||||
yield payload
|
||||
raise StopStream
|
||||
|
||||
class Adapter:
|
||||
def map_message(self, document: object) -> Quote:
|
||||
mapped_documents.append(document)
|
||||
return quote
|
||||
|
||||
class Cache:
|
||||
@classmethod
|
||||
def set_quote(
|
||||
cls,
|
||||
value: Quote,
|
||||
*,
|
||||
runtime_key: str = "default",
|
||||
) -> None:
|
||||
stored_quotes.append((value, runtime_key))
|
||||
|
||||
monkeypatch.setattr(stream_module, "load_settings", lambda: settings)
|
||||
monkeypatch.setattr(
|
||||
stream_module,
|
||||
"JournalService",
|
||||
lambda: SimpleNamespace(
|
||||
log_info=lambda *args, **kwargs: None,
|
||||
log_warning=lambda *args, **kwargs: None,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(stream_module, "ExchangeService", lambda: service)
|
||||
monkeypatch.setattr(stream_module, "ExchangeWebSocketClient", Client)
|
||||
monkeypatch.setattr(stream_module, "DzengiWebSocketQuoteAdapter", Adapter)
|
||||
monkeypatch.setattr(stream_module, "MarketPriceCache", Cache)
|
||||
monkeypatch.setattr(
|
||||
stream_module,
|
||||
"_extract_market_event",
|
||||
lambda payload: (_ for _ in ()).throw(
|
||||
AssertionError("Legacy parser must not be called.")
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(stream_module.asyncio, "sleep", _raise_stop_stream)
|
||||
|
||||
with pytest.raises(StopStream):
|
||||
asyncio.run(start_market_stream())
|
||||
|
||||
assert mapped_documents == [payload]
|
||||
assert stored_quotes == [(quote, "default")]
|
||||
|
||||
|
||||
def test_start_market_stream_skips_invalid_adapter_message(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.market_data.acquisition.exceptions import QuoteSchemaError
|
||||
|
||||
validation = SimpleNamespace(
|
||||
is_valid=True,
|
||||
normalized_symbol="BTC/USD_LEVERAGE",
|
||||
)
|
||||
service = StubExchangeService(validation=validation)
|
||||
settings = SimpleNamespace(
|
||||
exchange_enabled=True,
|
||||
default_symbol="BTC/USD_LEVERAGE",
|
||||
)
|
||||
cache_calls = 0
|
||||
|
||||
class Client:
|
||||
async def stream_depth(self, symbol: str):
|
||||
del symbol
|
||||
yield {"invalid": True}
|
||||
raise StopStream
|
||||
|
||||
class Adapter:
|
||||
def map_message(self, document: object) -> object:
|
||||
del document
|
||||
raise QuoteSchemaError("Invalid message.")
|
||||
|
||||
class Cache:
|
||||
@classmethod
|
||||
def set_quote(cls, *args: object, **kwargs: object) -> None:
|
||||
nonlocal cache_calls
|
||||
cache_calls += 1
|
||||
|
||||
monkeypatch.setattr(stream_module, "load_settings", lambda: settings)
|
||||
monkeypatch.setattr(
|
||||
stream_module,
|
||||
"JournalService",
|
||||
lambda: SimpleNamespace(
|
||||
log_info=lambda *args, **kwargs: None,
|
||||
log_warning=lambda *args, **kwargs: None,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(stream_module, "ExchangeService", lambda: service)
|
||||
monkeypatch.setattr(stream_module, "ExchangeWebSocketClient", Client)
|
||||
monkeypatch.setattr(stream_module, "DzengiWebSocketQuoteAdapter", Adapter)
|
||||
monkeypatch.setattr(stream_module, "MarketPriceCache", Cache)
|
||||
monkeypatch.setattr(stream_module.asyncio, "sleep", _raise_stop_stream)
|
||||
|
||||
with pytest.raises(StopStream):
|
||||
asyncio.run(start_market_stream())
|
||||
|
||||
assert cache_calls == 0
|
||||
@@ -0,0 +1,108 @@
|
||||
# app/tests/unit/integrations/exchange/test_service_execution_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
|
||||
|
||||
from src.core.config import Settings
|
||||
from src.integrations.exchange.market_cache import MarketPriceCache
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
|
||||
|
||||
def _service() -> ExchangeService:
|
||||
service = object.__new__(ExchangeService)
|
||||
service.settings = cast(
|
||||
Settings,
|
||||
SimpleNamespace(
|
||||
exchange_enabled=True,
|
||||
default_symbol="BTC/USD_LEVERAGE",
|
||||
tz="Europe/Minsk",
|
||||
),
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
def _quote(*, received_at: datetime | None = None) -> Quote:
|
||||
return Quote(
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
last_price=Decimal("100.5"),
|
||||
bid_price=Decimal("100"),
|
||||
ask_price=Decimal("101"),
|
||||
exchange_timestamp=datetime(2026, 7, 13, 12, 0, tzinfo=timezone.utc),
|
||||
received_at=received_at or datetime.now(timezone.utc),
|
||||
source="dzengi",
|
||||
)
|
||||
|
||||
|
||||
def _valid_validation() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
is_valid=True,
|
||||
normalized_symbol="BTC/USD_LEVERAGE",
|
||||
message="ok",
|
||||
)
|
||||
|
||||
|
||||
def test_execution_snapshot_reads_canonical_cached_quote(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _service()
|
||||
quote = _quote()
|
||||
|
||||
monkeypatch.setattr(service, "validate_symbol", lambda _: _valid_validation())
|
||||
monkeypatch.setattr(MarketPriceCache, "get_quote", lambda *_, **__: quote)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_get_fresh_quote",
|
||||
lambda _: (_ for _ in ()).throw(
|
||||
AssertionError("REST fallback must not run for fresh quote")
|
||||
),
|
||||
)
|
||||
|
||||
result = service.get_execution_snapshot(
|
||||
"BTC/USD_LEVERAGE",
|
||||
runtime_key="auto",
|
||||
)
|
||||
|
||||
assert result.symbol == quote.symbol
|
||||
assert result.last_price == 100.5
|
||||
assert result.bid_price == 100.0
|
||||
assert result.ask_price == 101.0
|
||||
assert result.source == "dzengi:fresh_cache"
|
||||
assert result.is_fresh is True
|
||||
assert result.age_seconds is not None
|
||||
|
||||
|
||||
def test_execution_snapshot_uses_fresh_quote_for_stale_cache(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _service()
|
||||
stale = _quote(
|
||||
received_at=datetime.now(timezone.utc) - timedelta(seconds=10),
|
||||
)
|
||||
fresh = _quote()
|
||||
stored: list[tuple[Quote, str]] = []
|
||||
|
||||
monkeypatch.setattr(service, "validate_symbol", lambda _: _valid_validation())
|
||||
monkeypatch.setattr(MarketPriceCache, "get_quote", lambda *_, **__: stale)
|
||||
monkeypatch.setattr(service, "_get_fresh_quote", lambda _: fresh)
|
||||
monkeypatch.setattr(
|
||||
MarketPriceCache,
|
||||
"set_quote",
|
||||
lambda quote, *, runtime_key: stored.append((quote, runtime_key)),
|
||||
)
|
||||
|
||||
result = service.get_execution_snapshot(
|
||||
"BTC/USD_LEVERAGE",
|
||||
runtime_key="auto",
|
||||
)
|
||||
|
||||
assert stored == [(fresh, "auto")]
|
||||
assert result.source == "rest_fallback"
|
||||
assert result.last_price == 100.5
|
||||
assert result.is_fresh is True
|
||||
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",
|
||||
)
|
||||
118
app/tests/unit/integrations/exchange/test_service_quote.py
Normal file
118
app/tests/unit/integrations/exchange/test_service_quote.py
Normal file
@@ -0,0 +1,118 @@
|
||||
# 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
|
||||
|
||||
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
|
||||
@@ -0,0 +1,646 @@
|
||||
# app/tests/unit/integrations/exchange/test_service_symbol_runtime_status.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
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.models import SymbolValidationResult
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.integrations.exchange.status import (
|
||||
ExchangeRuntimeStatus,
|
||||
ExchangeStatusCode,
|
||||
)
|
||||
from src.market_data.acquisition.models.instrument import Instrument
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
|
||||
|
||||
def _make_settings(
|
||||
*,
|
||||
exchange_enabled: bool = True,
|
||||
default_symbol: str = "BTC/USD",
|
||||
) -> Settings:
|
||||
return cast(
|
||||
Settings,
|
||||
SimpleNamespace(
|
||||
exchange_enabled=exchange_enabled,
|
||||
default_symbol=default_symbol,
|
||||
exchange_name="dzengi",
|
||||
exchange_base_url="https://demo-api-adapter.dzengi.com",
|
||||
tz="UTC",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _quote_for_status(
|
||||
symbol: str,
|
||||
*,
|
||||
age_seconds: float | None,
|
||||
) -> Quote:
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
exchange_timestamp = (
|
||||
now - timedelta(seconds=age_seconds)
|
||||
if age_seconds is not None
|
||||
else None
|
||||
)
|
||||
|
||||
return Quote(
|
||||
symbol=symbol,
|
||||
last_price=Decimal("100.0"),
|
||||
bid_price=Decimal("99.0"),
|
||||
ask_price=Decimal("101.0"),
|
||||
exchange_timestamp=exchange_timestamp,
|
||||
received_at=now,
|
||||
source="test",
|
||||
)
|
||||
|
||||
|
||||
def _make_instrument(
|
||||
*,
|
||||
symbol: str = "BTC/USD",
|
||||
status: str = "TRADING",
|
||||
) -> Instrument:
|
||||
return Instrument(
|
||||
symbol=symbol,
|
||||
name=symbol,
|
||||
status=status,
|
||||
base_asset="BTC",
|
||||
quote_asset="USD",
|
||||
asset_type="CRYPTOCURRENCY",
|
||||
market_type="unknown",
|
||||
market_modes=(),
|
||||
order_types=(),
|
||||
base_asset_precision=None,
|
||||
quote_asset_precision=None,
|
||||
tick_size=None,
|
||||
tick_value=None,
|
||||
step_size=None,
|
||||
min_qty=None,
|
||||
max_qty=None,
|
||||
min_notional=None,
|
||||
country=None,
|
||||
sector=None,
|
||||
industry=None,
|
||||
trading_hours=None,
|
||||
)
|
||||
|
||||
|
||||
def _make_valid_validation(
|
||||
*,
|
||||
symbol: str = "BTC/USD",
|
||||
status: str = "TRADING",
|
||||
) -> SymbolValidationResult:
|
||||
symbol_info = _make_instrument(
|
||||
symbol=symbol,
|
||||
status=status,
|
||||
)
|
||||
|
||||
return SymbolValidationResult(
|
||||
requested_symbol=symbol,
|
||||
normalized_symbol=symbol,
|
||||
is_valid=True,
|
||||
message="Символ найден в exchangeInfo.",
|
||||
symbol_info=symbol_info,
|
||||
)
|
||||
|
||||
|
||||
def _make_invalid_validation(
|
||||
*,
|
||||
symbol: str = "UNKNOWN/USD",
|
||||
message: str | None = None,
|
||||
) -> SymbolValidationResult:
|
||||
return SymbolValidationResult(
|
||||
requested_symbol=symbol,
|
||||
normalized_symbol=symbol,
|
||||
is_valid=False,
|
||||
message=message or f"Символ '{symbol}' не найден в exchangeInfo.",
|
||||
symbol_info=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> Iterator[ExchangeService]:
|
||||
monkeypatch.setattr(
|
||||
service_module,
|
||||
"load_settings",
|
||||
lambda: _make_settings(),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service_module,
|
||||
"JournalService",
|
||||
lambda: SimpleNamespace(),
|
||||
)
|
||||
|
||||
yield ExchangeService()
|
||||
|
||||
|
||||
def test_get_symbol_runtime_status_returns_mock_status_when_exchange_disabled(
|
||||
service: ExchangeService,
|
||||
) -> None:
|
||||
service.settings = _make_settings(
|
||||
exchange_enabled=False,
|
||||
default_symbol="ETH/USD",
|
||||
)
|
||||
|
||||
result = service.get_symbol_runtime_status()
|
||||
|
||||
assert isinstance(result, ExchangeRuntimeStatus)
|
||||
assert result.code == ExchangeStatusCode.OPEN
|
||||
assert result.is_open is True
|
||||
assert result.is_available is True
|
||||
assert result.is_auth_ok is True
|
||||
assert result.reason == "mock_exchange"
|
||||
assert result.symbol == "ETH/USD"
|
||||
assert result.raw_status == "OPEN"
|
||||
|
||||
|
||||
def test_get_symbol_runtime_status_uses_explicit_symbol(
|
||||
service: ExchangeService,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured_symbols: list[str] = []
|
||||
|
||||
def fake_validate_symbol(
|
||||
raw_symbol: str,
|
||||
) -> SymbolValidationResult:
|
||||
captured_symbols.append(raw_symbol)
|
||||
|
||||
return _make_invalid_validation(
|
||||
symbol=raw_symbol,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"validate_symbol",
|
||||
fake_validate_symbol,
|
||||
)
|
||||
|
||||
result = service.get_symbol_runtime_status("ETH/USD")
|
||||
|
||||
assert captured_symbols == ["ETH/USD"]
|
||||
assert result.code == ExchangeStatusCode.INVALID_SYMBOL
|
||||
assert result.symbol == "ETH/USD"
|
||||
|
||||
|
||||
def test_get_symbol_runtime_status_uses_default_symbol_when_symbol_is_none(
|
||||
service: ExchangeService,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service.settings = _make_settings(
|
||||
default_symbol="ETH/USD",
|
||||
)
|
||||
|
||||
captured_symbols: list[str] = []
|
||||
|
||||
def fake_validate_symbol(
|
||||
raw_symbol: str,
|
||||
) -> SymbolValidationResult:
|
||||
captured_symbols.append(raw_symbol)
|
||||
|
||||
return _make_invalid_validation(
|
||||
symbol=raw_symbol,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"validate_symbol",
|
||||
fake_validate_symbol,
|
||||
)
|
||||
|
||||
result = service.get_symbol_runtime_status()
|
||||
|
||||
assert captured_symbols == ["ETH/USD"]
|
||||
assert result.symbol == "ETH/USD"
|
||||
|
||||
|
||||
def test_get_symbol_runtime_status_returns_invalid_symbol_status(
|
||||
service: ExchangeService,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
validation = _make_invalid_validation(
|
||||
symbol="UNKNOWN/USD",
|
||||
message="Проверочное сообщение о недоступном инструменте.",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"validate_symbol",
|
||||
lambda raw_symbol: validation,
|
||||
)
|
||||
|
||||
result = service.get_symbol_runtime_status("UNKNOWN/USD")
|
||||
|
||||
assert result.code == ExchangeStatusCode.INVALID_SYMBOL
|
||||
assert result.is_open is False
|
||||
assert result.is_available is True
|
||||
assert result.is_auth_ok is True
|
||||
assert result.reason == "invalid_symbol"
|
||||
assert result.symbol == "UNKNOWN/USD"
|
||||
assert result.message == (
|
||||
"Проверочное сообщение о недоступном инструменте."
|
||||
)
|
||||
|
||||
|
||||
def test_get_symbol_runtime_status_returns_exchange_error_when_validation_fails(
|
||||
service: ExchangeService,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
error = ExchangeError("timeout while loading exchangeInfo")
|
||||
|
||||
def fake_validate_symbol(
|
||||
raw_symbol: str,
|
||||
) -> SymbolValidationResult:
|
||||
raise error
|
||||
|
||||
logged_errors: list[dict[str, object]] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"validate_symbol",
|
||||
fake_validate_symbol,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_log_exchange_error",
|
||||
lambda **kwargs: logged_errors.append(kwargs),
|
||||
)
|
||||
|
||||
result = service.get_symbol_runtime_status("BTC/USD")
|
||||
|
||||
assert result.code == ExchangeStatusCode.EXCHANGE_UNAVAILABLE
|
||||
assert result.is_open is False
|
||||
assert result.is_available is False
|
||||
assert result.reason == "exchange_unavailable"
|
||||
assert result.raw_error == str(error)
|
||||
|
||||
assert logged_errors == [
|
||||
{
|
||||
"endpoint": "symbol_market_status",
|
||||
"exc": error,
|
||||
"symbol": "BTC/USD",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw_status", "expected_code", "expected_reason"),
|
||||
[
|
||||
("TRADING", ExchangeStatusCode.OPEN, "market_open"),
|
||||
("OPEN", ExchangeStatusCode.OPEN, "market_open"),
|
||||
("ACTIVE", ExchangeStatusCode.OPEN, "market_open"),
|
||||
("ENABLED", ExchangeStatusCode.OPEN, "market_open"),
|
||||
("ONLINE", ExchangeStatusCode.OPEN, "market_open"),
|
||||
("BREAK", ExchangeStatusCode.BREAK, "market_break"),
|
||||
("CLOSED", ExchangeStatusCode.BREAK, "market_break"),
|
||||
("HALT", ExchangeStatusCode.BREAK, "market_break"),
|
||||
("HALTED", ExchangeStatusCode.BREAK, "market_break"),
|
||||
("PAUSED", ExchangeStatusCode.BREAK, "market_break"),
|
||||
("SUSPENDED", ExchangeStatusCode.BREAK, "market_break"),
|
||||
("DISABLED", ExchangeStatusCode.BREAK, "market_break"),
|
||||
("SETTLING", ExchangeStatusCode.BREAK, "market_break"),
|
||||
("POST_ONLY", ExchangeStatusCode.BREAK, "market_break"),
|
||||
(
|
||||
"NOT_TRADABLE",
|
||||
ExchangeStatusCode.BREAK,
|
||||
"market_not_tradable",
|
||||
),
|
||||
(
|
||||
"TRADING_DISABLED",
|
||||
ExchangeStatusCode.BREAK,
|
||||
"market_not_tradable",
|
||||
),
|
||||
(
|
||||
"MARKET_DISABLED",
|
||||
ExchangeStatusCode.BREAK,
|
||||
"market_not_tradable",
|
||||
),
|
||||
(
|
||||
"UNAVAILABLE_FOR_TRADING",
|
||||
ExchangeStatusCode.BREAK,
|
||||
"market_not_tradable",
|
||||
),
|
||||
(
|
||||
"CLOSE_ONLY",
|
||||
ExchangeStatusCode.BREAK,
|
||||
"market_not_tradable",
|
||||
),
|
||||
(
|
||||
"REDUCE_ONLY",
|
||||
ExchangeStatusCode.BREAK,
|
||||
"market_not_tradable",
|
||||
),
|
||||
(
|
||||
"VIEW_ONLY",
|
||||
ExchangeStatusCode.BREAK,
|
||||
"market_not_tradable",
|
||||
),
|
||||
(
|
||||
"UNRECOGNIZED_STATUS",
|
||||
ExchangeStatusCode.UNKNOWN,
|
||||
"market_status_unknown",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_get_symbol_runtime_status_preserves_status_classification(
|
||||
service: ExchangeService,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
raw_status: str,
|
||||
expected_code: ExchangeStatusCode,
|
||||
expected_reason: str,
|
||||
) -> None:
|
||||
validation = _make_valid_validation(
|
||||
status=raw_status,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"validate_symbol",
|
||||
lambda raw_symbol: validation,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_get_fresh_quote",
|
||||
lambda symbol: _quote_for_status(
|
||||
symbol,
|
||||
age_seconds=0.0,
|
||||
),
|
||||
)
|
||||
|
||||
result = service.get_symbol_runtime_status("BTC/USD")
|
||||
|
||||
assert isinstance(result, ExchangeRuntimeStatus)
|
||||
assert result.code == expected_code
|
||||
assert result.reason == expected_reason
|
||||
assert result.symbol == "BTC/USD"
|
||||
|
||||
|
||||
def test_get_symbol_runtime_status_checks_freshness_only_for_open_market(
|
||||
service: ExchangeService,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
validation = _make_valid_validation(
|
||||
status="BREAK",
|
||||
)
|
||||
|
||||
quote_calls: list[str] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"validate_symbol",
|
||||
lambda raw_symbol: validation,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_get_fresh_quote",
|
||||
lambda symbol: quote_calls.append(symbol),
|
||||
)
|
||||
|
||||
result = service.get_symbol_runtime_status("BTC/USD")
|
||||
|
||||
assert result.code == ExchangeStatusCode.BREAK
|
||||
assert result.reason == "market_break"
|
||||
assert quote_calls == []
|
||||
|
||||
|
||||
def test_get_symbol_runtime_status_returns_stale_status_for_old_market_data(
|
||||
service: ExchangeService,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
validation = _make_valid_validation(
|
||||
status="TRADING",
|
||||
)
|
||||
|
||||
fixed_timestamp = datetime(
|
||||
2026,
|
||||
7,
|
||||
10,
|
||||
12,
|
||||
0,
|
||||
0,
|
||||
tzinfo=timezone.utc,
|
||||
)
|
||||
|
||||
quote = Quote(
|
||||
symbol="BTC/USD",
|
||||
last_price=Decimal("100.0"),
|
||||
bid_price=Decimal("99.0"),
|
||||
ask_price=Decimal("101.0"),
|
||||
exchange_timestamp=fixed_timestamp,
|
||||
received_at=fixed_timestamp,
|
||||
source="test",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"validate_symbol",
|
||||
lambda raw_symbol: validation,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_get_fresh_quote",
|
||||
lambda symbol: quote,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_exchange_timestamp_age_seconds",
|
||||
lambda raw_timestamp: 61.0,
|
||||
)
|
||||
|
||||
result = service.get_symbol_runtime_status("BTC/USD")
|
||||
|
||||
assert result.code == ExchangeStatusCode.BREAK
|
||||
assert result.is_open is False
|
||||
assert result.is_available is True
|
||||
assert result.reason == "market_data_stale"
|
||||
assert result.raw_status == "STALE_MARKET_DATA"
|
||||
assert result.symbol == "BTC/USD"
|
||||
assert "61с" in result.message
|
||||
assert "10.07.2026 12:00:00" in result.message
|
||||
|
||||
|
||||
def test_get_symbol_runtime_status_keeps_open_status_at_stale_threshold(
|
||||
service: ExchangeService,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
validation = _make_valid_validation(
|
||||
status="TRADING",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"validate_symbol",
|
||||
lambda raw_symbol: validation,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_get_fresh_quote",
|
||||
lambda symbol: _quote_for_status(
|
||||
symbol,
|
||||
age_seconds=0.0,
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_exchange_timestamp_age_seconds",
|
||||
lambda raw_timestamp: 60.0,
|
||||
)
|
||||
|
||||
result = service.get_symbol_runtime_status("BTC/USD")
|
||||
|
||||
assert result.code == ExchangeStatusCode.OPEN
|
||||
assert result.is_open is True
|
||||
assert result.reason == "market_open"
|
||||
|
||||
|
||||
def test_get_symbol_runtime_status_keeps_open_status_when_age_is_missing(
|
||||
service: ExchangeService,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
validation = _make_valid_validation(
|
||||
status="TRADING",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"validate_symbol",
|
||||
lambda raw_symbol: validation,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_get_fresh_quote",
|
||||
lambda symbol: _quote_for_status(
|
||||
symbol,
|
||||
age_seconds=None,
|
||||
),
|
||||
)
|
||||
|
||||
result = service.get_symbol_runtime_status("BTC/USD")
|
||||
|
||||
assert result.code == ExchangeStatusCode.OPEN
|
||||
assert result.is_open is True
|
||||
assert result.reason == "market_open"
|
||||
|
||||
|
||||
def test_get_symbol_runtime_status_keeps_open_status_when_quote_fails(
|
||||
service: ExchangeService,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
validation = _make_valid_validation(
|
||||
status="TRADING",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"validate_symbol",
|
||||
lambda raw_symbol: validation,
|
||||
)
|
||||
|
||||
def fake_get_fresh_quote(
|
||||
symbol: str,
|
||||
) -> Quote:
|
||||
raise ExchangeError("ticker unavailable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_get_fresh_quote",
|
||||
fake_get_fresh_quote,
|
||||
)
|
||||
|
||||
result = service.get_symbol_runtime_status("BTC/USD")
|
||||
|
||||
assert result.code == ExchangeStatusCode.OPEN
|
||||
assert result.is_open is True
|
||||
assert result.reason == "market_open"
|
||||
|
||||
|
||||
def test_get_symbol_runtime_status_uses_normalized_matched_symbol(
|
||||
service: ExchangeService,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
symbol_info = _make_instrument(
|
||||
symbol="btc/usd",
|
||||
status="BREAK",
|
||||
)
|
||||
|
||||
validation = SymbolValidationResult(
|
||||
requested_symbol="BTC/USD",
|
||||
normalized_symbol="BTC/USD",
|
||||
is_valid=True,
|
||||
message="Символ найден в exchangeInfo.",
|
||||
symbol_info=symbol_info,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"validate_symbol",
|
||||
lambda raw_symbol: validation,
|
||||
)
|
||||
|
||||
result = service.get_symbol_runtime_status("btc/usd")
|
||||
|
||||
assert result.symbol == "BTC/USD"
|
||||
assert result.raw_status == "BREAK"
|
||||
|
||||
|
||||
def test_get_symbol_market_status_preserves_legacy_dict_contract(
|
||||
service: ExchangeService,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
runtime_status = ExchangeRuntimeStatus(
|
||||
code=ExchangeStatusCode.BREAK,
|
||||
is_open=False,
|
||||
is_available=True,
|
||||
is_auth_ok=True,
|
||||
title="Перерыв в торгах",
|
||||
message="Торги временно остановлены.",
|
||||
ui_line="⏸️ Перерыв в торгах",
|
||||
reason="market_break",
|
||||
symbol="BTC/USD",
|
||||
raw_status="BREAK",
|
||||
raw_error=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"get_symbol_runtime_status",
|
||||
lambda symbol=None: runtime_status,
|
||||
)
|
||||
|
||||
result = service.get_symbol_market_status("BTC/USD")
|
||||
|
||||
assert result == runtime_status.as_dict()
|
||||
|
||||
assert result == {
|
||||
"code": "BREAK",
|
||||
"status": "BREAK",
|
||||
"symbol": "BTC/USD",
|
||||
"is_open": False,
|
||||
"is_available": True,
|
||||
"is_auth_ok": True,
|
||||
"title": "Перерыв в торгах",
|
||||
"message": "Торги временно остановлены.",
|
||||
"ui_line": "⏸️ Перерыв в торгах",
|
||||
"reason": "market_break",
|
||||
"raw_status": "BREAK",
|
||||
"raw_error": None,
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
# app/tests/unit/integrations/exchange/test_service_validate_symbol.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.config import Settings
|
||||
from src.integrations.exchange.models import SymbolValidationResult
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.market_data.acquisition.models.instrument import Instrument
|
||||
|
||||
|
||||
def _settings(
|
||||
*,
|
||||
exchange_enabled: bool = True,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
exchange_enabled=exchange_enabled,
|
||||
)
|
||||
|
||||
|
||||
def _service(
|
||||
*,
|
||||
exchange_enabled: bool = True,
|
||||
) -> ExchangeService:
|
||||
service = object.__new__(ExchangeService)
|
||||
service.settings = cast(
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def _set_instruments(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
service: ExchangeService,
|
||||
instruments: tuple[Instrument, ...],
|
||||
) -> list[int]:
|
||||
call_count = [0]
|
||||
|
||||
def get_instruments() -> tuple[Instrument, ...]:
|
||||
call_count[0] += 1
|
||||
return instruments
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"get_instruments",
|
||||
get_instruments,
|
||||
)
|
||||
|
||||
return call_count
|
||||
|
||||
|
||||
def test_validate_symbol_rejects_empty_symbol(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _service()
|
||||
|
||||
call_count = _set_instruments(
|
||||
monkeypatch,
|
||||
service,
|
||||
(),
|
||||
)
|
||||
|
||||
result = service.validate_symbol(" ")
|
||||
|
||||
assert result == SymbolValidationResult(
|
||||
requested_symbol="",
|
||||
normalized_symbol="",
|
||||
is_valid=False,
|
||||
message="Символ пустой.",
|
||||
symbol_info=None,
|
||||
)
|
||||
assert call_count[0] == 0
|
||||
|
||||
|
||||
def test_validate_symbol_accepts_symbol_in_mock_mode(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _service(
|
||||
exchange_enabled=False,
|
||||
)
|
||||
|
||||
call_count = _set_instruments(
|
||||
monkeypatch,
|
||||
service,
|
||||
(),
|
||||
)
|
||||
|
||||
result = service.validate_symbol(
|
||||
" btc/usd_leverage "
|
||||
)
|
||||
|
||||
assert result == SymbolValidationResult(
|
||||
requested_symbol="BTC/USD_LEVERAGE",
|
||||
normalized_symbol="BTC/USD_LEVERAGE",
|
||||
is_valid=True,
|
||||
message="Mock mode active.",
|
||||
symbol_info=None,
|
||||
)
|
||||
assert call_count[0] == 0
|
||||
|
||||
|
||||
def test_validate_symbol_finds_exact_instrument(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _service()
|
||||
instrument = _instrument()
|
||||
|
||||
_set_instruments(
|
||||
monkeypatch,
|
||||
service,
|
||||
(
|
||||
instrument,
|
||||
),
|
||||
)
|
||||
|
||||
result = service.validate_symbol(
|
||||
"BTC/USD_LEVERAGE"
|
||||
)
|
||||
|
||||
assert result.is_valid
|
||||
assert result.symbol_info is instrument
|
||||
|
||||
|
||||
def test_validate_symbol_is_case_insensitive(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _service()
|
||||
instrument = _instrument()
|
||||
|
||||
_set_instruments(
|
||||
monkeypatch,
|
||||
service,
|
||||
(
|
||||
instrument,
|
||||
),
|
||||
)
|
||||
|
||||
result = service.validate_symbol(
|
||||
"btc/usd_leverage"
|
||||
)
|
||||
|
||||
assert result.is_valid
|
||||
assert result.normalized_symbol == "BTC/USD_LEVERAGE"
|
||||
|
||||
|
||||
def test_validate_symbol_ignores_outer_spaces(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _service()
|
||||
instrument = _instrument()
|
||||
|
||||
_set_instruments(
|
||||
monkeypatch,
|
||||
service,
|
||||
(
|
||||
instrument,
|
||||
),
|
||||
)
|
||||
|
||||
result = service.validate_symbol(
|
||||
" btc/usd_leverage "
|
||||
)
|
||||
|
||||
assert result.is_valid
|
||||
assert result.requested_symbol == "BTC/USD_LEVERAGE"
|
||||
|
||||
|
||||
def test_validate_symbol_supports_encoded_separator(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _service()
|
||||
instrument = _instrument()
|
||||
|
||||
_set_instruments(
|
||||
monkeypatch,
|
||||
service,
|
||||
(
|
||||
instrument,
|
||||
),
|
||||
)
|
||||
|
||||
result = service.validate_symbol(
|
||||
"btc%2fusd_leverage"
|
||||
)
|
||||
|
||||
assert result.is_valid
|
||||
assert result.symbol_info is instrument
|
||||
|
||||
|
||||
def test_validate_symbol_supports_internal_spaces(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _service()
|
||||
instrument = _instrument()
|
||||
|
||||
_set_instruments(
|
||||
monkeypatch,
|
||||
service,
|
||||
(
|
||||
instrument,
|
||||
),
|
||||
)
|
||||
|
||||
result = service.validate_symbol(
|
||||
"btc / usd_leverage"
|
||||
)
|
||||
|
||||
assert result.is_valid
|
||||
assert result.symbol_info is instrument
|
||||
|
||||
|
||||
def test_validate_symbol_rejects_missing_symbol(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _service()
|
||||
|
||||
_set_instruments(
|
||||
monkeypatch,
|
||||
service,
|
||||
(
|
||||
_instrument(),
|
||||
),
|
||||
)
|
||||
|
||||
result = service.validate_symbol(
|
||||
"XRP/USD_LEVERAGE"
|
||||
)
|
||||
|
||||
assert result == SymbolValidationResult(
|
||||
requested_symbol="XRP/USD_LEVERAGE",
|
||||
normalized_symbol="XRP/USD_LEVERAGE",
|
||||
is_valid=False,
|
||||
message=(
|
||||
"Символ 'XRP/USD_LEVERAGE' "
|
||||
"не найден в exchangeInfo."
|
||||
),
|
||||
symbol_info=None,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_symbol_returns_original_instrument_object(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _service()
|
||||
instrument = _instrument()
|
||||
|
||||
_set_instruments(
|
||||
monkeypatch,
|
||||
service,
|
||||
(
|
||||
instrument,
|
||||
),
|
||||
)
|
||||
|
||||
result = service.validate_symbol(
|
||||
"btc/usd_leverage"
|
||||
)
|
||||
|
||||
assert result.symbol_info is instrument
|
||||
|
||||
|
||||
def test_validate_symbol_normalizes_actual_matched_symbol(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _service()
|
||||
|
||||
instrument = _instrument(
|
||||
symbol=" btc/usd_leverage ",
|
||||
)
|
||||
|
||||
_set_instruments(
|
||||
monkeypatch,
|
||||
service,
|
||||
(
|
||||
instrument,
|
||||
),
|
||||
)
|
||||
|
||||
result = service.validate_symbol(
|
||||
"BTC/USD_LEVERAGE"
|
||||
)
|
||||
|
||||
assert result.normalized_symbol == "BTC/USD_LEVERAGE"
|
||||
|
||||
|
||||
def test_validate_symbol_preserves_success_message(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _service()
|
||||
|
||||
_set_instruments(
|
||||
monkeypatch,
|
||||
service,
|
||||
(
|
||||
_instrument(),
|
||||
),
|
||||
)
|
||||
|
||||
result = service.validate_symbol(
|
||||
"BTC/USD_LEVERAGE"
|
||||
)
|
||||
|
||||
assert result.message == "Символ найден в exchangeInfo."
|
||||
|
||||
|
||||
def test_validate_symbol_calls_get_instruments_once(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _service()
|
||||
|
||||
call_count = _set_instruments(
|
||||
monkeypatch,
|
||||
service,
|
||||
(
|
||||
_instrument(),
|
||||
),
|
||||
)
|
||||
|
||||
service.validate_symbol(
|
||||
"BTC/USD_LEVERAGE"
|
||||
)
|
||||
|
||||
assert call_count[0] == 1
|
||||
|
||||
|
||||
def test_validate_symbol_uses_canonical_instruments(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _service()
|
||||
|
||||
instrument = _instrument()
|
||||
|
||||
call_count = _set_instruments(
|
||||
monkeypatch,
|
||||
service,
|
||||
(
|
||||
instrument,
|
||||
),
|
||||
)
|
||||
|
||||
result = service.validate_symbol(
|
||||
"BTC/USD_LEVERAGE"
|
||||
)
|
||||
|
||||
assert result.is_valid
|
||||
assert result.symbol_info is instrument
|
||||
assert call_count[0] == 1
|
||||
|
||||
|
||||
def test_validate_symbol_does_not_call_acquisition_directly(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _service()
|
||||
|
||||
_set_instruments(
|
||||
monkeypatch,
|
||||
service,
|
||||
(
|
||||
_instrument(),
|
||||
),
|
||||
)
|
||||
|
||||
def fail_if_called() -> tuple[Instrument, ...]:
|
||||
raise AssertionError(
|
||||
"validate_symbol() must use get_instruments() "
|
||||
"and must not call acquisition directly."
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_load_instruments_via_acquisition",
|
||||
fail_if_called,
|
||||
)
|
||||
|
||||
result = service.validate_symbol(
|
||||
"BTC/USD_LEVERAGE"
|
||||
)
|
||||
|
||||
assert result.is_valid
|
||||
|
||||
|
||||
def test_validate_symbol_preserves_candidate_priority(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _service()
|
||||
|
||||
decoded_instrument = _instrument(
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
)
|
||||
encoded_instrument = _instrument(
|
||||
symbol="BTC%2FUSD_LEVERAGE",
|
||||
)
|
||||
|
||||
_set_instruments(
|
||||
monkeypatch,
|
||||
service,
|
||||
(
|
||||
decoded_instrument,
|
||||
encoded_instrument,
|
||||
),
|
||||
)
|
||||
|
||||
result = service.validate_symbol(
|
||||
"BTC%2FUSD_LEVERAGE"
|
||||
)
|
||||
|
||||
assert result.symbol_info is encoded_instrument
|
||||
|
||||
|
||||
def test_validate_symbol_returns_first_duplicate(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _service()
|
||||
|
||||
first = _instrument(
|
||||
name="First",
|
||||
)
|
||||
second = _instrument(
|
||||
name="Second",
|
||||
)
|
||||
|
||||
_set_instruments(
|
||||
monkeypatch,
|
||||
service,
|
||||
(
|
||||
first,
|
||||
second,
|
||||
),
|
||||
)
|
||||
|
||||
result = service.validate_symbol(
|
||||
"BTC/USD_LEVERAGE"
|
||||
)
|
||||
|
||||
assert result.symbol_info is first
|
||||
|
||||
|
||||
def test_validate_symbol_returns_symbol_validation_result(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _service()
|
||||
|
||||
_set_instruments(
|
||||
monkeypatch,
|
||||
service,
|
||||
(
|
||||
_instrument(),
|
||||
),
|
||||
)
|
||||
|
||||
result = service.validate_symbol(
|
||||
"BTC/USD_LEVERAGE"
|
||||
)
|
||||
|
||||
assert isinstance(
|
||||
result,
|
||||
SymbolValidationResult,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_symbol_returns_canonical_instrument(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _service()
|
||||
instrument = _instrument()
|
||||
|
||||
_set_instruments(
|
||||
monkeypatch,
|
||||
service,
|
||||
(
|
||||
instrument,
|
||||
),
|
||||
)
|
||||
|
||||
result = service.validate_symbol(
|
||||
"BTC/USD_LEVERAGE"
|
||||
)
|
||||
|
||||
assert isinstance(
|
||||
result.symbol_info,
|
||||
Instrument,
|
||||
)
|
||||
assert result.symbol_info is instrument
|
||||
|
||||
|
||||
def test_exchange_service_has_no_legacy_get_exchange_symbols() -> None:
|
||||
assert not hasattr(
|
||||
ExchangeService,
|
||||
"get_exchange_symbols",
|
||||
)
|
||||
173
app/tests/unit/integrations/exchange/test_status.py
Normal file
173
app/tests/unit/integrations/exchange/test_status.py
Normal file
@@ -0,0 +1,173 @@
|
||||
# app/tests/unit/integrations/exchange/test_status.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.integrations.exchange.status import (
|
||||
ExchangeRuntimeStatus,
|
||||
ExchangeStatusCode,
|
||||
build_market_status_from_symbol_status,
|
||||
)
|
||||
|
||||
|
||||
_SYMBOL = "BTC/USD_LEVERAGE"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_status",
|
||||
[
|
||||
"TRADING",
|
||||
"OPEN",
|
||||
"ACTIVE",
|
||||
"ENABLED",
|
||||
"ONLINE",
|
||||
" trading ",
|
||||
],
|
||||
)
|
||||
def test_open_instrument_status_preserves_legacy_contract(
|
||||
raw_status: str,
|
||||
) -> None:
|
||||
status = build_market_status_from_symbol_status(
|
||||
raw_status=raw_status,
|
||||
symbol=_SYMBOL,
|
||||
)
|
||||
|
||||
assert status == ExchangeRuntimeStatus(
|
||||
code=ExchangeStatusCode.OPEN,
|
||||
is_open=True,
|
||||
is_available=True,
|
||||
is_auth_ok=True,
|
||||
title="Биржа доступна",
|
||||
message="Рынок открыт.",
|
||||
ui_line="🟢 Биржа доступна",
|
||||
reason="market_open",
|
||||
raw_status=raw_status.strip().upper(),
|
||||
symbol=_SYMBOL,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_status",
|
||||
[
|
||||
"NOT_TRADABLE",
|
||||
"TRADING_DISABLED",
|
||||
"MARKET_DISABLED",
|
||||
"UNAVAILABLE_FOR_TRADING",
|
||||
"CLOSE_ONLY",
|
||||
"REDUCE_ONLY",
|
||||
"VIEW_ONLY",
|
||||
],
|
||||
)
|
||||
def test_not_tradable_status_preserves_legacy_contract(
|
||||
raw_status: str,
|
||||
) -> None:
|
||||
status = build_market_status_from_symbol_status(
|
||||
raw_status=raw_status,
|
||||
symbol=_SYMBOL,
|
||||
)
|
||||
|
||||
assert status == ExchangeRuntimeStatus(
|
||||
code=ExchangeStatusCode.BREAK,
|
||||
is_open=False,
|
||||
is_available=True,
|
||||
is_auth_ok=True,
|
||||
title="Рынок недоступен",
|
||||
message=(
|
||||
"Этот рынок недоступен для торговли: "
|
||||
"BTC/USD_LEVERAGE."
|
||||
),
|
||||
ui_line="⛔️ Рынок недоступен для торговли",
|
||||
reason="market_not_tradable",
|
||||
raw_status=raw_status,
|
||||
symbol=_SYMBOL,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_status",
|
||||
[
|
||||
"BREAK",
|
||||
"CLOSED",
|
||||
"HALT",
|
||||
"HALTED",
|
||||
"PAUSED",
|
||||
"SUSPENDED",
|
||||
"DISABLED",
|
||||
"SETTLING",
|
||||
"POST_ONLY",
|
||||
],
|
||||
)
|
||||
def test_break_status_preserves_legacy_contract(
|
||||
raw_status: str,
|
||||
) -> None:
|
||||
status = build_market_status_from_symbol_status(
|
||||
raw_status=raw_status,
|
||||
symbol=_SYMBOL,
|
||||
)
|
||||
|
||||
assert status == ExchangeRuntimeStatus(
|
||||
code=ExchangeStatusCode.BREAK,
|
||||
is_open=False,
|
||||
is_available=True,
|
||||
is_auth_ok=True,
|
||||
title="Перерыв в торгах",
|
||||
message="Торги по BTC/USD_LEVERAGE временно остановлены.",
|
||||
ui_line="⏸️ Перерыв в торгах",
|
||||
reason="market_break",
|
||||
raw_status=raw_status,
|
||||
symbol=_SYMBOL,
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_status_preserves_legacy_contract() -> None:
|
||||
status = build_market_status_from_symbol_status(
|
||||
raw_status=" maintenance ",
|
||||
symbol=_SYMBOL,
|
||||
)
|
||||
|
||||
assert status == ExchangeRuntimeStatus(
|
||||
code=ExchangeStatusCode.UNKNOWN,
|
||||
is_open=False,
|
||||
is_available=True,
|
||||
is_auth_ok=True,
|
||||
title="Статус торгов неизвестен",
|
||||
message=(
|
||||
"Биржа вернула неизвестный статус инструмента: "
|
||||
"MAINTENANCE."
|
||||
),
|
||||
ui_line="⚠️ Статус торгов неизвестен",
|
||||
reason="market_status_unknown",
|
||||
raw_status="MAINTENANCE",
|
||||
symbol=_SYMBOL,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_status",
|
||||
[
|
||||
None,
|
||||
"",
|
||||
" ",
|
||||
],
|
||||
)
|
||||
def test_empty_status_preserves_legacy_unknown_contract(
|
||||
raw_status: str | None,
|
||||
) -> None:
|
||||
status = build_market_status_from_symbol_status(
|
||||
raw_status=raw_status,
|
||||
symbol=_SYMBOL,
|
||||
)
|
||||
|
||||
assert status == ExchangeRuntimeStatus(
|
||||
code=ExchangeStatusCode.UNKNOWN,
|
||||
is_open=False,
|
||||
is_available=True,
|
||||
is_auth_ok=True,
|
||||
title="Статус торгов неизвестен",
|
||||
message="Биржа вернула неизвестный статус инструмента.",
|
||||
ui_line="⚠️ Статус торгов неизвестен",
|
||||
reason="market_status_unknown",
|
||||
raw_status=None,
|
||||
symbol=_SYMBOL,
|
||||
)
|
||||
74
app/tests/unit/integrations/exchange/test_symbol_utils.py
Normal file
74
app/tests/unit/integrations/exchange/test_symbol_utils.py
Normal file
@@ -0,0 +1,74 @@
|
||||
# app/tests/unit/integrations/exchange/test_symbol_utils.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.integrations.exchange.symbol_utils import (
|
||||
normalize_symbol as legacy_normalize_symbol,
|
||||
)
|
||||
from src.integrations.exchange.symbol_utils import (
|
||||
symbol_candidates as legacy_symbol_candidates,
|
||||
)
|
||||
from src.market_data.acquisition.symbols import (
|
||||
normalize_symbol as new_normalize_symbol,
|
||||
)
|
||||
from src.market_data.acquisition.symbols import (
|
||||
symbol_candidates as new_symbol_candidates,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_symbol",
|
||||
[
|
||||
"",
|
||||
" ",
|
||||
"btc/usd",
|
||||
" BTC/USD ",
|
||||
"btc%2fusd",
|
||||
"btc / usd",
|
||||
"btc%2f / usd",
|
||||
"eth/usd_leverage",
|
||||
"btc\t/usd",
|
||||
"btc\n/usd",
|
||||
],
|
||||
)
|
||||
def test_legacy_normalize_symbol_matches_new_implementation(
|
||||
raw_symbol: str,
|
||||
) -> None:
|
||||
assert (
|
||||
legacy_normalize_symbol(raw_symbol)
|
||||
== new_normalize_symbol(raw_symbol)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_symbol",
|
||||
[
|
||||
"",
|
||||
" ",
|
||||
"btc/usd",
|
||||
" BTC/USD ",
|
||||
"btc%2fusd",
|
||||
"btc / usd",
|
||||
"btc%2f / usd",
|
||||
"eth/usd_leverage",
|
||||
"btc\t/usd",
|
||||
"btc\n/usd",
|
||||
],
|
||||
)
|
||||
def test_legacy_symbol_candidates_matches_new_implementation(
|
||||
raw_symbol: str,
|
||||
) -> None:
|
||||
assert (
|
||||
legacy_symbol_candidates(raw_symbol)
|
||||
== new_symbol_candidates(raw_symbol)
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_facade_exports_same_normalize_function() -> None:
|
||||
assert legacy_normalize_symbol is new_normalize_symbol
|
||||
|
||||
|
||||
def test_legacy_facade_exports_same_candidates_function() -> None:
|
||||
assert legacy_symbol_candidates is new_symbol_candidates
|
||||
Reference in New Issue
Block a user