# app/tests/unit/integrations/exchange/test_service_candles.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 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 _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 _candle( *, symbol: str = "BTC/USD_LEVERAGE", interval: str = "1m", open_time_ms: int = 1_750_000_000_000, ) -> Candle: return Candle( symbol=symbol, interval=interval, open_time=datetime.fromtimestamp( open_time_ms / 1000, tz=timezone.utc, ), open_price=Decimal("100.10"), high_price=Decimal("110.20"), low_price=Decimal("90.30"), close_price=Decimal("105.40"), volume=Decimal("12.50"), source="rest_klines:bid", ) def test_get_candles_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_candles() assert result == () assert requested_symbols == ["ETH/USD_LEVERAGE"] assert acquisition_calls == [ { "symbol": "ETH/USD_LEVERAGE", "interval": "1m", "limit": 200, "price_type": "bid", } ] @pytest.mark.parametrize("limit", [0, -1, -100]) def test_get_candles_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_candles(limit=limit) assert captured[0]["limit"] == 200 def test_get_candles_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_candles(limit=500) assert captured[0]["limit"] == 200 @pytest.mark.parametrize("interval", ["1m", "5m", "15m", "1h"]) def test_get_candles_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 (), ) service.get_candles(interval=interval) assert captured[0]["interval"] == interval def test_get_candles_rejects_unsupported_interval() -> None: service = _service() with pytest.raises( ExchangeError, match="Unsupported kline interval", ): service.get_candles(interval="4h") @pytest.mark.parametrize( ("price_type", "expected"), [ ("bid", "bid"), ("ask", "ask"), (" BID ", "bid"), (" AsK ", "ask"), ("unknown", "bid"), ("", "bid"), ], ) def test_get_candles_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 (), ) service.get_candles(price_type=price_type) assert captured[0]["price_type"] == expected def test_get_candles_rejects_mock_mode() -> None: service = _service(exchange_enabled=False) with pytest.raises( ExchangeError, match="Candles are not available in mock exchange mode", ): service.get_candles() def test_get_candles_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_candles("UNKNOWN") def test_get_candles_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_candles( " 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_candles_preserves_result_identity() -> None: service = _service() candles = (_candle(),) _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_candles() assert result is candles def test_get_candles_preserves_order() -> None: service = _service() first = _candle(open_time_ms=1000) second = _candle(open_time_ms=2000) candles = (second, first) _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_candles() assert result is candles assert result == (second, first) def test_get_candles_preserves_empty_tuple_identity() -> None: service = _service() candles: tuple[Candle, ...] = () _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_candles() assert result is candles def test_get_candles_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_candles( 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", }, } ]