build 046: integrate canonical candles feed into acquisition service
This commit is contained in:
@@ -2,15 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.market_data.acquisition.models.candle import Candle
|
||||
from src.market_data.acquisition.models.instrument import Instrument
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
from src.market_data.acquisition.registry import (
|
||||
CandlesFeedRegistry,
|
||||
InstrumentFeedRegistry,
|
||||
QuoteFeedRegistry,
|
||||
)
|
||||
|
||||
|
||||
# Application-level сервис получения справочника инструментов.
|
||||
class InstrumentAcquisitionService:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -23,19 +24,11 @@ class InstrumentAcquisitionService:
|
||||
self,
|
||||
source_name: str,
|
||||
) -> tuple[Instrument, ...]:
|
||||
"""
|
||||
Получить Instrument Feed из Registry и загрузить справочник инструментов.
|
||||
|
||||
Service не создаёт Feed, не выполняет transport, parsing, validation,
|
||||
mapping, retry, кэширование или преобразование результата.
|
||||
"""
|
||||
|
||||
feed = self._registry.get(source_name)
|
||||
|
||||
return feed.load_instruments()
|
||||
|
||||
|
||||
# Application-level сервис получения текущих котировок.
|
||||
class QuoteAcquisitionService:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -49,14 +42,33 @@ class QuoteAcquisitionService:
|
||||
source_name: str,
|
||||
symbol: str,
|
||||
) -> Quote:
|
||||
"""
|
||||
Получить Quotes Feed из Registry и загрузить текущую котировку.
|
||||
|
||||
Service не создаёт Feed, не выполняет transport, parsing, validation,
|
||||
mapping, нормализацию symbol, retry, кэширование или преобразование
|
||||
результата.
|
||||
"""
|
||||
|
||||
feed = self._registry.get(source_name)
|
||||
|
||||
return feed.load_quote(symbol)
|
||||
|
||||
|
||||
class CandlesAcquisitionService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
registry: CandlesFeedRegistry,
|
||||
) -> None:
|
||||
self._registry = registry
|
||||
|
||||
def load_candles(
|
||||
self,
|
||||
source_name: str,
|
||||
symbol: str,
|
||||
*,
|
||||
interval: str,
|
||||
limit: int,
|
||||
price_type: str,
|
||||
) -> tuple[Candle, ...]:
|
||||
feed = self._registry.get(source_name)
|
||||
|
||||
return feed.load_candles(
|
||||
symbol,
|
||||
interval=interval,
|
||||
limit=limit,
|
||||
price_type=price_type,
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user