diff --git a/app/src/trading/market_analysis/htf.py b/app/src/trading/market_analysis/htf.py index 825fce9..f025d73 100644 --- a/app/src/trading/market_analysis/htf.py +++ b/app/src/trading/market_analysis/htf.py @@ -2,6 +2,8 @@ from __future__ import annotations +from math import isfinite + from src.core.numbers import safe_float from src.core.types import JsonDict from src.integrations.exchange.service import ExchangeService @@ -52,7 +54,7 @@ def htf_volatility_context( } try: - batch = ExchangeService().get_klines( + candles = ExchangeService().get_candles( symbol=symbol, interval=service._htf_interval, limit=service._htf_limit, @@ -67,10 +69,7 @@ def htf_volatility_context( "htf_reason": f"HTF_KLINES_ERROR: {exc}", } - candles = batch.candles - closes = [item.close_price for item in candles] - - if len(candles) < service._min_candles or not closes: + if len(candles) < service._min_candles: return { "htf_interval": service._htf_interval, "htf_atr_percent": None, @@ -80,10 +79,15 @@ def htf_volatility_context( "htf_reason": "HTF_NOT_ENOUGH_CANDLES", } - close_price = safe_float(closes[-1]) + close_price = safe_float(candles[-1].close_price) atr_value = atr(candles, service._atr_period) - if close_price is None or close_price <= 0 or atr_value is None: + if ( + close_price is None + or not isfinite(close_price) + or close_price <= 0 + or atr_value is None + ): return { "htf_interval": service._htf_interval, "htf_atr_percent": None, @@ -150,7 +154,7 @@ def htf_trend_context( } try: - batch = ExchangeService().get_klines( + candles = ExchangeService().get_candles( symbol=symbol, interval=service._htf_interval, limit=service._htf_limit, @@ -158,12 +162,19 @@ def htf_trend_context( except Exception as exc: return _htf_unknown_context(f"HTF_KLINES_ERROR: {exc}") - candles = batch.candles - closes = [item.close_price for item in candles] - if len(candles) < service._min_candles: return _htf_unknown_context("HTF_NOT_ENOUGH_CANDLES") + closes: list[float] = [] + + for candle in candles: + close_value = safe_float(candle.close_price) + + if close_value is None or not isfinite(close_value): + return _htf_unknown_context("HTF_INDICATORS_UNAVAILABLE") + + closes.append(close_value) + close_price = closes[-1] if closes else None ema_fast = ema(closes, service._fast_ema_period) ema_slow = ema(closes, service._slow_ema_period) @@ -369,7 +380,7 @@ def safe_volatility_state(value: object) -> VolatilityState | None: return VolatilityState(str(value)) except Exception: return None - + def _htf_unknown_context(reason: str) -> JsonDict: return { @@ -484,4 +495,4 @@ def _htf_confirmation_score( if trend_efficiency is not None: score += (trend_efficiency - 0.3) * 0.15 - return max(0.0, min(1.0, score)) \ No newline at end of file + return max(0.0, min(1.0, score)) diff --git a/app/tests/unit/trading/market_analysis/test_htf_candles.py b/app/tests/unit/trading/market_analysis/test_htf_candles.py new file mode 100644 index 0000000..41b275f --- /dev/null +++ b/app/tests/unit/trading/market_analysis/test_htf_candles.py @@ -0,0 +1,516 @@ +# app/tests/unit/trading/market_analysis/test_htf_candles.py + +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal +from types import SimpleNamespace + +import pytest + +import src.trading.market_analysis.htf as module +from src.market_data.acquisition.models.candle import Candle +from src.trading.market_analysis.models import ( + MarketPhase, + MarketState, + TrendDirection, + TrendQuality, + TrendStrength, + VolatilityState, +) + + +def _service() -> SimpleNamespace: + return SimpleNamespace( + _htf_interval="1h", + _htf_limit=120, + _min_candles=60, + _atr_period=14, + _atr_baseline_window=60, + _low_volatility_atr_percent=0.05, + _high_volatility_atr_percent=1.8, + _fast_ema_period=20, + _slow_ema_period=50, + _ema_fast_slope_window=5, + _ema_slow_slope_window=8, + _trend_consistency_window=20, + _candle_noise_window=20, + _min_clean_body_ratio=0.35, + _price_position_window=20, + _min_clean_candle_score=0.55, + _min_price_position_score=0.55, + ) + + +def _candle( + *, + index: int = 0, + close_price: str = "100", +) -> Candle: + return Candle( + symbol="BTC/USD_LEVERAGE", + interval="1h", + open_time=datetime.fromtimestamp( + 1_750_000_000 + index * 3600, + tz=timezone.utc, + ), + open_price=Decimal(str(99 + index)), + high_price=Decimal(str(101 + index)), + low_price=Decimal(str(98 + index)), + close_price=Decimal(close_price), + volume=Decimal("10"), + source="test", + ) + + +def _candles(count: int = 60) -> tuple[Candle, ...]: + return tuple( + _candle( + index=index, + close_price=str(100 + index), + ) + for index in range(count) + ) + + +def test_htf_volatility_skips_same_interval( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class ForbiddenExchangeService: + def __init__(self) -> None: + raise AssertionError("ExchangeService must not be created.") + + monkeypatch.setattr( + module, + "ExchangeService", + ForbiddenExchangeService, + ) + + result = module.htf_volatility_context( + _service(), + symbol="BTC/USD_LEVERAGE", + base_interval="1h", + ) + + assert result["htf_reason"] == "HTF_SKIPPED_SAME_INTERVAL" + assert result["htf_interval"] == "1h" + + +def test_htf_volatility_uses_get_candles_with_exact_arguments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[dict[str, 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(10) + + monkeypatch.setattr(module, "ExchangeService", FakeExchangeService) + + result = module.htf_volatility_context( + _service(), + symbol="BTC/USD_LEVERAGE", + base_interval="5m", + ) + + assert calls == [ + { + "symbol": "BTC/USD_LEVERAGE", + "interval": "1h", + "limit": 120, + } + ] + assert result["htf_reason"] == "HTF_NOT_ENOUGH_CANDLES" + + +def test_htf_volatility_preserves_legacy_error_reason( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeExchangeService: + def get_candles( + self, + symbol: str, + *, + interval: str, + limit: int, + ) -> tuple[Candle, ...]: + raise RuntimeError("candles unavailable") + + monkeypatch.setattr(module, "ExchangeService", FakeExchangeService) + + result = module.htf_volatility_context( + _service(), + symbol="BTC/USD_LEVERAGE", + base_interval="5m", + ) + + assert result["htf_reason"] == ( + "HTF_KLINES_ERROR: candles unavailable" + ) + + +def test_htf_volatility_rejects_non_finite_close( + monkeypatch: pytest.MonkeyPatch, +) -> None: + candles = list(_candles()) + candles[-1] = _candle(index=59, close_price="NaN") + + class FakeExchangeService: + def get_candles( + self, + symbol: str, + *, + interval: str, + limit: int, + ) -> tuple[Candle, ...]: + return tuple(candles) + + monkeypatch.setattr(module, "ExchangeService", FakeExchangeService) + monkeypatch.setattr(module, "atr", lambda candles, period: 1.0) + + result = module.htf_volatility_context( + _service(), + symbol="BTC/USD_LEVERAGE", + base_interval="5m", + ) + + assert result["htf_reason"] == "HTF_ATR_UNAVAILABLE" + + +def test_htf_volatility_returns_existing_success_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeExchangeService: + def get_candles( + self, + symbol: str, + *, + interval: str, + limit: int, + ) -> tuple[Candle, ...]: + return _candles() + + monkeypatch.setattr(module, "ExchangeService", FakeExchangeService) + monkeypatch.setattr(module, "atr", lambda candles, period: 2.0) + monkeypatch.setattr( + module, + "atr_percent_baseline", + lambda **kwargs: 1.0, + ) + monkeypatch.setattr( + module, + "classify_volatility", + lambda **kwargs: VolatilityState.HIGH, + ) + + result = module.htf_volatility_context( + _service(), + symbol="BTC/USD_LEVERAGE", + base_interval="5m", + ) + + expected_atr_percent = (2.0 / 159.0) * 100 + + assert result == { + "htf_interval": "1h", + "htf_atr_percent": round(expected_atr_percent, 4), + "htf_atr_percent_baseline": 1.0, + "htf_volatility_ratio": round(expected_atr_percent, 4), + "htf_volatility": VolatilityState.HIGH.value, + "htf_reason": "HTF_OK", + } + + +def test_htf_trend_skips_same_interval( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class ForbiddenExchangeService: + def __init__(self) -> None: + raise AssertionError("ExchangeService must not be created.") + + monkeypatch.setattr( + module, + "ExchangeService", + ForbiddenExchangeService, + ) + + result = module.htf_trend_context( + _service(), + symbol="BTC/USD_LEVERAGE", + base_interval="1h", + local_state=MarketState.TREND_UP, + local_trend=TrendDirection.UP, + ) + + assert result["htf_reason"] == "HTF_SKIPPED_SAME_INTERVAL" + assert result["htf_market_state"] == MarketState.TREND_UP.value + assert result["htf_trend"] == TrendDirection.UP.value + + +def test_htf_trend_uses_get_candles_with_exact_arguments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[dict[str, 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(10) + + monkeypatch.setattr(module, "ExchangeService", FakeExchangeService) + + result = module.htf_trend_context( + _service(), + symbol="BTC/USD_LEVERAGE", + base_interval="5m", + local_state=MarketState.TREND_UP, + local_trend=TrendDirection.UP, + ) + + assert calls == [ + { + "symbol": "BTC/USD_LEVERAGE", + "interval": "1h", + "limit": 120, + } + ] + assert result["htf_reason"] == "HTF_NOT_ENOUGH_CANDLES" + + +def test_htf_trend_preserves_legacy_error_reason( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeExchangeService: + def get_candles( + self, + symbol: str, + *, + interval: str, + limit: int, + ) -> tuple[Candle, ...]: + raise RuntimeError("candles unavailable") + + monkeypatch.setattr(module, "ExchangeService", FakeExchangeService) + + result = module.htf_trend_context( + _service(), + symbol="BTC/USD_LEVERAGE", + base_interval="5m", + local_state=MarketState.TREND_UP, + local_trend=TrendDirection.UP, + ) + + assert result["htf_reason"] == ( + "HTF_KLINES_ERROR: candles unavailable" + ) + + +def test_htf_trend_rejects_non_finite_close( + monkeypatch: pytest.MonkeyPatch, +) -> None: + candles = list(_candles()) + candles[10] = _candle(index=10, close_price="NaN") + + class FakeExchangeService: + def get_candles( + self, + symbol: str, + *, + interval: str, + limit: int, + ) -> tuple[Candle, ...]: + return tuple(candles) + + monkeypatch.setattr(module, "ExchangeService", FakeExchangeService) + + result = module.htf_trend_context( + _service(), + symbol="BTC/USD_LEVERAGE", + base_interval="5m", + local_state=MarketState.TREND_UP, + local_trend=TrendDirection.UP, + ) + + assert result["htf_reason"] == "HTF_INDICATORS_UNAVAILABLE" + + +def test_htf_trend_converts_decimal_closes_to_float( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ema_calls: list[list[float]] = [] + + 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 + + monkeypatch.setattr(module, "ExchangeService", FakeExchangeService) + monkeypatch.setattr(module, "ema", fake_ema) + monkeypatch.setattr(module, "atr", lambda candles, period: 1.0) + + result = module.htf_trend_context( + _service(), + symbol="BTC/USD_LEVERAGE", + base_interval="5m", + local_state=MarketState.TREND_UP, + local_trend=TrendDirection.UP, + ) + + assert result["htf_reason"] == "HTF_INDICATORS_UNAVAILABLE" + 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 + + +def test_htf_trend_returns_existing_success_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeExchangeService: + def get_candles( + self, + symbol: str, + *, + interval: str, + limit: int, + ) -> tuple[Candle, ...]: + return _candles() + + monkeypatch.setattr(module, "ExchangeService", FakeExchangeService) + monkeypatch.setattr(module, "ema", lambda values, period: 150.0) + monkeypatch.setattr(module, "atr", lambda candles, period: 2.0) + monkeypatch.setattr( + module, + "adaptive_threshold", + lambda **kwargs: 0.1, + ) + monkeypatch.setattr( + module, + "ema_slope_percent", + lambda **kwargs: 0.2, + ) + monkeypatch.setattr( + module, + "classify_trend", + lambda **kwargs: TrendDirection.UP, + ) + monkeypatch.setattr( + module, + "trend_gap_percent_value", + lambda **kwargs: 1.0, + ) + monkeypatch.setattr( + module, + "classify_trend_strength", + lambda **kwargs: TrendStrength.STRONG, + ) + monkeypatch.setattr( + module, + "trend_consistency", + lambda **kwargs: 0.8, + ) + monkeypatch.setattr( + module, + "trend_efficiency", + lambda **kwargs: 0.7, + ) + monkeypatch.setattr( + module, + "calculate_ema_distance_atr_ratio", + lambda **kwargs: 1.5, + ) + monkeypatch.setattr( + module, + "calculate_candle_noise_score", + lambda *args, **kwargs: 0.8, + ) + monkeypatch.setattr( + module, + "calculate_price_position_score", + lambda **kwargs: 0.9, + ) + monkeypatch.setattr( + module, + "classify_trend_quality", + lambda **kwargs: TrendQuality.CLEAN, + ) + monkeypatch.setattr( + module, + "_htf_market_phase", + lambda **kwargs: MarketPhase.IMPULSE, + ) + monkeypatch.setattr( + module, + "_htf_market_state", + lambda **kwargs: MarketState.TREND_UP, + ) + monkeypatch.setattr( + module, + "_htf_alignment", + lambda **kwargs: "ALIGNED", + ) + monkeypatch.setattr( + module, + "_htf_confirmation_score", + lambda **kwargs: 0.9, + ) + + result = module.htf_trend_context( + _service(), + symbol="BTC/USD_LEVERAGE", + base_interval="5m", + local_state=MarketState.TREND_UP, + local_trend=TrendDirection.UP, + ) + + assert result == { + "htf_market_state": MarketState.TREND_UP.value, + "htf_trend": TrendDirection.UP.value, + "htf_trend_strength": TrendStrength.STRONG.value, + "htf_trend_quality": TrendQuality.CLEAN.value, + "htf_market_phase": MarketPhase.IMPULSE.value, + "htf_alignment": "ALIGNED", + "htf_confirmation_score": 0.9, + "htf_reason": "HTF_1h:TREND_UP:UP:ALIGNED", + } diff --git a/docs/migrations/build_051.md b/docs/migrations/build_051.md new file mode 100644 index 0000000..6e05527 --- /dev/null +++ b/docs/migrations/build_051.md @@ -0,0 +1,640 @@ +# Build 051 — Переключение HTF-анализа на канонические Candle + +## Статус + +**Завершён** + +--- + +## Цель Build + +Переключить оба HTF-пути подсистемы `Market Analysis` с legacy API: + +```text +ExchangeService.get_klines() + ↓ +KlineBatch + ↓ +batch.candles +``` + +на канонический API: + +```text +ExchangeService.get_candles() + ↓ +tuple[Candle, ...] +``` + +без изменения существующей HTF-семантики, расчётных алгоритмов, порогов, payload-полей и диагностических причин. + +--- + +## Исходное состояние + +До Build 051 в файле: + +```text +src/trading/market_analysis/htf.py +``` + +существовали два legacy-пути получения свечей: + +```text +htf_volatility_context() +htf_trend_context() +``` + +Оба использовали: + +```text +ExchangeService.get_klines() +``` + +и затем извлекали: + +```text +batch.candles +``` + +После Build 050 основной `MarketAnalysisService` уже был переключён на: + +```text +ExchangeService.get_candles() +``` + +Поэтому `htf.py` оставался последним активным production-потребителем `get_klines()` внутри `src/trading/market_analysis`. + +--- + +## Объём изменений + +В Build 051 изменены: + +```text +src/trading/market_analysis/htf.py +tests/unit/trading/market_analysis/test_htf_candles.py +docs/migrations/build_051.md +``` + +Build 051 не изменяет: + +```text +src/trading/market_analysis/service.py +src/integrations/exchange/service.py +src/integrations/exchange/models.py +src/market_data/acquisition/ +``` + +Build также не удаляет: + +```text +ExchangeService.get_klines() +Kline +KlineBatch +_kline_from_candle() +``` + +Их удаление возможно только после отдельной общей проверки всех production-потребителей. + +--- + +## Переключение htf_volatility_context() + +До Build 051: + +```text +htf_volatility_context() + ↓ +ExchangeService.get_klines() + ↓ +KlineBatch + ↓ +batch.candles +``` + +После Build 051: + +```text +htf_volatility_context() + ↓ +ExchangeService.get_candles() + ↓ +tuple[Candle, ...] +``` + +Удалена зависимость от: + +```text +batch.candles +``` + +Все остальные расчёты сохранены. + +--- + +## Переключение htf_trend_context() + +До Build 051: + +```text +htf_trend_context() + ↓ +ExchangeService.get_klines() + ↓ +KlineBatch + ↓ +batch.candles +``` + +После Build 051: + +```text +htf_trend_context() + ↓ +ExchangeService.get_candles() + ↓ +tuple[Candle, ...] +``` + +Удалена зависимость от legacy-контейнера `KlineBatch`. + +--- + +## Каноническая модель Candle + +После Build 051 оба HTF-пути непосредственно получают: + +```text +tuple[Candle, ...] +``` + +Каноническая модель: + +```text +src.market_data.acquisition.models.candle.Candle +``` + +использует: + +```text +Decimal +``` + +для полей: + +```text +open_price +high_price +low_price +close_price +volume +``` + +Поэтому для вычислительных функций была сохранена явная числовая граница: + +```text +Decimal + ↓ +safe_float(...) + ↓ +isfinite(...) + ↓ +float +``` + +--- + +## Числовая граница в htf_volatility_context() + +В `htf_volatility_context()` последняя цена закрытия преобразуется через: + +```text +safe_float() +``` + +и дополнительно проверяется через: + +```text +math.isfinite() +``` + +Невалидные значения: + +```text +NaN +Infinity +-Infinity +``` + +не допускаются к вычислению ATR percent. + +При невозможности получить корректную цену закрытия сохраняется существующая причина: + +```text +HTF_ATR_UNAVAILABLE +``` + +--- + +## Числовая граница в htf_trend_context() + +В `htf_trend_context()` формируется: + +```text +closes: list[float] +``` + +Каждая каноническая цена: + +```text +Candle.close_price +``` + +проходит: + +```text +safe_float() +isfinite() +``` + +Если хотя бы одно значение невалидно, HTF-анализ возвращает существующую причину: + +```text +HTF_INDICATORS_UNAVAILABLE +``` + +Невалидные значения не пропускаются выборочно, поскольку это нарушило бы соответствие: + +```text +candles[index] ↔ closes[index] +``` + +--- + +## Сохранение диагностического контракта + +Несмотря на переход с `get_klines()` на `get_candles()`, существующая причина ошибки получения данных сохранена: + +```text +HTF_KLINES_ERROR +``` + +Она используется в обоих HTF-путях. + +Переименование в: + +```text +HTF_CANDLES_ERROR +``` + +не выполнялось, поскольку это не требуется для технической миграции и могло изменить внешний диагностический контракт. + +--- + +## Сохранённая логика htf_volatility_context() + +Без изменений сохранены: + +```text +проверка одинакового base и HTF interval +минимальное количество свечей +ATR +close price validation +ATR percent +ATR percent baseline +volatility ratio +classify_volatility() +rounding +формат payload +HTF_SKIPPED_SAME_INTERVAL +HTF_NOT_ENOUGH_CANDLES +HTF_ATR_UNAVAILABLE +HTF_OK +``` + +--- + +## Сохранённая логика htf_trend_context() + +Без изменений сохранены: + +```text +проверка одинакового base и HTF interval +минимальное количество свечей +EMA fast +EMA slow +ATR +ATR percent +adaptive thresholds +EMA slopes +trend classification +trend gap +trend strength +trend consistency +trend efficiency +EMA distance / ATR +candle noise score +price position score +trend quality +market phase +market state +alignment +confirmation score +формат HTF payload +``` + +--- + +## Новый тестовый файл + +Добавлен: + +```text +tests/unit/trading/market_analysis/test_htf_candles.py +``` + +Файл создан отдельно, поскольку до Build 051 специализированного `test_htf.py` в проекте не существовало. + +--- + +## Покрытие htf_volatility_context() + +Тесты проверяют: + +1. пропуск запроса при одинаковом base и HTF interval; +2. точные аргументы `get_candles()`; +3. сохранение причины `HTF_KLINES_ERROR`; +4. сохранение причины `HTF_NOT_ENOUGH_CANDLES`; +5. отклонение не конечной close price; +6. сохранение причины `HTF_ATR_UNAVAILABLE`; +7. успешный HTF volatility payload; +8. поддержку canonical `Candle` с `Decimal`. + +--- + +## Покрытие htf_trend_context() + +Тесты проверяют: + +1. пропуск запроса при одинаковом base и HTF interval; +2. точные аргументы `get_candles()`; +3. сохранение причины `HTF_KLINES_ERROR`; +4. сохранение причины `HTF_NOT_ENOUGH_CANDLES`; +5. отклонение не конечной close price; +6. сохранение причины `HTF_INDICATORS_UNAVAILABLE`; +7. преобразование `Decimal` closes в `float`; +8. успешный HTF trend payload; +9. неизменность структуры результата. + +--- + +## Targeted tests + +Выполнена команда: + +```bash +python -m pytest -q \ + tests/unit/trading/market_analysis/test_htf_candles.py +``` + +Результат: + +```text +11 passed in 0.12s +``` + +--- + +## Regression-набор Market Analysis и стратегий + +Выполнена команда: + +```bash +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 +``` + +Результат: + +```text +38 passed in 0.14s +``` + +--- + +## Полный regression suite + +Выполнена команда: + +```bash +python -m pytest -q +``` + +Результат: + +```text +805 passed in 2.72s +``` + +Регрессий не обнаружено. + +--- + +## Контроль отсутствия get_klines() в Market Analysis + +Выполнена команда: + +```bash +grep -RIn \ + --exclude-dir="__pycache__" \ + --exclude="*.pyc" \ + "\.get_klines(" \ + src/trading/market_analysis +``` + +Результат: + +```text +пусто +``` + +Это подтверждает, что в активном production-коде `Market Analysis` больше нет вызовов: + +```text +ExchangeService.get_klines() +``` + +--- + +## Контроль использования get_candles() + +Выполнена команда: + +```bash +grep -RIn \ + --exclude-dir="__pycache__" \ + --exclude="*.pyc" \ + "\.get_candles(" \ + src/trading/market_analysis +``` + +Результат: + +```text +src/trading/market_analysis/service.py:244: candles = ExchangeService().get_candles( +src/trading/market_analysis/htf.py:57: candles = ExchangeService().get_candles( +src/trading/market_analysis/htf.py:157: candles = ExchangeService().get_candles( +``` + +После Build 051 в `Market Analysis` существует ровно три production-вызова канонического API. + +--- + +## Контроль legacy batch-зависимостей + +Выполнена команда: + +```bash +grep -RIn \ + --exclude-dir="__pycache__" \ + --exclude="*.pyc" \ + "batch\.candles\|batch\.symbol\|KlineBatch\|Kline" \ + src/trading/market_analysis +``` + +Результат: + +```text +src/trading/market_analysis/indicators_legacy.py:5:from src.integrations.exchange.models import Kline +src/trading/market_analysis/indicators_legacy.py:21:def atr(candles: list[Kline], period: int = 14) -> float | None: +``` + +Следовательно: + +- активные production-компоненты `Market Analysis` больше не зависят от `Kline`; +- единственная оставшаяся зависимость находится в подтверждённом неиспользуемом legacy-файле; +- `indicators_legacy.py` намеренно не изменяется в Build 051. + +--- + +## Проверка форматирования + +Выполнена команда: + +```bash +git diff --check +``` + +Вывод отсутствует. + +Whitespace-ошибок не обнаружено. + +--- + +## Архитектурный результат + +После Build 051 весь активный production-путь `Market Analysis` использует канонические свечи: + +```text +MarketAnalysisService + ↓ +ExchangeService.get_candles() + ↓ +tuple[Candle, ...] + +HTF volatility + ↓ +ExchangeService.get_candles() + ↓ +tuple[Candle, ...] + +HTF trend + ↓ +ExchangeService.get_candles() + ↓ +tuple[Candle, ...] +``` + +Legacy API: + +```text +ExchangeService.get_klines() +``` + +больше не используется активным `Market Analysis`. + +--- + +## Что намеренно не выполнено + +Build 051 намеренно не включает: + +- удаление `ExchangeService.get_klines()`; +- удаление `Kline`; +- удаление `KlineBatch`; +- удаление `_kline_from_candle()`; +- удаление `indicators_legacy.py`; +- изменение диагностической причины `HTF_KLINES_ERROR`; +- изменение алгоритмов HTF; +- изменение торговой логики; +- изменение порогов; +- изменение scoring; +- изменение Market Data Acquisition; +- изменение структуры каталогов; +- удаление рабочего compatibility-кода. + +--- + +## Критерии завершения + +Build 051 считается завершённым, поскольку: + +- оба HTF-пути используют `get_candles()`; +- зависимости `batch.candles` удалены; +- `Decimal` close prices безопасно преобразуются в `float`; +- `NaN` и бесконечности отклоняются; +- существующие диагностические причины сохранены; +- HTF payload не изменён; +- специализированные тесты проходят; +- regression-набор проходит; +- полный suite проходит; +- `get_klines()` отсутствует во всём активном `Market Analysis`; +- legacy `Kline` остаётся только в неиспользуемом `indicators_legacy.py`; +- `git diff --check` чистый. + +--- + +## Итог + +**Build 051 завершён успешно.** + +Текущее состояние: + +```text +MarketAnalysisService → get_candles() +HTF volatility → get_candles() +HTF trend → get_candles() +``` + +Результаты: + +```text +Targeted HTF tests: 11 passed +Market Analysis + strategies: 38 passed +Full test suite: 805 passed +git diff --check: clean +``` + +Следующий безопасный этап — общая проверка всех оставшихся production-потребителей: + +```text +ExchangeService.get_klines() +Kline +KlineBatch +_kline_from_candle() +``` + +Только после этой проверки можно определять отдельный Build по удалению compatibility bridge. \ No newline at end of file