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

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