diff --git a/app/src/integrations/exchange/service.py b/app/src/integrations/exchange/service.py index 9637f61..7b84dfb 100644 --- a/app/src/integrations/exchange/service.py +++ b/app/src/integrations/exchange/service.py @@ -21,8 +21,6 @@ from src.integrations.exchange.models import ( BalanceSummary, ExchangeHealth, ExecutionPriceSnapshot, - Kline, - KlineBatch, PrivateAuthHealth, SymbolValidationResult, TimeSyncStatus, @@ -554,59 +552,6 @@ class ExchangeService: ) raise ExchangeError(str(exc)) from exc - # Сохранить legacy KlineBatch как compatibility-фасад над canonical Candle. - def get_klines( - self, - symbol: str | None = None, - *, - interval: str = "1m", - limit: int = 200, - price_type: str = "bid", - ) -> KlineBatch: - symbol_to_use = symbol or self.settings.default_symbol - - if not self.settings.exchange_enabled: - raise ExchangeError( - "Klines are not available in mock exchange mode." - ) - - if limit <= 0: - normalized_limit = 200 - elif limit > 200: - normalized_limit = 200 - else: - normalized_limit = limit - - normalized_price_type = price_type.strip().lower() - - if normalized_price_type not in {"bid", "ask"}: - normalized_price_type = "bid" - - canonical_candles = self.get_candles( - symbol=symbol_to_use, - interval=interval, - limit=normalized_limit, - price_type=normalized_price_type, - ) - - candles = [ - self._kline_from_candle(candle) - for candle in canonical_candles[-normalized_limit:] - ] - - normalized_symbol = ( - canonical_candles[0].symbol - if canonical_candles - else normalize_symbol(symbol_to_use) - ) - - return KlineBatch( - symbol=normalized_symbol, - interval=interval, - candles=candles, - source=f"rest_klines:{normalized_price_type}", - ) - # Собрать Candles acquisition pipeline и вернуть канонические модели Candle. def _load_candles_via_acquisition( self, @@ -642,20 +587,6 @@ class ExchangeService: price_type=price_type, ) - # Временно преобразовать 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: if not self.settings.exchange_enabled: diff --git a/app/tests/unit/integrations/exchange/test_service_klines.py b/app/tests/unit/integrations/exchange/test_service_klines.py deleted file mode 100644 index a0e9f39..0000000 --- a/app/tests/unit/integrations/exchange/test_service_klines.py +++ /dev/null @@ -1,551 +0,0 @@ -# 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 diff --git a/docs/migrations/build_054.md b/docs/migrations/build_054.md new file mode 100644 index 0000000..13302e0 --- /dev/null +++ b/docs/migrations/build_054.md @@ -0,0 +1,355 @@ +# Build 054 — Удаление legacy API get_klines и compatibility bridge + +## Статус + +**Завершён** + +--- + +## Цель Build + +Удалить больше не используемый compatibility API legacy OHLCV: + +```text +ExchangeService.get_klines() +``` + +а также внутренний bridge: + +```text +ExchangeService._kline_from_candle() +``` + +и связанный test-only файл: + +```text +tests/unit/integrations/exchange/test_service_klines.py +``` + +Build 054 не удаляет сами модели: + +```text +Kline +KlineBatch +``` + +Они сохраняются до отдельного Build после повторной глобальной проверки зависимостей. + +--- + +## Исходное состояние + +По результатам Build 052 было подтверждено: + +- production-вызовы `ExchangeService.get_klines()` отсутствуют; +- `get_klines()` используется только тестами; +- `_kline_from_candle()` используется только внутри `get_klines()` и его тестов; +- `Kline` и `KlineBatch` остаются только внутри compatibility-слоя; +- активный Market Analysis полностью использует `ExchangeService.get_candles()`. + +После Build 053 также был удалён последний неиспользуемый production-файл: + +```text +src/trading/market_analysis/indicators_legacy.py +``` + +Таким образом compatibility API больше не обслуживал ни одного рабочего потребителя. + +--- + +## Объём изменений + +В Build 054 изменён: + +```text +src/integrations/exchange/service.py +``` + +Удалён: + +```text +tests/unit/integrations/exchange/test_service_klines.py +``` + +Добавлена документация: + +```text +docs/migrations/build_054.md +``` + +Build 054 не изменяет: + +```text +src/integrations/exchange/models.py +src/market_data/acquisition/ +src/trading/market_analysis/ +``` + +Build также не удаляет: + +```text +Kline +KlineBatch +``` + +--- + +## Изменения в ExchangeService + +Из файла: + +```text +src/integrations/exchange/service.py +``` + +удалены импорты: + +```text +Kline +KlineBatch +``` + +Удалён публичный legacy-метод: + +```text +ExchangeService.get_klines() +``` + +Удалён внутренний compatibility helper: + +```text +ExchangeService._kline_from_candle() +``` + +Остальной код `ExchangeService` сохранён без изменений. + +--- + +## Удаление test-only compatibility coverage + +Удалён файл: + +```text +tests/unit/integrations/exchange/test_service_klines.py +``` + +Он полностью покрывал уже удалённые компоненты: + +```text +get_klines() +_kline_from_candle() +Candle → Kline +KlineBatch +``` + +Новый канонический API продолжает покрываться файлом: + +```text +tests/unit/integrations/exchange/test_service_candles.py +``` + +--- + +## Архитектурный результат + +До Build 054: + +```text +ExchangeService.get_klines() + ↓ +ExchangeService.get_candles() + ↓ +Candle + ↓ +_kline_from_candle() + ↓ +Kline + ↓ +KlineBatch +``` + +После Build 054: + +```text +ExchangeService.get_candles() + ↓ +tuple[Candle, ...] +``` + +Compatibility bridge полностью удалён. + +--- + +## Полный regression suite + +После удаления выполнена команда: + +```bash +python -m pytest -q +``` + +Результат: + +```text +779 passed in 2.66s +``` + +Снижение количества тестов связано с удалением test-only файла: + +```text +tests/unit/integrations/exchange/test_service_klines.py +``` + +Регрессий не обнаружено. + +--- + +## Контроль отсутствия get_klines + +Выполнена команда: + +```bash +grep -RIn \ + --exclude-dir="__pycache__" \ + --exclude="*.pyc" \ + "\.get_klines(" \ + src tests +``` + +Результат: + +```text +пусто +``` + +--- + +## Контроль отсутствия _kline_from_candle + +Выполнена команда: + +```bash +grep -RIn \ + --exclude-dir="__pycache__" \ + --exclude="*.pyc" \ + "_kline_from_candle" \ + src tests +``` + +Результат: + +```text +пусто +``` + +--- + +## Контроль оставшихся Kline и KlineBatch + +Выполнена команда: + +```bash +grep -RIn \ + --exclude-dir="__pycache__" \ + --exclude="*.pyc" \ + -E "\bKlineBatch\b|\bKline\b" \ + src tests +``` + +Результат: + +```text +src/integrations/exchange/models.py:93:class Kline: +src/integrations/exchange/models.py:110:class KlineBatch: +src/integrations/exchange/models.py:113: candles: list[Kline] +``` + +Следовательно, после Build 054 `Kline` и `KlineBatch` существуют только как неиспользуемые определения моделей. + +--- + +## Проверка форматирования + +Выполнена команда: + +```bash +git diff --check +``` + +Вывод отсутствует. + +Whitespace-ошибок не обнаружено. + +--- + +## Фактический diff + +Перед добавлением документации: + +```text +app/src/integrations/exchange/service.py | 69 ------------------ +app/tests/unit/integrations/exchange/test_service_klines.py | 551 ----------------------------------------------------------------------------------------------------------------------------------------------- +2 files changed, 620 deletions(-) +``` + +--- + +## Что намеренно не выполнено + +Build 054 намеренно не включает: + +- удаление `Kline`; +- удаление `KlineBatch`; +- изменение `src/integrations/exchange/models.py`; +- изменение `ExchangeService.get_candles()`; +- изменение Market Data Acquisition; +- изменение Market Analysis; +- изменение торговой логики; +- изменение структуры каталогов. + +--- + +## Критерии завершения + +Build 054 считается завершённым, поскольку: + +- production-потребители `get_klines()` отсутствовали до удаления; +- `get_klines()` удалён; +- `_kline_from_candle()` удалён; +- test-only compatibility coverage удалено; +- полный suite проходит; +- grep по `get_klines()` пустой; +- grep по `_kline_from_candle()` пустой; +- `Kline` и `KlineBatch` остались только в `models.py`; +- `git diff --check` чистый. + +--- + +## Итог + +**Build 054 завершён успешно.** + +Текущее состояние: + +```text +get_klines — удалён +_kline_from_candle — удалён +test_service_klines.py — удалён +Full test suite — 779 passed +Kline/KlineBatch — только неиспользуемые определения в models.py +``` + +Следующий этап — удалить модели: + +```text +Kline +KlineBatch +``` + +из: + +```text +src/integrations/exchange/models.py +``` + +после повторной глобальной проверки. \ No newline at end of file