build 046: integrate canonical candles feed into acquisition service

This commit is contained in:
2026-07-15 13:23:35 +03:00
parent 0e86cca19d
commit a10a760166
3 changed files with 928 additions and 19 deletions

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
import pytest
@@ -11,12 +12,16 @@ from src.market_data.acquisition.exceptions import (
InstrumentReferenceMappingError,
InstrumentReferenceTransportError,
InstrumentReferenceValueError,
CandleFeedRegistryError,
CandleMappingError,
CandleTransportError,
CandleValueError,
)
from src.market_data.acquisition.models.candle import Candle
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
from src.market_data.acquisition.service import InstrumentAcquisitionService, CandlesAcquisitionService
def _instrument(
*,
@@ -473,3 +478,380 @@ def test_quote_service_does_not_retry_after_error() -> None:
service.load_quote("dzengi", "BTC/USD_LEVERAGE")
assert feed.symbols == ["BTC/USD_LEVERAGE"]
# ---------------------------------------------------------------------------
# Candles Acquisition Service tests
# ---------------------------------------------------------------------------
def _make_candle(
*,
symbol: str = "BTC/USD",
interval: str = "1m",
open_time: datetime | None = None,
open_price: str = "100",
high_price: str = "110",
low_price: str = "90",
close_price: str = "105",
volume: str = "12.5",
source: str = "dzengi",
) -> Candle:
return Candle(
symbol=symbol,
interval=interval,
open_time=open_time or datetime(2026, 1, 1, tzinfo=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,
)
class _FakeCandlesFeed:
def __init__(
self,
*,
result: tuple[Candle, ...] = (),
error: Exception | None = None,
) -> None:
self.result = result
self.error = error
self.calls: list[tuple[str, str, int, str]] = []
def load_candles(
self,
symbol: str,
*,
interval: str,
limit: int,
price_type: str,
) -> tuple[Candle, ...]:
self.calls.append(
(
symbol,
interval,
limit,
price_type,
)
)
if self.error is not None:
raise self.error
return self.result
class _FakeCandlesRegistry:
def __init__(
self,
*,
feed: _FakeCandlesFeed | None = None,
error: Exception | None = None,
) -> None:
self.feed = feed
self.error = error
self.calls: list[str] = []
def get(
self,
source_name: str,
) -> _FakeCandlesFeed:
self.calls.append(source_name)
if self.error is not None:
raise self.error
if self.feed is None:
raise AssertionError("Candles Feed не настроен.")
return self.feed
def test_candles_acquisition_service_returns_registered_feed_result() -> None:
candles = (
_make_candle(),
)
feed = _FakeCandlesFeed(result=candles)
registry = _FakeCandlesRegistry(feed=feed)
service = CandlesAcquisitionService(registry=registry) # type: ignore[arg-type]
result = service.load_candles(
"dzengi",
"BTC/USD",
interval="1m",
limit=100,
price_type="bid",
)
assert result == candles
def test_candles_acquisition_service_preserves_result_identity() -> None:
candles = (
_make_candle(),
)
feed = _FakeCandlesFeed(result=candles)
registry = _FakeCandlesRegistry(feed=feed)
service = CandlesAcquisitionService(registry=registry) # type: ignore[arg-type]
result = service.load_candles(
"dzengi",
"BTC/USD",
interval="1m",
limit=100,
price_type="bid",
)
assert result is candles
def test_candles_acquisition_service_passes_source_name_unchanged() -> None:
feed = _FakeCandlesFeed()
registry = _FakeCandlesRegistry(feed=feed)
service = CandlesAcquisitionService(registry=registry) # type: ignore[arg-type]
service.load_candles(
" DzEnGi ",
"BTC/USD",
interval="1m",
limit=100,
price_type="bid",
)
assert registry.calls == [" DzEnGi "]
def test_candles_acquisition_service_calls_registry_once() -> None:
feed = _FakeCandlesFeed()
registry = _FakeCandlesRegistry(feed=feed)
service = CandlesAcquisitionService(registry=registry) # type: ignore[arg-type]
service.load_candles(
"dzengi",
"BTC/USD",
interval="1m",
limit=100,
price_type="bid",
)
assert len(registry.calls) == 1
def test_candles_acquisition_service_passes_symbol_unchanged() -> None:
feed = _FakeCandlesFeed()
registry = _FakeCandlesRegistry(feed=feed)
service = CandlesAcquisitionService(registry=registry) # type: ignore[arg-type]
service.load_candles(
"dzengi",
" btc/usd ",
interval="1m",
limit=100,
price_type="bid",
)
assert feed.calls == [
(
" btc/usd ",
"1m",
100,
"bid",
)
]
def test_candles_acquisition_service_passes_interval_unchanged() -> None:
feed = _FakeCandlesFeed()
registry = _FakeCandlesRegistry(feed=feed)
service = CandlesAcquisitionService(registry=registry) # type: ignore[arg-type]
service.load_candles(
"dzengi",
"BTC/USD",
interval=" 1M ",
limit=100,
price_type="bid",
)
assert feed.calls[0][1] == " 1M "
def test_candles_acquisition_service_passes_limit_unchanged() -> None:
feed = _FakeCandlesFeed()
registry = _FakeCandlesRegistry(feed=feed)
service = CandlesAcquisitionService(registry=registry) # type: ignore[arg-type]
service.load_candles(
"dzengi",
"BTC/USD",
interval="1m",
limit=123,
price_type="bid",
)
assert feed.calls[0][2] == 123
def test_candles_acquisition_service_passes_price_type_unchanged() -> None:
feed = _FakeCandlesFeed()
registry = _FakeCandlesRegistry(feed=feed)
service = CandlesAcquisitionService(registry=registry) # type: ignore[arg-type]
service.load_candles(
"dzengi",
"BTC/USD",
interval="1m",
limit=100,
price_type=" BiD ",
)
assert feed.calls[0][3] == " BiD "
def test_candles_acquisition_service_calls_feed_once() -> None:
feed = _FakeCandlesFeed()
registry = _FakeCandlesRegistry(feed=feed)
service = CandlesAcquisitionService(registry=registry) # type: ignore[arg-type]
service.load_candles(
"dzengi",
"BTC/USD",
interval="1m",
limit=100,
price_type="bid",
)
assert len(feed.calls) == 1
def test_candles_acquisition_service_preserves_empty_tuple_identity() -> None:
candles: tuple[Candle, ...] = ()
feed = _FakeCandlesFeed(result=candles)
registry = _FakeCandlesRegistry(feed=feed)
service = CandlesAcquisitionService(registry=registry) # type: ignore[arg-type]
result = service.load_candles(
"dzengi",
"BTC/USD",
interval="1m",
limit=100,
price_type="bid",
)
assert result is candles
def test_candles_acquisition_service_preserves_candle_order() -> None:
first = _make_candle(
open_time=datetime(2026, 1, 1, 0, 1, tzinfo=timezone.utc),
)
second = _make_candle(
open_time=datetime(2026, 1, 1, 0, 2, tzinfo=timezone.utc),
)
candles = (
second,
first,
)
feed = _FakeCandlesFeed(result=candles)
registry = _FakeCandlesRegistry(feed=feed)
service = CandlesAcquisitionService(registry=registry) # type: ignore[arg-type]
result = service.load_candles(
"dzengi",
"BTC/USD",
interval="1m",
limit=100,
price_type="bid",
)
assert result == (
second,
first,
)
def test_candles_acquisition_service_propagates_registry_error() -> None:
error = CandleFeedRegistryError("Registry error.")
registry = _FakeCandlesRegistry(error=error)
service = CandlesAcquisitionService(registry=registry) # type: ignore[arg-type]
with pytest.raises(CandleFeedRegistryError) as exc_info:
service.load_candles(
"dzengi",
"BTC/USD",
interval="1m",
limit=100,
price_type="bid",
)
assert exc_info.value is error
def test_candles_acquisition_service_does_not_call_feed_after_registry_error() -> None:
feed = _FakeCandlesFeed()
error = CandleFeedRegistryError("Registry error.")
registry = _FakeCandlesRegistry(
feed=feed,
error=error,
)
service = CandlesAcquisitionService(registry=registry) # type: ignore[arg-type]
with pytest.raises(CandleFeedRegistryError):
service.load_candles(
"dzengi",
"BTC/USD",
interval="1m",
limit=100,
price_type="bid",
)
assert feed.calls == []
@pytest.mark.parametrize(
"error",
[
CandleTransportError("Transport error."),
CandleValueError("Value error."),
CandleMappingError("Mapping error."),
],
)
def test_candles_acquisition_service_propagates_feed_error(
error: Exception,
) -> None:
feed = _FakeCandlesFeed(error=error)
registry = _FakeCandlesRegistry(feed=feed)
service = CandlesAcquisitionService(registry=registry) # type: ignore[arg-type]
with pytest.raises(type(error)) as exc_info:
service.load_candles(
"dzengi",
"BTC/USD",
interval="1m",
limit=100,
price_type="bid",
)
assert exc_info.value is error
def test_candles_acquisition_service_does_not_retry_after_feed_error() -> None:
error = CandleTransportError("Transport error.")
feed = _FakeCandlesFeed(error=error)
registry = _FakeCandlesRegistry(feed=feed)
service = CandlesAcquisitionService(registry=registry) # type: ignore[arg-type]
with pytest.raises(CandleTransportError):
service.load_candles(
"dzengi",
"BTC/USD",
interval="1m",
limit=100,
price_type="bid",
)
assert len(registry.calls) == 1
assert len(feed.calls) == 1