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