feat: add market data architecture and complete migration through build 039

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

View 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

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

View 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

View File

@@ -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

View File

@@ -0,0 +1,453 @@
# app/tests/unit/integrations/exchange/test_service_instruments.py
from __future__ import annotations
from collections.abc import Iterator
from decimal import Decimal
from types import SimpleNamespace
from typing import cast
import pytest
from src.core.config import Settings
from src.integrations.exchange.exceptions import ExchangeError
from src.integrations.exchange.service import ExchangeService
from src.market_data.acquisition.exceptions import (
InstrumentReferenceTransportError,
)
from src.market_data.acquisition.models.instrument import Instrument
from src.storage.instrument_store import InMemoryInstrumentStore
_SOURCE_NAME = "dzengi"
def _settings(
*,
exchange_enabled: bool = True,
) -> Settings:
return cast(
Settings,
SimpleNamespace(
exchange_enabled=exchange_enabled,
exchange_name="dzengi",
),
)
def _service(
*,
exchange_enabled: bool = True,
) -> ExchangeService:
service = object.__new__(ExchangeService)
service.settings = _settings(
exchange_enabled=exchange_enabled,
)
return service
def _instrument(
*,
symbol: str = "BTC/USD_LEVERAGE",
name: str = "BTC/USD",
base_asset: str = "BTC",
) -> Instrument:
return Instrument(
symbol=symbol,
name=name,
status="TRADING",
base_asset=base_asset,
quote_asset="USD",
asset_type="CRYPTOCURRENCY",
market_type="LEVERAGE",
market_modes=("REGULAR",),
order_types=("LIMIT", "MARKET", "STOP"),
base_asset_precision=4,
quote_asset_precision=4,
tick_size=Decimal("0.05"),
tick_value=Decimal("3878.86"),
step_size=Decimal("0.0001"),
min_qty=Decimal("0.0001"),
max_qty=Decimal("1000"),
min_notional=Decimal("1"),
country=None,
sector=None,
industry=None,
trading_hours=None,
)
@pytest.fixture(autouse=True)
def reset_instrument_storage() -> Iterator[None]:
original_store = ExchangeService._instrument_store
ExchangeService._instrument_store = InMemoryInstrumentStore()
yield
ExchangeService._instrument_store.clear()
ExchangeService._instrument_store = original_store
def test_get_instruments_returns_empty_tuple_when_exchange_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service(
exchange_enabled=False,
)
def fail_if_called() -> tuple[Instrument, ...]:
raise AssertionError(
"Acquisition must not run when exchange is disabled."
)
monkeypatch.setattr(
service,
"_load_instruments_via_acquisition",
fail_if_called,
)
result = service.get_instruments()
assert result == ()
assert isinstance(result, tuple)
def test_exchange_disabled_does_not_read_existing_store(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service(
exchange_enabled=False,
)
instruments = (
_instrument(),
)
ExchangeService._instrument_store.set(
_SOURCE_NAME,
instruments,
)
def fail_get(
source_name: str,
) -> tuple[Instrument, ...] | None:
del source_name
raise AssertionError(
"Instrument Store must not be read "
"when exchange is disabled."
)
monkeypatch.setattr(
ExchangeService._instrument_store,
"get",
fail_get,
)
assert service.get_instruments() == ()
def test_store_hit_returns_same_tuple_object(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
instruments = (
_instrument(),
)
ExchangeService._instrument_store.set(
_SOURCE_NAME,
instruments,
)
def fail_if_called() -> tuple[Instrument, ...]:
raise AssertionError(
"Acquisition must not run on store hit."
)
monkeypatch.setattr(
service,
"_load_instruments_via_acquisition",
fail_if_called,
)
result = service.get_instruments()
assert result is instruments
def test_store_miss_runs_acquisition_once(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
instruments = (
_instrument(),
)
call_count = 0
def load_instruments() -> tuple[Instrument, ...]:
nonlocal call_count
call_count += 1
return instruments
monkeypatch.setattr(
service,
"_load_instruments_via_acquisition",
load_instruments,
)
result = service.get_instruments()
assert result is instruments
assert call_count == 1
def test_loaded_instruments_are_saved_in_store(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
instruments = (
_instrument(),
)
monkeypatch.setattr(
service,
"_load_instruments_via_acquisition",
lambda: instruments,
)
result = service.get_instruments()
stored = ExchangeService._instrument_store.get(
_SOURCE_NAME
)
assert result is instruments
assert stored is instruments
def test_second_get_instruments_call_uses_store(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
instruments = (
_instrument(),
)
call_count = 0
def load_instruments() -> tuple[Instrument, ...]:
nonlocal call_count
call_count += 1
return instruments
monkeypatch.setattr(
service,
"_load_instruments_via_acquisition",
load_instruments,
)
first = service.get_instruments()
second = service.get_instruments()
assert first is instruments
assert second is instruments
assert call_count == 1
def test_empty_tuple_in_store_is_valid_cache_hit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
ExchangeService._instrument_store.set(
_SOURCE_NAME,
(),
)
def fail_if_called() -> tuple[Instrument, ...]:
raise AssertionError(
"Stored empty tuple must be treated as cache hit."
)
monkeypatch.setattr(
service,
"_load_instruments_via_acquisition",
fail_if_called,
)
result = service.get_instruments()
assert result == ()
def test_multiple_service_instances_share_instrument_store(
monkeypatch: pytest.MonkeyPatch,
) -> None:
first_service = _service()
second_service = _service()
instruments = (
_instrument(),
)
call_count = 0
def load_instruments() -> tuple[Instrument, ...]:
nonlocal call_count
call_count += 1
return instruments
monkeypatch.setattr(
first_service,
"_load_instruments_via_acquisition",
load_instruments,
)
monkeypatch.setattr(
second_service,
"_load_instruments_via_acquisition",
load_instruments,
)
first = first_service.get_instruments()
second = second_service.get_instruments()
assert first is instruments
assert second is instruments
assert call_count == 1
def test_acquisition_error_is_wrapped_in_exchange_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
original_error = InstrumentReferenceTransportError(
"Network error."
)
def raise_error() -> tuple[Instrument, ...]:
raise original_error
monkeypatch.setattr(
service,
"_load_instruments_via_acquisition",
raise_error,
)
monkeypatch.setattr(
service,
"_log_exchange_error",
lambda **_: None,
)
with pytest.raises(
ExchangeError,
match=r"Network error",
) as exc_info:
service.get_instruments()
assert exc_info.value.__cause__ is original_error
def test_acquisition_error_is_logged_as_exchange_info(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
original_error = InstrumentReferenceTransportError(
"Network error."
)
logged_calls: list[dict[str, object]] = []
def raise_error() -> tuple[Instrument, ...]:
raise original_error
def record_error(
*,
endpoint: str,
exc: Exception,
symbol: str | None = None,
extra_payload: dict[str, object] | None = None,
) -> None:
logged_calls.append(
{
"endpoint": endpoint,
"exc": exc,
"symbol": symbol,
"extra_payload": extra_payload,
}
)
monkeypatch.setattr(
service,
"_load_instruments_via_acquisition",
raise_error,
)
monkeypatch.setattr(
service,
"_log_exchange_error",
record_error,
)
with pytest.raises(ExchangeError):
service.get_instruments()
assert logged_calls == [
{
"endpoint": "exchangeInfo",
"exc": original_error,
"symbol": None,
"extra_payload": None,
}
]
def test_acquisition_error_does_not_fill_store(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
def raise_error() -> tuple[Instrument, ...]:
raise InstrumentReferenceTransportError(
"Network error."
)
monkeypatch.setattr(
service,
"_load_instruments_via_acquisition",
raise_error,
)
monkeypatch.setattr(
service,
"_log_exchange_error",
lambda **_: None,
)
with pytest.raises(ExchangeError):
service.get_instruments()
assert ExchangeService._instrument_store.get(
_SOURCE_NAME
) is None
def test_exchange_service_has_no_legacy_projection_cache() -> None:
assert not hasattr(
ExchangeService,
"_exchange_symbols_projection_cache",
)

View 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

View File

@@ -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,
}

View File

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

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

View 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