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

View File

@@ -0,0 +1,407 @@
# app/tests/unit/market_data/acquisition/adapters/dzengi/test_mapper.py
from __future__ import annotations
from dataclasses import FrozenInstanceError, replace
from decimal import Decimal
import pytest
from src.market_data.acquisition.adapters.dzengi.mapper import (
map_dzengi_exchange_info_to_instruments,
map_dzengi_symbol_to_instrument,
)
from src.market_data.acquisition.adapters.dzengi.models import (
DzengiExchangeInfoPayload,
DzengiExchangeInfoResponse,
DzengiExchangeInfoSymbol,
DzengiLotSizeFilter,
DzengiMinNotionalFilter,
DzengiUnknownFilter,
)
from src.market_data.acquisition.exceptions import (
InstrumentReferenceMappingError,
)
def _complete_symbol() -> DzengiExchangeInfoSymbol:
return DzengiExchangeInfoSymbol(
symbol="ETH/EUR_LEVERAGE",
name="ETH/EUR",
status="TRADING",
asset_type="CRYPTOCURRENCY",
base_asset="ETH",
base_asset_precision=3,
quote_asset="EUR",
quote_asset_id="EUR_LEVERAGE",
quote_precision=3,
order_types=("LIMIT", "MARKET", "STOP"),
filters=(
DzengiLotSizeFilter(
filter_type="LOT_SIZE",
min_qty="0.001",
max_qty="1000",
step_size="0.001",
),
DzengiMinNotionalFilter(
filter_type="MIN_NOTIONAL",
min_notional="2",
),
),
market_modes=("REGULAR",),
market_type="LEVERAGE",
country="",
sector="",
industry="",
trading_hours="UTC; Mon - 21:00, 21:05 -",
tick_size=0.01,
tick_value=18.3415,
trading_fee=0.06,
exchange_fee=None,
long_rate=-0.01,
short_rate=0.01,
swap_charge_interval=480,
min_sl_gap=0,
max_sl_gap=50.0,
min_tp_gap=0,
max_tp_gap=50.0,
)
def _response(
*symbols: DzengiExchangeInfoSymbol,
) -> DzengiExchangeInfoResponse:
return DzengiExchangeInfoResponse(
payload=DzengiExchangeInfoPayload(
timezone="UTC",
server_time=1783537921471,
rate_limits=(),
exchange_filters=(),
symbols=tuple(symbols),
),
)
def test_map_complete_dzengi_symbol_to_instrument() -> None:
instrument = map_dzengi_symbol_to_instrument(
_complete_symbol()
)
assert instrument.symbol == "ETH/EUR_LEVERAGE"
assert instrument.name == "ETH/EUR"
assert instrument.status == "TRADING"
assert instrument.base_asset == "ETH"
assert instrument.quote_asset == "EUR"
assert instrument.asset_type == "CRYPTOCURRENCY"
assert instrument.market_type == "LEVERAGE"
assert instrument.market_modes == ("REGULAR",)
assert instrument.order_types == ("LIMIT", "MARKET", "STOP")
assert instrument.base_asset_precision == 3
assert instrument.quote_asset_precision == 3
assert instrument.tick_size == Decimal("0.01")
assert instrument.tick_value == Decimal("18.3415")
assert instrument.step_size == Decimal("0.001")
assert instrument.min_qty == Decimal("0.001")
assert instrument.max_qty == Decimal("1000")
assert instrument.min_notional == Decimal("2")
assert instrument.country is None
assert instrument.sector is None
assert instrument.industry is None
assert instrument.trading_hours == "UTC; Mon - 21:00, 21:05 -"
def test_map_exchange_info_to_instruments() -> None:
first = _complete_symbol()
second = replace(
_complete_symbol(),
symbol="BTC/USD_LEVERAGE",
name="BTC/USD",
base_asset="BTC",
quote_asset="USD",
)
instruments = map_dzengi_exchange_info_to_instruments(
_response(first, second)
)
assert isinstance(instruments, tuple)
assert len(instruments) == 2
assert instruments[0].symbol == "ETH/EUR_LEVERAGE"
assert instruments[1].symbol == "BTC/USD_LEVERAGE"
def test_map_numeric_values_to_decimal_exactly() -> None:
symbol = replace(
_complete_symbol(),
tick_size=0.00000001,
tick_value=0,
filters=(
DzengiLotSizeFilter(
filter_type="LOT_SIZE",
min_qty="0.00000001",
max_qty=10000000,
step_size="0.00000001",
),
DzengiMinNotionalFilter(
filter_type="MIN_NOTIONAL",
min_notional="0.00000069",
),
),
)
instrument = map_dzengi_symbol_to_instrument(symbol)
assert instrument.tick_size == Decimal("1E-8")
assert instrument.tick_value == Decimal("0")
assert instrument.min_qty == Decimal("1E-8")
assert instrument.max_qty == Decimal("10000000")
assert instrument.step_size == Decimal("1E-8")
assert instrument.min_notional == Decimal("6.9E-7")
def test_map_symbol_without_filters() -> None:
symbol = replace(
_complete_symbol(),
filters=(),
)
instrument = map_dzengi_symbol_to_instrument(symbol)
assert instrument.step_size is None
assert instrument.min_qty is None
assert instrument.max_qty is None
assert instrument.min_notional is None
def test_map_symbol_with_missing_optional_numeric_values() -> None:
symbol = replace(
_complete_symbol(),
tick_size=None,
tick_value=None,
filters=(
DzengiLotSizeFilter(
filter_type="LOT_SIZE",
min_qty=None,
max_qty=None,
step_size=None,
),
DzengiMinNotionalFilter(
filter_type="MIN_NOTIONAL",
min_notional=None,
),
),
)
instrument = map_dzengi_symbol_to_instrument(symbol)
assert instrument.tick_size is None
assert instrument.tick_value is None
assert instrument.step_size is None
assert instrument.min_qty is None
assert instrument.max_qty is None
assert instrument.min_notional is None
def test_mapper_ignores_unknown_filters() -> None:
symbol = replace(
_complete_symbol(),
filters=(
DzengiUnknownFilter(
filter_type="FUTURE_FILTER",
fields=(
("enabled", True),
("limit", 10),
),
),
DzengiLotSizeFilter(
filter_type="LOT_SIZE",
min_qty="0.001",
max_qty="1000",
step_size="0.001",
),
),
)
instrument = map_dzengi_symbol_to_instrument(symbol)
assert instrument.min_qty == Decimal("0.001")
assert instrument.max_qty == Decimal("1000")
assert instrument.step_size == Decimal("0.001")
assert instrument.min_notional is None
@pytest.mark.parametrize(
("field", "value"),
[
("asset_type", ""),
("asset_type", " "),
("country", ""),
("country", " "),
("sector", ""),
("industry", " "),
("trading_hours", ""),
],
)
def test_mapper_converts_empty_optional_text_to_none(
field: str,
value: str,
) -> None:
symbol = replace(
_complete_symbol(),
**{field: value},
)
instrument = map_dzengi_symbol_to_instrument(symbol)
assert getattr(instrument, field) is None
def test_mapper_strips_non_empty_optional_text() -> None:
symbol = replace(
_complete_symbol(),
asset_type=" CRYPTOCURRENCY ",
country=" DE ",
sector=" Technology ",
industry=" Software ",
trading_hours=" UTC; Mon 07:00 - 15:30 ",
)
instrument = map_dzengi_symbol_to_instrument(symbol)
assert instrument.asset_type == "CRYPTOCURRENCY"
assert instrument.country == "DE"
assert instrument.sector == "Technology"
assert instrument.industry == "Software"
assert instrument.trading_hours == "UTC; Mon 07:00 - 15:30"
def test_mapper_preserves_market_modes_order() -> None:
symbol = replace(
_complete_symbol(),
market_modes=("REGULAR", "CLOSE_ONLY", "EXTENDED"),
)
instrument = map_dzengi_symbol_to_instrument(symbol)
assert instrument.market_modes == (
"REGULAR",
"CLOSE_ONLY",
"EXTENDED",
)
def test_mapper_preserves_order_types_order() -> None:
symbol = replace(
_complete_symbol(),
order_types=("MARKET", "LIMIT", "STOP"),
)
instrument = map_dzengi_symbol_to_instrument(symbol)
assert instrument.order_types == (
"MARKET",
"LIMIT",
"STOP",
)
def test_mapper_rejects_duplicate_lot_size_filters() -> None:
lot_size = DzengiLotSizeFilter(
filter_type="LOT_SIZE",
min_qty="0.001",
max_qty="1000",
step_size="0.001",
)
symbol = replace(
_complete_symbol(),
filters=(
lot_size,
lot_size,
),
)
with pytest.raises(
InstrumentReferenceMappingError,
match=r"несколько фильтров LOT_SIZE",
):
map_dzengi_symbol_to_instrument(symbol)
def test_mapper_rejects_duplicate_min_notional_filters() -> None:
min_notional = DzengiMinNotionalFilter(
filter_type="MIN_NOTIONAL",
min_notional="2",
)
symbol = replace(
_complete_symbol(),
filters=(
min_notional,
min_notional,
),
)
with pytest.raises(
InstrumentReferenceMappingError,
match=r"несколько фильтров MIN_NOTIONAL",
):
map_dzengi_symbol_to_instrument(symbol)
@pytest.mark.parametrize(
("field_name", "invalid_value"),
[
("tick_size", float("nan")),
("tick_value", float("inf")),
],
)
def test_mapper_rejects_non_finite_direct_numeric_value(
field_name: str,
invalid_value: float,
) -> None:
symbol = replace(
_complete_symbol(),
**{field_name: invalid_value},
)
with pytest.raises(
InstrumentReferenceMappingError,
match=r"должно быть конечным числом",
):
map_dzengi_symbol_to_instrument(symbol)
def test_mapper_rejects_invalid_filter_numeric_value() -> None:
symbol = replace(
_complete_symbol(),
filters=(
DzengiLotSizeFilter(
filter_type="LOT_SIZE",
min_qty="not-a-number",
max_qty="1000",
step_size="0.001",
),
),
)
with pytest.raises(
InstrumentReferenceMappingError,
match=r"minQty.*невозможно преобразовать в Decimal",
):
map_dzengi_symbol_to_instrument(symbol)
def test_mapped_instrument_is_immutable() -> None:
instrument = map_dzengi_symbol_to_instrument(
_complete_symbol()
)
with pytest.raises(FrozenInstanceError):
instrument.status = "BREAK" # type: ignore[misc]

View File

@@ -0,0 +1,206 @@
# app/tests/unit/market_data/acquisition/adapters/dzengi/test_models.py
from __future__ import annotations
from dataclasses import FrozenInstanceError
import pytest
from src.market_data.acquisition.adapters.dzengi.models import (
DzengiExchangeInfoPayload,
DzengiExchangeInfoResponse,
DzengiExchangeInfoSymbol,
DzengiLotSizeFilter,
DzengiMinNotionalFilter,
DzengiRateLimit,
DzengiUnknownFilter,
)
def test_exchange_info_response_stores_complete_raw_contract() -> None:
lot_size = DzengiLotSizeFilter(
filter_type="LOT_SIZE",
min_qty="0.001",
max_qty="1000",
step_size="0.001",
)
min_notional = DzengiMinNotionalFilter(
filter_type="MIN_NOTIONAL",
min_notional="2",
)
symbol = DzengiExchangeInfoSymbol(
symbol="ETH/EUR_LEVERAGE",
name="ETH/EUR",
status="TRADING",
asset_type="CRYPTOCURRENCY",
base_asset="ETH",
base_asset_precision=3,
quote_asset="EUR",
quote_asset_id="EUR_LEVERAGE",
quote_precision=3,
order_types=("LIMIT", "MARKET", "STOP"),
filters=(lot_size, min_notional),
market_modes=("REGULAR",),
market_type="LEVERAGE",
country="",
sector="",
industry="",
trading_hours="UTC; Mon - 21:00, 21:05 -",
tick_size=0.01,
tick_value=18.3415,
trading_fee=0.06,
exchange_fee=None,
long_rate=-0.01,
short_rate=0.01,
swap_charge_interval=480,
min_sl_gap=0,
max_sl_gap=50.0,
min_tp_gap=0,
max_tp_gap=50.0,
)
payload = DzengiExchangeInfoPayload(
timezone="UTC",
server_time=1783537921471,
rate_limits=(
DzengiRateLimit(
interval="MINUTE",
interval_num=1,
limit=1200,
rate_limit_type="REQUEST_WEIGHT",
),
),
exchange_filters=(),
symbols=(symbol,),
)
response = DzengiExchangeInfoResponse(payload=payload)
assert response.status is None
assert response.correlation_id is None
assert response.payload.timezone == "UTC"
assert response.payload.server_time == 1783537921471
assert len(response.payload.symbols) == 1
parsed_symbol = response.payload.symbols[0]
assert parsed_symbol.symbol == "ETH/EUR_LEVERAGE"
assert parsed_symbol.filters == (lot_size, min_notional)
assert parsed_symbol.tick_size == 0.01
assert parsed_symbol.trading_fee == 0.06
assert parsed_symbol.exchange_fee is None
def test_exchange_info_response_supports_wrapped_api_metadata() -> None:
payload = DzengiExchangeInfoPayload(
timezone="UTC",
server_time=1628193845310,
rate_limits=(),
exchange_filters=(),
symbols=(),
)
response = DzengiExchangeInfoResponse(
status="OK",
correlation_id="2",
payload=payload,
)
assert response.status == "OK"
assert response.correlation_id == "2"
assert response.payload.symbols == ()
def test_exchange_info_symbol_accepts_optional_transport_fields() -> None:
symbol = DzengiExchangeInfoSymbol(
symbol="TUI1.",
name="TUI - EUR",
status="TRADING",
asset_type="EQUITY",
base_asset="TUI1.",
base_asset_precision=3,
quote_asset="EUR",
quote_asset_id="EUR_LEVERAGE",
quote_precision=3,
order_types=("LIMIT", "MARKET", "STOP"),
filters=(
DzengiLotSizeFilter(
filter_type="LOT_SIZE",
min_qty="0.1",
max_qty="33000",
step_size="0.1",
),
),
market_modes=("REGULAR",),
market_type="LEVERAGE",
country="DE",
sector="Cyclical Consumer Goods & Services",
industry="Leisure & Recreation",
trading_hours="UTC; Mon 07:00 - 15:30",
tick_size=0.005,
tick_value=None,
trading_fee=0,
exchange_fee=None,
long_rate=-0.0165933,
short_rate=-0.0056289,
swap_charge_interval=1440,
min_sl_gap=0,
max_sl_gap=30.0,
min_tp_gap=0,
max_tp_gap=30.0,
)
assert symbol.tick_value is None
assert symbol.exchange_fee is None
assert len(symbol.filters) == 1
def test_unknown_filter_preserves_unrecognized_scalar_fields() -> None:
unknown_filter = DzengiUnknownFilter(
filter_type="FUTURE_FILTER",
fields=(
("enabled", True),
("limit", 10),
("mode", "STRICT"),
("description", None),
),
)
assert unknown_filter.filter_type == "FUTURE_FILTER"
assert unknown_filter.fields == (
("enabled", True),
("limit", 10),
("mode", "STRICT"),
("description", None),
)
def test_raw_models_use_immutable_sequences() -> None:
payload = DzengiExchangeInfoPayload(
timezone=None,
server_time=None,
rate_limits=(),
exchange_filters=(),
symbols=(),
)
assert isinstance(payload.rate_limits, tuple)
assert isinstance(payload.exchange_filters, tuple)
assert isinstance(payload.symbols, tuple)
def test_raw_models_are_immutable() -> None:
payload = DzengiExchangeInfoPayload(
timezone="UTC",
server_time=1783537921471,
rate_limits=(),
exchange_filters=(),
symbols=(),
)
response = DzengiExchangeInfoResponse(payload=payload)
with pytest.raises(FrozenInstanceError):
response.status = "OK" # type: ignore[misc]

View File

@@ -0,0 +1,405 @@
# app/tests/unit/market_data/acquisition/adapters/dzengi/test_parser.py
from __future__ import annotations
from types import MappingProxyType
import pytest
from src.market_data.acquisition.adapters.dzengi.models import (
DzengiLotSizeFilter,
DzengiMinNotionalFilter,
DzengiUnknownFilter,
)
from src.market_data.acquisition.adapters.dzengi.parser import (
parse_exchange_info,
)
from src.market_data.acquisition.exceptions import (
InstrumentReferenceParseError,
)
from src.market_data.acquisition.validation.schema import (
ValidatedExchangeInfoDocument,
)
def _validated_document(
payload: dict[str, object],
*,
is_wrapped: bool = False,
status: object | None = None,
correlation_id: object | None = None,
) -> ValidatedExchangeInfoDocument:
return ValidatedExchangeInfoDocument(
payload=MappingProxyType(payload),
is_wrapped=is_wrapped,
status=status,
correlation_id=correlation_id,
)
def _complete_symbol() -> dict[str, object]:
return {
"symbol": "ETH/EUR_LEVERAGE",
"name": "ETH/EUR",
"status": "TRADING",
"assetType": "CRYPTOCURRENCY",
"baseAsset": "ETH",
"baseAssetPrecision": 3,
"quoteAsset": "EUR",
"quoteAssetId": "EUR_LEVERAGE",
"quotePrecision": 3,
"orderTypes": ["LIMIT", "MARKET", "STOP"],
"filters": [
{
"filterType": "LOT_SIZE",
"minQty": "0.001",
"maxQty": "1000",
"stepSize": "0.001",
},
{
"filterType": "MIN_NOTIONAL",
"minNotional": "2",
},
],
"marketModes": ["REGULAR"],
"marketType": "LEVERAGE",
"country": "",
"sector": "",
"industry": "",
"tradingHours": "UTC; Mon - 21:00, 21:05 -",
"tickSize": 0.01,
"tickValue": 18.3415,
"tradingFee": 0.06,
"longRate": -0.01,
"shortRate": 0.01,
"swapChargeInterval": 480,
"minSLGap": 0,
"maxSLGap": 50.0,
"minTPGap": 0,
"maxTPGap": 50.0,
}
def test_parse_complete_unwrapped_exchange_info() -> None:
document = _validated_document(
{
"timezone": "UTC",
"serverTime": 1783537921471,
"rateLimits": [
{
"interval": "MINUTE",
"intervalNum": 1,
"limit": 1200,
"rateLimitType": "REQUEST_WEIGHT",
}
],
"exchangeFilters": [],
"symbols": [_complete_symbol()],
}
)
response = parse_exchange_info(document)
assert response.status is None
assert response.correlation_id is None
assert response.payload.timezone == "UTC"
assert response.payload.server_time == 1783537921471
assert len(response.payload.rate_limits) == 1
assert len(response.payload.symbols) == 1
symbol = response.payload.symbols[0]
assert symbol.symbol == "ETH/EUR_LEVERAGE"
assert symbol.name == "ETH/EUR"
assert symbol.status == "TRADING"
assert symbol.asset_type == "CRYPTOCURRENCY"
assert symbol.base_asset == "ETH"
assert symbol.quote_asset == "EUR"
assert symbol.order_types == ("LIMIT", "MARKET", "STOP")
assert symbol.market_modes == ("REGULAR",)
assert symbol.tick_size == 0.01
assert symbol.tick_value == 18.3415
assert symbol.trading_fee == 0.06
assert symbol.exchange_fee is None
def test_parse_wrapped_exchange_info_metadata() -> None:
document = _validated_document(
{
"timezone": "UTC",
"serverTime": 1628193845310,
"symbols": [],
},
is_wrapped=True,
status="OK",
correlation_id="2",
)
response = parse_exchange_info(document)
assert response.status == "OK"
assert response.correlation_id == "2"
assert response.payload.symbols == ()
def test_parse_known_instrument_filters() -> None:
document = _validated_document(
{
"symbols": [_complete_symbol()],
}
)
response = parse_exchange_info(document)
filters = response.payload.symbols[0].filters
assert isinstance(filters[0], DzengiLotSizeFilter)
assert filters[0].min_qty == "0.001"
assert filters[0].max_qty == "1000"
assert filters[0].step_size == "0.001"
assert isinstance(filters[1], DzengiMinNotionalFilter)
assert filters[1].min_notional == "2"
def test_parse_unknown_instrument_filter() -> None:
symbol = _complete_symbol()
symbol["filters"] = [
{
"filterType": "FUTURE_FILTER",
"enabled": True,
"limit": 10,
"mode": "STRICT",
}
]
document = _validated_document(
{
"symbols": [symbol],
}
)
response = parse_exchange_info(document)
parsed_filter = response.payload.symbols[0].filters[0]
assert isinstance(parsed_filter, DzengiUnknownFilter)
assert parsed_filter.filter_type == "FUTURE_FILTER"
assert parsed_filter.fields == (
("enabled", True),
("limit", 10),
("mode", "STRICT"),
)
def test_parse_exchange_filters_as_unknown_filters() -> None:
document = _validated_document(
{
"exchangeFilters": [
{
"filterType": "GLOBAL_LIMIT",
"enabled": True,
"limit": 100,
}
],
"symbols": [],
}
)
response = parse_exchange_info(document)
exchange_filter = response.payload.exchange_filters[0]
assert exchange_filter.filter_type == "GLOBAL_LIMIT"
assert exchange_filter.fields == (
("enabled", True),
("limit", 100),
)
def test_parse_exchange_filter_without_filter_type() -> None:
document = _validated_document(
{
"exchangeFilters": [
{
"enabled": True,
}
],
"symbols": [],
}
)
response = parse_exchange_info(document)
assert response.payload.exchange_filters[0].filter_type == ""
assert response.payload.exchange_filters[0].fields == (
("enabled", True),
)
def test_parse_symbol_with_missing_optional_fields() -> None:
document = _validated_document(
{
"symbols": [
{
"symbol": "TEST/USD",
"name": "Test",
"status": "BREAK",
"baseAsset": "TEST",
"quoteAsset": "USD",
"marketType": "SPOT",
}
],
}
)
response = parse_exchange_info(document)
symbol = response.payload.symbols[0]
assert symbol.asset_type is None
assert symbol.base_asset_precision is None
assert symbol.quote_asset_id is None
assert symbol.quote_precision is None
assert symbol.order_types == ()
assert symbol.filters == ()
assert symbol.market_modes == ()
assert symbol.country is None
assert symbol.tick_size is None
assert symbol.min_sl_gap is None
@pytest.mark.parametrize(
"field",
[
"symbol",
"name",
"status",
"baseAsset",
"quoteAsset",
"marketType",
],
)
def test_reject_missing_required_symbol_field(field: str) -> None:
symbol = _complete_symbol()
symbol.pop(field)
document = _validated_document(
{
"symbols": [symbol],
}
)
with pytest.raises(
InstrumentReferenceParseError,
match=rf"\.{field} должен быть строкой",
):
parse_exchange_info(document)
def test_reject_invalid_required_string_type() -> None:
symbol = _complete_symbol()
symbol["symbol"] = 123
document = _validated_document(
{
"symbols": [symbol],
}
)
with pytest.raises(
InstrumentReferenceParseError,
match=r"\.symbol должен быть строкой",
):
parse_exchange_info(document)
def test_reject_invalid_json_number_type() -> None:
symbol = _complete_symbol()
symbol["tickSize"] = "0.01"
document = _validated_document(
{
"symbols": [symbol],
}
)
with pytest.raises(
InstrumentReferenceParseError,
match=r"\.tickSize должен быть JSON-числом",
):
parse_exchange_info(document)
def test_reject_bool_as_json_number() -> None:
symbol = _complete_symbol()
symbol["tickSize"] = True
document = _validated_document(
{
"symbols": [symbol],
}
)
with pytest.raises(
InstrumentReferenceParseError,
match=r"\.tickSize должен быть JSON-числом",
):
parse_exchange_info(document)
def test_reject_float_as_integer_field() -> None:
symbol = _complete_symbol()
symbol["baseAssetPrecision"] = 3.0
document = _validated_document(
{
"symbols": [symbol],
}
)
with pytest.raises(
InstrumentReferenceParseError,
match=r"\.baseAssetPrecision должен быть целым числом",
):
parse_exchange_info(document)
def test_reject_nested_unknown_filter_value() -> None:
symbol = _complete_symbol()
symbol["filters"] = [
{
"filterType": "FUTURE_FILTER",
"settings": {
"enabled": True,
},
}
]
document = _validated_document(
{
"symbols": [symbol],
}
)
with pytest.raises(
InstrumentReferenceParseError,
match=r"\.settings должен быть скалярным JSON-значением",
):
parse_exchange_info(document)
def test_parse_result_uses_immutable_sequences() -> None:
document = _validated_document(
{
"rateLimits": [],
"exchangeFilters": [],
"symbols": [_complete_symbol()],
}
)
response = parse_exchange_info(document)
symbol = response.payload.symbols[0]
assert isinstance(response.payload.rate_limits, tuple)
assert isinstance(response.payload.exchange_filters, tuple)
assert isinstance(response.payload.symbols, tuple)
assert isinstance(symbol.order_types, tuple)
assert isinstance(symbol.filters, tuple)
assert isinstance(symbol.market_modes, tuple)

View File

@@ -0,0 +1,128 @@
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
import pytest
from src.market_data.acquisition.adapters.dzengi.mapper import (
map_dzengi_ticker_to_quote,
)
from src.market_data.acquisition.adapters.dzengi.models import (
DzengiTicker24hrResponse,
)
from src.market_data.acquisition.exceptions import QuoteMappingError
from src.market_data.acquisition.models.quote import Quote
def _response(**overrides: object) -> DzengiTicker24hrResponse:
values: dict[str, object] = {
"symbol": "BTC/USD_LEVERAGE",
"last_price": "64159.45",
"bid_price": "64159.45",
"ask_price": "64159.55",
"close_time": 1783887270312,
}
values.update(overrides)
return DzengiTicker24hrResponse(**values) # type: ignore[arg-type]
def test_mapper_returns_canonical_quote() -> None:
received_at = datetime(2026, 7, 12, 18, 0, tzinfo=timezone.utc)
result = map_dzengi_ticker_to_quote(
_response(),
received_at=received_at,
)
assert result == Quote(
symbol="BTC/USD_LEVERAGE",
last_price=Decimal("64159.45"),
bid_price=Decimal("64159.45"),
ask_price=Decimal("64159.55"),
exchange_timestamp=datetime.fromtimestamp(
1783887270312 / 1000,
tz=timezone.utc,
),
received_at=received_at,
source="dzengi",
)
def test_mapper_preserves_decimal_precision() -> None:
result = map_dzengi_ticker_to_quote(
_response(
last_price="0.123456789123456789",
bid_price="0.123456789123456788",
ask_price="0.123456789123456790",
),
received_at=datetime.now(timezone.utc),
)
assert result.last_price == Decimal("0.123456789123456789")
assert result.bid_price == Decimal("0.123456789123456788")
assert result.ask_price == Decimal("0.123456789123456790")
def test_mapper_strips_symbol_outer_spaces() -> None:
result = map_dzengi_ticker_to_quote(
_response(symbol=" BTC/USD_LEVERAGE "),
received_at=datetime.now(timezone.utc),
)
assert result.symbol == "BTC/USD_LEVERAGE"
def test_mapper_rejects_non_finite_price() -> None:
with pytest.raises(
QuoteMappingError,
match=r"lastPrice.*конечным числом",
):
map_dzengi_ticker_to_quote(
_response(last_price="NaN"),
received_at=datetime.now(timezone.utc),
)
def test_mapper_wraps_invalid_price_conversion() -> None:
with pytest.raises(
QuoteMappingError,
match=r"bidPrice.*Decimal",
):
map_dzengi_ticker_to_quote(
_response(bid_price="not-a-number"),
received_at=datetime.now(timezone.utc),
)
def test_mapper_rejects_naive_received_at() -> None:
with pytest.raises(
QuoteMappingError,
match=r"received_at.*timezone-aware",
):
map_dzengi_ticker_to_quote(
_response(),
received_at=datetime(2026, 7, 12, 18, 0),
)
def test_mapper_preserves_received_at_timezone() -> None:
received_at = datetime.fromisoformat("2026-07-12T21:00:00+03:00")
result = map_dzengi_ticker_to_quote(
_response(),
received_at=received_at,
)
assert result.received_at is received_at
def test_mapper_wraps_invalid_close_time() -> None:
with pytest.raises(
QuoteMappingError,
match=r"closeTime.*UTC datetime",
):
map_dzengi_ticker_to_quote(
_response(close_time=10**30),
received_at=datetime.now(timezone.utc),
)

View File

@@ -0,0 +1,77 @@
from __future__ import annotations
import pytest
from src.market_data.acquisition.adapters.dzengi.models import (
DzengiTicker24hrResponse,
)
from src.market_data.acquisition.adapters.dzengi.parser import parse_quote
from src.market_data.acquisition.exceptions import QuoteParseError
from src.market_data.acquisition.validation.schema import validate_quote_schema
def _document() -> dict[str, object]:
return {
"symbol": "BTC/USD_LEVERAGE",
"lastPrice": "64159.45",
"bidPrice": "64159.45",
"askPrice": "64159.55",
"closeTime": 1783887270312,
"highPrice": "64261.45",
"volume": "9.6002",
}
def test_parse_quote_builds_dzengi_transport_model() -> None:
validated = validate_quote_schema(_document())
result = parse_quote(validated)
assert result == DzengiTicker24hrResponse(
symbol="BTC/USD_LEVERAGE",
last_price="64159.45",
bid_price="64159.45",
ask_price="64159.55",
close_time=1783887270312,
)
def test_parse_quote_ignores_unrelated_24hr_statistics() -> None:
document = _document()
document["openPrice"] = "63785.75"
document["weightedAvgPrice"] = "64159.50"
result = parse_quote(validate_quote_schema(document))
assert result.symbol == "BTC/USD_LEVERAGE"
assert not hasattr(result, "open_price")
assert not hasattr(result, "weighted_avg_price")
def test_parse_quote_accepts_json_numbers_for_prices() -> None:
document = _document()
document["lastPrice"] = 64159.45
document["bidPrice"] = 64159
document["askPrice"] = 64160
result = parse_quote(validate_quote_schema(document))
assert result.last_price == 64159.45
assert result.bid_price == 64159
assert result.ask_price == 64160
def test_parse_quote_rejects_boolean_price() -> None:
document = _document()
document["lastPrice"] = True
with pytest.raises(QuoteParseError, match="lastPrice"):
parse_quote(validate_quote_schema(document))
def test_parse_quote_rejects_non_integer_close_time() -> None:
document = _document()
document["closeTime"] = "1783887270312"
with pytest.raises(QuoteParseError, match="closeTime"):
parse_quote(validate_quote_schema(document))

View File

@@ -0,0 +1,371 @@
# app/tests/unit/market_data/acquisition/adapters/dzengi/test_rest.py
from __future__ import annotations
import pytest
from src.integrations.exchange.exceptions import (
ExchangeConnectionError,
ExchangeResponseError,
)
from src.market_data.acquisition.adapters.dzengi.rest import (
DzengiInstrumentDocumentSource,
)
from src.market_data.acquisition.exceptions import (
InstrumentReferenceTransportError,
)
from src.market_data.acquisition.protocol import (
InstrumentDocumentSource,
)
class StubRestClient:
def __init__(
self,
*,
result: object = None,
error: Exception | None = None,
) -> None:
self.result = result
self.error = error
self.calls: list[str] = []
def get_payload(
self,
path: str,
params: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
) -> object:
del params
del headers
self.calls.append(path)
if self.error is not None:
raise self.error
return self.result
def test_source_satisfies_instrument_document_source_protocol() -> None:
source = DzengiInstrumentDocumentSource(
client=StubRestClient(
result={
"symbols": [],
}
)
)
assert isinstance(source, InstrumentDocumentSource)
def test_fetch_instrument_document_calls_exchange_info_endpoint() -> None:
client = StubRestClient(
result={
"symbols": [],
}
)
source = DzengiInstrumentDocumentSource(client=client)
result = source.fetch_instrument_document()
assert result == {
"symbols": [],
}
assert client.calls == [
"/api/v1/exchangeInfo",
]
def test_fetch_instrument_document_returns_dict_without_changes() -> None:
document = {
"timezone": "UTC",
"serverTime": 1783537921471,
"symbols": [
{
"symbol": "BTC/USD_LEVERAGE",
}
],
}
source = DzengiInstrumentDocumentSource(
client=StubRestClient(result=document)
)
result = source.fetch_instrument_document()
assert result is document
def test_fetch_instrument_document_returns_list_without_changes() -> None:
document = [
{
"symbol": "BTC/USD_LEVERAGE",
}
]
source = DzengiInstrumentDocumentSource(
client=StubRestClient(result=document)
)
result = source.fetch_instrument_document()
assert result is document
def test_source_uses_injected_client() -> None:
client = StubRestClient(
result={
"symbols": [],
}
)
source = DzengiInstrumentDocumentSource(client=client)
source.fetch_instrument_document()
source.fetch_instrument_document()
assert client.calls == [
"/api/v1/exchangeInfo",
"/api/v1/exchangeInfo",
]
@pytest.mark.parametrize(
"error",
[
ExchangeConnectionError("Network error."),
ExchangeResponseError("Invalid response."),
RuntimeError("Unexpected transport failure."),
],
)
def test_transport_errors_are_wrapped(
error: Exception,
) -> None:
source = DzengiInstrumentDocumentSource(
client=StubRestClient(error=error)
)
with pytest.raises(
InstrumentReferenceTransportError,
match=r"Не удалось получить Instrument Reference Data от Dzengi",
) as exc_info:
source.fetch_instrument_document()
assert exc_info.value.__cause__ is error
assert str(error) in str(exc_info.value)
def test_client_creation_error_is_wrapped(
monkeypatch: pytest.MonkeyPatch,
) -> None:
original_error = RuntimeError("EXCHANGE_BASE_URL is invalid.")
def raise_client_creation_error() -> None:
raise original_error
monkeypatch.setattr(
"src.market_data.acquisition.adapters.dzengi.rest.ExchangeRestClient",
raise_client_creation_error,
)
source = DzengiInstrumentDocumentSource()
with pytest.raises(
InstrumentReferenceTransportError,
match=r"Не удалось получить Instrument Reference Data от Dzengi",
) as exc_info:
source.fetch_instrument_document()
assert exc_info.value.__cause__ is original_error
def test_adapter_does_not_transform_returned_document() -> None:
document = {
"status": "OK",
"payload": {
"symbols": [],
},
}
source = DzengiInstrumentDocumentSource(
client=StubRestClient(result=document)
)
result = source.fetch_instrument_document()
assert result is document
assert result == {
"status": "OK",
"payload": {
"symbols": [],
},
}
# Quotes Feed REST source tests.
from src.market_data.acquisition.adapters.dzengi.rest import (
DzengiQuoteDocumentSource,
)
from src.market_data.acquisition.exceptions import QuoteTransportError
from src.market_data.acquisition.protocol import QuoteDocumentSource
class RecordingQuoteRestClient:
def __init__(
self,
*,
result: object = None,
error: Exception | None = None,
) -> None:
self.result = result
self.error = error
self.calls: list[dict[str, object]] = []
def get_payload(
self,
path: str,
params: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
) -> object:
self.calls.append(
{
"path": path,
"params": params,
"headers": headers,
}
)
if self.error is not None:
raise self.error
return self.result
def test_quote_source_satisfies_quote_document_source_protocol() -> None:
source = DzengiQuoteDocumentSource(
client=RecordingQuoteRestClient(result={})
)
assert isinstance(source, QuoteDocumentSource)
def test_fetch_quote_document_calls_ticker_endpoint_with_symbol() -> None:
client = RecordingQuoteRestClient(result={})
source = DzengiQuoteDocumentSource(client=client)
source.fetch_quote_document("BTC/USD_LEVERAGE")
assert client.calls == [
{
"path": "/api/v1/ticker/24hr",
"params": {
"symbol": "BTC/USD_LEVERAGE",
},
"headers": None,
}
]
def test_fetch_quote_document_passes_symbol_without_changes() -> None:
client = RecordingQuoteRestClient(result={})
source = DzengiQuoteDocumentSource(client=client)
source.fetch_quote_document(" btc/usd_leverage ")
assert client.calls[0]["params"] == {
"symbol": " btc/usd_leverage ",
}
def test_fetch_quote_document_returns_payload_without_changes() -> None:
document = {
"symbol": "BTC/USD_LEVERAGE",
"lastPrice": "64159.45",
"bidPrice": "64159.45",
"askPrice": "64159.55",
"closeTime": 1783887270312,
}
source = DzengiQuoteDocumentSource(
client=RecordingQuoteRestClient(result=document)
)
result = source.fetch_quote_document("BTC/USD_LEVERAGE")
assert result is document
def test_quote_source_uses_injected_client_once() -> None:
client = RecordingQuoteRestClient(result={})
source = DzengiQuoteDocumentSource(client=client)
source.fetch_quote_document("BTC/USD_LEVERAGE")
assert len(client.calls) == 1
def test_quote_source_creates_default_client(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client = RecordingQuoteRestClient(result={})
client_creation_count = 0
def create_client() -> RecordingQuoteRestClient:
nonlocal client_creation_count
client_creation_count += 1
return client
monkeypatch.setattr(
"src.market_data.acquisition.adapters.dzengi.rest.ExchangeRestClient",
create_client,
)
source = DzengiQuoteDocumentSource()
source.fetch_quote_document("BTC/USD_LEVERAGE")
assert client_creation_count == 1
assert len(client.calls) == 1
@pytest.mark.parametrize(
"error",
[
ExchangeConnectionError("Network error."),
ExchangeResponseError("Invalid response."),
RuntimeError("Unexpected transport failure."),
],
)
def test_quote_transport_errors_are_wrapped(
error: Exception,
) -> None:
source = DzengiQuoteDocumentSource(
client=RecordingQuoteRestClient(error=error)
)
with pytest.raises(
QuoteTransportError,
match=r"Не удалось получить текущую котировку от Dzengi",
) as exc_info:
source.fetch_quote_document("BTC/USD_LEVERAGE")
assert exc_info.value.__cause__ is error
assert str(error) in str(exc_info.value)
def test_quote_client_creation_error_is_wrapped(
monkeypatch: pytest.MonkeyPatch,
) -> None:
original_error = RuntimeError("EXCHANGE_BASE_URL is invalid.")
def raise_client_creation_error() -> None:
raise original_error
monkeypatch.setattr(
"src.market_data.acquisition.adapters.dzengi.rest.ExchangeRestClient",
raise_client_creation_error,
)
source = DzengiQuoteDocumentSource()
with pytest.raises(QuoteTransportError) as exc_info:
source.fetch_quote_document("BTC/USD_LEVERAGE")
assert exc_info.value.__cause__ is original_error

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from src.market_data.acquisition.adapters.dzengi.websocket import (
DzengiWebSocketQuoteAdapter,
)
from src.market_data.acquisition.exceptions import QuoteValueError
def test_adapter_maps_document_to_quote() -> None:
received_at = datetime(2026, 7, 13, tzinfo=timezone.utc)
result = DzengiWebSocketQuoteAdapter().map_message(
{
"Payload": {
"symbolName": "BTC/USD",
"bids": [["10", "1"]],
"asks": [["12", "1"]],
"timestamp": 1000,
}
},
received_at=received_at,
)
assert str(result.last_price) == "11"
assert result.received_at is received_at
def test_adapter_preserves_layer_error() -> None:
with pytest.raises(QuoteValueError):
DzengiWebSocketQuoteAdapter().map_message(
{"symbol": "BTC/USD", "bid": "12", "ask": "11"}
)

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
import pytest
from src.market_data.acquisition.adapters.dzengi.mapper import (
map_dzengi_websocket_quote_to_quote,
)
from src.market_data.acquisition.adapters.dzengi.models import DzengiWebSocketQuoteResponse
from src.market_data.acquisition.exceptions import QuoteMappingError
def _response(timestamp: int | None = 1000) -> DzengiWebSocketQuoteResponse:
return DzengiWebSocketQuoteResponse(
symbol="BTC/USD",
bid_price="10.1",
ask_price="10.3",
timestamp=timestamp,
)
def test_maps_midpoint_and_timestamps() -> None:
received_at = datetime(2026, 7, 13, tzinfo=timezone.utc)
result = map_dzengi_websocket_quote_to_quote(_response(), received_at=received_at)
assert result.last_price == Decimal("10.2")
assert result.bid_price == Decimal("10.1")
assert result.ask_price == Decimal("10.3")
assert result.exchange_timestamp == datetime.fromtimestamp(1, tz=timezone.utc)
assert result.received_at is received_at
assert result.source == "dzengi"
def test_allows_missing_exchange_timestamp() -> None:
result = map_dzengi_websocket_quote_to_quote(
_response(None),
received_at=datetime.now(timezone.utc),
)
assert result.exchange_timestamp is None
def test_rejects_naive_received_at() -> None:
with pytest.raises(QuoteMappingError):
map_dzengi_websocket_quote_to_quote(
_response(),
received_at=datetime(2026, 7, 13),
)

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
import pytest
from src.market_data.acquisition.adapters.dzengi.parser import (
parse_dzengi_websocket_quote,
)
from src.market_data.acquisition.exceptions import QuoteParseError
from src.market_data.acquisition.validation.schema import (
validate_dzengi_websocket_quote_schema,
)
def _parse(document: object):
return parse_dzengi_websocket_quote(
validate_dzengi_websocket_quote_schema(document)
)
def test_parses_direct_quote() -> None:
result = _parse(
{"symbolName": "BTC/USD", "bid": "10", "ofr": "11", "timestamp": 1000}
)
assert result.symbol == "BTC/USD"
assert result.bid_price == "10"
assert result.ask_price == "11"
assert result.timestamp == 1000
def test_parses_depth_list_entries() -> None:
result = _parse(
{"symbol": "BTC/USD", "bids": [["10", "2"]], "asks": [["11", "3"]]}
)
assert result.bid_price == "10"
assert result.ask_price == "11"
assert result.timestamp is None
def test_parses_depth_dict_aliases() -> None:
result = _parse(
{"symbol": "BTC/USD", "bids": [{"p": "10"}], "asks": [{"askPrice": "11"}]}
)
assert result.bid_price == "10"
assert result.ask_price == "11"
def test_rejects_invalid_depth_item() -> None:
validated = validate_dzengi_websocket_quote_schema(
{"symbol": "BTC/USD", "bids": ["10"], "asks": [["11"]]}
)
with pytest.raises(QuoteParseError):
parse_dzengi_websocket_quote(validated)

View File

@@ -0,0 +1,328 @@
# app/tests/unit/market_data/acquisition/feeds/test_instrument_feed.py
from __future__ import annotations
from decimal import Decimal
import pytest
from src.market_data.acquisition.exceptions import (
InstrumentReferenceTransportError,
InstrumentReferenceValueError,
)
from src.market_data.acquisition.feeds.instrument_feed import InstrumentFeed
from src.market_data.acquisition.models.instrument import Instrument
from src.market_data.acquisition.protocol import InstrumentFeedProtocol
def _instrument(
*,
symbol: str = "BTC/USD_LEVERAGE",
) -> Instrument:
return Instrument(
symbol=symbol,
name=symbol,
status="TRADING",
base_asset="BTC",
quote_asset="USD",
asset_type="CRYPTOCURRENCY",
market_type="LEVERAGE",
market_modes=("REGULAR",),
order_types=("LIMIT", "MARKET"),
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,
)
class StubInstrumentDocumentSource:
def __init__(
self,
*,
document: object,
error: Exception | None = None,
) -> None:
self.document = document
self.error = error
self.call_count = 0
def fetch_instrument_document(self) -> object:
self.call_count += 1
if self.error is not None:
raise self.error
return self.document
class StubInstrumentDocumentHandler:
def __init__(
self,
*,
instruments: tuple[Instrument, ...],
error: Exception | None = None,
) -> None:
self.instruments = instruments
self.error = error
self.documents: list[object] = []
def handle_instrument_document(
self,
document: object,
) -> tuple[Instrument, ...]:
self.documents.append(document)
if self.error is not None:
raise self.error
return self.instruments
def test_feed_satisfies_instrument_feed_protocol() -> None:
source = StubInstrumentDocumentSource(
document={
"symbols": [],
}
)
handler = StubInstrumentDocumentHandler(
instruments=(),
)
feed = InstrumentFeed(
source=source,
handler=handler,
)
assert isinstance(feed, InstrumentFeedProtocol)
def test_feed_calls_source_once() -> None:
source = StubInstrumentDocumentSource(
document={
"symbols": [],
}
)
handler = StubInstrumentDocumentHandler(
instruments=(),
)
feed = InstrumentFeed(
source=source,
handler=handler,
)
feed.load_instruments()
assert source.call_count == 1
def test_feed_calls_handler_once() -> None:
source = StubInstrumentDocumentSource(
document={
"symbols": [],
}
)
handler = StubInstrumentDocumentHandler(
instruments=(),
)
feed = InstrumentFeed(
source=source,
handler=handler,
)
feed.load_instruments()
assert len(handler.documents) == 1
def test_feed_passes_document_to_handler_without_changes() -> None:
document = {
"status": "OK",
"payload": {
"symbols": [],
},
}
source = StubInstrumentDocumentSource(
document=document,
)
handler = StubInstrumentDocumentHandler(
instruments=(),
)
feed = InstrumentFeed(
source=source,
handler=handler,
)
feed.load_instruments()
assert handler.documents == [document]
assert handler.documents[0] is document
def test_feed_returns_handler_result_without_changes() -> None:
instruments = (
_instrument(),
)
source = StubInstrumentDocumentSource(
document={
"symbols": [],
}
)
handler = StubInstrumentDocumentHandler(
instruments=instruments,
)
feed = InstrumentFeed(
source=source,
handler=handler,
)
result = feed.load_instruments()
assert result is instruments
def test_feed_preserves_instrument_order() -> None:
instruments = (
_instrument(symbol="BTC/USD_LEVERAGE"),
_instrument(symbol="ETH/USD_LEVERAGE"),
_instrument(symbol="XRP/USD_LEVERAGE"),
)
source = StubInstrumentDocumentSource(
document={
"symbols": [],
}
)
handler = StubInstrumentDocumentHandler(
instruments=instruments,
)
feed = InstrumentFeed(
source=source,
handler=handler,
)
result = feed.load_instruments()
assert tuple(item.symbol for item in result) == (
"BTC/USD_LEVERAGE",
"ETH/USD_LEVERAGE",
"XRP/USD_LEVERAGE",
)
def test_feed_returns_empty_tuple_without_error() -> None:
source = StubInstrumentDocumentSource(
document={
"symbols": [],
}
)
handler = StubInstrumentDocumentHandler(
instruments=(),
)
feed = InstrumentFeed(
source=source,
handler=handler,
)
result = feed.load_instruments()
assert result == ()
def test_feed_preserves_transport_error_without_wrapping() -> None:
original_error = InstrumentReferenceTransportError(
"Не удалось получить exchangeInfo."
)
source = StubInstrumentDocumentSource(
document=None,
error=original_error,
)
handler = StubInstrumentDocumentHandler(
instruments=(),
)
feed = InstrumentFeed(
source=source,
handler=handler,
)
with pytest.raises(
InstrumentReferenceTransportError,
) as exc_info:
feed.load_instruments()
assert exc_info.value is original_error
assert source.call_count == 1
assert handler.documents == []
def test_feed_preserves_processing_error_without_wrapping() -> None:
document = {
"symbols": [],
}
original_error = InstrumentReferenceValueError(
"Некорректное значение."
)
source = StubInstrumentDocumentSource(
document=document,
)
handler = StubInstrumentDocumentHandler(
instruments=(),
error=original_error,
)
feed = InstrumentFeed(
source=source,
handler=handler,
)
with pytest.raises(
InstrumentReferenceValueError,
) as exc_info:
feed.load_instruments()
assert exc_info.value is original_error
assert source.call_count == 1
assert handler.documents == [document]
def test_feed_does_not_retry_source_after_transport_error() -> None:
original_error = InstrumentReferenceTransportError(
"Network error."
)
source = StubInstrumentDocumentSource(
document=None,
error=original_error,
)
handler = StubInstrumentDocumentHandler(
instruments=(),
)
feed = InstrumentFeed(
source=source,
handler=handler,
)
with pytest.raises(InstrumentReferenceTransportError):
feed.load_instruments()
assert source.call_count == 1

View File

@@ -0,0 +1,220 @@
# app/tests/unit/market_data/acquisition/feeds/test_quotes_feed.py
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
import pytest
from src.market_data.acquisition.exceptions import (
QuoteTransportError,
QuoteValueError,
)
from src.market_data.acquisition.feeds.quotes_feed import QuotesFeed
from src.market_data.acquisition.models.quote import Quote
from src.market_data.acquisition.protocol import QuoteFeedProtocol
def _quote(
*,
symbol: str = "BTC/USD_LEVERAGE",
) -> Quote:
return Quote(
symbol=symbol,
last_price=Decimal("64159.45"),
bid_price=Decimal("64159.45"),
ask_price=Decimal("64159.55"),
exchange_timestamp=datetime(
2026,
7,
12,
16,
14,
30,
tzinfo=timezone.utc,
),
received_at=datetime(
2026,
7,
12,
16,
14,
31,
tzinfo=timezone.utc,
),
source="dzengi",
)
class StubQuoteDocumentSource:
def __init__(
self,
*,
document: object,
error: Exception | None = None,
) -> None:
self.document = document
self.error = error
self.symbols: list[str] = []
def fetch_quote_document(
self,
symbol: str,
) -> object:
self.symbols.append(symbol)
if self.error is not None:
raise self.error
return self.document
class StubQuoteDocumentHandler:
def __init__(
self,
*,
quote: Quote,
error: Exception | None = None,
) -> None:
self.quote = quote
self.error = error
self.documents: list[object] = []
def handle_quote_document(
self,
document: object,
) -> Quote:
self.documents.append(document)
if self.error is not None:
raise self.error
return self.quote
def test_feed_satisfies_quote_feed_protocol() -> None:
feed = QuotesFeed(
source=StubQuoteDocumentSource(document={}),
handler=StubQuoteDocumentHandler(quote=_quote()),
)
assert isinstance(feed, QuoteFeedProtocol)
def test_feed_calls_source_once() -> None:
source = StubQuoteDocumentSource(document={})
feed = QuotesFeed(
source=source,
handler=StubQuoteDocumentHandler(quote=_quote()),
)
feed.load_quote("BTC/USD_LEVERAGE")
assert source.symbols == ["BTC/USD_LEVERAGE"]
def test_feed_passes_symbol_to_source_without_changes() -> None:
source = StubQuoteDocumentSource(document={})
feed = QuotesFeed(
source=source,
handler=StubQuoteDocumentHandler(quote=_quote()),
)
feed.load_quote(" btc/usd_leverage ")
assert source.symbols == [" btc/usd_leverage "]
def test_feed_calls_handler_once() -> None:
handler = StubQuoteDocumentHandler(quote=_quote())
feed = QuotesFeed(
source=StubQuoteDocumentSource(document={}),
handler=handler,
)
feed.load_quote("BTC/USD_LEVERAGE")
assert len(handler.documents) == 1
def test_feed_passes_document_to_handler_without_changes() -> None:
document = {
"symbol": "BTC/USD_LEVERAGE",
"lastPrice": "64159.45",
}
handler = StubQuoteDocumentHandler(quote=_quote())
feed = QuotesFeed(
source=StubQuoteDocumentSource(document=document),
handler=handler,
)
feed.load_quote("BTC/USD_LEVERAGE")
assert handler.documents == [document]
assert handler.documents[0] is document
def test_feed_returns_handler_result_without_copying() -> None:
quote = _quote()
feed = QuotesFeed(
source=StubQuoteDocumentSource(document={}),
handler=StubQuoteDocumentHandler(quote=quote),
)
result = feed.load_quote("BTC/USD_LEVERAGE")
assert result is quote
def test_feed_preserves_transport_error_without_wrapping() -> None:
original_error = QuoteTransportError("Network error.")
handler = StubQuoteDocumentHandler(quote=_quote())
feed = QuotesFeed(
source=StubQuoteDocumentSource(
document=None,
error=original_error,
),
handler=handler,
)
with pytest.raises(QuoteTransportError) as exc_info:
feed.load_quote("BTC/USD_LEVERAGE")
assert exc_info.value is original_error
assert handler.documents == []
def test_feed_preserves_handler_error_without_wrapping() -> None:
document = {"symbol": "BTC/USD_LEVERAGE"}
original_error = QuoteValueError("Invalid quote.")
handler = StubQuoteDocumentHandler(
quote=_quote(),
error=original_error,
)
feed = QuotesFeed(
source=StubQuoteDocumentSource(document=document),
handler=handler,
)
with pytest.raises(QuoteValueError) as exc_info:
feed.load_quote("BTC/USD_LEVERAGE")
assert exc_info.value is original_error
assert handler.documents == [document]
def test_feed_does_not_retry_source_after_error() -> None:
source = StubQuoteDocumentSource(
document=None,
error=QuoteTransportError("Network error."),
)
feed = QuotesFeed(
source=source,
handler=StubQuoteDocumentHandler(quote=_quote()),
)
with pytest.raises(QuoteTransportError):
feed.load_quote("BTC/USD_LEVERAGE")
assert source.symbols == ["BTC/USD_LEVERAGE"]

View File

@@ -0,0 +1,220 @@
# app/tests/unit/market_data/acquisition/handlers/test_instrument_handler.py
from __future__ import annotations
from decimal import Decimal
import pytest
from src.market_data.acquisition.exceptions import (
InstrumentReferenceMappingError,
InstrumentReferenceParseError,
InstrumentReferenceSchemaError,
InstrumentReferenceValueError,
)
from src.market_data.acquisition.handlers.instrument_handler import (
DzengiInstrumentDocumentHandler,
)
from src.market_data.acquisition.protocol import (
InstrumentDocumentHandler,
)
def _valid_symbol_document() -> dict[str, object]:
return {
"symbol": "BTC/USD_LEVERAGE",
"name": "BTC/USD",
"status": "TRADING",
"assetType": "CRYPTOCURRENCY",
"baseAsset": "BTC",
"baseAssetPrecision": 4,
"quoteAsset": "USD",
"quoteAssetId": "USD_LEVERAGE",
"quotePrecision": 4,
"orderTypes": [
"LIMIT",
"MARKET",
"STOP",
],
"filters": [
{
"filterType": "LOT_SIZE",
"minQty": "0.0001",
"maxQty": "1000",
"stepSize": "0.0001",
},
{
"filterType": "MIN_NOTIONAL",
"minNotional": "1",
},
],
"marketModes": [
"REGULAR",
],
"marketType": "LEVERAGE",
"country": "",
"sector": "",
"industry": "",
"tradingHours": None,
"tickSize": 0.05,
"tickValue": 3878.86,
"tradingFee": 0.06,
"exchangeFee": None,
"longRate": -0.01,
"shortRate": 0.01,
"swapChargeInterval": 480,
"minSLGap": 0,
"maxSLGap": 50.0,
"minTPGap": 0,
"maxTPGap": 50.0,
}
def _valid_unwrapped_document() -> dict[str, object]:
return {
"timezone": "UTC",
"serverTime": 1783537921471,
"rateLimits": [],
"exchangeFilters": [],
"symbols": [
_valid_symbol_document(),
],
}
def _valid_wrapped_document() -> dict[str, object]:
return {
"status": "OK",
"correlationId": "2",
"payload": _valid_unwrapped_document(),
}
def test_handler_satisfies_instrument_document_handler_protocol() -> None:
handler = DzengiInstrumentDocumentHandler()
assert isinstance(handler, InstrumentDocumentHandler)
def test_handler_processes_valid_unwrapped_document() -> None:
handler = DzengiInstrumentDocumentHandler()
instruments = handler.handle_instrument_document(
_valid_unwrapped_document()
)
assert isinstance(instruments, tuple)
assert len(instruments) == 1
instrument = instruments[0]
assert instrument.symbol == "BTC/USD_LEVERAGE"
assert instrument.name == "BTC/USD"
assert instrument.status == "TRADING"
assert instrument.base_asset == "BTC"
assert instrument.quote_asset == "USD"
assert instrument.asset_type == "CRYPTOCURRENCY"
assert instrument.market_type == "LEVERAGE"
assert instrument.market_modes == ("REGULAR",)
assert instrument.order_types == (
"LIMIT",
"MARKET",
"STOP",
)
def test_handler_processes_valid_wrapped_document() -> None:
handler = DzengiInstrumentDocumentHandler()
instruments = handler.handle_instrument_document(
_valid_wrapped_document()
)
assert len(instruments) == 1
assert instruments[0].symbol == "BTC/USD_LEVERAGE"
def test_handler_returns_exact_decimal_values() -> None:
handler = DzengiInstrumentDocumentHandler()
instruments = handler.handle_instrument_document(
_valid_unwrapped_document()
)
instrument = instruments[0]
assert instrument.tick_size == Decimal("0.05")
assert instrument.tick_value == Decimal("3878.86")
assert instrument.step_size == Decimal("0.0001")
assert instrument.min_qty == Decimal("0.0001")
assert instrument.max_qty == Decimal("1000")
assert instrument.min_notional == Decimal("1")
def test_handler_returns_empty_tuple_for_empty_symbols() -> None:
document = _valid_unwrapped_document()
document["symbols"] = []
handler = DzengiInstrumentDocumentHandler()
instruments = handler.handle_instrument_document(document)
assert instruments == ()
def test_handler_preserves_schema_error() -> None:
handler = DzengiInstrumentDocumentHandler()
with pytest.raises(InstrumentReferenceSchemaError):
handler.handle_instrument_document([])
def test_handler_preserves_parse_error() -> None:
document = _valid_unwrapped_document()
symbol = _valid_symbol_document()
symbol["baseAssetPrecision"] = True
document["symbols"] = [symbol]
handler = DzengiInstrumentDocumentHandler()
with pytest.raises(InstrumentReferenceParseError):
handler.handle_instrument_document(document)
def test_handler_preserves_value_error() -> None:
document = _valid_unwrapped_document()
symbol = _valid_symbol_document()
symbol["tickSize"] = 0
document["symbols"] = [symbol]
handler = DzengiInstrumentDocumentHandler()
with pytest.raises(InstrumentReferenceValueError):
handler.handle_instrument_document(document)
def test_handler_preserves_mapping_error() -> None:
document = _valid_unwrapped_document()
symbol = _valid_symbol_document()
lot_size = {
"filterType": "LOT_SIZE",
"minQty": "0.0001",
"maxQty": "1000",
"stepSize": "0.0001",
}
symbol["filters"] = [
lot_size,
lot_size.copy(),
]
document["symbols"] = [symbol]
handler = DzengiInstrumentDocumentHandler()
with pytest.raises(
InstrumentReferenceMappingError,
match=r"несколько фильтров LOT_SIZE",
):
handler.handle_instrument_document(document)

View File

@@ -0,0 +1,156 @@
# app/tests/unit/market_data/acquisition/handlers/test_quotes_handler.py
from __future__ import annotations
from datetime import timezone
from decimal import Decimal
from typing import TypeAlias
import pytest
import src.market_data.acquisition.handlers.quotes_handler as handler_module
from src.market_data.acquisition.adapters.dzengi.models import (
DzengiTicker24hrResponse,
)
from src.market_data.acquisition.exceptions import QuoteSchemaError
from src.market_data.acquisition.handlers.quotes_handler import (
DzengiQuoteDocumentHandler,
)
from src.market_data.acquisition.models.quote import Quote
from src.market_data.acquisition.protocol import QuoteDocumentHandler
PipelineCall: TypeAlias = (
tuple[str, object]
| tuple[str, object, object]
)
def _document() -> dict[str, object]:
return {
"symbol": "BTC/USD_LEVERAGE",
"lastPrice": "64159.45",
"bidPrice": "64159.45",
"askPrice": "64159.55",
"closeTime": 1783887270312,
"volume": "9.6002",
}
def test_handler_implements_quote_document_handler_protocol() -> None:
handler = DzengiQuoteDocumentHandler()
assert isinstance(handler, QuoteDocumentHandler)
def test_handler_returns_canonical_quote() -> None:
before = handler_module.datetime.now(timezone.utc)
result = DzengiQuoteDocumentHandler().handle_quote_document(
_document()
)
after = handler_module.datetime.now(timezone.utc)
assert isinstance(result, Quote)
assert result.symbol == "BTC/USD_LEVERAGE"
assert result.last_price == Decimal("64159.45")
assert result.bid_price == Decimal("64159.45")
assert result.ask_price == Decimal("64159.55")
assert result.source == "dzengi"
assert result.exchange_timestamp is not None
assert result.exchange_timestamp.tzinfo is timezone.utc
assert before <= result.received_at <= after
def test_handler_executes_pipeline_in_order(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[PipelineCall] = []
validated = object()
response = DzengiTicker24hrResponse(
symbol="BTC/USD_LEVERAGE",
last_price="64159.45",
bid_price="64159.45",
ask_price="64159.55",
close_time=1783887270312,
)
quote = Quote(
symbol="BTC/USD_LEVERAGE",
last_price=Decimal("64159.45"),
bid_price=Decimal("64159.45"),
ask_price=Decimal("64159.55"),
exchange_timestamp=None,
received_at=handler_module.datetime.now(timezone.utc),
source="dzengi",
)
def validate_schema(document: object) -> object:
calls.append(("schema", document))
return validated
def parse(document: object) -> DzengiTicker24hrResponse:
calls.append(("parser", document))
return response
def validate_values(
value: DzengiTicker24hrResponse,
) -> None:
calls.append(("values", value))
def map_quote(
value: DzengiTicker24hrResponse,
*,
received_at: object,
) -> Quote:
calls.append(
(
"mapper",
value,
received_at,
)
)
return quote
monkeypatch.setattr(
handler_module,
"validate_quote_schema",
validate_schema,
)
monkeypatch.setattr(
handler_module,
"parse_quote",
parse,
)
monkeypatch.setattr(
handler_module,
"validate_quote_values",
validate_values,
)
monkeypatch.setattr(
handler_module,
"map_dzengi_ticker_to_quote",
map_quote,
)
document = _document()
result = (
DzengiQuoteDocumentHandler()
.handle_quote_document(document)
)
assert result is quote
assert calls[0] == ("schema", document)
assert calls[1] == ("parser", validated)
assert calls[2] == ("values", response)
mapper_call = calls[3]
assert len(mapper_call) == 3
assert mapper_call[0] == "mapper"
assert mapper_call[1] is response
def test_handler_propagates_schema_error() -> None:
with pytest.raises(QuoteSchemaError):
DzengiQuoteDocumentHandler().handle_quote_document({})

View File

@@ -0,0 +1,164 @@
# app/tests/unit/market_data/acquisition/models/test_instrument.py
from __future__ import annotations
from dataclasses import FrozenInstanceError
from decimal import Decimal
import pytest
from src.market_data.acquisition.models.instrument import Instrument
def test_instrument_stores_complete_reference_data() -> None:
instrument = Instrument(
symbol="ETH/EUR_LEVERAGE",
name="ETH/EUR",
status="TRADING",
base_asset="ETH",
quote_asset="EUR",
asset_type="CRYPTOCURRENCY",
market_type="LEVERAGE",
market_modes=("REGULAR",),
order_types=("LIMIT", "MARKET", "STOP"),
base_asset_precision=3,
quote_asset_precision=3,
tick_size=Decimal("0.01"),
tick_value=Decimal("18.3415"),
step_size=Decimal("0.001"),
min_qty=Decimal("0.001"),
max_qty=Decimal("1000"),
min_notional=Decimal("2"),
country=None,
sector=None,
industry=None,
trading_hours=(
"UTC; Mon - 21:00, 21:05 -; "
"Tue - 21:00, 21:05 -"
),
)
assert instrument.symbol == "ETH/EUR_LEVERAGE"
assert instrument.name == "ETH/EUR"
assert instrument.status == "TRADING"
assert instrument.base_asset == "ETH"
assert instrument.quote_asset == "EUR"
assert instrument.asset_type == "CRYPTOCURRENCY"
assert instrument.market_type == "LEVERAGE"
assert instrument.market_modes == ("REGULAR",)
assert instrument.order_types == ("LIMIT", "MARKET", "STOP")
assert instrument.base_asset_precision == 3
assert instrument.quote_asset_precision == 3
assert instrument.tick_size == Decimal("0.01")
assert instrument.tick_value == Decimal("18.3415")
assert instrument.step_size == Decimal("0.001")
assert instrument.min_qty == Decimal("0.001")
assert instrument.max_qty == Decimal("1000")
assert instrument.min_notional == Decimal("2")
assert instrument.country is None
assert instrument.sector is None
assert instrument.industry is None
assert instrument.trading_hours is not None
def test_instrument_accepts_missing_optional_reference_values() -> None:
instrument = Instrument(
symbol="TEST/USD",
name="Test instrument",
status="BREAK",
base_asset="TEST",
quote_asset="USD",
asset_type=None,
market_type="SPOT",
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,
)
assert instrument.asset_type is None
assert instrument.market_modes == ()
assert instrument.order_types == ()
assert instrument.base_asset_precision is None
assert instrument.quote_asset_precision is None
assert instrument.tick_size is None
assert instrument.tick_value is None
assert instrument.step_size is None
assert instrument.min_qty is None
assert instrument.max_qty is None
assert instrument.min_notional is None
assert instrument.trading_hours is None
def test_instrument_uses_immutable_sequences() -> None:
instrument = Instrument(
symbol="BTC/USD",
name="BTC/USD",
status="TRADING",
base_asset="BTC",
quote_asset="USD",
asset_type="CRYPTOCURRENCY",
market_type="SPOT",
market_modes=("REGULAR",),
order_types=("MARKET",),
base_asset_precision=8,
quote_asset_precision=2,
tick_size=Decimal("0.01"),
tick_value=None,
step_size=Decimal("0.00000001"),
min_qty=Decimal("0.00000001"),
max_qty=Decimal("100"),
min_notional=Decimal("1"),
country=None,
sector=None,
industry=None,
trading_hours=None,
)
assert isinstance(instrument.market_modes, tuple)
assert isinstance(instrument.order_types, tuple)
def test_instrument_is_immutable() -> None:
instrument = Instrument(
symbol="BTC/USD",
name="BTC/USD",
status="TRADING",
base_asset="BTC",
quote_asset="USD",
asset_type="CRYPTOCURRENCY",
market_type="SPOT",
market_modes=("REGULAR",),
order_types=("MARKET",),
base_asset_precision=8,
quote_asset_precision=2,
tick_size=Decimal("0.01"),
tick_value=None,
step_size=Decimal("0.00000001"),
min_qty=Decimal("0.00000001"),
max_qty=Decimal("100"),
min_notional=Decimal("1"),
country=None,
sector=None,
industry=None,
trading_hours=None,
)
with pytest.raises(FrozenInstanceError):
instrument.status = "BREAK" # type: ignore[misc]

View File

@@ -0,0 +1,127 @@
# app/tests/unit/market_data/acquisition/models/test_status.py
from __future__ import annotations
from dataclasses import FrozenInstanceError
import pytest
from src.market_data.acquisition.models.status import (
InstrumentStatusClassification,
InstrumentTradingState,
classify_instrument_status,
)
@pytest.mark.parametrize(
"raw_status",
[
"TRADING",
"OPEN",
"ACTIVE",
"ENABLED",
"ONLINE",
],
)
def test_classify_open_statuses(raw_status: str) -> None:
result = classify_instrument_status(raw_status)
assert result == InstrumentStatusClassification(
state=InstrumentTradingState.OPEN,
normalized_status=raw_status,
)
@pytest.mark.parametrize(
"raw_status",
[
"NOT_TRADABLE",
"TRADING_DISABLED",
"MARKET_DISABLED",
"UNAVAILABLE_FOR_TRADING",
"CLOSE_ONLY",
"REDUCE_ONLY",
"VIEW_ONLY",
],
)
def test_classify_not_tradable_statuses(raw_status: str) -> None:
result = classify_instrument_status(raw_status)
assert result == InstrumentStatusClassification(
state=InstrumentTradingState.NOT_TRADABLE,
normalized_status=raw_status,
)
@pytest.mark.parametrize(
"raw_status",
[
"BREAK",
"CLOSED",
"HALT",
"HALTED",
"PAUSED",
"SUSPENDED",
"DISABLED",
"SETTLING",
"POST_ONLY",
],
)
def test_classify_break_statuses(raw_status: str) -> None:
result = classify_instrument_status(raw_status)
assert result == InstrumentStatusClassification(
state=InstrumentTradingState.BREAK,
normalized_status=raw_status,
)
def test_classification_normalizes_case_and_outer_spaces() -> None:
result = classify_instrument_status(
" trading "
)
assert result == InstrumentStatusClassification(
state=InstrumentTradingState.OPEN,
normalized_status="TRADING",
)
@pytest.mark.parametrize(
"raw_status",
[
None,
"",
" ",
" ",
],
)
def test_empty_status_is_unknown(
raw_status: str | None,
) -> None:
result = classify_instrument_status(raw_status)
assert result == InstrumentStatusClassification(
state=InstrumentTradingState.UNKNOWN,
normalized_status=None,
)
def test_unknown_status_preserves_normalized_value() -> None:
result = classify_instrument_status(
" maintenance "
)
assert result == InstrumentStatusClassification(
state=InstrumentTradingState.UNKNOWN,
normalized_status="MAINTENANCE",
)
def test_classification_result_is_frozen() -> None:
result = classify_instrument_status(
"TRADING"
)
with pytest.raises(FrozenInstanceError):
result.normalized_status = "BREAK" # type: ignore[misc]

View File

@@ -0,0 +1,159 @@
# app/tests/unit/market_data/acquisition/test_protocol.py
from __future__ import annotations
from decimal import Decimal
from src.market_data.acquisition.exceptions import (
InstrumentReferenceMappingError,
InstrumentReferenceParseError,
InstrumentReferenceSchemaError,
InstrumentReferenceTransportError,
InstrumentReferenceValueError,
MarketDataAcquisitionError,
)
from src.market_data.acquisition.models.instrument import Instrument
from src.market_data.acquisition.protocol import (
InstrumentDocumentHandler,
InstrumentDocumentSource,
InstrumentFeedProtocol,
)
def _instrument() -> Instrument:
return Instrument(
symbol="BTC/USD_LEVERAGE",
name="BTC/USD",
status="TRADING",
base_asset="BTC",
quote_asset="USD",
asset_type="CRYPTOCURRENCY",
market_type="LEVERAGE",
market_modes=("REGULAR",),
order_types=("LIMIT", "MARKET"),
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,
)
class StubInstrumentDocumentSource:
def fetch_instrument_document(self) -> object:
return {
"symbols": [],
}
class StubInstrumentDocumentHandler:
def handle_instrument_document(
self,
document: object,
) -> tuple[Instrument, ...]:
del document
return (_instrument(),)
class StubInstrumentFeed:
def load_instruments(self) -> tuple[Instrument, ...]:
return (_instrument(),)
class InvalidSource:
pass
class InvalidHandler:
pass
class InvalidFeed:
pass
def test_document_source_satisfies_protocol() -> None:
source = StubInstrumentDocumentSource()
assert isinstance(source, InstrumentDocumentSource)
assert source.fetch_instrument_document() == {
"symbols": [],
}
def test_document_handler_satisfies_protocol() -> None:
handler = StubInstrumentDocumentHandler()
assert isinstance(handler, InstrumentDocumentHandler)
instruments = handler.handle_instrument_document(
{
"symbols": [],
}
)
assert isinstance(instruments, tuple)
assert len(instruments) == 1
assert instruments[0].symbol == "BTC/USD_LEVERAGE"
def test_instrument_feed_satisfies_protocol() -> None:
feed = StubInstrumentFeed()
assert isinstance(feed, InstrumentFeedProtocol)
instruments = feed.load_instruments()
assert isinstance(instruments, tuple)
assert len(instruments) == 1
assert instruments[0].symbol == "BTC/USD_LEVERAGE"
def test_objects_without_required_methods_do_not_satisfy_protocols() -> None:
assert not isinstance(InvalidSource(), InstrumentDocumentSource)
assert not isinstance(InvalidHandler(), InstrumentDocumentHandler)
assert not isinstance(InvalidFeed(), InstrumentFeedProtocol)
def test_protocols_support_structural_typing_without_inheritance() -> None:
source: InstrumentDocumentSource = StubInstrumentDocumentSource()
handler: InstrumentDocumentHandler = StubInstrumentDocumentHandler()
feed: InstrumentFeedProtocol = StubInstrumentFeed()
document = source.fetch_instrument_document()
handled_instruments = handler.handle_instrument_document(document)
loaded_instruments = feed.load_instruments()
assert handled_instruments[0].symbol == "BTC/USD_LEVERAGE"
assert loaded_instruments[0].symbol == "BTC/USD_LEVERAGE"
def test_transport_error_inherits_acquisition_error() -> None:
error = InstrumentReferenceTransportError(
"Не удалось получить exchangeInfo."
)
assert isinstance(error, MarketDataAcquisitionError)
assert str(error) == "Не удалось получить exchangeInfo."
def test_all_instrument_reference_errors_share_base_type() -> None:
errors = (
InstrumentReferenceTransportError(),
InstrumentReferenceSchemaError(),
InstrumentReferenceParseError(),
InstrumentReferenceValueError(),
InstrumentReferenceMappingError(),
)
assert all(
isinstance(error, MarketDataAcquisitionError)
for error in errors
)

View File

@@ -0,0 +1,433 @@
# app/tests/unit/market_data/acquisition/test_registry.py
from __future__ import annotations
from decimal import Decimal
import pytest
from src.market_data.acquisition.exceptions import (
InstrumentFeedRegistryError,
MarketDataAcquisitionError,
)
from src.market_data.acquisition.models.instrument import Instrument
from src.market_data.acquisition.protocol import InstrumentFeedProtocol
from src.market_data.acquisition.registry import InstrumentFeedRegistry
def _instrument(
*,
symbol: str = "BTC/USD_LEVERAGE",
) -> Instrument:
return Instrument(
symbol=symbol,
name=symbol,
status="TRADING",
base_asset="BTC",
quote_asset="USD",
asset_type="CRYPTOCURRENCY",
market_type="LEVERAGE",
market_modes=("REGULAR",),
order_types=("LIMIT", "MARKET"),
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,
)
class StubInstrumentFeed:
def __init__(
self,
*,
instruments: tuple[Instrument, ...] = (),
) -> None:
self.instruments = instruments
self.load_call_count = 0
def load_instruments(self) -> tuple[Instrument, ...]:
self.load_call_count += 1
return self.instruments
class InvalidFeed:
pass
def test_register_and_get_feed() -> None:
registry = InstrumentFeedRegistry()
feed = StubInstrumentFeed()
registry.register("dzengi", feed)
result = registry.get("dzengi")
assert result is feed
def test_registry_preserves_feed_identity() -> None:
registry = InstrumentFeedRegistry()
feed = StubInstrumentFeed(
instruments=(
_instrument(),
)
)
registry.register("dzengi", feed)
registered_feed = registry.get("dzengi")
assert registered_feed is feed
assert registered_feed.load_instruments() is feed.instruments
def test_registry_accepts_instrument_feed_protocol() -> None:
registry = InstrumentFeedRegistry()
feed = StubInstrumentFeed()
assert isinstance(feed, InstrumentFeedProtocol)
registry.register("dzengi", feed)
assert registry.get("dzengi") is feed
def test_registry_supports_multiple_source_names() -> None:
registry = InstrumentFeedRegistry()
dzengi_feed = StubInstrumentFeed(
instruments=(
_instrument(symbol="BTC/USD_LEVERAGE"),
)
)
secondary_feed = StubInstrumentFeed(
instruments=(
_instrument(symbol="ETH/USD_LEVERAGE"),
)
)
registry.register("dzengi", dzengi_feed)
registry.register("secondary", secondary_feed)
assert registry.get("dzengi") is dzengi_feed
assert registry.get("secondary") is secondary_feed
def test_registry_strips_outer_whitespace_from_source_name() -> None:
registry = InstrumentFeedRegistry()
feed = StubInstrumentFeed()
registry.register(" dzengi ", feed)
assert registry.get("dzengi") is feed
assert registry.get(" dzengi ") is feed
@pytest.mark.parametrize(
"source_name",
[
"",
" ",
" ",
"\t",
"\n",
],
)
def test_registry_rejects_empty_source_name(
source_name: str,
) -> None:
registry = InstrumentFeedRegistry()
feed = StubInstrumentFeed()
with pytest.raises(
InstrumentFeedRegistryError,
match=r"Имя источника Instrument Feed не должно быть пустым",
):
registry.register(source_name, feed)
@pytest.mark.parametrize(
"source_name",
[
"",
" ",
" ",
"\t",
"\n",
],
)
def test_registry_rejects_empty_source_name_on_get(
source_name: str,
) -> None:
registry = InstrumentFeedRegistry()
with pytest.raises(
InstrumentFeedRegistryError,
match=r"Имя источника Instrument Feed не должно быть пустым",
):
registry.get(source_name)
def test_registry_rejects_duplicate_registration() -> None:
registry = InstrumentFeedRegistry()
first_feed = StubInstrumentFeed()
second_feed = StubInstrumentFeed()
registry.register("dzengi", first_feed)
with pytest.raises(
InstrumentFeedRegistryError,
match=r"уже зарегистрирован",
):
registry.register("dzengi", second_feed)
def test_duplicate_registration_does_not_replace_original_feed() -> None:
registry = InstrumentFeedRegistry()
first_feed = StubInstrumentFeed()
second_feed = StubInstrumentFeed()
registry.register("dzengi", first_feed)
with pytest.raises(InstrumentFeedRegistryError):
registry.register("dzengi", second_feed)
assert registry.get("dzengi") is first_feed
def test_duplicate_registration_uses_normalized_source_name() -> None:
registry = InstrumentFeedRegistry()
first_feed = StubInstrumentFeed()
second_feed = StubInstrumentFeed()
registry.register("dzengi", first_feed)
with pytest.raises(
InstrumentFeedRegistryError,
match=r"уже зарегистрирован",
):
registry.register(" dzengi ", second_feed)
def test_registry_keeps_source_name_case_sensitive() -> None:
registry = InstrumentFeedRegistry()
lowercase_feed = StubInstrumentFeed()
uppercase_feed = StubInstrumentFeed()
registry.register("dzengi", lowercase_feed)
registry.register("DZENGI", uppercase_feed)
assert registry.get("dzengi") is lowercase_feed
assert registry.get("DZENGI") is uppercase_feed
def test_registry_rejects_unregistered_source() -> None:
registry = InstrumentFeedRegistry()
with pytest.raises(
InstrumentFeedRegistryError,
match=r"не зарегистрирован",
):
registry.get("dzengi")
def test_registry_rejects_object_without_feed_protocol() -> None:
registry = InstrumentFeedRegistry()
with pytest.raises(
InstrumentFeedRegistryError,
match=r"не соответствует InstrumentFeedProtocol",
):
registry.register(
"invalid",
InvalidFeed(), # type: ignore[arg-type]
)
def test_registry_does_not_load_feed_during_registration() -> None:
registry = InstrumentFeedRegistry()
feed = StubInstrumentFeed()
registry.register("dzengi", feed)
assert feed.load_call_count == 0
def test_registry_does_not_load_feed_during_get() -> None:
registry = InstrumentFeedRegistry()
feed = StubInstrumentFeed()
registry.register("dzengi", feed)
result = registry.get("dzengi")
assert result is feed
assert feed.load_call_count == 0
def test_registry_stores_feed_not_instrument_result() -> None:
registry = InstrumentFeedRegistry()
instruments = (
_instrument(),
)
feed = StubInstrumentFeed(
instruments=instruments,
)
registry.register("dzengi", feed)
registered_feed = registry.get("dzengi")
assert registered_feed is feed
assert registered_feed is not instruments
def test_registry_error_inherits_acquisition_error() -> None:
error = InstrumentFeedRegistryError(
"Registry error."
)
assert isinstance(error, MarketDataAcquisitionError)
assert str(error) == "Registry error."
# Quotes Feed Registry tests.
from datetime import datetime, timezone
from src.market_data.acquisition.exceptions import QuoteFeedRegistryError
from src.market_data.acquisition.models.quote import Quote
from src.market_data.acquisition.protocol import QuoteFeedProtocol
from src.market_data.acquisition.registry import QuoteFeedRegistry
def _quote() -> Quote:
return Quote(
symbol="BTC/USD_LEVERAGE",
last_price=Decimal("64159.45"),
bid_price=Decimal("64159.45"),
ask_price=Decimal("64159.55"),
exchange_timestamp=datetime.now(timezone.utc),
received_at=datetime.now(timezone.utc),
source="dzengi",
)
class StubQuoteFeed:
def __init__(self) -> None:
self.quote = _quote()
self.symbols: list[str] = []
def load_quote(
self,
symbol: str,
) -> Quote:
self.symbols.append(symbol)
return self.quote
def test_quote_registry_registers_and_returns_feed() -> None:
registry = QuoteFeedRegistry()
feed = StubQuoteFeed()
registry.register("dzengi", feed)
assert registry.get("dzengi") is feed
def test_quote_registry_accepts_quote_feed_protocol() -> None:
registry = QuoteFeedRegistry()
feed = StubQuoteFeed()
assert isinstance(feed, QuoteFeedProtocol)
registry.register("dzengi", feed)
assert registry.get("dzengi") is feed
def test_quote_registry_strips_outer_whitespace() -> None:
registry = QuoteFeedRegistry()
feed = StubQuoteFeed()
registry.register(" dzengi ", feed)
assert registry.get("dzengi") is feed
assert registry.get(" dzengi ") is feed
@pytest.mark.parametrize("source_name", ["", " ", "\t", "\n"])
def test_quote_registry_rejects_empty_source_name(
source_name: str,
) -> None:
registry = QuoteFeedRegistry()
with pytest.raises(
QuoteFeedRegistryError,
match=r"Имя источника Quotes Feed не должно быть пустым",
):
registry.register(source_name, StubQuoteFeed())
def test_quote_registry_rejects_duplicate_registration() -> None:
registry = QuoteFeedRegistry()
first_feed = StubQuoteFeed()
registry.register("dzengi", first_feed)
with pytest.raises(
QuoteFeedRegistryError,
match=r"уже зарегистрирован",
):
registry.register("dzengi", StubQuoteFeed())
assert registry.get("dzengi") is first_feed
def test_quote_registry_rejects_unregistered_source() -> None:
registry = QuoteFeedRegistry()
with pytest.raises(
QuoteFeedRegistryError,
match=r"не зарегистрирован",
):
registry.get("dzengi")
def test_quote_registry_rejects_invalid_feed() -> None:
registry = QuoteFeedRegistry()
with pytest.raises(
QuoteFeedRegistryError,
match=r"не соответствует QuoteFeedProtocol",
):
registry.register(
"invalid",
InvalidFeed(), # type: ignore[arg-type]
)
def test_quote_registry_does_not_load_feed() -> None:
registry = QuoteFeedRegistry()
feed = StubQuoteFeed()
registry.register("dzengi", feed)
result = registry.get("dzengi")
assert result is feed
assert feed.symbols == []
def test_quote_registry_error_inherits_acquisition_error() -> None:
error = QuoteFeedRegistryError("Registry error.")
assert isinstance(error, MarketDataAcquisitionError)

View File

@@ -0,0 +1,475 @@
# app/tests/unit/market_data/acquisition/test_service.py
from __future__ import annotations
from decimal import Decimal
import pytest
from src.market_data.acquisition.exceptions import (
InstrumentFeedRegistryError,
InstrumentReferenceMappingError,
InstrumentReferenceTransportError,
InstrumentReferenceValueError,
)
from src.market_data.acquisition.models.instrument import Instrument
from src.market_data.acquisition.protocol import InstrumentFeedProtocol
from src.market_data.acquisition.registry import InstrumentFeedRegistry
from src.market_data.acquisition.service import InstrumentAcquisitionService
def _instrument(
*,
symbol: str = "BTC/USD_LEVERAGE",
) -> Instrument:
return Instrument(
symbol=symbol,
name=symbol,
status="TRADING",
base_asset="BTC",
quote_asset="USD",
asset_type="CRYPTOCURRENCY",
market_type="LEVERAGE",
market_modes=("REGULAR",),
order_types=("LIMIT", "MARKET"),
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,
)
class StubInstrumentFeed:
def __init__(
self,
*,
instruments: tuple[Instrument, ...] = (),
error: Exception | None = None,
) -> None:
self.instruments = instruments
self.error = error
self.load_call_count = 0
def load_instruments(self) -> tuple[Instrument, ...]:
self.load_call_count += 1
if self.error is not None:
raise self.error
return self.instruments
class RecordingInstrumentFeedRegistry(InstrumentFeedRegistry):
def __init__(self) -> None:
super().__init__()
self.requested_source_names: list[str] = []
self.get_call_count = 0
def get(
self,
source_name: str,
) -> InstrumentFeedProtocol:
self.get_call_count += 1
self.requested_source_names.append(source_name)
return super().get(source_name)
def test_service_loads_instruments_from_registered_feed() -> None:
registry = InstrumentFeedRegistry()
instruments = (
_instrument(),
)
feed = StubInstrumentFeed(
instruments=instruments,
)
registry.register("dzengi", feed)
service = InstrumentAcquisitionService(
registry=registry,
)
result = service.load_instruments("dzengi")
assert result is instruments
def test_service_passes_source_name_to_registry_without_changes() -> None:
registry = RecordingInstrumentFeedRegistry()
feed = StubInstrumentFeed()
registry.register("dzengi", feed)
service = InstrumentAcquisitionService(
registry=registry,
)
service.load_instruments(" dzengi ")
assert registry.requested_source_names == [
" dzengi ",
]
def test_service_calls_registry_once() -> None:
registry = RecordingInstrumentFeedRegistry()
feed = StubInstrumentFeed()
registry.register("dzengi", feed)
service = InstrumentAcquisitionService(
registry=registry,
)
service.load_instruments("dzengi")
assert registry.get_call_count == 1
def test_service_calls_feed_once() -> None:
registry = InstrumentFeedRegistry()
feed = StubInstrumentFeed()
registry.register("dzengi", feed)
service = InstrumentAcquisitionService(
registry=registry,
)
service.load_instruments("dzengi")
assert feed.load_call_count == 1
def test_service_returns_feed_result_without_copying() -> None:
registry = InstrumentFeedRegistry()
instruments = (
_instrument(),
_instrument(symbol="ETH/USD_LEVERAGE"),
)
feed = StubInstrumentFeed(
instruments=instruments,
)
registry.register("dzengi", feed)
service = InstrumentAcquisitionService(
registry=registry,
)
result = service.load_instruments("dzengi")
assert result is instruments
def test_service_preserves_instrument_order() -> None:
registry = InstrumentFeedRegistry()
instruments = (
_instrument(symbol="BTC/USD_LEVERAGE"),
_instrument(symbol="ETH/USD_LEVERAGE"),
_instrument(symbol="XRP/USD_LEVERAGE"),
)
feed = StubInstrumentFeed(
instruments=instruments,
)
registry.register("dzengi", feed)
service = InstrumentAcquisitionService(
registry=registry,
)
result = service.load_instruments("dzengi")
assert tuple(item.symbol for item in result) == (
"BTC/USD_LEVERAGE",
"ETH/USD_LEVERAGE",
"XRP/USD_LEVERAGE",
)
def test_service_returns_empty_tuple_without_error() -> None:
registry = InstrumentFeedRegistry()
feed = StubInstrumentFeed(
instruments=(),
)
registry.register("dzengi", feed)
service = InstrumentAcquisitionService(
registry=registry,
)
result = service.load_instruments("dzengi")
assert result == ()
def test_service_preserves_registry_error_without_wrapping() -> None:
registry = InstrumentFeedRegistry()
service = InstrumentAcquisitionService(
registry=registry,
)
with pytest.raises(
InstrumentFeedRegistryError,
) as exc_info:
service.load_instruments("dzengi")
assert "не зарегистрирован" in str(exc_info.value)
def test_service_does_not_call_feed_when_registry_fails() -> None:
registry = InstrumentFeedRegistry()
registered_feed = StubInstrumentFeed()
registry.register("registered", registered_feed)
service = InstrumentAcquisitionService(
registry=registry,
)
with pytest.raises(InstrumentFeedRegistryError):
service.load_instruments("missing")
assert registered_feed.load_call_count == 0
@pytest.mark.parametrize(
"error",
[
InstrumentReferenceTransportError("Network error."),
InstrumentReferenceValueError("Invalid value."),
InstrumentReferenceMappingError("Mapping error."),
],
)
def test_service_preserves_feed_error_without_wrapping(
error: Exception,
) -> None:
registry = InstrumentFeedRegistry()
feed = StubInstrumentFeed(
error=error,
)
registry.register("dzengi", feed)
service = InstrumentAcquisitionService(
registry=registry,
)
with pytest.raises(type(error)) as exc_info:
service.load_instruments("dzengi")
assert exc_info.value is error
assert feed.load_call_count == 1
def test_service_does_not_retry_feed_after_error() -> None:
registry = InstrumentFeedRegistry()
original_error = InstrumentReferenceTransportError(
"Network error."
)
feed = StubInstrumentFeed(
error=original_error,
)
registry.register("dzengi", feed)
service = InstrumentAcquisitionService(
registry=registry,
)
with pytest.raises(InstrumentReferenceTransportError):
service.load_instruments("dzengi")
assert feed.load_call_count == 1
# Quote Acquisition Service tests.
from datetime import datetime, timezone
from src.market_data.acquisition.exceptions import (
QuoteFeedRegistryError,
QuoteTransportError,
QuoteValueError,
)
from src.market_data.acquisition.models.quote import Quote
from src.market_data.acquisition.protocol import QuoteFeedProtocol
from src.market_data.acquisition.registry import QuoteFeedRegistry
from src.market_data.acquisition.service import QuoteAcquisitionService
def _quote() -> Quote:
return Quote(
symbol="BTC/USD_LEVERAGE",
last_price=Decimal("64159.45"),
bid_price=Decimal("64159.45"),
ask_price=Decimal("64159.55"),
exchange_timestamp=datetime.now(timezone.utc),
received_at=datetime.now(timezone.utc),
source="dzengi",
)
class StubQuoteFeed:
def __init__(
self,
*,
quote: Quote | None = None,
error: Exception | None = None,
) -> None:
self.quote = quote or _quote()
self.error = error
self.symbols: list[str] = []
def load_quote(
self,
symbol: str,
) -> Quote:
self.symbols.append(symbol)
if self.error is not None:
raise self.error
return self.quote
class RecordingQuoteFeedRegistry(QuoteFeedRegistry):
def __init__(self) -> None:
super().__init__()
self.requested_source_names: list[str] = []
self.get_call_count = 0
def get(
self,
source_name: str,
) -> QuoteFeedProtocol:
self.get_call_count += 1
self.requested_source_names.append(source_name)
return super().get(source_name)
def test_quote_service_loads_quote_from_registered_feed() -> None:
registry = QuoteFeedRegistry()
quote = _quote()
feed = StubQuoteFeed(quote=quote)
registry.register("dzengi", feed)
service = QuoteAcquisitionService(registry=registry)
result = service.load_quote(
"dzengi",
"BTC/USD_LEVERAGE",
)
assert result is quote
def test_quote_service_passes_source_name_without_changes() -> None:
registry = RecordingQuoteFeedRegistry()
registry.register("dzengi", StubQuoteFeed())
service = QuoteAcquisitionService(registry=registry)
service.load_quote(
" dzengi ",
"BTC/USD_LEVERAGE",
)
assert registry.requested_source_names == [" dzengi "]
def test_quote_service_calls_registry_once() -> None:
registry = RecordingQuoteFeedRegistry()
registry.register("dzengi", StubQuoteFeed())
service = QuoteAcquisitionService(registry=registry)
service.load_quote("dzengi", "BTC/USD_LEVERAGE")
assert registry.get_call_count == 1
def test_quote_service_passes_symbol_without_changes() -> None:
registry = QuoteFeedRegistry()
feed = StubQuoteFeed()
registry.register("dzengi", feed)
service = QuoteAcquisitionService(registry=registry)
service.load_quote("dzengi", " btc/usd_leverage ")
assert feed.symbols == [" btc/usd_leverage "]
def test_quote_service_calls_feed_once() -> None:
registry = QuoteFeedRegistry()
feed = StubQuoteFeed()
registry.register("dzengi", feed)
service = QuoteAcquisitionService(registry=registry)
service.load_quote("dzengi", "BTC/USD_LEVERAGE")
assert feed.symbols == ["BTC/USD_LEVERAGE"]
def test_quote_service_preserves_registry_error() -> None:
service = QuoteAcquisitionService(
registry=QuoteFeedRegistry(),
)
with pytest.raises(QuoteFeedRegistryError):
service.load_quote("missing", "BTC/USD_LEVERAGE")
def test_quote_service_does_not_call_registered_feed_when_registry_fails() -> None:
registry = QuoteFeedRegistry()
feed = StubQuoteFeed()
registry.register("registered", feed)
service = QuoteAcquisitionService(registry=registry)
with pytest.raises(QuoteFeedRegistryError):
service.load_quote("missing", "BTC/USD_LEVERAGE")
assert feed.symbols == []
@pytest.mark.parametrize(
"error",
[
QuoteTransportError("Network error."),
QuoteValueError("Invalid quote."),
],
)
def test_quote_service_preserves_feed_error(
error: Exception,
) -> None:
registry = QuoteFeedRegistry()
feed = StubQuoteFeed(error=error)
registry.register("dzengi", feed)
service = QuoteAcquisitionService(registry=registry)
with pytest.raises(type(error)) as exc_info:
service.load_quote("dzengi", "BTC/USD_LEVERAGE")
assert exc_info.value is error
assert feed.symbols == ["BTC/USD_LEVERAGE"]
def test_quote_service_does_not_retry_after_error() -> None:
registry = QuoteFeedRegistry()
feed = StubQuoteFeed(
error=QuoteTransportError("Network error."),
)
registry.register("dzengi", feed)
service = QuoteAcquisitionService(registry=registry)
with pytest.raises(QuoteTransportError):
service.load_quote("dzengi", "BTC/USD_LEVERAGE")
assert feed.symbols == ["BTC/USD_LEVERAGE"]

View File

@@ -0,0 +1,366 @@
# app/tests/unit/market_data/acquisition/test_symbols.py
from __future__ import annotations
import pytest
from src.market_data.acquisition.symbols import (
normalize_symbol,
resolve_symbol_index,
symbol_candidates,
)
@pytest.mark.parametrize(
("raw_symbol", "expected"),
[
(
"BTC/USD",
"BTC/USD",
),
(
"btc/usd",
"BTC/USD",
),
(
" btc/usd ",
"BTC/USD",
),
(
"",
"",
),
(
" ",
"",
),
(
"btc / usd",
"BTC / USD",
),
(
"btc%2fusd",
"BTC%2FUSD",
),
(
"eth/usd_leverage",
"ETH/USD_LEVERAGE",
),
],
)
def test_normalize_symbol_preserves_existing_contract(
raw_symbol: str,
expected: str,
) -> None:
assert normalize_symbol(raw_symbol) == expected
def test_normalize_symbol_does_not_decode_encoded_separator() -> None:
result = normalize_symbol(
"btc%2fusd"
)
assert result == "BTC%2FUSD"
def test_normalize_symbol_does_not_remove_internal_spaces() -> None:
result = normalize_symbol(
" btc / usd "
)
assert result == "BTC / USD"
def test_normalize_symbol_does_not_add_leverage_suffix() -> None:
result = normalize_symbol(
"btc/usd"
)
assert result == "BTC/USD"
def test_normalize_symbol_preserves_existing_leverage_suffix() -> None:
result = normalize_symbol(
"btc/usd_leverage"
)
assert result == "BTC/USD_LEVERAGE"
@pytest.mark.parametrize(
"raw_symbol",
[
"",
" ",
" ",
"\t",
"\n",
],
)
def test_symbol_candidates_returns_empty_list_for_empty_value(
raw_symbol: str,
) -> None:
assert symbol_candidates(raw_symbol) == []
def test_symbol_candidates_returns_single_normalized_candidate() -> None:
result = symbol_candidates(
" btc/usd "
)
assert result == [
"BTC/USD",
]
def test_symbol_candidates_adds_decoded_separator_candidate() -> None:
result = symbol_candidates(
"btc%2fusd"
)
assert result == [
"BTC%2FUSD",
"BTC/USD",
]
def test_symbol_candidates_adds_no_spaces_candidate() -> None:
result = symbol_candidates(
"btc / usd"
)
assert result == [
"BTC / USD",
"BTC/USD",
]
def test_symbol_candidates_preserves_transformation_order() -> None:
result = symbol_candidates(
" btc%2f / usd "
)
assert result == [
"BTC%2F / USD",
"BTC/ / USD",
"BTC//USD",
]
def test_symbol_candidates_does_not_add_duplicate_after_separator_decode() -> None:
result = symbol_candidates(
"btc/usd"
)
assert result == [
"BTC/USD",
]
def test_symbol_candidates_does_not_add_duplicate_after_space_removal() -> None:
result = symbol_candidates(
"btc%2fusd"
)
assert result == [
"BTC%2FUSD",
"BTC/USD",
]
def test_symbol_candidates_returns_new_list_for_each_call() -> None:
first = symbol_candidates(
"btc/usd"
)
second = symbol_candidates(
"btc/usd"
)
assert first == second
assert first is not second
def test_symbol_candidates_does_not_modify_source_string() -> None:
raw_symbol = " btc%2f / usd "
symbol_candidates(raw_symbol)
assert raw_symbol == " btc%2f / usd "
def test_symbol_candidates_does_not_remove_internal_tab() -> None:
result = symbol_candidates(
"btc\t/usd"
)
assert result == [
"BTC\t/USD",
]
def test_symbol_candidates_does_not_remove_internal_newline() -> None:
result = symbol_candidates(
"btc\n/usd"
)
assert result == [
"BTC\n/USD",
]
def test_symbol_candidates_preserves_leverage_suffix() -> None:
result = symbol_candidates(
" btc / usd_leverage "
)
assert result == [
"BTC / USD_LEVERAGE",
"BTC/USD_LEVERAGE",
]
def test_symbol_candidates_returns_list() -> None:
result = symbol_candidates(
"btc/usd"
)
assert isinstance(result, list)
def test_resolve_symbol_index_finds_exact_match() -> None:
result = resolve_symbol_index(
"BTC/USD_LEVERAGE",
(
"ETH/USD_LEVERAGE",
"BTC/USD_LEVERAGE",
),
)
assert result == 1
def test_resolve_symbol_index_is_case_insensitive() -> None:
result = resolve_symbol_index(
"btc/usd_leverage",
(
"BTC/USD_LEVERAGE",
),
)
assert result == 0
def test_resolve_symbol_index_ignores_outer_spaces() -> None:
result = resolve_symbol_index(
" btc/usd_leverage ",
(
"BTC/USD_LEVERAGE",
),
)
assert result == 0
def test_resolve_symbol_index_supports_encoded_separator() -> None:
result = resolve_symbol_index(
"btc%2fusd_leverage",
(
"BTC/USD_LEVERAGE",
),
)
assert result == 0
def test_resolve_symbol_index_supports_internal_spaces() -> None:
result = resolve_symbol_index(
"btc / usd_leverage",
(
"BTC/USD_LEVERAGE",
),
)
assert result == 0
def test_resolve_symbol_index_returns_none_for_missing_symbol() -> None:
result = resolve_symbol_index(
"XRP/USD_LEVERAGE",
(
"BTC/USD_LEVERAGE",
"ETH/USD_LEVERAGE",
),
)
assert result is None
def test_resolve_symbol_index_returns_none_for_empty_request() -> None:
result = resolve_symbol_index(
" ",
(
"BTC/USD_LEVERAGE",
),
)
assert result is None
def test_resolve_symbol_index_returns_none_for_empty_available_symbols() -> None:
result = resolve_symbol_index(
"BTC/USD_LEVERAGE",
(),
)
assert result is None
def test_resolve_symbol_index_preserves_candidate_priority() -> None:
result = resolve_symbol_index(
"BTC%2FUSD_LEVERAGE",
(
"BTC/USD_LEVERAGE",
"BTC%2FUSD_LEVERAGE",
),
)
assert result == 1
def test_resolve_symbol_index_preserves_available_symbol_order() -> None:
result = resolve_symbol_index(
"BTC/USD_LEVERAGE",
(
"btc/usd_leverage",
"BTC/USD_LEVERAGE",
),
)
assert result == 0
def test_resolve_symbol_index_returns_first_duplicate() -> None:
result = resolve_symbol_index(
"BTC/USD_LEVERAGE",
(
"BTC/USD_LEVERAGE",
"BTC/USD_LEVERAGE",
),
)
assert result == 0
def test_resolve_symbol_index_does_not_modify_available_symbols() -> None:
available_symbols = [
"BTC/USD_LEVERAGE",
"ETH/USD_LEVERAGE",
]
original_symbols = list(available_symbols)
resolve_symbol_index(
"BTC/USD_LEVERAGE",
available_symbols,
)
assert available_symbols == original_symbols

View File

@@ -0,0 +1,72 @@
# app/tests/unit/market_data/acquisition/validation/test_quote_schema.py
from __future__ import annotations
import pytest
from src.market_data.acquisition.exceptions import QuoteSchemaError
from src.market_data.acquisition.validation.schema import validate_quote_schema
def _document() -> dict[str, object]:
return {
"symbol": "BTC/USD_LEVERAGE",
"lastPrice": "64159.45",
"bidPrice": "64159.45",
"askPrice": "64159.55",
"closeTime": 1783887270312,
"highPrice": "64261.45",
}
def test_validate_quote_schema_accepts_real_unwrapped_document() -> None:
document = _document()
result = validate_quote_schema(document)
assert result.is_wrapped is False
assert result.status is None
assert result.correlation_id is None
assert dict(result.payload) == document
def test_validate_quote_schema_accepts_wrapped_document() -> None:
payload = _document()
result = validate_quote_schema(
{
"status": "OK",
"correlationId": "quote-1",
"payload": payload,
}
)
assert result.is_wrapped is True
assert result.status == "OK"
assert result.correlation_id == "quote-1"
assert dict(result.payload) == payload
@pytest.mark.parametrize(
"missing_key",
[
"symbol",
"lastPrice",
"bidPrice",
"askPrice",
"closeTime",
],
)
def test_validate_quote_schema_rejects_missing_required_field(
missing_key: str,
) -> None:
document = _document()
document.pop(missing_key)
with pytest.raises(QuoteSchemaError, match=missing_key):
validate_quote_schema(document)
def test_validate_quote_schema_rejects_non_mapping_root() -> None:
with pytest.raises(QuoteSchemaError, match="JSON-объектом"):
validate_quote_schema([])

View File

@@ -0,0 +1,91 @@
# app/tests/unit/market_data/acquisition/validation/test_quote_values.py
from __future__ import annotations
import pytest
from src.market_data.acquisition.adapters.dzengi.models import (
DzengiTicker24hrResponse,
)
from src.market_data.acquisition.exceptions import QuoteValueError
from src.market_data.acquisition.validation.values import validate_quote_values
def _response(
*,
symbol: str = "BTC/USD_LEVERAGE",
last_price: str | int | float = "64159.45",
bid_price: str | int | float = "64159.45",
ask_price: str | int | float = "64159.55",
close_time: int = 1783887270312,
) -> DzengiTicker24hrResponse:
return DzengiTicker24hrResponse(
symbol=symbol,
last_price=last_price,
bid_price=bid_price,
ask_price=ask_price,
close_time=close_time,
)
def test_validate_quote_values_accepts_real_response() -> None:
validate_quote_values(_response())
@pytest.mark.parametrize(
("field_name", "value"),
[
("last_price", "0"),
("bid_price", "-1"),
("ask_price", "NaN"),
("last_price", "not-a-number"),
],
)
def test_validate_quote_values_rejects_invalid_price(
field_name: str,
value: str,
) -> None:
values = {
"last_price": "64159.45",
"bid_price": "64159.45",
"ask_price": "64159.55",
}
values[field_name] = value
with pytest.raises(QuoteValueError):
validate_quote_values(
_response(
last_price=values["last_price"],
bid_price=values["bid_price"],
ask_price=values["ask_price"],
)
)
def test_validate_quote_values_rejects_empty_symbol() -> None:
with pytest.raises(QuoteValueError, match="symbol"):
validate_quote_values(_response(symbol=" "))
def test_validate_quote_values_rejects_non_positive_close_time() -> None:
with pytest.raises(QuoteValueError, match="closeTime"):
validate_quote_values(_response(close_time=0))
def test_validate_quote_values_rejects_crossed_market() -> None:
with pytest.raises(QuoteValueError, match="bidPrice"):
validate_quote_values(
_response(
bid_price="64160.00",
ask_price="64159.55",
)
)
def test_validate_quote_values_accepts_equal_bid_and_ask() -> None:
validate_quote_values(
_response(
bid_price="64159.45",
ask_price="64159.45",
)
)

View File

@@ -0,0 +1,268 @@
# app/tests/unit/market_data/acquisition/validation/test_schema.py
from __future__ import annotations
from types import MappingProxyType
import pytest
from src.market_data.acquisition.exceptions import (
InstrumentReferenceSchemaError,
)
from src.market_data.acquisition.validation.schema import (
validate_exchange_info_schema,
)
def test_validate_unwrapped_exchange_info_document() -> None:
document = {
"timezone": "UTC",
"serverTime": 1783537921471,
"rateLimits": [],
"exchangeFilters": [],
"symbols": [
{
"symbol": "BTC/USD_LEVERAGE",
"filters": [
{
"filterType": "LOT_SIZE",
"minQty": "0.0001",
"maxQty": "1000",
"stepSize": "0.0001",
}
],
"marketModes": ["REGULAR"],
"orderTypes": ["LIMIT", "MARKET", "STOP"],
}
],
}
validated = validate_exchange_info_schema(document)
assert validated.is_wrapped is False
assert validated.status is None
assert validated.correlation_id is None
assert validated.payload["symbols"] == document["symbols"]
assert isinstance(validated.payload, MappingProxyType)
def test_validate_wrapped_exchange_info_document() -> None:
document = {
"status": "OK",
"correlationId": "2",
"payload": {
"timezone": "UTC",
"serverTime": 1628193845310,
"rateLimits": [],
"exchangeFilters": [],
"symbols": [],
},
}
validated = validate_exchange_info_schema(document)
assert validated.is_wrapped is True
assert validated.status == "OK"
assert validated.correlation_id == "2"
assert validated.payload["symbols"] == []
@pytest.mark.parametrize(
"document",
[
None,
[],
"invalid",
123,
],
)
def test_reject_non_object_root(document: object) -> None:
with pytest.raises(
InstrumentReferenceSchemaError,
match=r"\$ должен быть JSON-объектом",
):
validate_exchange_info_schema(document)
def test_reject_non_object_wrapped_payload() -> None:
document = {
"status": "OK",
"payload": [],
}
with pytest.raises(
InstrumentReferenceSchemaError,
match=r"\$\.payload должен быть JSON-объектом",
):
validate_exchange_info_schema(document)
def test_reject_missing_symbols() -> None:
document = {
"timezone": "UTC",
"serverTime": 1783537921471,
}
with pytest.raises(
InstrumentReferenceSchemaError,
match=r"\$\.payload\.symbols должен быть JSON-массивом",
):
validate_exchange_info_schema(document)
def test_reject_non_list_symbols() -> None:
document = {
"symbols": {},
}
with pytest.raises(
InstrumentReferenceSchemaError,
match=r"\$\.payload\.symbols должен быть JSON-массивом",
):
validate_exchange_info_schema(document)
def test_reject_non_object_symbol_item() -> None:
document = {
"symbols": [
"BTC/USD",
],
}
with pytest.raises(
InstrumentReferenceSchemaError,
match=r"\$\.payload\.symbols\[0\] должен быть JSON-объектом",
):
validate_exchange_info_schema(document)
def test_reject_non_list_filters() -> None:
document = {
"symbols": [
{
"symbol": "BTC/USD",
"filters": {},
}
],
}
with pytest.raises(
InstrumentReferenceSchemaError,
match=r"\.filters должен быть JSON-массивом",
):
validate_exchange_info_schema(document)
def test_reject_non_object_filter_item() -> None:
document = {
"symbols": [
{
"symbol": "BTC/USD",
"filters": [
"LOT_SIZE",
],
}
],
}
with pytest.raises(
InstrumentReferenceSchemaError,
match=r"\.filters\[0\] должен быть JSON-объектом",
):
validate_exchange_info_schema(document)
@pytest.mark.parametrize(
("key", "invalid_value"),
[
("marketModes", {}),
("orderTypes", "MARKET"),
],
)
def test_reject_non_list_string_collections(
key: str,
invalid_value: object,
) -> None:
document = {
"symbols": [
{
"symbol": "BTC/USD",
key: invalid_value,
}
],
}
with pytest.raises(
InstrumentReferenceSchemaError,
match=rf"\.{key} должен быть JSON-массивом",
):
validate_exchange_info_schema(document)
@pytest.mark.parametrize(
"key",
[
"marketModes",
"orderTypes",
],
)
def test_reject_non_string_collection_item(key: str) -> None:
document = {
"symbols": [
{
"symbol": "BTC/USD",
key: [
"REGULAR",
123,
],
}
],
}
with pytest.raises(
InstrumentReferenceSchemaError,
match=rf"\.{key}\[1\] должен быть строкой",
):
validate_exchange_info_schema(document)
@pytest.mark.parametrize(
"key",
[
"rateLimits",
"exchangeFilters",
],
)
def test_reject_non_list_payload_collections(key: str) -> None:
document = {
"symbols": [],
key: {},
}
with pytest.raises(
InstrumentReferenceSchemaError,
match=rf"\.{key} должен быть JSON-массивом",
):
validate_exchange_info_schema(document)
@pytest.mark.parametrize(
"key",
[
"rateLimits",
"exchangeFilters",
],
)
def test_reject_non_object_payload_collection_item(key: str) -> None:
document = {
"symbols": [],
key: [
"invalid",
],
}
with pytest.raises(
InstrumentReferenceSchemaError,
match=rf"\.{key}\[0\] должен быть JSON-объектом",
):
validate_exchange_info_schema(document)

View File

@@ -0,0 +1,481 @@
# app/tests/unit/market_data/acquisition/validation/test_values.py
from __future__ import annotations
from dataclasses import replace
import pytest
from src.market_data.acquisition.adapters.dzengi.models import (
DzengiExchangeInfoPayload,
DzengiExchangeInfoResponse,
DzengiExchangeInfoSymbol,
DzengiLotSizeFilter,
DzengiMinNotionalFilter,
DzengiRateLimit,
DzengiUnknownFilter,
)
from src.market_data.acquisition.exceptions import (
InstrumentReferenceValueError,
)
from src.market_data.acquisition.validation.values import (
validate_exchange_info_values,
)
def _valid_symbol() -> DzengiExchangeInfoSymbol:
return DzengiExchangeInfoSymbol(
symbol="ETH/EUR_LEVERAGE",
name="ETH/EUR",
status="TRADING",
asset_type="CRYPTOCURRENCY",
base_asset="ETH",
base_asset_precision=3,
quote_asset="EUR",
quote_asset_id="EUR_LEVERAGE",
quote_precision=3,
order_types=("LIMIT", "MARKET", "STOP"),
filters=(
DzengiLotSizeFilter(
filter_type="LOT_SIZE",
min_qty="0.001",
max_qty="1000",
step_size="0.001",
),
DzengiMinNotionalFilter(
filter_type="MIN_NOTIONAL",
min_notional="2",
),
),
market_modes=("REGULAR",),
market_type="LEVERAGE",
country="",
sector="",
industry="",
trading_hours="UTC; Mon - 21:00, 21:05 -",
tick_size=0.01,
tick_value=18.3415,
trading_fee=0.06,
exchange_fee=None,
long_rate=-0.01,
short_rate=0.01,
swap_charge_interval=480,
min_sl_gap=0,
max_sl_gap=50.0,
min_tp_gap=0,
max_tp_gap=50.0,
)
def _valid_response(
*,
symbol: DzengiExchangeInfoSymbol | None = None,
rate_limits: tuple[DzengiRateLimit, ...] = (),
exchange_filters: tuple[DzengiUnknownFilter, ...] = (),
) -> DzengiExchangeInfoResponse:
return DzengiExchangeInfoResponse(
status="OK",
correlation_id="2",
payload=DzengiExchangeInfoPayload(
timezone="UTC",
server_time=1783537921471,
rate_limits=rate_limits,
exchange_filters=exchange_filters,
symbols=(symbol or _valid_symbol(),),
),
)
def test_validate_complete_exchange_info_values() -> None:
response = _valid_response(
rate_limits=(
DzengiRateLimit(
interval="MINUTE",
interval_num=1,
limit=1200,
rate_limit_type="REQUEST_WEIGHT",
),
),
)
assert validate_exchange_info_values(response) is None
@pytest.mark.parametrize(
"field",
[
"symbol",
"name",
"status",
"base_asset",
"quote_asset",
"market_type",
],
)
def test_reject_empty_required_symbol_string(field: str) -> None:
symbol = replace(
_valid_symbol(),
**{field: " "},
)
with pytest.raises(
InstrumentReferenceValueError,
match="не должен быть пустым",
):
validate_exchange_info_values(
_valid_response(symbol=symbol)
)
def test_reject_empty_order_type() -> None:
symbol = replace(
_valid_symbol(),
order_types=("LIMIT", " "),
)
with pytest.raises(
InstrumentReferenceValueError,
match=r"orderTypes\[1\] не должен быть пустым",
):
validate_exchange_info_values(
_valid_response(symbol=symbol)
)
def test_reject_empty_market_mode() -> None:
symbol = replace(
_valid_symbol(),
market_modes=("REGULAR", ""),
)
with pytest.raises(
InstrumentReferenceValueError,
match=r"marketModes\[1\] не должен быть пустым",
):
validate_exchange_info_values(
_valid_response(symbol=symbol)
)
@pytest.mark.parametrize(
"field",
[
"base_asset_precision",
"quote_precision",
"swap_charge_interval",
],
)
def test_reject_negative_non_negative_integer_field(field: str) -> None:
symbol = replace(
_valid_symbol(),
**{field: -1},
)
with pytest.raises(
InstrumentReferenceValueError,
match="должно быть больше или равно нулю",
):
validate_exchange_info_values(
_valid_response(symbol=symbol)
)
@pytest.mark.parametrize(
"tick_size",
[
0,
-0.01,
],
)
def test_reject_non_positive_tick_size(tick_size: float) -> None:
symbol = replace(
_valid_symbol(),
tick_size=tick_size,
)
with pytest.raises(
InstrumentReferenceValueError,
match=r"tickSize должно быть больше нуля",
):
validate_exchange_info_values(
_valid_response(symbol=symbol)
)
@pytest.mark.parametrize(
"tick_size",
[
float("nan"),
float("inf"),
float("-inf"),
],
)
def test_reject_non_finite_tick_size(tick_size: float) -> None:
symbol = replace(
_valid_symbol(),
tick_size=tick_size,
)
with pytest.raises(
InstrumentReferenceValueError,
match=r"tickSize должно быть конечным числом",
):
validate_exchange_info_values(
_valid_response(symbol=symbol)
)
def test_reject_non_numeric_lot_size_value() -> None:
symbol = replace(
_valid_symbol(),
filters=(
DzengiLotSizeFilter(
filter_type="LOT_SIZE",
min_qty="not-a-number",
max_qty="1000",
step_size="0.001",
),
),
)
with pytest.raises(
InstrumentReferenceValueError,
match=r"minQty должно быть корректным числом",
):
validate_exchange_info_values(
_valid_response(symbol=symbol)
)
@pytest.mark.parametrize(
("field", "value"),
[
("min_qty", "0"),
("max_qty", 0),
("step_size", -1),
],
)
def test_reject_non_positive_lot_size_values(
field: str,
value: str | int,
) -> None:
lot_size = DzengiLotSizeFilter(
filter_type="LOT_SIZE",
min_qty="0.001",
max_qty="1000",
step_size="0.001",
)
lot_size = replace(
lot_size,
**{field: value},
)
symbol = replace(
_valid_symbol(),
filters=(lot_size,),
)
with pytest.raises(
InstrumentReferenceValueError,
match="должно быть больше нуля",
):
validate_exchange_info_values(
_valid_response(symbol=symbol)
)
def test_reject_min_qty_greater_than_max_qty() -> None:
symbol = replace(
_valid_symbol(),
filters=(
DzengiLotSizeFilter(
filter_type="LOT_SIZE",
min_qty="10",
max_qty="1",
step_size="0.1",
),
),
)
with pytest.raises(
InstrumentReferenceValueError,
match="minQty не должно превышать",
):
validate_exchange_info_values(
_valid_response(symbol=symbol)
)
def test_reject_negative_min_notional() -> None:
symbol = replace(
_valid_symbol(),
filters=(
DzengiMinNotionalFilter(
filter_type="MIN_NOTIONAL",
min_notional="-1",
),
),
)
with pytest.raises(
InstrumentReferenceValueError,
match=r"minNotional должно быть больше или равно нулю",
):
validate_exchange_info_values(
_valid_response(symbol=symbol)
)
def test_accept_zero_min_notional() -> None:
symbol = replace(
_valid_symbol(),
filters=(
DzengiMinNotionalFilter(
filter_type="MIN_NOTIONAL",
min_notional="0",
),
),
)
assert (
validate_exchange_info_values(
_valid_response(symbol=symbol)
)
is None
)
def test_accept_negative_long_and_short_rates() -> None:
symbol = replace(
_valid_symbol(),
long_rate=-0.15,
short_rate=-0.25,
)
assert (
validate_exchange_info_values(
_valid_response(symbol=symbol)
)
is None
)
def test_accept_zero_optional_numeric_values() -> None:
symbol = replace(
_valid_symbol(),
tick_value=0,
trading_fee=0,
exchange_fee=0,
min_sl_gap=0,
max_sl_gap=0,
min_tp_gap=0,
max_tp_gap=0,
)
assert (
validate_exchange_info_values(
_valid_response(symbol=symbol)
)
is None
)
@pytest.mark.parametrize(
("interval_num", "limit"),
[
(0, 1200),
(1, 0),
(-1, 1200),
(1, -100),
],
)
def test_reject_invalid_rate_limit_values(
interval_num: int,
limit: int,
) -> None:
response = _valid_response(
rate_limits=(
DzengiRateLimit(
interval="MINUTE",
interval_num=interval_num,
limit=limit,
rate_limit_type="REQUEST_WEIGHT",
),
),
)
with pytest.raises(
InstrumentReferenceValueError,
match="должно быть больше нуля",
):
validate_exchange_info_values(response)
def test_reject_empty_rate_limit_string() -> None:
response = _valid_response(
rate_limits=(
DzengiRateLimit(
interval=" ",
interval_num=1,
limit=1200,
rate_limit_type="REQUEST_WEIGHT",
),
),
)
with pytest.raises(
InstrumentReferenceValueError,
match=r"interval не должен быть пустым",
):
validate_exchange_info_values(response)
def test_reject_empty_unknown_instrument_filter_type() -> None:
symbol = replace(
_valid_symbol(),
filters=(
DzengiUnknownFilter(
filter_type=" ",
fields=(("enabled", True),),
),
),
)
with pytest.raises(
InstrumentReferenceValueError,
match=r"filterType не должен быть пустым",
):
validate_exchange_info_values(
_valid_response(symbol=symbol)
)
def test_accept_empty_global_exchange_filter_type() -> None:
response = _valid_response(
exchange_filters=(
DzengiUnknownFilter(
filter_type="",
fields=(("enabled", True),),
),
),
)
assert validate_exchange_info_values(response) is None
def test_reject_whitespace_global_exchange_filter_type() -> None:
response = _valid_response(
exchange_filters=(
DzengiUnknownFilter(
filter_type=" ",
fields=(("enabled", True),),
),
),
)
with pytest.raises(
InstrumentReferenceValueError,
match=r"filterType не должен состоять только из пробелов",
):
validate_exchange_info_values(response)

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
import pytest
from src.market_data.acquisition.exceptions import QuoteSchemaError
from src.market_data.acquisition.validation.schema import (
validate_dzengi_websocket_quote_schema,
)
def test_accepts_direct_unwrapped_message() -> None:
result = validate_dzengi_websocket_quote_schema(
{"symbol": "BTC/USD", "bid": "10", "ask": "11"}
)
assert result.payload["bid"] == "10"
def test_accepts_double_payload_wrapper_and_root_symbol() -> None:
result = validate_dzengi_websocket_quote_schema(
{
"symbol": "BTC/USD",
"Payload": {"payload": {"bids": [["10", "1"]], "asks": [["11", "1"]]}},
}
)
assert result.root_symbol == "BTC/USD"
def test_accepts_ofr_alias() -> None:
validate_dzengi_websocket_quote_schema(
{"symbolName": "BTC/USD", "bid": "10", "ofr": "11"}
)
@pytest.mark.parametrize(
"document",
[
[],
{"bid": "10", "ask": "11"},
{"symbol": "BTC/USD", "bid": "10"},
{"symbol": "BTC/USD", "bids": [], "asks": [["11"]]},
{"symbol": "BTC/USD", "bids": [["10"]], "asks": []},
],
)
def test_rejects_invalid_structure(document: object) -> None:
with pytest.raises(QuoteSchemaError):
validate_dzengi_websocket_quote_schema(document)

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
import pytest
from src.market_data.acquisition.adapters.dzengi.models import DzengiWebSocketQuoteResponse
from src.market_data.acquisition.exceptions import QuoteValueError
from src.market_data.acquisition.validation.values import (
validate_dzengi_websocket_quote_values,
)
def _response(**overrides: object) -> DzengiWebSocketQuoteResponse:
values = {
"symbol": "BTC/USD",
"bid_price": "10",
"ask_price": "11",
"timestamp": 1000,
}
values.update(overrides)
return DzengiWebSocketQuoteResponse(**values) # type: ignore[arg-type]
def test_accepts_valid_values_and_missing_timestamp() -> None:
validate_dzengi_websocket_quote_values(_response())
validate_dzengi_websocket_quote_values(_response(timestamp=None))
@pytest.mark.parametrize(
"overrides",
[
{"symbol": " "},
{"bid_price": "0"},
{"ask_price": "-1"},
{"bid_price": "NaN"},
{"bid_price": "12", "ask_price": "11"},
{"timestamp": 0},
],
)
def test_rejects_invalid_values(overrides: dict[str, object]) -> None:
with pytest.raises(QuoteValueError):
validate_dzengi_websocket_quote_values(_response(**overrides))

View File

@@ -0,0 +1,463 @@
# app/tests/unit/storage/test_instrument_store.py
from __future__ import annotations
from decimal import Decimal
from typing import cast
import pytest
from src.market_data.acquisition.models.instrument import Instrument
from src.storage.exceptions import (
InstrumentStoreError,
StorageError,
)
from src.storage.instrument_store import (
InMemoryInstrumentStore,
InstrumentStoreProtocol,
)
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 test_in_memory_store_matches_protocol() -> None:
store = InMemoryInstrumentStore()
assert isinstance(
store,
InstrumentStoreProtocol,
)
def test_get_returns_none_for_unknown_source() -> None:
store = InMemoryInstrumentStore()
assert store.get("dzengi") is None
def test_set_and_get_preserve_same_tuple_object() -> None:
store = InMemoryInstrumentStore()
instruments = (
_instrument(),
)
store.set(
"dzengi",
instruments,
)
result = store.get("dzengi")
assert result is instruments
def test_empty_tuple_is_distinct_from_cache_miss() -> None:
store = InMemoryInstrumentStore()
assert store.get("dzengi") is None
store.set(
"dzengi",
(),
)
result = store.get("dzengi")
assert result == ()
assert result is not None
def test_different_sources_are_isolated() -> None:
store = InMemoryInstrumentStore()
dzengi_instruments = (
_instrument(
symbol="BTC/USD_LEVERAGE",
),
)
secondary_instruments = (
_instrument(
symbol="ETH/USD_LEVERAGE",
name="ETH/USD",
base_asset="ETH",
),
)
store.set(
"dzengi",
dzengi_instruments,
)
store.set(
"secondary",
secondary_instruments,
)
assert store.get("dzengi") is dzengi_instruments
assert store.get("secondary") is secondary_instruments
def test_clear_removes_only_requested_source() -> None:
store = InMemoryInstrumentStore()
dzengi_instruments = (
_instrument(
symbol="BTC/USD_LEVERAGE",
),
)
secondary_instruments = (
_instrument(
symbol="ETH/USD_LEVERAGE",
name="ETH/USD",
base_asset="ETH",
),
)
store.set(
"dzengi",
dzengi_instruments,
)
store.set(
"secondary",
secondary_instruments,
)
store.clear("dzengi")
assert store.get("dzengi") is None
assert store.get("secondary") is secondary_instruments
def test_clear_without_source_removes_all_sources() -> None:
store = InMemoryInstrumentStore()
store.set(
"dzengi",
(
_instrument(),
),
)
store.set(
"secondary",
(
_instrument(
symbol="ETH/USD_LEVERAGE",
name="ETH/USD",
base_asset="ETH",
),
),
)
store.clear()
assert store.get("dzengi") is None
assert store.get("secondary") is None
def test_set_replaces_existing_value() -> None:
store = InMemoryInstrumentStore()
first = (
_instrument(
symbol="BTC/USD_LEVERAGE",
),
)
second = (
_instrument(
symbol="ETH/USD_LEVERAGE",
name="ETH/USD",
base_asset="ETH",
),
)
store.set(
"dzengi",
first,
)
store.set(
"dzengi",
second,
)
assert store.get("dzengi") is second
def test_source_name_outer_spaces_are_normalized() -> None:
store = InMemoryInstrumentStore()
instruments = (
_instrument(),
)
store.set(
" dzengi ",
instruments,
)
assert store.get("dzengi") is instruments
assert store.get(" dzengi ") is instruments
def test_source_name_case_is_preserved() -> None:
store = InMemoryInstrumentStore()
lowercase = (
_instrument(
symbol="BTC/USD_LEVERAGE",
),
)
uppercase = (
_instrument(
symbol="ETH/USD_LEVERAGE",
name="ETH/USD",
base_asset="ETH",
),
)
store.set(
"dzengi",
lowercase,
)
store.set(
"DZENGI",
uppercase,
)
assert store.get("dzengi") is lowercase
assert store.get("DZENGI") is uppercase
@pytest.mark.parametrize(
"source_name",
[
"",
" ",
" ",
"\t",
"\n",
],
)
def test_get_rejects_empty_source_name(
source_name: str,
) -> None:
store = InMemoryInstrumentStore()
with pytest.raises(
InstrumentStoreError,
match=r"Имя источника Instrument Store не должно быть пустым",
):
store.get(source_name)
@pytest.mark.parametrize(
"source_name",
[
"",
" ",
" ",
"\t",
"\n",
],
)
def test_set_rejects_empty_source_name(
source_name: str,
) -> None:
store = InMemoryInstrumentStore()
with pytest.raises(
InstrumentStoreError,
match=r"Имя источника Instrument Store не должно быть пустым",
):
store.set(
source_name,
(),
)
@pytest.mark.parametrize(
"source_name",
[
"",
" ",
" ",
"\t",
"\n",
],
)
def test_targeted_clear_rejects_empty_source_name(
source_name: str,
) -> None:
store = InMemoryInstrumentStore()
with pytest.raises(
InstrumentStoreError,
match=r"Имя источника Instrument Store не должно быть пустым",
):
store.clear(source_name)
def test_set_rejects_list_instead_of_tuple() -> None:
store = InMemoryInstrumentStore()
invalid_instruments = cast(
tuple[Instrument, ...],
[
_instrument(),
],
)
with pytest.raises(
InstrumentStoreError,
match=r"должен быть передан как tuple",
):
store.set(
"dzengi",
invalid_instruments,
)
def test_set_rejects_non_instrument_item() -> None:
store = InMemoryInstrumentStore()
invalid_instruments = cast(
tuple[Instrument, ...],
(
"BTC/USD_LEVERAGE",
),
)
with pytest.raises(
InstrumentStoreError,
match=r"не являющийся Instrument",
):
store.set(
"dzengi",
invalid_instruments,
)
def test_store_preserves_instrument_order() -> None:
store = InMemoryInstrumentStore()
instruments = (
_instrument(
symbol="BTC/USD_LEVERAGE",
),
_instrument(
symbol="ETH/USD_LEVERAGE",
name="ETH/USD",
base_asset="ETH",
),
_instrument(
symbol="XRP/USD_LEVERAGE",
name="XRP/USD",
base_asset="XRP",
),
)
store.set(
"dzengi",
instruments,
)
result = store.get("dzengi")
assert result is not None
assert [
instrument.symbol
for instrument in result
] == [
"BTC/USD_LEVERAGE",
"ETH/USD_LEVERAGE",
"XRP/USD_LEVERAGE",
]
def test_store_does_not_modify_input_tuple() -> None:
store = InMemoryInstrumentStore()
instruments = (
_instrument(
symbol="BTC/USD_LEVERAGE",
),
_instrument(
symbol="ETH/USD_LEVERAGE",
name="ETH/USD",
base_asset="ETH",
),
)
original = instruments
store.set(
"dzengi",
instruments,
)
assert instruments is original
assert store.get("dzengi") is original
def test_store_instances_are_isolated() -> None:
first_store = InMemoryInstrumentStore()
second_store = InMemoryInstrumentStore()
instruments = (
_instrument(),
)
first_store.set(
"dzengi",
instruments,
)
assert first_store.get("dzengi") is instruments
assert second_store.get("dzengi") is None
def test_clear_unknown_source_is_idempotent() -> None:
store = InMemoryInstrumentStore()
store.clear("dzengi")
store.clear("dzengi")
assert store.get("dzengi") is None
def test_instrument_store_error_inherits_storage_error() -> None:
error = InstrumentStoreError(
"Storage failure."
)
assert isinstance(
error,
StorageError,
)
assert str(error) == "Storage failure."

View File

@@ -0,0 +1,448 @@
# app/tests/unit/storage/test_quote_store.py
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from typing import cast
import pytest
from src.market_data.acquisition.models.quote import Quote
from src.storage.exceptions import QuoteStoreError, StorageError
from src.storage.quote_store import (
InMemoryQuoteStore,
QuoteStoreProtocol,
)
def _quote(
*,
symbol: str = "BTC/USD_LEVERAGE",
last_price: Decimal = Decimal("64159.45"),
source: str = "dzengi",
) -> Quote:
return Quote(
symbol=symbol,
last_price=last_price,
bid_price=last_price - Decimal("0.05"),
ask_price=last_price + Decimal("0.05"),
exchange_timestamp=datetime(
2026,
7,
12,
18,
0,
tzinfo=timezone.utc,
),
received_at=datetime(
2026,
7,
12,
18,
0,
1,
tzinfo=timezone.utc,
),
source=source,
)
def test_in_memory_quote_store_matches_protocol() -> None:
assert isinstance(
InMemoryQuoteStore(),
QuoteStoreProtocol,
)
def test_get_returns_none_for_unknown_key() -> None:
store = InMemoryQuoteStore()
assert store.get("dzengi", "BTC/USD_LEVERAGE") is None
def test_set_and_get_preserve_quote_identity() -> None:
store = InMemoryQuoteStore()
quote = _quote()
store.set("dzengi", quote)
assert store.get("dzengi", quote.symbol) is quote
def test_set_replaces_existing_quote() -> None:
store = InMemoryQuoteStore()
first = _quote(last_price=Decimal("100"))
second = _quote(last_price=Decimal("101"))
store.set("dzengi", first)
store.set("dzengi", second)
assert store.get("dzengi", second.symbol) is second
def test_different_sources_are_isolated() -> None:
store = InMemoryQuoteStore()
first = _quote(source="dzengi")
second = _quote(source="secondary")
store.set("dzengi", first)
store.set("secondary", second)
assert store.get("dzengi", first.symbol) is first
assert store.get("secondary", second.symbol) is second
def test_source_name_alias_does_not_have_to_match_quote_source() -> None:
store = InMemoryQuoteStore()
quote = _quote(source="dzengi")
store.set("dzengi-primary", quote)
assert store.get("dzengi-primary", quote.symbol) is quote
def test_source_name_outer_spaces_are_normalized() -> None:
store = InMemoryQuoteStore()
quote = _quote()
store.set(" dzengi ", quote)
assert store.get("dzengi", quote.symbol) is quote
assert store.get(" dzengi ", quote.symbol) is quote
def test_source_name_case_is_preserved() -> None:
store = InMemoryQuoteStore()
lowercase = _quote(last_price=Decimal("100"))
uppercase = _quote(last_price=Decimal("200"))
store.set("dzengi", lowercase)
store.set("DZENGI", uppercase)
assert store.get("dzengi", lowercase.symbol) is lowercase
assert store.get("DZENGI", uppercase.symbol) is uppercase
def test_runtime_keys_are_isolated() -> None:
store = InMemoryQuoteStore()
auto_quote = _quote(last_price=Decimal("100"))
debug_quote = _quote(last_price=Decimal("200"))
store.set("dzengi", auto_quote, runtime_key="auto")
store.set("dzengi", debug_quote, runtime_key="debug_auto")
assert store.get(
"dzengi",
auto_quote.symbol,
runtime_key="auto",
) is auto_quote
assert store.get(
"dzengi",
debug_quote.symbol,
runtime_key="debug_auto",
) is debug_quote
def test_runtime_key_is_trimmed_and_lowercased() -> None:
store = InMemoryQuoteStore()
quote = _quote()
store.set("dzengi", quote, runtime_key=" AUTO ")
assert store.get(
"dzengi",
quote.symbol,
runtime_key="auto",
) is quote
assert store.get(
"dzengi",
quote.symbol,
runtime_key=" AUTO ",
) is quote
def test_symbols_are_isolated() -> None:
store = InMemoryQuoteStore()
btc = _quote(symbol="BTC/USD_LEVERAGE")
eth = _quote(
symbol="ETH/USD_LEVERAGE",
last_price=Decimal("3500"),
)
store.set("dzengi", btc)
store.set("dzengi", eth)
assert store.get("dzengi", btc.symbol) is btc
assert store.get("dzengi", eth.symbol) is eth
def test_symbol_key_is_trimmed_and_uppercased() -> None:
store = InMemoryQuoteStore()
quote = _quote(symbol=" btc/usd_leverage ")
store.set("dzengi", quote)
assert store.get("dzengi", "BTC/USD_LEVERAGE") is quote
assert store.get("dzengi", " btc/usd_leverage ") is quote
@pytest.mark.parametrize("source_name", ["", " ", "\t", "\n"])
def test_get_rejects_empty_source_name(source_name: str) -> None:
store = InMemoryQuoteStore()
with pytest.raises(
QuoteStoreError,
match=r"Имя источника Quote Store не должно быть пустым",
):
store.get(source_name, "BTC/USD_LEVERAGE")
@pytest.mark.parametrize("source_name", ["", " ", "\t", "\n"])
def test_set_rejects_empty_source_name(source_name: str) -> None:
store = InMemoryQuoteStore()
with pytest.raises(QuoteStoreError):
store.set(source_name, _quote())
@pytest.mark.parametrize("source_name", ["", " ", "\t", "\n"])
def test_clear_rejects_empty_source_name(source_name: str) -> None:
store = InMemoryQuoteStore()
with pytest.raises(QuoteStoreError):
store.clear(source_name=source_name)
@pytest.mark.parametrize("runtime_key", ["", " ", "\t", "\n"])
def test_get_rejects_empty_runtime_key(runtime_key: str) -> None:
store = InMemoryQuoteStore()
with pytest.raises(
QuoteStoreError,
match=r"Runtime key Quote Store не должен быть пустым",
):
store.get(
"dzengi",
"BTC/USD_LEVERAGE",
runtime_key=runtime_key,
)
@pytest.mark.parametrize("runtime_key", ["", " ", "\t", "\n"])
def test_set_rejects_empty_runtime_key(runtime_key: str) -> None:
store = InMemoryQuoteStore()
with pytest.raises(QuoteStoreError):
store.set(
"dzengi",
_quote(),
runtime_key=runtime_key,
)
@pytest.mark.parametrize("runtime_key", ["", " ", "\t", "\n"])
def test_clear_rejects_empty_runtime_key(runtime_key: str) -> None:
store = InMemoryQuoteStore()
with pytest.raises(QuoteStoreError):
store.clear(runtime_key=runtime_key)
@pytest.mark.parametrize("symbol", ["", " ", "\t", "\n"])
def test_get_rejects_empty_symbol(symbol: str) -> None:
store = InMemoryQuoteStore()
with pytest.raises(
QuoteStoreError,
match=r"Символ Quote Store не должен быть пустым",
):
store.get("dzengi", symbol)
@pytest.mark.parametrize("symbol", ["", " ", "\t", "\n"])
def test_set_rejects_quote_with_empty_symbol(symbol: str) -> None:
store = InMemoryQuoteStore()
with pytest.raises(QuoteStoreError):
store.set("dzengi", _quote(symbol=symbol))
@pytest.mark.parametrize("symbol", ["", " ", "\t", "\n"])
def test_clear_rejects_empty_symbol(symbol: str) -> None:
store = InMemoryQuoteStore()
with pytest.raises(QuoteStoreError):
store.clear(symbol=symbol)
def test_set_rejects_non_quote_object() -> None:
store = InMemoryQuoteStore()
with pytest.raises(
QuoteStoreError,
match=r"принимает только объект Quote",
):
store.set(
"dzengi",
cast(Quote, object()),
)
def test_clear_without_filters_removes_all_quotes() -> None:
store = InMemoryQuoteStore()
store.set("dzengi", _quote(symbol="BTC/USD_LEVERAGE"))
store.set("secondary", _quote(symbol="ETH/USD_LEVERAGE"))
store.clear()
assert store.get("dzengi", "BTC/USD_LEVERAGE") is None
assert store.get("secondary", "ETH/USD_LEVERAGE") is None
def test_clear_by_source_removes_all_source_quotes() -> None:
store = InMemoryQuoteStore()
dzengi_btc = _quote(symbol="BTC/USD_LEVERAGE")
dzengi_eth = _quote(symbol="ETH/USD_LEVERAGE")
secondary_btc = _quote(symbol="BTC/USD_LEVERAGE")
store.set("dzengi", dzengi_btc, runtime_key="auto")
store.set("dzengi", dzengi_eth, runtime_key="debug_auto")
store.set("secondary", secondary_btc, runtime_key="auto")
store.clear(source_name="dzengi")
assert store.get("dzengi", dzengi_btc.symbol, runtime_key="auto") is None
assert store.get("dzengi", dzengi_eth.symbol, runtime_key="debug_auto") is None
assert store.get("secondary", secondary_btc.symbol, runtime_key="auto") is secondary_btc
def test_clear_by_runtime_removes_all_runtime_quotes() -> None:
store = InMemoryQuoteStore()
auto_btc = _quote(symbol="BTC/USD_LEVERAGE")
auto_eth = _quote(symbol="ETH/USD_LEVERAGE")
debug_btc = _quote(symbol="BTC/USD_LEVERAGE")
store.set("dzengi", auto_btc, runtime_key="auto")
store.set("secondary", auto_eth, runtime_key="auto")
store.set("dzengi", debug_btc, runtime_key="debug_auto")
store.clear(runtime_key="AUTO")
assert store.get("dzengi", auto_btc.symbol, runtime_key="auto") is None
assert store.get("secondary", auto_eth.symbol, runtime_key="auto") is None
assert store.get("dzengi", debug_btc.symbol, runtime_key="debug_auto") is debug_btc
def test_clear_by_symbol_removes_symbol_from_all_namespaces() -> None:
store = InMemoryQuoteStore()
dzengi_btc = _quote(symbol="BTC/USD_LEVERAGE")
secondary_btc = _quote(symbol="BTC/USD_LEVERAGE")
dzengi_eth = _quote(symbol="ETH/USD_LEVERAGE")
store.set("dzengi", dzengi_btc, runtime_key="auto")
store.set("secondary", secondary_btc, runtime_key="debug_auto")
store.set("dzengi", dzengi_eth, runtime_key="auto")
store.clear(symbol=" btc/usd_leverage ")
assert store.get("dzengi", dzengi_btc.symbol, runtime_key="auto") is None
assert store.get("secondary", secondary_btc.symbol, runtime_key="debug_auto") is None
assert store.get("dzengi", dzengi_eth.symbol, runtime_key="auto") is dzengi_eth
def test_clear_exact_record_removes_only_that_record() -> None:
store = InMemoryQuoteStore()
auto_quote = _quote()
debug_quote = _quote(last_price=Decimal("65000"))
store.set("dzengi", auto_quote, runtime_key="auto")
store.set("dzengi", debug_quote, runtime_key="debug_auto")
store.clear(
source_name="dzengi",
symbol="BTC/USD_LEVERAGE",
runtime_key="auto",
)
assert store.get("dzengi", auto_quote.symbol, runtime_key="auto") is None
assert store.get("dzengi", debug_quote.symbol, runtime_key="debug_auto") is debug_quote
def test_clear_combined_source_and_runtime_filters() -> None:
store = InMemoryQuoteStore()
dzengi_auto_btc = _quote(symbol="BTC/USD_LEVERAGE")
dzengi_auto_eth = _quote(symbol="ETH/USD_LEVERAGE")
dzengi_debug_btc = _quote(symbol="BTC/USD_LEVERAGE")
secondary_auto_btc = _quote(symbol="BTC/USD_LEVERAGE")
store.set("dzengi", dzengi_auto_btc, runtime_key="auto")
store.set("dzengi", dzengi_auto_eth, runtime_key="auto")
store.set("dzengi", dzengi_debug_btc, runtime_key="debug_auto")
store.set("secondary", secondary_auto_btc, runtime_key="auto")
store.clear(source_name="dzengi", runtime_key="auto")
assert store.get("dzengi", dzengi_auto_btc.symbol, runtime_key="auto") is None
assert store.get("dzengi", dzengi_auto_eth.symbol, runtime_key="auto") is None
assert store.get("dzengi", dzengi_debug_btc.symbol, runtime_key="debug_auto") is dzengi_debug_btc
assert store.get("secondary", secondary_auto_btc.symbol, runtime_key="auto") is secondary_auto_btc
def test_clear_unknown_record_is_idempotent() -> None:
store = InMemoryQuoteStore()
quote = _quote()
store.set("dzengi", quote)
store.clear(
source_name="unknown",
symbol="ETH/USD_LEVERAGE",
runtime_key="missing",
)
assert store.get("dzengi", quote.symbol) is quote
def test_store_instances_are_independent() -> None:
first = InMemoryQuoteStore()
second = InMemoryQuoteStore()
quote = _quote()
first.set("dzengi", quote)
assert first.get("dzengi", quote.symbol) is quote
assert second.get("dzengi", quote.symbol) is None
def test_store_preserves_decimal_values() -> None:
store = InMemoryQuoteStore()
quote = _quote(
last_price=Decimal("64159.45000001"),
)
store.set("dzengi", quote)
result = store.get("dzengi", quote.symbol)
assert result is not None
assert result is quote
assert isinstance(result.last_price, Decimal)
assert result.last_price == Decimal("64159.45000001")
def test_store_preserves_datetime_values() -> None:
store = InMemoryQuoteStore()
quote = _quote()
store.set("dzengi", quote)
result = store.get("dzengi", quote.symbol)
assert result is not None
assert result is quote
assert result.exchange_timestamp is quote.exchange_timestamp
assert result.received_at is quote.received_at
def test_quote_store_error_inherits_storage_error() -> None:
error = QuoteStoreError("test")
assert isinstance(error, StorageError)

View File

View File

View File

@@ -0,0 +1,607 @@
# app/tests/unit/telegram/ui/test_currency_ui.py
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from decimal import Decimal
from types import SimpleNamespace
from typing import cast
import pytest
from src.integrations.exchange.exceptions import ExchangeError
from src.integrations.exchange.models import (
BalanceSummary,
)
from src.integrations.exchange.service import ExchangeService
from src.market_data.acquisition.models.instrument import Instrument
from src.market_data.acquisition.models.quote import Quote
from src.telegram.ui.currency_ui import (
_resolve_asset_quote_instrument,
estimate_balance_usd,
get_asset_usd_rate,
)
def _instrument(
*,
symbol: str,
base_asset: str,
quote_asset: str = "USD",
status: str = "TRADING",
market_type: str = "SPOT",
) -> Instrument:
return Instrument(
symbol=symbol,
name=symbol,
status=status,
base_asset=base_asset,
quote_asset=quote_asset,
asset_type="CRYPTOCURRENCY",
market_type=market_type,
market_modes=("REGULAR",),
order_types=("LIMIT", "MARKET"),
base_asset_precision=8,
quote_asset_precision=8,
tick_size=Decimal("0.01"),
tick_value=None,
step_size=Decimal("0.0001"),
min_qty=Decimal("0.0001"),
max_qty=None,
min_notional=Decimal("1"),
country=None,
sector=None,
industry=None,
trading_hours=None,
)
def _service() -> ExchangeService:
return object.__new__(
ExchangeService
)
def test_resolver_uses_get_instruments(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
instruments = (
_instrument(
symbol="BTC/USD",
base_asset="BTC",
),
)
call_count = 0
def get_instruments() -> tuple[Instrument, ...]:
nonlocal call_count
call_count += 1
return instruments
monkeypatch.setattr(
service,
"get_instruments",
get_instruments,
)
result = _resolve_asset_quote_instrument(
service,
"BTC",
)
assert result is instruments[0]
assert call_count == 1
def test_resolver_uses_canonical_instrument_reference(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
instrument = _instrument(
symbol="BTC/USD",
base_asset="BTC",
)
calls = 0
def get_instruments() -> tuple[Instrument, ...]:
nonlocal calls
calls += 1
return (
instrument,
)
monkeypatch.setattr(
service,
"get_instruments",
get_instruments,
)
result = _resolve_asset_quote_instrument(
service,
"BTC",
)
assert result is instrument
assert calls == 1
def test_resolver_prefers_usd_over_usdt(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
usd = _instrument(
symbol="BTC/USD",
base_asset="BTC",
quote_asset="USD",
market_type="LEVERAGE",
)
usdt = _instrument(
symbol="BTC/USDT",
base_asset="BTC",
quote_asset="USDT",
market_type="SPOT",
)
monkeypatch.setattr(
service,
"get_instruments",
lambda: (
usdt,
usd,
),
)
result = _resolve_asset_quote_instrument(
service,
"BTC",
)
assert result is usd
def test_resolver_prefers_trading_status(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
active = _instrument(
symbol="BTC/USD_ACTIVE",
base_asset="BTC",
status="TRADING",
market_type="LEVERAGE",
)
inactive = _instrument(
symbol="BTC/USD_INACTIVE",
base_asset="BTC",
status="UNKNOWN",
market_type="SPOT",
)
monkeypatch.setattr(
service,
"get_instruments",
lambda: (
inactive,
active,
),
)
result = _resolve_asset_quote_instrument(
service,
"BTC",
)
assert result is active
def test_resolver_prefers_spot_when_other_priority_is_equal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
spot = _instrument(
symbol="BTC/USD_SPOT",
base_asset="BTC",
market_type="SPOT",
)
leverage = _instrument(
symbol="BTC/USD_LEVERAGE",
base_asset="BTC",
market_type="LEVERAGE",
)
monkeypatch.setattr(
service,
"get_instruments",
lambda: (
leverage,
spot,
),
)
result = _resolve_asset_quote_instrument(
service,
"BTC",
)
assert result is spot
def test_resolver_ignores_other_base_assets(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
monkeypatch.setattr(
service,
"get_instruments",
lambda: (
_instrument(
symbol="ETH/USD",
base_asset="ETH",
),
),
)
result = _resolve_asset_quote_instrument(
service,
"BTC",
)
assert result is None
def test_resolver_ignores_unsupported_quote_assets(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
monkeypatch.setattr(
service,
"get_instruments",
lambda: (
_instrument(
symbol="BTC/EUR",
base_asset="BTC",
quote_asset="EUR",
),
),
)
result = _resolve_asset_quote_instrument(
service,
"BTC",
)
assert result is None
def test_resolver_returns_none_when_get_instruments_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
def raise_error() -> tuple[Instrument, ...]:
raise ExchangeError(
"exchangeInfo unavailable"
)
monkeypatch.setattr(
service,
"get_instruments",
raise_error,
)
result = _resolve_asset_quote_instrument(
service,
"BTC",
)
assert result is None
@pytest.mark.parametrize(
"currency",
[
"USD",
"usd",
"USDT",
"usdt",
],
)
def test_usd_and_usdt_rates_are_one(
currency: str,
) -> None:
service = _service()
cache: dict[str, float | None] = {}
result = get_asset_usd_rate(
service,
currency,
cache,
)
assert result == 1.0
assert cache == {}
def test_get_asset_usd_rate_uses_existing_price_cache(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
cache: dict[str, float | None] = {
"BTC": 50_000.0,
}
def fail_if_called() -> tuple[Instrument, ...]:
raise AssertionError(
"Instrument lookup must not run on price cache hit."
)
monkeypatch.setattr(
service,
"get_instruments",
fail_if_called,
)
result = get_asset_usd_rate(
service,
"BTC",
cache,
)
assert result == 50_000.0
def test_get_asset_usd_rate_loads_price_by_instrument_symbol(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
instrument = _instrument(
symbol="BTC/USD",
base_asset="BTC",
)
requested_symbols: list[str] = []
monkeypatch.setattr(
service,
"get_instruments",
lambda: (
instrument,
),
)
def get_quote(
symbol: str,
*,
runtime_key: str | None = None,
) -> Quote:
del runtime_key
requested_symbols.append(symbol)
return Quote(
symbol=symbol,
last_price=Decimal("50000"),
bid_price=Decimal("49999"),
ask_price=Decimal("50001"),
exchange_timestamp=None,
received_at=datetime.now(timezone.utc),
source="test",
)
monkeypatch.setattr(
service,
"get_quote",
get_quote,
)
cache: dict[str, float | None] = {}
result = get_asset_usd_rate(
service,
"BTC",
cache,
)
assert result == 50_000.0
assert requested_symbols == [
"BTC/USD",
]
assert cache == {
"BTC": 50_000.0,
}
def test_get_asset_usd_rate_caches_missing_instrument(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
monkeypatch.setattr(
service,
"get_instruments",
lambda: (),
)
cache: dict[str, float | None] = {}
result = get_asset_usd_rate(
service,
"BTC",
cache,
)
assert result is None
assert cache == {
"BTC": None,
}
def test_get_asset_usd_rate_caches_price_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
monkeypatch.setattr(
service,
"get_instruments",
lambda: (
_instrument(
symbol="BTC/USD",
base_asset="BTC",
),
),
)
def raise_error(
symbol: str,
*,
runtime_key: str | None = None,
) -> Quote:
del symbol
del runtime_key
raise ExchangeError(
"ticker unavailable"
)
monkeypatch.setattr(
service,
"get_quote",
raise_error,
)
cache: dict[str, float | None] = {}
result = get_asset_usd_rate(
service,
"BTC",
cache,
)
assert result is None
assert cache == {
"BTC": None,
}
def test_estimate_balance_usd_for_fiat_balance() -> None:
service = _service()
balance = BalanceSummary(
currency="USD",
available=100.0,
locked=25.0,
source="test",
)
result = estimate_balance_usd(
balance,
service,
{},
)
assert result == 125.0
def test_estimate_balance_usd_for_crypto_balance(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
monkeypatch.setattr(
service,
"get_instruments",
lambda: (
_instrument(
symbol="BTC/USD",
base_asset="BTC",
),
),
)
monkeypatch.setattr(
service,
"get_quote",
lambda symbol, **_: Quote(
symbol=symbol,
last_price=Decimal("50000"),
bid_price=Decimal("49999"),
ask_price=Decimal("50001"),
exchange_timestamp=None,
received_at=datetime.now(timezone.utc),
source="test",
),
)
balance = BalanceSummary(
currency="BTC",
available=0.01,
locked=0.005,
source="test",
)
result = estimate_balance_usd(
balance,
service,
{},
)
assert result == pytest.approx(
750.0
)
def test_estimate_balance_usd_returns_none_for_zero_total() -> None:
service = _service()
balance = BalanceSummary(
currency="BTC",
available=0.0,
locked=0.0,
source="test",
)
result = estimate_balance_usd(
balance,
service,
{},
)
assert result is None
def test_estimate_balance_usd_returns_none_when_rate_is_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _service()
monkeypatch.setattr(
service,
"get_instruments",
lambda: (),
)
balance = BalanceSummary(
currency="BTC",
available=1.0,
locked=0.0,
source="test",
)
result = estimate_balance_usd(
balance,
service,
{},
)
assert result is None

View File

@@ -0,0 +1,83 @@
# app/tests/unit/trading/auto/test_execution_quality.py
from __future__ import annotations
from types import SimpleNamespace
import pytest
import src.trading.auto.execution_quality as module
from src.integrations.exchange.models import ExecutionPriceSnapshot
from src.trading.auto.execution_quality import AutoExecutionQualityMixin
class Harness(AutoExecutionQualityMixin):
_spread_thresholds_by_asset = {}
_default_spread_thresholds = {
"warning_enter": 1.0,
"warning_exit": 0.8,
"block_enter": 2.0,
"block_exit": 1.5,
}
_max_snapshot_age_seconds = 5.0
_warning_snapshot_age_seconds = 2.0
_last_logged_execution_quality_key = None
def _log_execution_quality_if_changed(self, **_: object) -> None:
return None
def _apply_exchange_block_state(self, **_: object) -> None:
raise AssertionError("exchange block must not be applied")
def _state() -> SimpleNamespace:
return SimpleNamespace(
market_is_open=True,
symbol="BTC/USD_LEVERAGE",
strategy="trend",
status="RUNNING",
execution_quality=None,
execution_quality_reason=None,
execution_quality_message=None,
execution_block_reason=None,
market_runtime_degraded=False,
snapshot_age_seconds=None,
spread_percent=None,
execution_price_age_seconds=None,
execution_bid_price=None,
execution_ask_price=None,
execution_last_price=None,
execution_price_freshness=None,
)
def test_execution_quality_uses_typed_execution_snapshot(
monkeypatch: pytest.MonkeyPatch,
) -> None:
snapshot = ExecutionPriceSnapshot(
symbol="BTC/USD_LEVERAGE",
last_price=100.5,
bid_price=100.0,
ask_price=101.0,
updated_at="13.07.2026 15:00:00",
source="dzengi:fresh_cache",
is_fresh=True,
age_seconds=0.5,
)
class Service:
def get_execution_snapshot(self, *_: object, **__: object) -> ExecutionPriceSnapshot:
return snapshot
monkeypatch.setattr(module, "ExchangeService", Service)
state = _state()
Harness()._sync_execution_quality_state(state) # type: ignore[arg-type]
assert state.execution_bid_price == 100.0
assert state.execution_ask_price == 101.0
assert state.execution_last_price == 100.5
assert state.execution_price_source == "dzengi:fresh_cache"
assert state.snapshot_age_seconds == 0.5

View File

@@ -0,0 +1,75 @@
# app/tests/unit/trading/auto/test_signal_runtime_quote.py
from __future__ import annotations
import inspect
from datetime import datetime, timezone
from decimal import Decimal
from types import SimpleNamespace
import pytest
import src.trading.auto.signal_runtime as module
from src.market_data.acquisition.models.quote import Quote
from src.trading.auto.signal_runtime import AutoSignalRuntimeMixin
def _quote() -> Quote:
return Quote(
symbol="BTC/USD_LEVERAGE",
last_price=Decimal("100.5"),
bid_price=Decimal("100.0"),
ask_price=Decimal("101.0"),
exchange_timestamp=None,
received_at=datetime.now(timezone.utc),
source="dzengi",
)
class Harness(AutoSignalRuntimeMixin):
_ready_confidence = 0.3
def test_ready_signal_uses_canonical_quote(
monkeypatch: pytest.MonkeyPatch,
) -> None:
quote = _quote()
requested: list[tuple[str, str]] = []
captured: dict[str, object] = {}
class Service:
def get_quote(self, symbol: str, *, runtime_key: str) -> Quote:
requested.append((symbol, runtime_key))
return quote
class Journal:
def log_ui_info(self, **kwargs: object) -> None:
captured.update(kwargs)
harness = Harness()
def build_payload(**kwargs: object) -> dict[str, object]:
captured["quote"] = kwargs["quote"]
return {"ok": True}
monkeypatch.setattr(module, "ExchangeService", Service)
monkeypatch.setattr(module, "JournalService", Journal)
monkeypatch.setattr(harness, "_build_ready_signal_payload", build_payload)
state = SimpleNamespace(symbol="BTC/USD_LEVERAGE")
harness._log_ready_signal(
state=state, # type: ignore[arg-type]
signal="BUY",
reason="test",
confidence=0.9,
signal_intent="ENTRY",
)
assert requested == [("BTC/USD_LEVERAGE", "auto")]
assert captured["quote"] is quote
def test_signal_runtime_has_no_legacy_market_snapshot_call() -> None:
source = inspect.getsource(module.AutoSignalRuntimeMixin)
assert "get_quote(" in source

View File

@@ -0,0 +1,49 @@
# app/tests/unit/trading/debug/test_execution.py
from __future__ import annotations
import pytest
import src.trading.debug.execution as module
from src.integrations.exchange.models import ExecutionPriceSnapshot
from src.trading.debug.execution import DebugExecutionEngine
def _snapshot() -> ExecutionPriceSnapshot:
return ExecutionPriceSnapshot(
symbol="BTC/USD_LEVERAGE",
last_price=100.5,
bid_price=100.0,
ask_price=101.0,
updated_at="13.07.2026 15:00:00",
source="rest_fallback",
is_fresh=True,
age_seconds=0.0,
)
def test_debug_execution_uses_execution_snapshot(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[str, str | None]] = []
class Service:
def get_execution_snapshot(
self,
symbol: str,
*,
runtime_key: str | None = None,
) -> ExecutionPriceSnapshot:
calls.append((symbol, runtime_key))
return _snapshot()
monkeypatch.setattr(module, "ExchangeService", Service)
engine = DebugExecutionEngine()
assert engine._entry_price_for_side("BTC/USD_LEVERAGE", "LONG") == 101.0
assert engine._entry_price_for_side("BTC/USD_LEVERAGE", "SHORT") == 100.0
assert engine._exit_price_for_side("BTC/USD_LEVERAGE", "LONG") == 100.0
assert engine._exit_price_for_side("BTC/USD_LEVERAGE", "SHORT") == 101.0
assert engine._market_last_price("BTC/USD_LEVERAGE") == 100.5
assert calls == [("BTC/USD_LEVERAGE", "debug_auto")] * 5

View File

@@ -0,0 +1,52 @@
# app/tests/unit/trading/strategies/test_scalp_quote.py
from __future__ import annotations
import inspect
from datetime import datetime, timezone
from decimal import Decimal
import src.trading.strategies.scalp as module
from src.market_data.acquisition.models.quote import Quote
from src.trading.strategies.scalp import ScalpStrategy
def _quote(
*,
last: str = "100.5",
bid: str = "100.0",
ask: str = "101.0",
) -> Quote:
return Quote(
symbol="BTC/USD_LEVERAGE",
last_price=Decimal(last),
bid_price=Decimal(bid),
ask_price=Decimal(ask),
exchange_timestamp=None,
received_at=datetime.now(timezone.utc),
source="dzengi",
)
def test_scalp_uses_midpoint_from_quote() -> None:
result = ScalpStrategy()._analysis_price(_quote())
assert result == 100.5
def test_scalp_quote_snapshot_is_json_compatible_projection() -> None:
result = ScalpStrategy()._quote_snapshot(_quote())
assert result == {
"symbol": "BTC/USD_LEVERAGE",
"last_price": 100.5,
"bid_price": 100.0,
"ask_price": 101.0,
"source": "dzengi",
}
def test_scalp_has_no_legacy_market_snapshot_call() -> None:
source = inspect.getsource(module.ScalpStrategy)
assert "get_quote(" in source

View File

@@ -0,0 +1,52 @@
# app/tests/unit/trading/strategies/test_trend_quote.py
from __future__ import annotations
import inspect
from datetime import datetime, timezone
from decimal import Decimal
import src.trading.strategies.trend as module
from src.market_data.acquisition.models.quote import Quote
from src.trading.strategies.trend import TrendStrategy
def _quote(
*,
last: str = "100.5",
bid: str = "100.0",
ask: str = "101.0",
) -> Quote:
return Quote(
symbol="BTC/USD_LEVERAGE",
last_price=Decimal(last),
bid_price=Decimal(bid),
ask_price=Decimal(ask),
exchange_timestamp=None,
received_at=datetime.now(timezone.utc),
source="dzengi",
)
def test_trend_uses_midpoint_from_quote() -> None:
result = TrendStrategy()._analysis_price(_quote())
assert result == 100.5
def test_trend_quote_snapshot_is_json_compatible_projection() -> None:
result = TrendStrategy()._quote_snapshot(_quote())
assert result == {
"symbol": "BTC/USD_LEVERAGE",
"last_price": 100.5,
"bid_price": 100.0,
"ask_price": 101.0,
"source": "dzengi",
}
def test_trend_has_no_legacy_market_snapshot_call() -> None:
source = inspect.getsource(module.TrendStrategy)
assert "get_quote(" in source