# app/tests/unit/integrations/exchange/test_service_klines.py from __future__ import annotations from datetime import datetime, timezone from decimal import Decimal from types import SimpleNamespace from typing import Any, cast import pytest import src.integrations.exchange.service as service_module from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.models import SymbolValidationResult from src.integrations.exchange.service import ExchangeService from src.market_data.acquisition.models.candle import Candle def _candle( *, symbol: str = "BTC/USD_LEVERAGE", interval: str = "1m", open_time_ms: int = 1_750_000_000_000, open_price: str = "100.10", high_price: str = "110.20", low_price: str = "90.30", close_price: str = "105.40", volume: str = "12.50", source: str = "rest_klines:bid", ) -> Candle: return Candle( symbol=symbol, interval=interval, open_time=datetime.fromtimestamp( open_time_ms / 1000, tz=timezone.utc, ), open_price=Decimal(open_price), high_price=Decimal(high_price), low_price=Decimal(low_price), close_price=Decimal(close_price), volume=Decimal(volume), source=source, ) def _set_test_attribute( target: object, name: str, value: object, ) -> None: setattr(cast(Any, target), name, value) def _service( *, default_symbol: str = "BTC/USD_LEVERAGE", exchange_enabled: bool = True, ) -> ExchangeService: service = ExchangeService.__new__(ExchangeService) _set_test_attribute( service, "settings", SimpleNamespace( default_symbol=default_symbol, exchange_enabled=exchange_enabled, ), ) return service def _valid_symbol( symbol: str = "BTC/USD_LEVERAGE", ) -> SymbolValidationResult: return SymbolValidationResult( requested_symbol=symbol, normalized_symbol=symbol, is_valid=True, message="OK", symbol_info=None, ) def _invalid_symbol( symbol: str = "UNKNOWN", ) -> SymbolValidationResult: return SymbolValidationResult( requested_symbol=symbol, normalized_symbol=symbol, is_valid=False, message="Invalid symbol.", symbol_info=None, ) def test_get_klines_uses_default_symbol() -> None: service = _service(default_symbol="ETH/USD_LEVERAGE") requested_symbols: list[str] = [] acquisition_calls: list[dict[str, object]] = [] _set_test_attribute( service, "validate_symbol", lambda symbol: ( requested_symbols.append(symbol) or _valid_symbol(symbol) ), ) _set_test_attribute( service, "_load_candles_via_acquisition", lambda **kwargs: acquisition_calls.append(kwargs) or (), ) result = service.get_klines() assert requested_symbols == ["ETH/USD_LEVERAGE"] assert acquisition_calls[0]["symbol"] == "ETH/USD_LEVERAGE" assert result.symbol == "ETH/USD_LEVERAGE" @pytest.mark.parametrize("limit", [0, -1, -100]) def test_get_klines_normalizes_non_positive_limit(limit: int) -> None: service = _service() captured: list[dict[str, object]] = [] _set_test_attribute( service, "validate_symbol", lambda symbol: _valid_symbol(symbol), ) _set_test_attribute( service, "_load_candles_via_acquisition", lambda **kwargs: captured.append(kwargs) or (), ) service.get_klines(limit=limit) assert captured[0]["limit"] == 200 def test_get_klines_caps_limit_at_200() -> None: service = _service() captured: list[dict[str, object]] = [] _set_test_attribute( service, "validate_symbol", lambda symbol: _valid_symbol(symbol), ) _set_test_attribute( service, "_load_candles_via_acquisition", lambda **kwargs: captured.append(kwargs) or (), ) service.get_klines(limit=500) assert captured[0]["limit"] == 200 @pytest.mark.parametrize("interval", ["1m", "5m", "15m", "1h"]) def test_get_klines_accepts_supported_intervals(interval: str) -> None: service = _service() captured: list[dict[str, object]] = [] _set_test_attribute( service, "validate_symbol", lambda symbol: _valid_symbol(symbol), ) _set_test_attribute( service, "_load_candles_via_acquisition", lambda **kwargs: captured.append(kwargs) or (), ) result = service.get_klines(interval=interval) assert captured[0]["interval"] == interval assert result.interval == interval def test_get_klines_rejects_unsupported_interval() -> None: service = _service() with pytest.raises( ExchangeError, match="Unsupported kline interval", ): service.get_klines(interval="4h") @pytest.mark.parametrize( ("price_type", "expected"), [ ("bid", "bid"), ("ask", "ask"), (" BID ", "bid"), (" AsK ", "ask"), ("unknown", "bid"), ("", "bid"), ], ) def test_get_klines_normalizes_price_type( price_type: str, expected: str, ) -> None: service = _service() captured: list[dict[str, object]] = [] _set_test_attribute( service, "validate_symbol", lambda symbol: _valid_symbol(symbol), ) _set_test_attribute( service, "_load_candles_via_acquisition", lambda **kwargs: captured.append(kwargs) or (), ) result = service.get_klines(price_type=price_type) assert captured[0]["price_type"] == expected assert result.source == f"rest_klines:{expected}" def test_get_klines_rejects_mock_mode() -> None: service = _service(exchange_enabled=False) with pytest.raises( ExchangeError, match="Klines are not available in mock exchange mode", ): service.get_klines() def test_get_klines_rejects_invalid_symbol() -> None: service = _service() _set_test_attribute( service, "validate_symbol", lambda symbol: _invalid_symbol(symbol), ) with pytest.raises(ExchangeError, match="Invalid symbol"): service.get_klines("UNKNOWN") def test_get_klines_calls_acquisition_with_exact_arguments() -> None: service = _service() captured: list[dict[str, object]] = [] _set_test_attribute( service, "validate_symbol", lambda symbol: _valid_symbol("BTC/USD_LEVERAGE"), ) _set_test_attribute( service, "_load_candles_via_acquisition", lambda **kwargs: captured.append(kwargs) or (), ) service.get_klines( " btc/usd ", interval="5m", limit=123, price_type="ask", ) assert captured == [ { "symbol": "BTC/USD_LEVERAGE", "interval": "5m", "limit": 123, "price_type": "ask", } ] def test_get_klines_maps_canonical_candle_to_legacy_kline() -> None: service = _service() candle = _candle() _set_test_attribute( service, "validate_symbol", lambda symbol: _valid_symbol(symbol), ) _set_test_attribute( service, "_load_candles_via_acquisition", lambda **kwargs: (candle,), ) result = service.get_klines() assert len(result.candles) == 1 kline = result.candles[0] assert kline.symbol == candle.symbol assert kline.interval == candle.interval assert kline.open_time == 1_750_000_000_000 assert kline.open_price == 100.10 assert kline.high_price == 110.20 assert kline.low_price == 90.30 assert kline.close_price == 105.40 assert kline.volume == 12.50 assert kline.source == candle.source def test_get_klines_preserves_candle_order() -> None: service = _service() first = _candle(open_time_ms=1000) second = _candle(open_time_ms=2000) _set_test_attribute( service, "validate_symbol", lambda symbol: _valid_symbol(symbol), ) _set_test_attribute( service, "_load_candles_via_acquisition", lambda **kwargs: (first, second), ) result = service.get_klines() assert [item.open_time for item in result.candles] == [1000, 2000] def test_get_klines_trims_result_to_limit() -> None: service = _service() candles = tuple( _candle(open_time_ms=index * 1000) for index in range(1, 6) ) _set_test_attribute( service, "validate_symbol", lambda symbol: _valid_symbol(symbol), ) _set_test_attribute( service, "_load_candles_via_acquisition", lambda **kwargs: candles, ) result = service.get_klines(limit=2) assert [item.open_time for item in result.candles] == [4000, 5000] def test_get_klines_returns_empty_batch() -> None: service = _service() _set_test_attribute( service, "validate_symbol", lambda symbol: _valid_symbol(symbol), ) _set_test_attribute( service, "_load_candles_via_acquisition", lambda **kwargs: (), ) result = service.get_klines() assert result.candles == [] assert result.source == "rest_klines:bid" def test_get_klines_logs_and_wraps_acquisition_error() -> None: service = _service() original_error = RuntimeError("candles unavailable") log_calls: list[dict[str, object]] = [] _set_test_attribute( service, "validate_symbol", lambda symbol: _valid_symbol(symbol), ) def raise_error(**kwargs: object) -> tuple[Candle, ...]: raise original_error _set_test_attribute( service, "_load_candles_via_acquisition", raise_error, ) _set_test_attribute( service, "_log_exchange_error", lambda **kwargs: log_calls.append(kwargs), ) with pytest.raises( ExchangeError, match="candles unavailable", ) as error_info: service.get_klines( interval="15m", limit=25, price_type="ask", ) assert error_info.value.__cause__ is original_error assert log_calls == [ { "endpoint": "klines", "exc": original_error, "symbol": "BTC/USD_LEVERAGE", "extra_payload": { "interval": "15m", "limit": 25, "price_type": "ask", }, } ] def test_kline_from_candle_returns_new_legacy_model() -> None: service = _service() candle = _candle() result = service._kline_from_candle(candle) assert result is not candle assert result.symbol == candle.symbol assert result.open_time == 1_750_000_000_000 def test_load_candles_via_acquisition_builds_complete_pipeline( monkeypatch: pytest.MonkeyPatch, ) -> None: events: list[tuple[str, object]] = [] expected = (_candle(),) class FakeSource: def __init__(self) -> None: events.append(("source", self)) class FakeHandler: def __init__(self) -> None: events.append(("handler", self)) class FakeFeed: def __init__(self, *, source: object, handler: object) -> None: self.source = source self.handler = handler events.append(("feed", self)) class FakeRegistry: def __init__(self) -> None: self.registered: tuple[str, object] | None = None events.append(("registry", self)) def register(self, source_name: str, feed: object) -> None: self.registered = (source_name, feed) events.append(("register", self.registered)) class FakeAcquisitionService: def __init__(self, *, registry: FakeRegistry) -> None: self.registry = registry events.append(("service", registry)) def load_candles( self, source_name: str, symbol: str, *, interval: str, limit: int, price_type: str, ) -> tuple[Candle, ...]: events.append( ( "load", ( source_name, symbol, interval, limit, price_type, ), ) ) return expected monkeypatch.setattr( service_module, "DzengiCandlesDocumentSource", FakeSource, ) monkeypatch.setattr( service_module, "DzengiCandlesDocumentHandler", FakeHandler, ) monkeypatch.setattr(service_module, "CandlesFeed", FakeFeed) monkeypatch.setattr( service_module, "CandlesFeedRegistry", FakeRegistry, ) monkeypatch.setattr( service_module, "CandlesAcquisitionService", FakeAcquisitionService, ) service = _service() result = service._load_candles_via_acquisition( symbol="BTC/USD_LEVERAGE", interval="1m", limit=100, price_type="bid", ) assert result is expected source = events[0][1] handler = events[1][1] feed = events[2][1] registry = events[3][1] assert isinstance(source, FakeSource) assert isinstance(handler, FakeHandler) assert isinstance(feed, FakeFeed) assert isinstance(registry, FakeRegistry) assert feed.source is source assert feed.handler is handler assert registry.registered == ("dzengi", feed) assert ( "load", ( "dzengi", "BTC/USD_LEVERAGE", "1m", 100, "bid", ), ) in events