From 3a253d89a95039bfd563221cf4746320dba8d7ed Mon Sep 17 00:00:00 2001 From: Sergey Date: Wed, 15 Jul 2026 18:55:17 +0300 Subject: [PATCH] build 048: switch market analysis consumers to canonical Candle model --- .../market_analysis/indicators/volatility.py | 120 ++- app/src/trading/market_analysis/quality.py | 18 +- app/src/trading/market_analysis/structure.py | 6 +- .../indicators/test_volatility_candle.py | 136 +++ .../market_analysis/test_quality_candle.py | 154 +++ .../market_analysis/test_structure_candle.py | 170 ++++ docs/migrations/build_048.md | 907 ++++++++++++++++++ 7 files changed, 1478 insertions(+), 33 deletions(-) create mode 100644 app/tests/unit/trading/market_analysis/indicators/test_volatility_candle.py create mode 100644 app/tests/unit/trading/market_analysis/test_quality_candle.py create mode 100644 app/tests/unit/trading/market_analysis/test_structure_candle.py create mode 100644 docs/migrations/build_048.md diff --git a/app/src/trading/market_analysis/indicators/volatility.py b/app/src/trading/market_analysis/indicators/volatility.py index d4c8de1..c12d969 100644 --- a/app/src/trading/market_analysis/indicators/volatility.py +++ b/app/src/trading/market_analysis/indicators/volatility.py @@ -3,59 +3,90 @@ from __future__ import annotations from collections.abc import Sequence +from math import isfinite from src.core.numbers import safe_float from src.core.types import NumericLike -from src.integrations.exchange.models import Kline +from src.market_data.acquisition.models.candle import Candle from src.trading.market_analysis.models import VolatilityState -def atr(candles: list[Kline], period: int = 14) -> float | None: +def atr( + candles: Sequence[Candle], + period: int = 14, +) -> float | None: if period <= 0 or len(candles) < period + 1: return None true_ranges: list[float] = [] for previous, current in zip(candles, candles[1:]): - high_low = current.high_price - current.low_price - high_close = abs(current.high_price - previous.close_price) - low_close = abs(current.low_price - previous.close_price) + previous_close = safe_float(previous.close_price) + current_high = safe_float(current.high_price) + current_low = safe_float(current.low_price) - true_ranges.append(max(high_low, high_close, low_close)) + if ( + previous_close is None + or current_high is None + or current_low is None + or not isfinite(previous_close) + or not isfinite(current_high) + or not isfinite(current_low) + ): + continue + + high_low = current_high - current_low + high_close = abs(current_high - previous_close) + low_close = abs(current_low - previous_close) + + true_ranges.append( + max( + high_low, + high_close, + low_close, + ) + ) if len(true_ranges) < period: return None recent = true_ranges[-period:] + return sum(recent) / period def atr_percent_baseline( *, - candles: Sequence[Kline], + candles: Sequence[Candle], close_price: float, atr_period: int, atr_baseline_window: int, ) -> float | None: - if close_price <= 0: + if not isfinite(close_price) or close_price <= 0: return None values: list[float] = [] - window: list[Kline] = list(candles[-atr_baseline_window:]) + window = list(candles[-atr_baseline_window:]) for index in range(atr_period, len(window) + 1): - part: list[Kline] = window[:index] - atr_value = atr(list(part), atr_period) + part = window[:index] + atr_value = atr(part, atr_period) - if atr_value is None: + if atr_value is None or not isfinite(atr_value): continue - close = getattr(part[-1], "close_price", None) + close = safe_float(part[-1].close_price) - if close is None or close <= 0: + if ( + close is None + or not isfinite(close) + or close <= 0 + ): continue - values.append((atr_value / close) * 100) + values.append( + (atr_value / close) * 100 + ) if not values: return None @@ -66,7 +97,10 @@ def atr_percent_baseline( if len(values) % 2 == 1: return values[middle] - return (values[middle - 1] + values[middle]) / 2 + return ( + values[middle - 1] + + values[middle] + ) / 2 def adaptive_threshold( @@ -79,10 +113,20 @@ def adaptive_threshold( multiplier_value = safe_float(multiplier) minimum_value = safe_float(minimum) or 0.0 - if atr_value is None or atr_value <= 0 or multiplier_value is None: - return minimum_value + if ( + atr_value is None + or multiplier_value is None + or not isfinite(atr_value) + or not isfinite(multiplier_value) + or not isfinite(minimum_value) + or atr_value <= 0 + ): + return minimum_value if isfinite(minimum_value) else 0.0 - return max(minimum_value, atr_value * multiplier_value) + return max( + minimum_value, + atr_value * multiplier_value, + ) def classify_volatility( @@ -95,22 +139,50 @@ def classify_volatility( ) -> VolatilityState: atr_value = safe_float(atr_percent) - if atr_value is None or atr_value <= 0: + if ( + atr_value is None + or not isfinite(atr_value) + or atr_value <= 0 + ): return VolatilityState.UNKNOWN local_ratio = safe_float(volatility_ratio) htf_ratio = safe_float(htf_volatility_ratio) + if local_ratio is not None and not isfinite(local_ratio): + local_ratio = None + + if htf_ratio is not None and not isfinite(htf_ratio): + htf_ratio = None + if htf_ratio is not None: - if htf_ratio > 1.8 and (local_ratio is None or local_ratio > 1.1): + if ( + htf_ratio > 1.8 + and ( + local_ratio is None + or local_ratio > 1.1 + ) + ): return VolatilityState.HIGH - if htf_ratio < 0.55 and (local_ratio is None or local_ratio < 0.85): + if ( + htf_ratio < 0.55 + and ( + local_ratio is None + or local_ratio < 0.85 + ) + ): return VolatilityState.LOW if local_ratio is None: - low_value = safe_float(low_volatility_atr_percent) or 0.05 - high_value = safe_float(high_volatility_atr_percent) or 1.8 + low_value = safe_float(low_volatility_atr_percent) + high_value = safe_float(high_volatility_atr_percent) + + if low_value is None or not isfinite(low_value): + low_value = 0.05 + + if high_value is None or not isfinite(high_value): + high_value = 1.8 if atr_value < low_value: return VolatilityState.LOW diff --git a/app/src/trading/market_analysis/quality.py b/app/src/trading/market_analysis/quality.py index 050372d..f9bfa30 100644 --- a/app/src/trading/market_analysis/quality.py +++ b/app/src/trading/market_analysis/quality.py @@ -3,13 +3,15 @@ from __future__ import annotations from collections.abc import Sequence +from math import isfinite -from src.integrations.exchange.models import Kline +from src.core.numbers import safe_float +from src.market_data.acquisition.models.candle import Candle from src.trading.market_analysis.models import TrendDirection def candle_noise_score( - candles: Sequence[Kline], + candles: Sequence[Candle], *, candle_noise_window: int, min_clean_body_ratio: float, @@ -23,16 +25,20 @@ def candle_noise_score( total_count = 0 for candle in window: - high = getattr(candle, "high_price", None) - low = getattr(candle, "low_price", None) - open_price = getattr(candle, "open_price", None) - close_price = getattr(candle, "close_price", None) + high = safe_float(candle.high_price) + low = safe_float(candle.low_price) + open_price = safe_float(candle.open_price) + close_price = safe_float(candle.close_price) if ( high is None or low is None or open_price is None or close_price is None + or not isfinite(high) + or not isfinite(low) + or not isfinite(open_price) + or not isfinite(close_price) or high <= low ): continue diff --git a/app/src/trading/market_analysis/structure.py b/app/src/trading/market_analysis/structure.py index 226394c..9181f87 100644 --- a/app/src/trading/market_analysis/structure.py +++ b/app/src/trading/market_analysis/structure.py @@ -6,7 +6,7 @@ from collections.abc import Sequence from src.core.numbers import safe_float from src.core.types import NumericLike -from src.integrations.exchange.models import Kline +from src.market_data.acquisition.models.candle import Candle from src.trading.market_analysis.models import MarketStructure @@ -47,12 +47,12 @@ def structure_params( return window, left, right -# определить структуру рынка по swing high / swing low: +# Определить структуру рынка по swing high / swing low: # HH/HL = восходящая структура # LH/LL = нисходящая структура # MIXED = противоречивая структура def market_structure( - candles: Sequence[Kline], + candles: Sequence[Candle], *, atr_percent: NumericLike | None = None, candle_noise_score: NumericLike | None = None, diff --git a/app/tests/unit/trading/market_analysis/indicators/test_volatility_candle.py b/app/tests/unit/trading/market_analysis/indicators/test_volatility_candle.py new file mode 100644 index 0000000..52b2106 --- /dev/null +++ b/app/tests/unit/trading/market_analysis/indicators/test_volatility_candle.py @@ -0,0 +1,136 @@ +# app/tests/unit/trading/market_analysis/indicators/test_volatility_candle.py + +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal + +from src.market_data.acquisition.models.candle import Candle + + +def _candle( + *, + index: int = 0, + open_price: str = "100", + high_price: str = "110", + low_price: str = "90", + close_price: str = "105", + volume: str = "10", +) -> Candle: + return Candle( + symbol="BTC/USD_LEVERAGE", + interval="1m", + open_time=datetime.fromtimestamp( + 1_750_000_000 + index * 60, + 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="test", + ) + + +from src.trading.market_analysis.indicators.volatility import ( + atr, + atr_percent_baseline, +) + + +def test_atr_accepts_canonical_candles_with_decimal_values() -> None: + candles = [ + _candle( + index=0, + high_price="105", + low_price="95", + close_price="100", + ), + _candle( + index=1, + high_price="110", + low_price="95", + close_price="105", + ), + _candle( + index=2, + high_price="112", + low_price="100", + close_price="108", + ), + ] + + result = atr(candles, period=2) + + assert result == 13.5 + assert isinstance(result, float) + + +def test_atr_returns_none_when_candles_are_insufficient() -> None: + candles = [ + _candle(index=0), + _candle(index=1), + ] + + assert atr(candles, period=2) is None + + +def test_atr_returns_none_for_non_positive_period() -> None: + candles = [ + _candle(index=0), + _candle(index=1), + ] + + assert atr(candles, period=0) is None + + +def test_atr_skips_pair_with_non_finite_decimal_value() -> None: + candles = [ + _candle(index=0, close_price="100"), + _candle(index=1, high_price="NaN", low_price="95"), + _candle(index=2, high_price="112", low_price="100"), + ] + + assert atr(candles, period=2) is None + + +def test_atr_percent_baseline_accepts_decimal_candles() -> None: + candles = [ + _candle( + index=index, + open_price=str(100 + index), + high_price=str(105 + index), + low_price=str(95 + index), + close_price=str(101 + index), + ) + for index in range(8) + ] + + result = atr_percent_baseline( + candles=candles, + close_price=108.0, + atr_period=2, + atr_baseline_window=8, + ) + + assert result is not None + assert isinstance(result, float) + assert result > 0 + + +def test_atr_percent_baseline_returns_none_for_invalid_close_price() -> None: + candles = [ + _candle(index=index) + for index in range(5) + ] + + assert ( + atr_percent_baseline( + candles=candles, + close_price=0.0, + atr_period=2, + atr_baseline_window=5, + ) + is None + ) diff --git a/app/tests/unit/trading/market_analysis/test_quality_candle.py b/app/tests/unit/trading/market_analysis/test_quality_candle.py new file mode 100644 index 0000000..dcf6a89 --- /dev/null +++ b/app/tests/unit/trading/market_analysis/test_quality_candle.py @@ -0,0 +1,154 @@ +# app/tests/unit/trading/market_analysis/test_quality_candle.py + +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal + +from src.market_data.acquisition.models.candle import Candle + + +def _candle( + *, + index: int = 0, + open_price: str = "100", + high_price: str = "110", + low_price: str = "90", + close_price: str = "105", + volume: str = "10", +) -> Candle: + return Candle( + symbol="BTC/USD_LEVERAGE", + interval="1m", + open_time=datetime.fromtimestamp( + 1_750_000_000 + index * 60, + 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="test", + ) + + +from src.trading.market_analysis.quality import candle_noise_score + + +def test_candle_noise_score_accepts_decimal_candles() -> None: + candles = [ + _candle( + index=0, + open_price="91", + high_price="100", + low_price="90", + close_price="99", + ), + _candle( + index=1, + open_price="94", + high_price="100", + low_price="90", + close_price="96", + ), + ] + + result = candle_noise_score( + candles, + candle_noise_window=2, + min_clean_body_ratio=0.5, + ) + + assert result == 0.5 + assert isinstance(result, float) + + +def test_candle_noise_score_returns_none_for_empty_sequence() -> None: + assert ( + candle_noise_score( + [], + candle_noise_window=10, + min_clean_body_ratio=0.5, + ) + is None + ) + + +def test_candle_noise_score_skips_zero_range_candle() -> None: + candles = [ + _candle( + index=0, + open_price="100", + high_price="100", + low_price="100", + close_price="100", + ), + _candle( + index=1, + open_price="91", + high_price="100", + low_price="90", + close_price="99", + ), + ] + + result = candle_noise_score( + candles, + candle_noise_window=2, + min_clean_body_ratio=0.5, + ) + + assert result == 1.0 + + +def test_candle_noise_score_skips_non_finite_decimal_value() -> None: + candles = [ + _candle( + index=0, + high_price="NaN", + ), + ] + + assert ( + candle_noise_score( + candles, + candle_noise_window=1, + min_clean_body_ratio=0.5, + ) + is None + ) + + +def test_candle_noise_score_uses_requested_tail_window() -> None: + candles = [ + _candle( + index=0, + open_price="91", + high_price="100", + low_price="90", + close_price="99", + ), + _candle( + index=1, + open_price="94", + high_price="100", + low_price="90", + close_price="96", + ), + _candle( + index=2, + open_price="92", + high_price="100", + low_price="90", + close_price="98", + ), + ] + + result = candle_noise_score( + candles, + candle_noise_window=2, + min_clean_body_ratio=0.5, + ) + + assert result == 0.5 diff --git a/app/tests/unit/trading/market_analysis/test_structure_candle.py b/app/tests/unit/trading/market_analysis/test_structure_candle.py new file mode 100644 index 0000000..2801e05 --- /dev/null +++ b/app/tests/unit/trading/market_analysis/test_structure_candle.py @@ -0,0 +1,170 @@ +# app/tests/unit/trading/market_analysis/test_structure_candle.py + +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal + +from src.market_data.acquisition.models.candle import Candle + + +def _candle( + *, + index: int = 0, + open_price: str = "100", + high_price: str = "110", + low_price: str = "90", + close_price: str = "105", + volume: str = "10", +) -> Candle: + return Candle( + symbol="BTC/USD_LEVERAGE", + interval="1m", + open_time=datetime.fromtimestamp( + 1_750_000_000 + index * 60, + 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="test", + ) + + +from src.trading.market_analysis.models import MarketStructure +from src.trading.market_analysis.structure import market_structure + + +def _structure_candles( + *, + highs: list[float], + lows: list[float], +) -> list[Candle]: + candles: list[Candle] = [] + + for index, (high, low) in enumerate(zip(highs, lows)): + midpoint = (high + low) / 2 + + candles.append( + _candle( + index=index, + open_price=str(midpoint), + high_price=str(high), + low_price=str(low), + close_price=str(midpoint), + ) + ) + + return candles + + +def test_market_structure_detects_higher_highs_and_higher_lows() -> None: + candles = _structure_candles( + highs=[2, 4, 3, 5, 4, 6, 5, 7, 6, 5], + lows=[1, 0, 1, 0.5, 1.5, 1, 2, 1.5, 2.5, 2], + ) + + result, reason = market_structure( + candles, + atr_percent=0.30, + structure_window=10, + structure_swing_left=1, + structure_swing_right=1, + ) + + assert result == MarketStructure.HH_HL + assert reason.startswith("HIGHER_HIGH_HIGHER_LOW:") + + +def test_market_structure_detects_lower_highs_and_lower_lows() -> None: + candles = _structure_candles( + highs=[ + 8.0, + 10.0, + 9.0, + 9.5, + 8.5, + 9.0, + 8.0, + 8.5, + 7.5, + 7.0, + ], + lows=[ + 7.0, + 6.0, + 7.0, + 5.5, + 6.5, + 5.0, + 6.0, + 4.5, + 5.5, + 5.0, + ], + ) + + result, reason = market_structure( + candles, + atr_percent=0.30, + structure_window=10, + structure_swing_left=1, + structure_swing_right=1, + ) + + assert result == MarketStructure.LH_LL + assert reason.startswith("LOWER_HIGH_LOWER_LOW:") + + +def test_market_structure_detects_mixed_structure() -> None: + candles = _structure_candles( + highs=[2, 4, 3, 5, 4, 6, 5, 7, 6, 5], + lows=[5, 4, 5, 3, 4, 2, 3, 1, 2, 1.5], + ) + + result, reason = market_structure( + candles, + atr_percent=0.30, + structure_window=10, + structure_swing_left=1, + structure_swing_right=1, + ) + + assert result == MarketStructure.MIXED + assert reason.startswith("MIXED_MARKET_STRUCTURE:") + + +def test_market_structure_returns_unknown_when_candles_are_insufficient() -> None: + candles = [ + _candle(index=index) + for index in range(5) + ] + + result, reason = market_structure( + candles, + structure_window=10, + structure_swing_left=1, + structure_swing_right=1, + ) + + assert result == MarketStructure.UNKNOWN + assert reason == "STRUCTURE_NOT_ENOUGH_CANDLES" + + +def test_market_structure_accepts_decimal_candle_values() -> None: + candles = _structure_candles( + highs=[2, 4, 3, 5, 4, 6, 5, 7, 6, 5], + lows=[1, 0, 1, 0.5, 1.5, 1, 2, 1.5, 2.5, 2], + ) + + result, _ = market_structure( + candles, + atr_percent=0.30, + structure_window=10, + structure_swing_left=1, + structure_swing_right=1, + ) + + assert result == MarketStructure.HH_HL diff --git a/docs/migrations/build_048.md b/docs/migrations/build_048.md new file mode 100644 index 0000000..733f774 --- /dev/null +++ b/docs/migrations/build_048.md @@ -0,0 +1,907 @@ +# Build 048 — Переключение вычислительных потребителей Market Analysis на каноническую модель Candle + +**Статус:** Completed + +**Дата:** 2026-07-15 + +**Подсистема:** Market Data Acquisition / OHLCV Feed / Market Analysis + +--- + +## 1. Назначение Build + +Build 048 переводит активные вычислительные компоненты подсистемы `Market Analysis`, непосредственно работающие с последовательностями рыночных свечей, с legacy-модели: + +```text +src.integrations.exchange.models.Kline +``` + +на каноническую модель рыночной свечи: + +```text +src.market_data.acquisition.models.candle.Candle +``` + +Build является очередным этапом поэтапной миграции получения и использования OHLCV-данных в канонический слой: + +```text +Market Data Acquisition +``` + +Основная цель Build — устранить прямую зависимость активных вычислительных функций анализа рынка от legacy-модели `Kline`, сохранив существующую торговую семантику и поведение системы. + +--- + +## 2. Контекст миграции + +До Build 048 канонический OHLCV pipeline уже был создан и интегрирован поэтапно. + +### Build 044 + +Создан фундамент канонического Candles Feed: + +```text +REST transport + ↓ +schema validation + ↓ +parser + ↓ +value validation + ↓ +mapper + ↓ +Candle + ↓ +handler + ↓ +CandlesFeed +``` + +Каноническая модель: + +```text +src/market_data/acquisition/models/candle.py +``` + +### Build 045 + +Создан и протестирован: + +```text +CandlesFeedRegistry +``` + +### Build 046 + +Создан и интегрирован: + +```text +CandlesAcquisitionService +``` + +### Build 047 + +Legacy-метод: + +```text +ExchangeService.get_klines() +``` + +переключён на канонический Candles Feed. + +После Build 047 транспортный запрос: + +```text +/api/v1/klines +``` + +выполняется только внутри: + +```text +src/market_data/acquisition/adapters/dzengi/rest.py +``` + +При этом `ExchangeService.get_klines()` временно сохраняет обратную совместимость и преобразует канонические `Candle` обратно в legacy-модели `Kline`. + +Build 048 начинает устранять эту обратную legacy-зависимость непосредственно внутри вычислительных потребителей `Market Analysis`. + +--- + +## 3. Границы Build + +В Build 048 изменены только активные вычислительные компоненты `Market Analysis`, которые непосредственно принимают последовательности свечей и используют поля OHLCV. + +Изменены: + +```text +src/trading/market_analysis/indicators/volatility.py +src/trading/market_analysis/quality.py +src/trading/market_analysis/structure.py +``` + +Добавлены специализированные unit-тесты: + +```text +tests/unit/trading/market_analysis/indicators/test_volatility_candle.py +tests/unit/trading/market_analysis/test_quality_candle.py +tests/unit/trading/market_analysis/test_structure_candle.py +``` + +Build 048 не изменяет: + +```text +src/trading/market_analysis/service.py +src/trading/market_analysis/htf.py +src/trading/market_analysis/indicators_legacy.py +src/integrations/exchange/service.py +src/integrations/exchange/models.py +``` + +Build также не удаляет: + +```text +Kline +KlineBatch +ExchangeService.get_klines() +``` + +Их дальнейшая судьба определяется отдельными последующими этапами миграции. + +--- + +## 4. Основное архитектурное изменение + +До Build 048 активные вычислительные функции использовали legacy-модель: + +```python +from src.integrations.exchange.models import Kline +``` + +После Build 048 они используют каноническую модель: + +```python +from src.market_data.acquisition.models.candle import Candle +``` + +Изменение выполнено в: + +```text +src/trading/market_analysis/indicators/volatility.py +src/trading/market_analysis/quality.py +src/trading/market_analysis/structure.py +``` + +Таким образом, активные вычислительные функции: + +```text +atr() +atr_percent_baseline() +candle_noise_score() +market_structure() +``` + +больше не зависят от: + +```text +src.integrations.exchange.models.Kline +``` + +и работают непосредственно с: + +```text +src.market_data.acquisition.models.candle.Candle +``` + +--- + +## 5. Адаптация к канонической модели Candle + +Каноническая модель `Candle` использует точные числовые значения: + +```text +Decimal +``` + +для полей: + +```text +open_price +high_price +low_price +close_price +volume +``` + +Legacy-модель `Kline` использовала: + +```text +float +``` + +Поэтому Build 048 выполняет явную адаптацию вычислительного слоя к `Decimal`. + +Для безопасного преобразования используется существующий helper: + +```text +src.core.numbers.safe_float +``` + +Числовые значения преобразуются непосредственно на границе вычислений. + +Принцип: + +```text +Candle + ↓ +Decimal OHLCV + ↓ +safe_float(...) + ↓ +float + ↓ +существующая вычислительная логика Market Analysis +``` + +Это позволяет: + +- сохранить точность канонической модели данных; +- не переносить `float` в слой Market Data Acquisition; +- сохранить существующие вычислительные алгоритмы; +- минимизировать область изменений; +- не изменять торговую семантику в рамках миграционного Build. + +--- + +## 6. Изменения volatility.py + +Файл: + +```text +src/trading/market_analysis/indicators/volatility.py +``` + +переведён с: + +```text +Sequence[Kline] +``` + +на: + +```text +Sequence[Candle] +``` + +Адаптированы функции: + +```text +atr() +atr_percent_baseline() +``` + +Поля канонической модели: + +```text +previous.close_price +current.high_price +current.low_price +``` + +преобразуются через: + +```text +safe_float(...) +``` + +Дополнительно выполняется проверка: + +```text +math.isfinite(...) +``` + +для исключения нечисловых конечных значений: + +```text +NaN +Infinity +-Infinity +``` + +Это необходимо, поскольку преобразование: + +```text +Decimal("NaN") +``` + +в `float` само по себе не возвращает `None`, а создаёт: + +```text +float("nan") +``` + +Без проверки `isfinite()` такое значение могло проникнуть в вычисление ATR. + +--- + +## 7. Изменения quality.py + +Файл: + +```text +src/trading/market_analysis/quality.py +``` + +переведён с: + +```text +Sequence[Kline] +``` + +на: + +```text +Sequence[Candle] +``` + +Адаптирована функция: + +```text +candle_noise_score() +``` + +Поля: + +```text +high_price +low_price +open_price +close_price +``` + +преобразуются через: + +```text +safe_float(...) +``` + +и проверяются через: + +```text +isfinite(...) +``` + +Невалидные свечи не участвуют в расчёте. + +Если после фильтрации отсутствуют допустимые свечи, функция возвращает: + +```text +None +``` + +Существующая функция: + +```text +price_position_score() +``` + +сохранена. + +Build 048 не изменяет её назначение и вычислительную семантику. + +--- + +## 8. Изменения structure.py + +Файл: + +```text +src/trading/market_analysis/structure.py +``` + +переведён с: + +```text +Sequence[Kline] +``` + +на: + +```text +Sequence[Candle] +``` + +Адаптирована функция: + +```text +market_structure() +``` + +Для построения последовательностей максимумов и минимумов используются значения: + +```text +candle.high_price +candle.low_price +``` + +с безопасным преобразованием к вычислительному типу. + +Существующая логика определения структуры рынка сохранена: + +```text +HH_HL +LH_LL +MIXED +UNKNOWN +``` + +Build 048 не изменяет: + +- правила определения swing high; +- правила определения swing low; +- правила сравнения последних swing-точек; +- adaptive structure parameters; +- существующие причины результата; +- существующую классификацию `MarketStructure`. + +--- + +## 9. Защита от невалидных числовых значений + +В ходе специализированного тестирования была выявлена необходимость явной проверки конечности числовых значений. + +Для вычислительных функций используется: + +```python +from math import isfinite +``` + +После: + +```text +safe_float(...) +``` + +значения дополнительно проверяются на: + +```text +NaN +Infinity +-Infinity +``` + +Это предотвращает распространение невалидных числовых значений внутри: + +```text +ATR +ATR baseline +candle noise score +``` + +В частности, предотвращается ситуация: + +```text +Decimal("NaN") + ↓ +float("nan") + ↓ +результат вычисления nan +``` + +Вместо этого невалидное значение исключается из вычисления согласно локальной логике соответствующей функции. + +--- + +## 10. Специализированные unit-тесты + +Добавлены три специализированных тестовых файла. + +### 10.1. Volatility + +Файл: + +```text +tests/unit/trading/market_analysis/indicators/test_volatility_candle.py +``` + +Проверяет: + +- работу `atr()` с каноническими `Candle`; +- поддержку `Decimal`; +- недостаточное количество свечей; +- невалидный период; +- обработку не конечных числовых значений; +- работу `atr_percent_baseline()`; +- невалидную цену закрытия. + +### 10.2. Quality + +Файл: + +```text +tests/unit/trading/market_analysis/test_quality_candle.py +``` + +Проверяет: + +- работу `candle_noise_score()` с `Candle`; +- поддержку `Decimal`; +- пустую последовательность; +- свечу с нулевым диапазоном; +- не конечные числовые значения; +- применение указанного tail window. + +### 10.3. Structure + +Файл: + +```text +tests/unit/trading/market_analysis/test_structure_candle.py +``` + +Проверяет: + +- структуру `HH_HL`; +- структуру `LH_LL`; +- структуру `MIXED`; +- недостаточное количество свечей; +- непосредственную работу с `Decimal`-значениями `Candle`. + +--- + +## 11. Результаты специализированных тестов + +Команда: + +```bash +python -m pytest -q \ + tests/unit/trading/market_analysis/indicators/test_volatility_candle.py \ + tests/unit/trading/market_analysis/test_quality_candle.py \ + tests/unit/trading/market_analysis/test_structure_candle.py +``` + +Результат: + +```text +16 passed in 0.02s +``` + +--- + +## 12. Regression-проверка стратегий + +Дополнительно выполнена проверка стратегий: + +```bash +python -m pytest -q \ + tests/unit/trading/strategies/test_scalp_quote.py \ + tests/unit/trading/strategies/test_trend_quote.py +``` + +Результат: + +```text +6 passed in 0.14s +``` + +Эта проверка подтверждает сохранение импортов и существующей интеграции `Market Analysis` с торговыми стратегиями. + +--- + +## 13. Полный regression suite + +Выполнена команда: + +```bash +python -m pytest -q +``` + +Результат: + +```text +766 passed in 2.53s +``` + +Полный unit test suite проекта проходит успешно. + +--- + +## 14. Контроль legacy-зависимости Kline + +Выполнена команда: + +```bash +grep -RIn \ + --exclude-dir="__pycache__" \ + --exclude="*.pyc" \ + "integrations.exchange.models import Kline" \ + src/trading/market_analysis +``` + +Результат: + +```text +src/trading/market_analysis/indicators_legacy.py:5:from src.integrations.exchange.models import Kline +``` + +Следовательно, после Build 048 активные вычислительные компоненты: + +```text +volatility.py +quality.py +structure.py +``` + +больше не импортируют legacy-модель `Kline`. + +Оставшийся импорт находится только в: + +```text +indicators_legacy.py +``` + +который не изменяется в рамках Build 048. + +--- + +## 15. Контроль использования канонической модели Candle + +Выполнена команда: + +```bash +grep -RIn \ + --exclude-dir="__pycache__" \ + --exclude="*.pyc" \ + "models.candle import Candle" \ + src/trading/market_analysis +``` + +Результат: + +```text +src/trading/market_analysis/indicators/volatility.py:10:from src.market_data.acquisition.models.candle import Candle +src/trading/market_analysis/structure.py:9:from src.market_data.acquisition.models.candle import Candle +src/trading/market_analysis/quality.py:9:from src.market_data.acquisition.models.candle import Candle +``` + +Это подтверждает прямую зависимость трёх активных вычислительных потребителей от канонической модели `Candle`. + +--- + +## 16. Контроль оставшихся вызовов ExchangeService.get_klines() + +Выполнена команда: + +```bash +grep -RIn \ + --exclude-dir="__pycache__" \ + --exclude="*.pyc" \ + "\.get_klines(" \ + src/trading/market_analysis +``` + +Результат: + +```text +src/trading/market_analysis/service.py:243: batch = ExchangeService().get_klines( +src/trading/market_analysis/htf.py:55: batch = ExchangeService().get_klines( +src/trading/market_analysis/htf.py:153: batch = ExchangeService().get_klines( +``` + +Эти три вызова являются ожидаемыми и не удаляются в Build 048. + +Они показывают следующую границу дальнейшей миграции: + +```text +Market Analysis orchestration + ↓ +прямое получение Candle + ↓ +устранение зависимости от ExchangeService.get_klines() +``` + +Такое переключение должно выполняться отдельным Build после анализа контрактов: + +```text +service.py +htf.py +``` + +--- + +## 17. Проверка форматирования diff + +Выполнена команда: + +```bash +git diff --check +``` + +Вывод отсутствует. + +Следовательно: + +```text +trailing whitespace отсутствует +ошибки whitespace отсутствуют +``` + +--- + +## 18. Архитектурный результат + +После Build 048 активный вычислительный путь выглядит следующим образом: + +```text +Dzengi REST API + ↓ +DzengiCandlesDocumentSource + ↓ +validate_candles_schema() + ↓ +parse_candles() + ↓ +validate_candles_values() + ↓ +map_candles() + ↓ +Candle + ↓ +CandlesDocumentHandler + ↓ +CandlesFeed + ↓ +CandlesFeedRegistry + ↓ +CandlesAcquisitionService + ↓ +ExchangeService.get_klines() + ↓ +legacy compatibility conversion + ↓ +Market Analysis orchestration + ↓ +active computational consumers typed for Candle +``` + +При этом три активных вычислительных компонента уже используют канонический контракт: + +```text +volatility.py → Candle +quality.py → Candle +structure.py → Candle +``` + +Однако orchestration-слой `Market Analysis` пока продолжает получать данные через: + +```text +ExchangeService.get_klines() +``` + +Поэтому полное устранение legacy-преобразования `Candle → Kline` ещё не завершено. + +--- + +## 19. Что сознательно не сделано + +В Build 048 сознательно не выполнялось: + +- удаление `Kline`; +- удаление `KlineBatch`; +- удаление `ExchangeService.get_klines()`; +- изменение `MarketAnalysisService`; +- изменение HTF orchestration; +- изменение `indicators_legacy.py`; +- изменение торговых стратегий; +- изменение scoring; +- изменение signal logic; +- изменение торговых порогов; +- изменение определения market structure; +- изменение правил ATR; +- изменение существующей архитектуры каталогов; +- удаление рабочего legacy-кода. + +Это соответствует принципу поэтапной миграции: + +```text +сначала новый канонический путь + ↓ +затем переключение потребителей + ↓ +затем подтверждение отсутствия legacy-потребителей + ↓ +только после этого отдельное удаление legacy-кода +``` + +--- + +## 20. Соблюдение правил миграции + +Build 048 соответствует утверждённым правилам проекта Dzentra: + +1. Рабочий код не удаляется без отдельного согласования. +2. Изменения ограничены согласованными границами Build. +3. Канонический слой Market Data не зависит от `src.trading`. +4. Миграция выполняется поэтапно. +5. Обратная совместимость сохраняется там, где она ещё необходима. +6. Legacy-код не удаляется до подтверждённого переключения всех его потребителей. +7. Существующая торговая семантика не изменяется в рамках инфраструктурной миграции. +8. Каждый Build подтверждается специализированными тестами, полным regression suite и контрольными grep. + +--- + +## 21. Итог Build 048 + +Build 048 завершён успешно. + +В результате: + +```text +volatility.py +quality.py +structure.py +``` + +переведены с: + +```text +Kline +``` + +на: + +```text +Candle +``` + +Канонические `Decimal`-значения адаптированы к существующему вычислительному слою через: + +```text +safe_float(...) +``` + +с дополнительной защитой от: + +```text +NaN +Infinity +-Infinity +``` + +Подтверждено: + +```text +16 специализированных тестов passed +6 regression-тестов стратегий passed +766 тестов полного suite passed +git diff --check — чисто +``` + +Build 048 завершает переключение выбранных активных вычислительных потребителей `Market Analysis` на каноническую модель `Candle`. + +Следующая архитектурная граница миграции — прямое переключение orchestration-компонентов: + +```text +src/trading/market_analysis/service.py +src/trading/market_analysis/htf.py +``` + +с: + +```text +ExchangeService.get_klines() +``` + +на получение канонических: + +```text +Candle +``` + +через слой: + +```text +Market Data Acquisition +``` + +Такое переключение должно выполняться отдельным Build с сохранением существующего поведения системы. \ No newline at end of file