build 049: expose canonical candles through ExchangeService
This commit is contained in:
391
app/tests/unit/integrations/exchange/test_service_candles.py
Normal file
391
app/tests/unit/integrations/exchange/test_service_candles.py
Normal file
@@ -0,0 +1,391 @@
|
||||
# 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",
|
||||
},
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user