build 050: switch MarketAnalysisService to canonical candles
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
from math import isfinite
|
||||||
|
|
||||||
from src.core.numbers import safe_float
|
from src.core.numbers import safe_float
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
@@ -240,7 +241,7 @@ class MarketAnalysisService:
|
|||||||
limit: int = 200,
|
limit: int = 200,
|
||||||
) -> MarketAnalysisResult:
|
) -> MarketAnalysisResult:
|
||||||
try:
|
try:
|
||||||
batch = ExchangeService().get_klines(
|
candles = ExchangeService().get_candles(
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
interval=interval,
|
interval=interval,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
@@ -253,18 +254,38 @@ class MarketAnalysisService:
|
|||||||
htf_interval=self._htf_interval,
|
htf_interval=self._htf_interval,
|
||||||
)
|
)
|
||||||
|
|
||||||
candles = batch.candles
|
analysis_symbol = candles[0].symbol if candles else symbol
|
||||||
closes = [item.close_price for item in candles]
|
|
||||||
|
|
||||||
if len(candles) < self._min_candles:
|
if len(candles) < self._min_candles:
|
||||||
return build_unknown_market_analysis_result(
|
return build_unknown_market_analysis_result(
|
||||||
symbol=batch.symbol,
|
symbol=analysis_symbol,
|
||||||
interval=interval,
|
interval=interval,
|
||||||
reason="Недостаточно свечей для анализа рынка.",
|
reason="Недостаточно свечей для анализа рынка.",
|
||||||
candles_count=len(candles),
|
candles_count=len(candles),
|
||||||
htf_interval=self._htf_interval,
|
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
|
close_price = closes[-1] if closes else None
|
||||||
ema_fast = ema(closes, self._fast_ema_period)
|
ema_fast = ema(closes, self._fast_ema_period)
|
||||||
ema_slow = ema(closes, self._slow_ema_period)
|
ema_slow = ema(closes, self._slow_ema_period)
|
||||||
@@ -279,7 +300,7 @@ class MarketAnalysisService:
|
|||||||
or atr_value is None
|
or atr_value is None
|
||||||
):
|
):
|
||||||
return build_unknown_market_analysis_result(
|
return build_unknown_market_analysis_result(
|
||||||
symbol=batch.symbol,
|
symbol=analysis_symbol,
|
||||||
interval=interval,
|
interval=interval,
|
||||||
reason="Недостаточно данных для расчёта EMA / ATR.",
|
reason="Недостаточно данных для расчёта EMA / ATR.",
|
||||||
candles_count=len(candles),
|
candles_count=len(candles),
|
||||||
@@ -303,7 +324,7 @@ class MarketAnalysisService:
|
|||||||
|
|
||||||
htf_context = build_htf_volatility_context(
|
htf_context = build_htf_volatility_context(
|
||||||
self,
|
self,
|
||||||
symbol=batch.symbol,
|
symbol=analysis_symbol,
|
||||||
base_interval=interval,
|
base_interval=interval,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -483,7 +504,7 @@ class MarketAnalysisService:
|
|||||||
phase_window=self._phase_window,
|
phase_window=self._phase_window,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Текущая свеча — последняя свеча из batch.
|
# Текущая свеча — последняя свеча из canonical candles.
|
||||||
# Обычно это ещё формирующаяся свеча текущего 5m-интервала.
|
# Обычно это ещё формирующаяся свеча текущего 5m-интервала.
|
||||||
current_interval_change_percent = self._candle_change_percent(
|
current_interval_change_percent = self._candle_change_percent(
|
||||||
candles,
|
candles,
|
||||||
@@ -563,7 +584,7 @@ class MarketAnalysisService:
|
|||||||
|
|
||||||
htf_trend_context = build_htf_trend_context(
|
htf_trend_context = build_htf_trend_context(
|
||||||
self,
|
self,
|
||||||
symbol=batch.symbol,
|
symbol=analysis_symbol,
|
||||||
base_interval=interval,
|
base_interval=interval,
|
||||||
local_state=state,
|
local_state=state,
|
||||||
local_trend=trend,
|
local_trend=trend,
|
||||||
@@ -681,7 +702,7 @@ class MarketAnalysisService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
payload = build_market_analysis_payload(
|
payload = build_market_analysis_payload(
|
||||||
symbol=batch.symbol,
|
symbol=analysis_symbol,
|
||||||
interval=interval,
|
interval=interval,
|
||||||
state=state,
|
state=state,
|
||||||
trend=trend,
|
trend=trend,
|
||||||
@@ -744,7 +765,7 @@ class MarketAnalysisService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return build_market_analysis_result(
|
return build_market_analysis_result(
|
||||||
symbol=batch.symbol,
|
symbol=analysis_symbol,
|
||||||
interval=interval,
|
interval=interval,
|
||||||
state=state,
|
state=state,
|
||||||
trend=trend,
|
trend=trend,
|
||||||
|
|||||||
@@ -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
|
||||||
578
docs/migrations/build_050.md
Normal file
578
docs/migrations/build_050.md
Normal 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-контракта.
|
||||||
Reference in New Issue
Block a user