build 050: switch MarketAnalysisService to canonical candles

This commit is contained in:
2026-07-15 22:54:40 +03:00
parent 16ed64f7c6
commit c8d33f8baa
3 changed files with 879 additions and 10 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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-контракта.