diff --git a/app/src/trading/market_analysis/service.py b/app/src/trading/market_analysis/service.py index 65104ee..93a007e 100644 --- a/app/src/trading/market_analysis/service.py +++ b/app/src/trading/market_analysis/service.py @@ -3,6 +3,7 @@ from __future__ import annotations from enum import StrEnum +from math import isfinite from src.core.numbers import safe_float from src.integrations.exchange.service import ExchangeService @@ -240,7 +241,7 @@ class MarketAnalysisService: limit: int = 200, ) -> MarketAnalysisResult: try: - batch = ExchangeService().get_klines( + candles = ExchangeService().get_candles( symbol=symbol, interval=interval, limit=limit, @@ -253,18 +254,38 @@ class MarketAnalysisService: htf_interval=self._htf_interval, ) - candles = batch.candles - closes = [item.close_price for item in candles] + analysis_symbol = candles[0].symbol if candles else symbol if len(candles) < self._min_candles: return build_unknown_market_analysis_result( - symbol=batch.symbol, + symbol=analysis_symbol, interval=interval, reason="Недостаточно свечей для анализа рынка.", candles_count=len(candles), htf_interval=self._htf_interval, ) + closes: list[float] = [] + + for candle in candles: + close_price_value = safe_float(candle.close_price) + + if ( + close_price_value is None + or not isfinite(close_price_value) + ): + return build_unknown_market_analysis_result( + symbol=analysis_symbol, + interval=interval, + reason=( + "Получены некорректные цены закрытия свечей." + ), + candles_count=len(candles), + htf_interval=self._htf_interval, + ) + + closes.append(close_price_value) + close_price = closes[-1] if closes else None ema_fast = ema(closes, self._fast_ema_period) ema_slow = ema(closes, self._slow_ema_period) @@ -279,7 +300,7 @@ class MarketAnalysisService: or atr_value is None ): return build_unknown_market_analysis_result( - symbol=batch.symbol, + symbol=analysis_symbol, interval=interval, reason="Недостаточно данных для расчёта EMA / ATR.", candles_count=len(candles), @@ -303,7 +324,7 @@ class MarketAnalysisService: htf_context = build_htf_volatility_context( self, - symbol=batch.symbol, + symbol=analysis_symbol, base_interval=interval, ) @@ -483,7 +504,7 @@ class MarketAnalysisService: phase_window=self._phase_window, ) - # Текущая свеча — последняя свеча из batch. + # Текущая свеча — последняя свеча из canonical candles. # Обычно это ещё формирующаяся свеча текущего 5m-интервала. current_interval_change_percent = self._candle_change_percent( candles, @@ -563,7 +584,7 @@ class MarketAnalysisService: htf_trend_context = build_htf_trend_context( self, - symbol=batch.symbol, + symbol=analysis_symbol, base_interval=interval, local_state=state, local_trend=trend, @@ -681,7 +702,7 @@ class MarketAnalysisService: ) payload = build_market_analysis_payload( - symbol=batch.symbol, + symbol=analysis_symbol, interval=interval, state=state, trend=trend, @@ -744,7 +765,7 @@ class MarketAnalysisService: ) return build_market_analysis_result( - symbol=batch.symbol, + symbol=analysis_symbol, interval=interval, state=state, trend=trend, diff --git a/app/tests/unit/trading/market_analysis/test_market_analysis_service_candles.py b/app/tests/unit/trading/market_analysis/test_market_analysis_service_candles.py new file mode 100644 index 0000000..2f441d7 --- /dev/null +++ b/app/tests/unit/trading/market_analysis/test_market_analysis_service_candles.py @@ -0,0 +1,270 @@ +# app/tests/unit/trading/market_analysis/test_service_candles.py + +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal +from typing import Any, cast + +import pytest + +import src.trading.market_analysis.service as module +from src.market_data.acquisition.models.candle import Candle +from src.trading.market_analysis.service import MarketAnalysisService + + +def _set_test_attribute( + target: object, + name: str, + value: object, +) -> None: + setattr(cast(Any, target), name, value) + + +def _candle( + *, + index: int = 0, + symbol: str = "BTC/USD_LEVERAGE", + close_price: str = "100", +) -> Candle: + return Candle( + symbol=symbol, + interval="5m", + open_time=datetime.fromtimestamp( + 1_750_000_000 + index * 300, + tz=timezone.utc, + ), + open_price=Decimal("99"), + high_price=Decimal("101"), + low_price=Decimal("98"), + close_price=Decimal(close_price), + volume=Decimal("10"), + source="test", + ) + + +def test_analyze_uses_get_candles_with_exact_arguments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[dict[str, object]] = [] + candles = (_candle(symbol="BTC/USD_LEVERAGE"),) + expected = object() + + class FakeExchangeService: + def get_candles( + self, + symbol: str, + *, + interval: str, + limit: int, + ) -> tuple[Candle, ...]: + calls.append( + { + "symbol": symbol, + "interval": interval, + "limit": limit, + } + ) + return candles + + def fake_unknown(**kwargs: object) -> object: + assert kwargs["symbol"] == "BTC/USD_LEVERAGE" + assert kwargs["candles_count"] == 1 + return expected + + monkeypatch.setattr(module, "ExchangeService", FakeExchangeService) + monkeypatch.setattr( + module, + "build_unknown_market_analysis_result", + fake_unknown, + ) + + result = MarketAnalysisService().analyze( + " btc/usd ", + interval="5m", + limit=123, + ) + + assert result is expected + assert calls == [ + { + "symbol": " btc/usd ", + "interval": "5m", + "limit": 123, + } + ] + + +def test_analyze_uses_requested_symbol_when_candles_are_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + expected = object() + + class FakeExchangeService: + def get_candles( + self, + symbol: str, + *, + interval: str, + limit: int, + ) -> tuple[Candle, ...]: + return () + + def fake_unknown(**kwargs: object) -> object: + assert kwargs["symbol"] == "ETH/USD_LEVERAGE" + assert kwargs["candles_count"] == 0 + assert kwargs["reason"] == "Недостаточно свечей для анализа рынка." + return expected + + monkeypatch.setattr(module, "ExchangeService", FakeExchangeService) + monkeypatch.setattr( + module, + "build_unknown_market_analysis_result", + fake_unknown, + ) + + result = MarketAnalysisService().analyze("ETH/USD_LEVERAGE") + + assert result is expected + + +def test_analyze_returns_unknown_when_get_candles_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + expected = object() + original_error = RuntimeError("candles unavailable") + + class FakeExchangeService: + def get_candles( + self, + symbol: str, + *, + interval: str, + limit: int, + ) -> tuple[Candle, ...]: + raise original_error + + def fake_unknown(**kwargs: object) -> object: + assert kwargs["symbol"] == "BTC/USD_LEVERAGE" + assert kwargs["interval"] == "15m" + assert kwargs["reason"] == ( + "Не удалось получить свечи: candles unavailable" + ) + assert "candles_count" not in kwargs + return expected + + monkeypatch.setattr(module, "ExchangeService", FakeExchangeService) + monkeypatch.setattr( + module, + "build_unknown_market_analysis_result", + fake_unknown, + ) + + result = MarketAnalysisService().analyze( + "BTC/USD_LEVERAGE", + interval="15m", + limit=80, + ) + + assert result is expected + + +def test_analyze_rejects_non_finite_canonical_close_price( + monkeypatch: pytest.MonkeyPatch, +) -> None: + candles = tuple( + _candle( + index=index, + close_price="NaN" if index == 10 else str(100 + index), + ) + for index in range(60) + ) + expected = object() + + class FakeExchangeService: + def get_candles( + self, + symbol: str, + *, + interval: str, + limit: int, + ) -> tuple[Candle, ...]: + return candles + + def fake_unknown(**kwargs: object) -> object: + assert kwargs["symbol"] == "BTC/USD_LEVERAGE" + assert kwargs["candles_count"] == 60 + assert kwargs["reason"] == ( + "Получены некорректные цены закрытия свечей." + ) + return expected + + monkeypatch.setattr(module, "ExchangeService", FakeExchangeService) + monkeypatch.setattr( + module, + "build_unknown_market_analysis_result", + fake_unknown, + ) + + result = MarketAnalysisService().analyze("BTC/USD_LEVERAGE") + + assert result is expected + + +def test_analyze_converts_decimal_closes_to_float_before_indicators( + monkeypatch: pytest.MonkeyPatch, +) -> None: + candles = tuple( + _candle( + index=index, + close_price=str(100 + index), + ) + for index in range(60) + ) + ema_calls: list[list[float]] = [] + expected = object() + + class FakeExchangeService: + def get_candles( + self, + symbol: str, + *, + interval: str, + limit: int, + ) -> tuple[Candle, ...]: + return candles + + def fake_ema( + values: list[float], + period: int, + ) -> None: + ema_calls.append(values) + return None + + def fake_unknown(**kwargs: object) -> object: + assert kwargs["reason"] == ( + "Недостаточно данных для расчёта EMA / ATR." + ) + return expected + + monkeypatch.setattr(module, "ExchangeService", FakeExchangeService) + monkeypatch.setattr(module, "ema", fake_ema) + monkeypatch.setattr(module, "atr", lambda candles, period: 1.0) + monkeypatch.setattr(module, "rsi", lambda values, period: 50.0) + monkeypatch.setattr( + module, + "build_unknown_market_analysis_result", + fake_unknown, + ) + + result = MarketAnalysisService().analyze("BTC/USD_LEVERAGE") + + assert result is expected + assert len(ema_calls) == 2 + assert all( + isinstance(value, float) + for values in ema_calls + for value in values + ) + assert ema_calls[0][0] == 100.0 + assert ema_calls[0][-1] == 159.0 diff --git a/docs/migrations/build_050.md b/docs/migrations/build_050.md new file mode 100644 index 0000000..0efa6a0 --- /dev/null +++ b/docs/migrations/build_050.md @@ -0,0 +1,578 @@ +# Build 050 — Переключение MarketAnalysisService на канонические Candle + +## Статус + +**Завершён** + +--- + +## Цель Build + +Переключить основной orchestration-компонент анализа рынка: + +`MarketAnalysisService` + +с legacy API получения свечей: + + ExchangeService.get_klines() + ↓ + KlineBatch + ↓ + list[Kline] + +на канонический API: + + ExchangeService.get_candles() + ↓ + tuple[Candle, ...] + +без изменения торговой логики, алгоритмов анализа рынка, порогов, scoring, HTF-логики и существующих compatibility-контрактов. + +--- + +## Исходное состояние + +До Build 050 основной orchestration-компонент: + +`src/trading/market_analysis/service.py` + +получал свечи следующим образом: + + ExchangeService.get_klines() + ↓ + KlineBatch + ↓ + batch.candles + ↓ + list[Kline] + +При этом после Build 048 следующие вычислительные компоненты уже были переключены на каноническую модель `Candle`: + + src/trading/market_analysis/indicators/volatility.py + src/trading/market_analysis/quality.py + src/trading/market_analysis/structure.py + +Таким образом возникло переходное несоответствие типов: + + MarketAnalysisService + ↓ + list[Kline] + + market_structure() + candle_noise_score() + ATR calculations + ↓ + Sequence[Candle] + +Build 050 устраняет это несоответствие для основного `MarketAnalysisService`. + +--- + +## Объём изменений + +В Build 050 изменены: + + src/trading/market_analysis/service.py + tests/unit/trading/market_analysis/test_market_analysis_service_candles.py + docs/migrations/build_050.md + +Build 050 намеренно не изменяет: + + src/trading/market_analysis/htf.py + src/integrations/exchange/service.py + src/integrations/exchange/models.py + src/market_data/acquisition/ + +Также не изменяются: + + ExchangeService.get_klines() + Kline + KlineBatch + _kline_from_candle() + +--- + +## Переключение MarketAnalysisService на get_candles() + +До Build 050 основной путь получения свечей выглядел так: + + MarketAnalysisService.analyze() + ↓ + ExchangeService.get_klines() + ↓ + KlineBatch + ↓ + batch.candles + +После Build 050: + + MarketAnalysisService.analyze() + ↓ + ExchangeService.get_candles() + ↓ + tuple[Candle, ...] + +Таким образом основной orchestration-компонент больше не зависит от: + + Kline + KlineBatch + batch.candles + batch.symbol + +--- + +## Каноническая модель данных + +После Build 050 `MarketAnalysisService` непосредственно получает: + + tuple[Candle, ...] + +где каждая свеча представлена канонической моделью: + +`src.market_data.acquisition.models.candle.Candle` + +Основные числовые поля модели: + + open_price: Decimal + high_price: Decimal + low_price: Decimal + close_price: Decimal + volume: Decimal + +Таким образом основной Market Analysis orchestration теперь работает на той же канонической модели свечи, что и вычислительные компоненты, переключённые в Build 048. + +--- + +## Определение analysis_symbol + +Ранее symbol результата получался из: + + batch.symbol + +После удаления зависимости от `KlineBatch` введена локальная переменная: + + analysis_symbol = candles[0].symbol if candles else symbol + +Логика: + +- если получена хотя бы одна каноническая свеча, используется `Candle.symbol`; +- если набор свечей пуст, сохраняется исходный запрошенный symbol. + +Все прежние обращения: + + batch.symbol + +в `MarketAnalysisService` заменены на: + + analysis_symbol + +Это устраняет зависимость основного orchestration от legacy-контейнера `KlineBatch`. + +--- + +## Числовая граница Decimal → float + +Каноническая модель `Candle` хранит цены как: + + Decimal + +При этом существующие индикаторы и часть вычислительного pipeline работают с: + + float + +Поэтому в `MarketAnalysisService` введена явная числовая граница преобразования цен закрытия: + + Candle.close_price + ↓ + safe_float() + ↓ + float + ↓ + list[float] + +После Build 050: + + closes: list[float] = [] + + for candle in candles: + close_price_value = safe_float(candle.close_price) + + ... + + closes.append(close_price_value) + +Это сохраняет существующий числовой контракт индикаторов и одновременно позволяет основному orchestration работать непосредственно с каноническими `Candle`. + +--- + +## Проверка невалидных цен закрытия + +При преобразовании `Candle.close_price` выполняется проверка: + +- результат `safe_float()` не должен быть `None`; +- результат должен быть конечным числом; +- `NaN` не допускается; +- положительная или отрицательная бесконечность не допускается. + +Для проверки конечности используется: + + math.isfinite() + +Если обнаружена некорректная цена закрытия, анализ безопасно завершается результатом `UNKNOWN` с причиной: + + Получены некорректные цены закрытия свечей. + +При этом сохраняется полное соответствие: + + candles[index] ↔ closes[index] + +Невалидные значения не пропускаются выборочно, поскольку это нарушило бы временное соответствие последовательностей свечей и цен. + +--- + +## Сохранение недостаточного количества свечей + +Проверка минимального количества свечей сохранена. + +Если: + + len(candles) < 60 + +анализ завершается безопасным результатом `UNKNOWN` с причиной: + + Недостаточно свечей для анализа рынка. + +При пустом наборе свечей используется исходный запрошенный symbol. + +При непустом наборе используется canonical symbol первой свечи. + +--- + +## Сохранение обработки ошибок ExchangeService + +Вызов: + + ExchangeService.get_candles() + +остаётся внутри существующей обработки исключений. + +Если получение свечей завершается ошибкой, `MarketAnalysisService` возвращает безопасный результат `UNKNOWN` с причиной, содержащей сообщение исходной ошибки. + +Build 050 не изменяет общую стратегию обработки ошибок анализа рынка. + +--- + +## Сохранение вычислительной логики + +Build 050 не изменяет: + +- EMA; +- ATR; +- RSI; +- momentum; +- market phase; +- market structure; +- candle noise score; +- price position score; +- scoring; +- confidence; +- payload; +- причины решений; +- пороги; +- HTF calculations; +- индексы текущей и закрытой свечи; +- торговые решения. + +Изменяется только источник и тип входных свечей основного orchestration-компонента: + + Было: + KlineBatch → list[Kline] + + Стало: + tuple[Candle, ...] + +--- + +## HTF намеренно не изменён + +В Build 050 файл: + +`src/trading/market_analysis/htf.py` + +не изменяется. + +В нём остаются два legacy-вызова: + + ExchangeService().get_klines(...) + +Это намеренное переходное состояние. + +HTF должен быть переключён на канонический `Candle` отдельным Build после анализа его фактических контрактов и зависимостей. + +--- + +## Новый тестовый файл + +Добавлен: + +`tests/unit/trading/market_analysis/test_market_analysis_service_candles.py` + +Изначально тестовый файл имел имя: + + test_service_candles.py + +Однако такое имя уже использовалось другим тестовым модулем: + +`tests/unit/integrations/exchange/test_service_candles.py` + +Из-за одинакового basename pytest обнаружил import mismatch. + +Новый Market Analysis тест был переименован в: + + test_market_analysis_service_candles.py + +После переименования конфликт модулей устранён. + +--- + +## Покрытие специализированных тестов + +Новый тестовый файл проверяет: + +1. вызов `ExchangeService.get_candles()` с точными аргументами; +2. использование canonical symbol первой полученной свечи; +3. использование исходного запрошенного symbol при пустом наборе; +4. безопасный `UNKNOWN` при ошибке получения свечей; +5. отклонение нечислового конечного значения `NaN`; +6. преобразование `Decimal`-цен закрытия в `float` до передачи индикаторам. + +--- + +## Targeted tests + +Выполнена команда: + + python -m pytest -q \ + tests/unit/trading/market_analysis/test_market_analysis_service_candles.py + +Результат: + + 5 passed in 0.12s + +--- + +## Regression-набор Market Analysis и стратегий + +До финальной полной проверки был выполнен набор: + + python -m pytest -q \ + tests/unit/trading/market_analysis \ + tests/unit/trading/strategies/test_scalp_quote.py \ + tests/unit/trading/strategies/test_trend_quote.py + +Результат: + + 27 passed in 0.14s + +--- + +## Полный regression suite + +После устранения конфликта имён тестовых модулей выполнена команда: + + python -m pytest -q + +Результат: + + 794 passed in 2.97s + +Регрессий не обнаружено. + +--- + +## Архитектурная проверка legacy get_klines() + +Выполнена команда: + + grep -RIn \ + --exclude-dir="__pycache__" \ + --exclude="*.pyc" \ + "\.get_klines(" \ + src/trading/market_analysis + +Результат: + + src/trading/market_analysis/htf.py:55: batch = ExchangeService().get_klines( + src/trading/market_analysis/htf.py:153: batch = ExchangeService().get_klines( + +Таким образом: + +- основной `MarketAnalysisService` больше не использует `get_klines()`; +- остаются ровно два legacy-вызова; +- оба находятся в `htf.py`; +- их миграция отложена на отдельный Build. + +--- + +## Архитектурная проверка canonical get_candles() + +Выполнена команда: + + grep -RIn \ + --exclude-dir="__pycache__" \ + --exclude="*.pyc" \ + "\.get_candles(" \ + src/trading/market_analysis \ + tests/unit/trading/market_analysis + +Production-вызов обнаружен в: + + src/trading/market_analysis/service.py + +Это подтверждает переключение основного orchestration-компонента на канонический API. + +--- + +## Проверка отсутствия legacy-моделей в MarketAnalysisService + +Выполнена команда: + + grep -nE \ + "Kline|KlineBatch|batch\.candles|batch\.symbol" \ + src/trading/market_analysis/service.py + +Вывод отсутствует. + +Это подтверждает отсутствие в основном orchestration-файле зависимостей от: + + Kline + KlineBatch + batch.candles + batch.symbol + +--- + +## Проверка форматирования + +Выполнена команда: + + git diff --check + +Вывод отсутствует. + +Whitespace-ошибок не обнаружено. + +--- + +## Фактический diff Build 050 + +Перед подготовкой документации состояние изменений: + + app/src/trading/market_analysis/service.py | 41 +++++++++++++++++++++++++++++++---------- + 1 file changed, 31 insertions(+), 10 deletions(-) + +Дополнительно добавлен новый тестовый файл: + + tests/unit/trading/market_analysis/test_market_analysis_service_candles.py + +Документация Build добавляется как: + + docs/migrations/build_050.md + +--- + +## Архитектурный результат + +После Build 050 основной Market Analysis orchestration имеет следующий путь: + + MarketAnalysisService.analyze() + ↓ + ExchangeService.get_candles() + ↓ + tuple[Candle, ...] + ↓ + canonical Candle consumers + ↓ + explicit Decimal → float boundary for closes + ↓ + Market Analysis calculations + +Переходный HTF-путь пока остаётся: + + htf.py + ↓ + ExchangeService.get_klines() + ↓ + KlineBatch + +Такое разделение позволяет продолжить миграцию поэтапно без удаления рабочего compatibility-кода. + +--- + +## Что намеренно не выполнено + +Build 050 намеренно не включает: + +- переключение `htf.py` на `get_candles()`; +- удаление `ExchangeService.get_klines()`; +- удаление `Kline`; +- удаление `KlineBatch`; +- удаление `_kline_from_candle()`; +- изменение Market Data Acquisition; +- изменение алгоритмов анализа; +- изменение торговой логики; +- изменение scoring; +- изменение порогов; +- изменение HTF-логики; +- изменение структуры каталогов; +- удаление рабочего legacy-кода. + +--- + +## Критерии завершения + +Build 050 считается завершённым, поскольку: + +- `MarketAnalysisService` переключён на `ExchangeService.get_candles()`; +- основной orchestration получает `tuple[Candle, ...]`; +- зависимость от `KlineBatch` в `service.py` устранена; +- обращения `batch.candles` устранены; +- обращения `batch.symbol` устранены; +- canonical symbol берётся из первой свечи; +- для пустого набора сохраняется исходный symbol; +- `Decimal` close prices явно преобразуются в `float`; +- невалидные и не конечные close prices безопасно отклоняются; +- новый контракт покрыт специализированными тестами; +- targeted tests проходят; +- regression-набор Market Analysis и стратегий проходит; +- полный suite проходит; +- legacy-вызовы остаются только в `htf.py`; +- `git diff --check` чистый. + +--- + +## Итог + +**Build 050 завершён успешно.** + +Текущее состояние: + + MarketAnalysisService → get_candles() → tuple[Candle, ...] + HTF → get_klines() → KlineBatch + +Результаты проверок: + + Targeted tests: 5 passed + Market Analysis + strategies: 27 passed + Full test suite: 794 passed + git diff --check: clean + +Следующий безопасный этап — отдельная миграция двух оставшихся HTF-вызовов с: + + ExchangeService.get_klines() + +на: + + ExchangeService.get_candles() + +без одновременного удаления legacy compatibility-контракта. \ No newline at end of file