build 049: expose canonical candles through ExchangeService

This commit is contained in:
2026-07-15 20:54:24 +03:00
parent 3a253d89a9
commit 16ed64f7c6
3 changed files with 1010 additions and 7 deletions

View 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",
},
}
]