build 047: switch ExchangeService klines to canonical candles feed

This commit is contained in:
2026-07-15 16:35:54 +03:00
parent a10a760166
commit 77e87c0504
3 changed files with 1184 additions and 139 deletions

View File

@@ -41,24 +41,32 @@ from src.integrations.exchange.status import (
classify_exchange_error,
)
from src.market_data.acquisition.adapters.dzengi.rest import (
DzengiCandlesDocumentSource,
DzengiInstrumentDocumentSource,
DzengiQuoteDocumentSource,
)
from src.market_data.acquisition.feeds.candles_feed import CandlesFeed
from src.market_data.acquisition.feeds.instrument_feed import InstrumentFeed
from src.market_data.acquisition.feeds.quotes_feed import QuotesFeed
from src.market_data.acquisition.handlers.candles_handler import (
DzengiCandlesDocumentHandler,
)
from src.market_data.acquisition.handlers.instrument_handler import (
DzengiInstrumentDocumentHandler,
)
from src.market_data.acquisition.handlers.quotes_handler import (
DzengiQuoteDocumentHandler,
)
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,
)
from src.market_data.acquisition.service import (
CandlesAcquisitionService,
InstrumentAcquisitionService,
QuoteAcquisitionService,
)
@@ -79,6 +87,7 @@ from src.trading.journal.service import JournalService
_INSTRUMENT_REFERENCE_SOURCE_NAME = "dzengi"
_QUOTE_SOURCE_NAME = "dzengi"
_CANDLES_SOURCE_NAME = "dzengi"
class ExchangeService:
@@ -491,7 +500,7 @@ class ExchangeService:
def _runtime_key(self, runtime_key: str | None) -> str:
return (runtime_key or self._default_runtime_key).strip().lower()
# Получить свечи инструмента через REST API.
# Получить свечи инструмента через канонический Candles Feed.
def get_klines(
self,
symbol: str | None = None,
@@ -523,17 +532,12 @@ class ExchangeService:
if not validation.is_valid:
raise ExchangeError(validation.message)
client = ExchangeRestClient()
try:
payload = client.get_payload(
"/api/v1/klines",
params={
"symbol": validation.normalized_symbol,
"interval": interval,
"limit": str(limit),
"priceType": normalized_price_type,
},
canonical_candles = self._load_candles_via_acquisition(
symbol=validation.normalized_symbol,
interval=interval,
limit=limit,
price_type=normalized_price_type,
)
except Exception as exc:
self._log_exchange_error(
@@ -548,149 +552,66 @@ class ExchangeService:
)
raise ExchangeError(str(exc)) from exc
candles = self._parse_klines_payload(
payload=payload,
symbol=validation.normalized_symbol,
interval=interval,
source=f"rest_klines:{normalized_price_type}",
)
candles = [
self._kline_from_candle(candle)
for candle in canonical_candles[-limit:]
]
return KlineBatch(
symbol=validation.normalized_symbol,
interval=interval,
candles=candles[-limit:],
candles=candles,
source=f"rest_klines:{normalized_price_type}",
)
# Преобразовать сырой payload свечей в список Kline.
def _parse_klines_payload(
# Собрать Candles acquisition pipeline и вернуть канонические модели Candle.
def _load_candles_via_acquisition(
self,
*,
payload: object,
symbol: str,
interval: str,
source: str,
) -> list[Kline]:
raw_items = self._extract_klines_items(payload)
limit: int,
price_type: str,
) -> tuple[Candle, ...]:
source = DzengiCandlesDocumentSource()
handler = DzengiCandlesDocumentHandler()
candles: list[Kline] = []
feed = CandlesFeed(
source=source,
handler=handler,
)
for item in raw_items:
candle = self._parse_kline_item(
item=item,
symbol=symbol,
interval=interval,
source=source,
)
registry = CandlesFeedRegistry()
registry.register(
_CANDLES_SOURCE_NAME,
feed,
)
if candle is not None:
candles.append(candle)
acquisition_service = CandlesAcquisitionService(
registry=registry,
)
candles.sort(key=lambda item: item.open_time)
return acquisition_service.load_candles(
_CANDLES_SOURCE_NAME,
symbol,
interval=interval,
limit=limit,
price_type=price_type,
)
return candles
# Извлечь массив свечей из разных возможных форматов ответа API.
def _extract_klines_items(self, payload: object) -> list[object]:
if isinstance(payload, list):
return payload
if not isinstance(payload, dict):
return []
for key in ("klines", "candles", "data", "result"):
value = payload.get(key)
if isinstance(value, list):
return value
inner = payload.get("payload")
if isinstance(inner, list):
return inner
if isinstance(inner, dict):
for key in ("klines", "candles", "data"):
value = inner.get(key)
if isinstance(value, list):
return value
return []
# Преобразовать одну свечу из dict/list формата в Kline.
def _parse_kline_item(
self,
*,
item: object,
symbol: str,
interval: str,
source: str,
) -> Kline | None:
if isinstance(item, dict):
open_time = (
item.get("openTime")
or item.get("open_time")
or item.get("time")
or item.get("timestamp")
)
open_time_value = safe_float(open_time)
open_price = safe_float(item.get("open"))
high_price = safe_float(item.get("high"))
low_price = safe_float(item.get("low"))
close_price = safe_float(item.get("close"))
volume = safe_float(item.get("volume")) or 0.0
if (
open_time_value is None
or open_price is None
or high_price is None
or low_price is None
or close_price is None
):
return None
return Kline(
symbol=symbol,
interval=interval,
open_time=int(open_time_value),
open_price=open_price,
high_price=high_price,
low_price=low_price,
close_price=close_price,
volume=volume,
source=source,
)
if isinstance(item, list) and len(item) >= 6:
open_time_value = safe_float(item[0])
open_price = safe_float(item[1])
high_price = safe_float(item[2])
low_price = safe_float(item[3])
close_price = safe_float(item[4])
volume = safe_float(item[5]) or 0.0
if (
open_time_value is None
or open_price is None
or high_price is None
or low_price is None
or close_price is None
):
return None
return Kline(
symbol=symbol,
interval=interval,
open_time=int(open_time_value),
open_price=open_price,
high_price=high_price,
low_price=low_price,
close_price=close_price,
volume=volume,
source=source,
)
return None
# Временно преобразовать canonical Candle в legacy Kline.
def _kline_from_candle(self, candle: Candle) -> Kline:
return Kline(
symbol=candle.symbol,
interval=candle.interval,
open_time=int(candle.open_time.timestamp() * 1000),
open_price=float(candle.open_price),
high_price=float(candle.high_price),
low_price=float(candle.low_price),
close_price=float(candle.close_price),
volume=float(candle.volume),
source=candle.source,
)
# Проверить публичную доступность биржи.
def get_health(self) -> ExchangeHealth:
@@ -1244,4 +1165,4 @@ class ExchangeService:
except Exception:
pass
return None
return None

View File

@@ -0,0 +1,551 @@
# app/tests/unit/integrations/exchange/test_service_klines.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
import src.integrations.exchange.service as service_module
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 _candle(
*,
symbol: str = "BTC/USD_LEVERAGE",
interval: str = "1m",
open_time_ms: int = 1_750_000_000_000,
open_price: str = "100.10",
high_price: str = "110.20",
low_price: str = "90.30",
close_price: str = "105.40",
volume: str = "12.50",
source: str = "rest_klines:bid",
) -> Candle:
return Candle(
symbol=symbol,
interval=interval,
open_time=datetime.fromtimestamp(
open_time_ms / 1000,
tz=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,
)
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 test_get_klines_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_klines()
assert requested_symbols == ["ETH/USD_LEVERAGE"]
assert acquisition_calls[0]["symbol"] == "ETH/USD_LEVERAGE"
assert result.symbol == "ETH/USD_LEVERAGE"
@pytest.mark.parametrize("limit", [0, -1, -100])
def test_get_klines_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_klines(limit=limit)
assert captured[0]["limit"] == 200
def test_get_klines_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_klines(limit=500)
assert captured[0]["limit"] == 200
@pytest.mark.parametrize("interval", ["1m", "5m", "15m", "1h"])
def test_get_klines_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 (),
)
result = service.get_klines(interval=interval)
assert captured[0]["interval"] == interval
assert result.interval == interval
def test_get_klines_rejects_unsupported_interval() -> None:
service = _service()
with pytest.raises(
ExchangeError,
match="Unsupported kline interval",
):
service.get_klines(interval="4h")
@pytest.mark.parametrize(
("price_type", "expected"),
[
("bid", "bid"),
("ask", "ask"),
(" BID ", "bid"),
(" AsK ", "ask"),
("unknown", "bid"),
("", "bid"),
],
)
def test_get_klines_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 (),
)
result = service.get_klines(price_type=price_type)
assert captured[0]["price_type"] == expected
assert result.source == f"rest_klines:{expected}"
def test_get_klines_rejects_mock_mode() -> None:
service = _service(exchange_enabled=False)
with pytest.raises(
ExchangeError,
match="Klines are not available in mock exchange mode",
):
service.get_klines()
def test_get_klines_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_klines("UNKNOWN")
def test_get_klines_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_klines(
" 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_klines_maps_canonical_candle_to_legacy_kline() -> None:
service = _service()
candle = _candle()
_set_test_attribute(
service,
"validate_symbol",
lambda symbol: _valid_symbol(symbol),
)
_set_test_attribute(
service,
"_load_candles_via_acquisition",
lambda **kwargs: (candle,),
)
result = service.get_klines()
assert len(result.candles) == 1
kline = result.candles[0]
assert kline.symbol == candle.symbol
assert kline.interval == candle.interval
assert kline.open_time == 1_750_000_000_000
assert kline.open_price == 100.10
assert kline.high_price == 110.20
assert kline.low_price == 90.30
assert kline.close_price == 105.40
assert kline.volume == 12.50
assert kline.source == candle.source
def test_get_klines_preserves_candle_order() -> None:
service = _service()
first = _candle(open_time_ms=1000)
second = _candle(open_time_ms=2000)
_set_test_attribute(
service,
"validate_symbol",
lambda symbol: _valid_symbol(symbol),
)
_set_test_attribute(
service,
"_load_candles_via_acquisition",
lambda **kwargs: (first, second),
)
result = service.get_klines()
assert [item.open_time for item in result.candles] == [1000, 2000]
def test_get_klines_trims_result_to_limit() -> None:
service = _service()
candles = tuple(
_candle(open_time_ms=index * 1000)
for index in range(1, 6)
)
_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_klines(limit=2)
assert [item.open_time for item in result.candles] == [4000, 5000]
def test_get_klines_returns_empty_batch() -> None:
service = _service()
_set_test_attribute(
service,
"validate_symbol",
lambda symbol: _valid_symbol(symbol),
)
_set_test_attribute(
service,
"_load_candles_via_acquisition",
lambda **kwargs: (),
)
result = service.get_klines()
assert result.candles == []
assert result.source == "rest_klines:bid"
def test_get_klines_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_klines(
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",
},
}
]
def test_kline_from_candle_returns_new_legacy_model() -> None:
service = _service()
candle = _candle()
result = service._kline_from_candle(candle)
assert result is not candle
assert result.symbol == candle.symbol
assert result.open_time == 1_750_000_000_000
def test_load_candles_via_acquisition_builds_complete_pipeline(
monkeypatch: pytest.MonkeyPatch,
) -> None:
events: list[tuple[str, object]] = []
expected = (_candle(),)
class FakeSource:
def __init__(self) -> None:
events.append(("source", self))
class FakeHandler:
def __init__(self) -> None:
events.append(("handler", self))
class FakeFeed:
def __init__(self, *, source: object, handler: object) -> None:
self.source = source
self.handler = handler
events.append(("feed", self))
class FakeRegistry:
def __init__(self) -> None:
self.registered: tuple[str, object] | None = None
events.append(("registry", self))
def register(self, source_name: str, feed: object) -> None:
self.registered = (source_name, feed)
events.append(("register", self.registered))
class FakeAcquisitionService:
def __init__(self, *, registry: FakeRegistry) -> None:
self.registry = registry
events.append(("service", registry))
def load_candles(
self,
source_name: str,
symbol: str,
*,
interval: str,
limit: int,
price_type: str,
) -> tuple[Candle, ...]:
events.append(
(
"load",
(
source_name,
symbol,
interval,
limit,
price_type,
),
)
)
return expected
monkeypatch.setattr(
service_module,
"DzengiCandlesDocumentSource",
FakeSource,
)
monkeypatch.setattr(
service_module,
"DzengiCandlesDocumentHandler",
FakeHandler,
)
monkeypatch.setattr(service_module, "CandlesFeed", FakeFeed)
monkeypatch.setattr(
service_module,
"CandlesFeedRegistry",
FakeRegistry,
)
monkeypatch.setattr(
service_module,
"CandlesAcquisitionService",
FakeAcquisitionService,
)
service = _service()
result = service._load_candles_via_acquisition(
symbol="BTC/USD_LEVERAGE",
interval="1m",
limit=100,
price_type="bid",
)
assert result is expected
source = events[0][1]
handler = events[1][1]
feed = events[2][1]
registry = events[3][1]
assert isinstance(source, FakeSource)
assert isinstance(handler, FakeHandler)
assert isinstance(feed, FakeFeed)
assert isinstance(registry, FakeRegistry)
assert feed.source is source
assert feed.handler is handler
assert registry.registered == ("dzengi", feed)
assert (
"load",
(
"dzengi",
"BTC/USD_LEVERAGE",
"1m",
100,
"bid",
),
) in events