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