build 050: switch MarketAnalysisService to canonical candles
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user