build 051: switch HTF analysis to canonical candles
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from math import isfinite
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import JsonDict
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
@@ -52,7 +54,7 @@ def htf_volatility_context(
|
||||
}
|
||||
|
||||
try:
|
||||
batch = ExchangeService().get_klines(
|
||||
candles = ExchangeService().get_candles(
|
||||
symbol=symbol,
|
||||
interval=service._htf_interval,
|
||||
limit=service._htf_limit,
|
||||
@@ -67,10 +69,7 @@ def htf_volatility_context(
|
||||
"htf_reason": f"HTF_KLINES_ERROR: {exc}",
|
||||
}
|
||||
|
||||
candles = batch.candles
|
||||
closes = [item.close_price for item in candles]
|
||||
|
||||
if len(candles) < service._min_candles or not closes:
|
||||
if len(candles) < service._min_candles:
|
||||
return {
|
||||
"htf_interval": service._htf_interval,
|
||||
"htf_atr_percent": None,
|
||||
@@ -80,10 +79,15 @@ def htf_volatility_context(
|
||||
"htf_reason": "HTF_NOT_ENOUGH_CANDLES",
|
||||
}
|
||||
|
||||
close_price = safe_float(closes[-1])
|
||||
close_price = safe_float(candles[-1].close_price)
|
||||
atr_value = atr(candles, service._atr_period)
|
||||
|
||||
if close_price is None or close_price <= 0 or atr_value is None:
|
||||
if (
|
||||
close_price is None
|
||||
or not isfinite(close_price)
|
||||
or close_price <= 0
|
||||
or atr_value is None
|
||||
):
|
||||
return {
|
||||
"htf_interval": service._htf_interval,
|
||||
"htf_atr_percent": None,
|
||||
@@ -150,7 +154,7 @@ def htf_trend_context(
|
||||
}
|
||||
|
||||
try:
|
||||
batch = ExchangeService().get_klines(
|
||||
candles = ExchangeService().get_candles(
|
||||
symbol=symbol,
|
||||
interval=service._htf_interval,
|
||||
limit=service._htf_limit,
|
||||
@@ -158,12 +162,19 @@ def htf_trend_context(
|
||||
except Exception as exc:
|
||||
return _htf_unknown_context(f"HTF_KLINES_ERROR: {exc}")
|
||||
|
||||
candles = batch.candles
|
||||
closes = [item.close_price for item in candles]
|
||||
|
||||
if len(candles) < service._min_candles:
|
||||
return _htf_unknown_context("HTF_NOT_ENOUGH_CANDLES")
|
||||
|
||||
closes: list[float] = []
|
||||
|
||||
for candle in candles:
|
||||
close_value = safe_float(candle.close_price)
|
||||
|
||||
if close_value is None or not isfinite(close_value):
|
||||
return _htf_unknown_context("HTF_INDICATORS_UNAVAILABLE")
|
||||
|
||||
closes.append(close_value)
|
||||
|
||||
close_price = closes[-1] if closes else None
|
||||
ema_fast = ema(closes, service._fast_ema_period)
|
||||
ema_slow = ema(closes, service._slow_ema_period)
|
||||
@@ -369,7 +380,7 @@ def safe_volatility_state(value: object) -> VolatilityState | None:
|
||||
return VolatilityState(str(value))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def _htf_unknown_context(reason: str) -> JsonDict:
|
||||
return {
|
||||
@@ -484,4 +495,4 @@ def _htf_confirmation_score(
|
||||
if trend_efficiency is not None:
|
||||
score += (trend_efficiency - 0.3) * 0.15
|
||||
|
||||
return max(0.0, min(1.0, score))
|
||||
return max(0.0, min(1.0, score))
|
||||
|
||||
516
app/tests/unit/trading/market_analysis/test_htf_candles.py
Normal file
516
app/tests/unit/trading/market_analysis/test_htf_candles.py
Normal file
@@ -0,0 +1,516 @@
|
||||
# app/tests/unit/trading/market_analysis/test_htf_candles.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import src.trading.market_analysis.htf as module
|
||||
from src.market_data.acquisition.models.candle import Candle
|
||||
from src.trading.market_analysis.models import (
|
||||
MarketPhase,
|
||||
MarketState,
|
||||
TrendDirection,
|
||||
TrendQuality,
|
||||
TrendStrength,
|
||||
VolatilityState,
|
||||
)
|
||||
|
||||
|
||||
def _service() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
_htf_interval="1h",
|
||||
_htf_limit=120,
|
||||
_min_candles=60,
|
||||
_atr_period=14,
|
||||
_atr_baseline_window=60,
|
||||
_low_volatility_atr_percent=0.05,
|
||||
_high_volatility_atr_percent=1.8,
|
||||
_fast_ema_period=20,
|
||||
_slow_ema_period=50,
|
||||
_ema_fast_slope_window=5,
|
||||
_ema_slow_slope_window=8,
|
||||
_trend_consistency_window=20,
|
||||
_candle_noise_window=20,
|
||||
_min_clean_body_ratio=0.35,
|
||||
_price_position_window=20,
|
||||
_min_clean_candle_score=0.55,
|
||||
_min_price_position_score=0.55,
|
||||
)
|
||||
|
||||
|
||||
def _candle(
|
||||
*,
|
||||
index: int = 0,
|
||||
close_price: str = "100",
|
||||
) -> Candle:
|
||||
return Candle(
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
interval="1h",
|
||||
open_time=datetime.fromtimestamp(
|
||||
1_750_000_000 + index * 3600,
|
||||
tz=timezone.utc,
|
||||
),
|
||||
open_price=Decimal(str(99 + index)),
|
||||
high_price=Decimal(str(101 + index)),
|
||||
low_price=Decimal(str(98 + index)),
|
||||
close_price=Decimal(close_price),
|
||||
volume=Decimal("10"),
|
||||
source="test",
|
||||
)
|
||||
|
||||
|
||||
def _candles(count: int = 60) -> tuple[Candle, ...]:
|
||||
return tuple(
|
||||
_candle(
|
||||
index=index,
|
||||
close_price=str(100 + index),
|
||||
)
|
||||
for index in range(count)
|
||||
)
|
||||
|
||||
|
||||
def test_htf_volatility_skips_same_interval(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class ForbiddenExchangeService:
|
||||
def __init__(self) -> None:
|
||||
raise AssertionError("ExchangeService must not be created.")
|
||||
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"ExchangeService",
|
||||
ForbiddenExchangeService,
|
||||
)
|
||||
|
||||
result = module.htf_volatility_context(
|
||||
_service(),
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
base_interval="1h",
|
||||
)
|
||||
|
||||
assert result["htf_reason"] == "HTF_SKIPPED_SAME_INTERVAL"
|
||||
assert result["htf_interval"] == "1h"
|
||||
|
||||
|
||||
def test_htf_volatility_uses_get_candles_with_exact_arguments(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[dict[str, 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(10)
|
||||
|
||||
monkeypatch.setattr(module, "ExchangeService", FakeExchangeService)
|
||||
|
||||
result = module.htf_volatility_context(
|
||||
_service(),
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
base_interval="5m",
|
||||
)
|
||||
|
||||
assert calls == [
|
||||
{
|
||||
"symbol": "BTC/USD_LEVERAGE",
|
||||
"interval": "1h",
|
||||
"limit": 120,
|
||||
}
|
||||
]
|
||||
assert result["htf_reason"] == "HTF_NOT_ENOUGH_CANDLES"
|
||||
|
||||
|
||||
def test_htf_volatility_preserves_legacy_error_reason(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class FakeExchangeService:
|
||||
def get_candles(
|
||||
self,
|
||||
symbol: str,
|
||||
*,
|
||||
interval: str,
|
||||
limit: int,
|
||||
) -> tuple[Candle, ...]:
|
||||
raise RuntimeError("candles unavailable")
|
||||
|
||||
monkeypatch.setattr(module, "ExchangeService", FakeExchangeService)
|
||||
|
||||
result = module.htf_volatility_context(
|
||||
_service(),
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
base_interval="5m",
|
||||
)
|
||||
|
||||
assert result["htf_reason"] == (
|
||||
"HTF_KLINES_ERROR: candles unavailable"
|
||||
)
|
||||
|
||||
|
||||
def test_htf_volatility_rejects_non_finite_close(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
candles = list(_candles())
|
||||
candles[-1] = _candle(index=59, close_price="NaN")
|
||||
|
||||
class FakeExchangeService:
|
||||
def get_candles(
|
||||
self,
|
||||
symbol: str,
|
||||
*,
|
||||
interval: str,
|
||||
limit: int,
|
||||
) -> tuple[Candle, ...]:
|
||||
return tuple(candles)
|
||||
|
||||
monkeypatch.setattr(module, "ExchangeService", FakeExchangeService)
|
||||
monkeypatch.setattr(module, "atr", lambda candles, period: 1.0)
|
||||
|
||||
result = module.htf_volatility_context(
|
||||
_service(),
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
base_interval="5m",
|
||||
)
|
||||
|
||||
assert result["htf_reason"] == "HTF_ATR_UNAVAILABLE"
|
||||
|
||||
|
||||
def test_htf_volatility_returns_existing_success_payload(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class FakeExchangeService:
|
||||
def get_candles(
|
||||
self,
|
||||
symbol: str,
|
||||
*,
|
||||
interval: str,
|
||||
limit: int,
|
||||
) -> tuple[Candle, ...]:
|
||||
return _candles()
|
||||
|
||||
monkeypatch.setattr(module, "ExchangeService", FakeExchangeService)
|
||||
monkeypatch.setattr(module, "atr", lambda candles, period: 2.0)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"atr_percent_baseline",
|
||||
lambda **kwargs: 1.0,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"classify_volatility",
|
||||
lambda **kwargs: VolatilityState.HIGH,
|
||||
)
|
||||
|
||||
result = module.htf_volatility_context(
|
||||
_service(),
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
base_interval="5m",
|
||||
)
|
||||
|
||||
expected_atr_percent = (2.0 / 159.0) * 100
|
||||
|
||||
assert result == {
|
||||
"htf_interval": "1h",
|
||||
"htf_atr_percent": round(expected_atr_percent, 4),
|
||||
"htf_atr_percent_baseline": 1.0,
|
||||
"htf_volatility_ratio": round(expected_atr_percent, 4),
|
||||
"htf_volatility": VolatilityState.HIGH.value,
|
||||
"htf_reason": "HTF_OK",
|
||||
}
|
||||
|
||||
|
||||
def test_htf_trend_skips_same_interval(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class ForbiddenExchangeService:
|
||||
def __init__(self) -> None:
|
||||
raise AssertionError("ExchangeService must not be created.")
|
||||
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"ExchangeService",
|
||||
ForbiddenExchangeService,
|
||||
)
|
||||
|
||||
result = module.htf_trend_context(
|
||||
_service(),
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
base_interval="1h",
|
||||
local_state=MarketState.TREND_UP,
|
||||
local_trend=TrendDirection.UP,
|
||||
)
|
||||
|
||||
assert result["htf_reason"] == "HTF_SKIPPED_SAME_INTERVAL"
|
||||
assert result["htf_market_state"] == MarketState.TREND_UP.value
|
||||
assert result["htf_trend"] == TrendDirection.UP.value
|
||||
|
||||
|
||||
def test_htf_trend_uses_get_candles_with_exact_arguments(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[dict[str, 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(10)
|
||||
|
||||
monkeypatch.setattr(module, "ExchangeService", FakeExchangeService)
|
||||
|
||||
result = module.htf_trend_context(
|
||||
_service(),
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
base_interval="5m",
|
||||
local_state=MarketState.TREND_UP,
|
||||
local_trend=TrendDirection.UP,
|
||||
)
|
||||
|
||||
assert calls == [
|
||||
{
|
||||
"symbol": "BTC/USD_LEVERAGE",
|
||||
"interval": "1h",
|
||||
"limit": 120,
|
||||
}
|
||||
]
|
||||
assert result["htf_reason"] == "HTF_NOT_ENOUGH_CANDLES"
|
||||
|
||||
|
||||
def test_htf_trend_preserves_legacy_error_reason(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class FakeExchangeService:
|
||||
def get_candles(
|
||||
self,
|
||||
symbol: str,
|
||||
*,
|
||||
interval: str,
|
||||
limit: int,
|
||||
) -> tuple[Candle, ...]:
|
||||
raise RuntimeError("candles unavailable")
|
||||
|
||||
monkeypatch.setattr(module, "ExchangeService", FakeExchangeService)
|
||||
|
||||
result = module.htf_trend_context(
|
||||
_service(),
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
base_interval="5m",
|
||||
local_state=MarketState.TREND_UP,
|
||||
local_trend=TrendDirection.UP,
|
||||
)
|
||||
|
||||
assert result["htf_reason"] == (
|
||||
"HTF_KLINES_ERROR: candles unavailable"
|
||||
)
|
||||
|
||||
|
||||
def test_htf_trend_rejects_non_finite_close(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
candles = list(_candles())
|
||||
candles[10] = _candle(index=10, close_price="NaN")
|
||||
|
||||
class FakeExchangeService:
|
||||
def get_candles(
|
||||
self,
|
||||
symbol: str,
|
||||
*,
|
||||
interval: str,
|
||||
limit: int,
|
||||
) -> tuple[Candle, ...]:
|
||||
return tuple(candles)
|
||||
|
||||
monkeypatch.setattr(module, "ExchangeService", FakeExchangeService)
|
||||
|
||||
result = module.htf_trend_context(
|
||||
_service(),
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
base_interval="5m",
|
||||
local_state=MarketState.TREND_UP,
|
||||
local_trend=TrendDirection.UP,
|
||||
)
|
||||
|
||||
assert result["htf_reason"] == "HTF_INDICATORS_UNAVAILABLE"
|
||||
|
||||
|
||||
def test_htf_trend_converts_decimal_closes_to_float(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
ema_calls: list[list[float]] = []
|
||||
|
||||
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
|
||||
|
||||
monkeypatch.setattr(module, "ExchangeService", FakeExchangeService)
|
||||
monkeypatch.setattr(module, "ema", fake_ema)
|
||||
monkeypatch.setattr(module, "atr", lambda candles, period: 1.0)
|
||||
|
||||
result = module.htf_trend_context(
|
||||
_service(),
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
base_interval="5m",
|
||||
local_state=MarketState.TREND_UP,
|
||||
local_trend=TrendDirection.UP,
|
||||
)
|
||||
|
||||
assert result["htf_reason"] == "HTF_INDICATORS_UNAVAILABLE"
|
||||
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
|
||||
|
||||
|
||||
def test_htf_trend_returns_existing_success_payload(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class FakeExchangeService:
|
||||
def get_candles(
|
||||
self,
|
||||
symbol: str,
|
||||
*,
|
||||
interval: str,
|
||||
limit: int,
|
||||
) -> tuple[Candle, ...]:
|
||||
return _candles()
|
||||
|
||||
monkeypatch.setattr(module, "ExchangeService", FakeExchangeService)
|
||||
monkeypatch.setattr(module, "ema", lambda values, period: 150.0)
|
||||
monkeypatch.setattr(module, "atr", lambda candles, period: 2.0)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"adaptive_threshold",
|
||||
lambda **kwargs: 0.1,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"ema_slope_percent",
|
||||
lambda **kwargs: 0.2,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"classify_trend",
|
||||
lambda **kwargs: TrendDirection.UP,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"trend_gap_percent_value",
|
||||
lambda **kwargs: 1.0,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"classify_trend_strength",
|
||||
lambda **kwargs: TrendStrength.STRONG,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"trend_consistency",
|
||||
lambda **kwargs: 0.8,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"trend_efficiency",
|
||||
lambda **kwargs: 0.7,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"calculate_ema_distance_atr_ratio",
|
||||
lambda **kwargs: 1.5,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"calculate_candle_noise_score",
|
||||
lambda *args, **kwargs: 0.8,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"calculate_price_position_score",
|
||||
lambda **kwargs: 0.9,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"classify_trend_quality",
|
||||
lambda **kwargs: TrendQuality.CLEAN,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"_htf_market_phase",
|
||||
lambda **kwargs: MarketPhase.IMPULSE,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"_htf_market_state",
|
||||
lambda **kwargs: MarketState.TREND_UP,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"_htf_alignment",
|
||||
lambda **kwargs: "ALIGNED",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"_htf_confirmation_score",
|
||||
lambda **kwargs: 0.9,
|
||||
)
|
||||
|
||||
result = module.htf_trend_context(
|
||||
_service(),
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
base_interval="5m",
|
||||
local_state=MarketState.TREND_UP,
|
||||
local_trend=TrendDirection.UP,
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"htf_market_state": MarketState.TREND_UP.value,
|
||||
"htf_trend": TrendDirection.UP.value,
|
||||
"htf_trend_strength": TrendStrength.STRONG.value,
|
||||
"htf_trend_quality": TrendQuality.CLEAN.value,
|
||||
"htf_market_phase": MarketPhase.IMPULSE.value,
|
||||
"htf_alignment": "ALIGNED",
|
||||
"htf_confirmation_score": 0.9,
|
||||
"htf_reason": "HTF_1h:TREND_UP:UP:ALIGNED",
|
||||
}
|
||||
640
docs/migrations/build_051.md
Normal file
640
docs/migrations/build_051.md
Normal file
@@ -0,0 +1,640 @@
|
||||
# Build 051 — Переключение HTF-анализа на канонические Candle
|
||||
|
||||
## Статус
|
||||
|
||||
**Завершён**
|
||||
|
||||
---
|
||||
|
||||
## Цель Build
|
||||
|
||||
Переключить оба HTF-пути подсистемы `Market Analysis` с legacy API:
|
||||
|
||||
```text
|
||||
ExchangeService.get_klines()
|
||||
↓
|
||||
KlineBatch
|
||||
↓
|
||||
batch.candles
|
||||
```
|
||||
|
||||
на канонический API:
|
||||
|
||||
```text
|
||||
ExchangeService.get_candles()
|
||||
↓
|
||||
tuple[Candle, ...]
|
||||
```
|
||||
|
||||
без изменения существующей HTF-семантики, расчётных алгоритмов, порогов, payload-полей и диагностических причин.
|
||||
|
||||
---
|
||||
|
||||
## Исходное состояние
|
||||
|
||||
До Build 051 в файле:
|
||||
|
||||
```text
|
||||
src/trading/market_analysis/htf.py
|
||||
```
|
||||
|
||||
существовали два legacy-пути получения свечей:
|
||||
|
||||
```text
|
||||
htf_volatility_context()
|
||||
htf_trend_context()
|
||||
```
|
||||
|
||||
Оба использовали:
|
||||
|
||||
```text
|
||||
ExchangeService.get_klines()
|
||||
```
|
||||
|
||||
и затем извлекали:
|
||||
|
||||
```text
|
||||
batch.candles
|
||||
```
|
||||
|
||||
После Build 050 основной `MarketAnalysisService` уже был переключён на:
|
||||
|
||||
```text
|
||||
ExchangeService.get_candles()
|
||||
```
|
||||
|
||||
Поэтому `htf.py` оставался последним активным production-потребителем `get_klines()` внутри `src/trading/market_analysis`.
|
||||
|
||||
---
|
||||
|
||||
## Объём изменений
|
||||
|
||||
В Build 051 изменены:
|
||||
|
||||
```text
|
||||
src/trading/market_analysis/htf.py
|
||||
tests/unit/trading/market_analysis/test_htf_candles.py
|
||||
docs/migrations/build_051.md
|
||||
```
|
||||
|
||||
Build 051 не изменяет:
|
||||
|
||||
```text
|
||||
src/trading/market_analysis/service.py
|
||||
src/integrations/exchange/service.py
|
||||
src/integrations/exchange/models.py
|
||||
src/market_data/acquisition/
|
||||
```
|
||||
|
||||
Build также не удаляет:
|
||||
|
||||
```text
|
||||
ExchangeService.get_klines()
|
||||
Kline
|
||||
KlineBatch
|
||||
_kline_from_candle()
|
||||
```
|
||||
|
||||
Их удаление возможно только после отдельной общей проверки всех production-потребителей.
|
||||
|
||||
---
|
||||
|
||||
## Переключение htf_volatility_context()
|
||||
|
||||
До Build 051:
|
||||
|
||||
```text
|
||||
htf_volatility_context()
|
||||
↓
|
||||
ExchangeService.get_klines()
|
||||
↓
|
||||
KlineBatch
|
||||
↓
|
||||
batch.candles
|
||||
```
|
||||
|
||||
После Build 051:
|
||||
|
||||
```text
|
||||
htf_volatility_context()
|
||||
↓
|
||||
ExchangeService.get_candles()
|
||||
↓
|
||||
tuple[Candle, ...]
|
||||
```
|
||||
|
||||
Удалена зависимость от:
|
||||
|
||||
```text
|
||||
batch.candles
|
||||
```
|
||||
|
||||
Все остальные расчёты сохранены.
|
||||
|
||||
---
|
||||
|
||||
## Переключение htf_trend_context()
|
||||
|
||||
До Build 051:
|
||||
|
||||
```text
|
||||
htf_trend_context()
|
||||
↓
|
||||
ExchangeService.get_klines()
|
||||
↓
|
||||
KlineBatch
|
||||
↓
|
||||
batch.candles
|
||||
```
|
||||
|
||||
После Build 051:
|
||||
|
||||
```text
|
||||
htf_trend_context()
|
||||
↓
|
||||
ExchangeService.get_candles()
|
||||
↓
|
||||
tuple[Candle, ...]
|
||||
```
|
||||
|
||||
Удалена зависимость от legacy-контейнера `KlineBatch`.
|
||||
|
||||
---
|
||||
|
||||
## Каноническая модель Candle
|
||||
|
||||
После Build 051 оба HTF-пути непосредственно получают:
|
||||
|
||||
```text
|
||||
tuple[Candle, ...]
|
||||
```
|
||||
|
||||
Каноническая модель:
|
||||
|
||||
```text
|
||||
src.market_data.acquisition.models.candle.Candle
|
||||
```
|
||||
|
||||
использует:
|
||||
|
||||
```text
|
||||
Decimal
|
||||
```
|
||||
|
||||
для полей:
|
||||
|
||||
```text
|
||||
open_price
|
||||
high_price
|
||||
low_price
|
||||
close_price
|
||||
volume
|
||||
```
|
||||
|
||||
Поэтому для вычислительных функций была сохранена явная числовая граница:
|
||||
|
||||
```text
|
||||
Decimal
|
||||
↓
|
||||
safe_float(...)
|
||||
↓
|
||||
isfinite(...)
|
||||
↓
|
||||
float
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Числовая граница в htf_volatility_context()
|
||||
|
||||
В `htf_volatility_context()` последняя цена закрытия преобразуется через:
|
||||
|
||||
```text
|
||||
safe_float()
|
||||
```
|
||||
|
||||
и дополнительно проверяется через:
|
||||
|
||||
```text
|
||||
math.isfinite()
|
||||
```
|
||||
|
||||
Невалидные значения:
|
||||
|
||||
```text
|
||||
NaN
|
||||
Infinity
|
||||
-Infinity
|
||||
```
|
||||
|
||||
не допускаются к вычислению ATR percent.
|
||||
|
||||
При невозможности получить корректную цену закрытия сохраняется существующая причина:
|
||||
|
||||
```text
|
||||
HTF_ATR_UNAVAILABLE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Числовая граница в htf_trend_context()
|
||||
|
||||
В `htf_trend_context()` формируется:
|
||||
|
||||
```text
|
||||
closes: list[float]
|
||||
```
|
||||
|
||||
Каждая каноническая цена:
|
||||
|
||||
```text
|
||||
Candle.close_price
|
||||
```
|
||||
|
||||
проходит:
|
||||
|
||||
```text
|
||||
safe_float()
|
||||
isfinite()
|
||||
```
|
||||
|
||||
Если хотя бы одно значение невалидно, HTF-анализ возвращает существующую причину:
|
||||
|
||||
```text
|
||||
HTF_INDICATORS_UNAVAILABLE
|
||||
```
|
||||
|
||||
Невалидные значения не пропускаются выборочно, поскольку это нарушило бы соответствие:
|
||||
|
||||
```text
|
||||
candles[index] ↔ closes[index]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Сохранение диагностического контракта
|
||||
|
||||
Несмотря на переход с `get_klines()` на `get_candles()`, существующая причина ошибки получения данных сохранена:
|
||||
|
||||
```text
|
||||
HTF_KLINES_ERROR
|
||||
```
|
||||
|
||||
Она используется в обоих HTF-путях.
|
||||
|
||||
Переименование в:
|
||||
|
||||
```text
|
||||
HTF_CANDLES_ERROR
|
||||
```
|
||||
|
||||
не выполнялось, поскольку это не требуется для технической миграции и могло изменить внешний диагностический контракт.
|
||||
|
||||
---
|
||||
|
||||
## Сохранённая логика htf_volatility_context()
|
||||
|
||||
Без изменений сохранены:
|
||||
|
||||
```text
|
||||
проверка одинакового base и HTF interval
|
||||
минимальное количество свечей
|
||||
ATR
|
||||
close price validation
|
||||
ATR percent
|
||||
ATR percent baseline
|
||||
volatility ratio
|
||||
classify_volatility()
|
||||
rounding
|
||||
формат payload
|
||||
HTF_SKIPPED_SAME_INTERVAL
|
||||
HTF_NOT_ENOUGH_CANDLES
|
||||
HTF_ATR_UNAVAILABLE
|
||||
HTF_OK
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Сохранённая логика htf_trend_context()
|
||||
|
||||
Без изменений сохранены:
|
||||
|
||||
```text
|
||||
проверка одинакового base и HTF interval
|
||||
минимальное количество свечей
|
||||
EMA fast
|
||||
EMA slow
|
||||
ATR
|
||||
ATR percent
|
||||
adaptive thresholds
|
||||
EMA slopes
|
||||
trend classification
|
||||
trend gap
|
||||
trend strength
|
||||
trend consistency
|
||||
trend efficiency
|
||||
EMA distance / ATR
|
||||
candle noise score
|
||||
price position score
|
||||
trend quality
|
||||
market phase
|
||||
market state
|
||||
alignment
|
||||
confirmation score
|
||||
формат HTF payload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Новый тестовый файл
|
||||
|
||||
Добавлен:
|
||||
|
||||
```text
|
||||
tests/unit/trading/market_analysis/test_htf_candles.py
|
||||
```
|
||||
|
||||
Файл создан отдельно, поскольку до Build 051 специализированного `test_htf.py` в проекте не существовало.
|
||||
|
||||
---
|
||||
|
||||
## Покрытие htf_volatility_context()
|
||||
|
||||
Тесты проверяют:
|
||||
|
||||
1. пропуск запроса при одинаковом base и HTF interval;
|
||||
2. точные аргументы `get_candles()`;
|
||||
3. сохранение причины `HTF_KLINES_ERROR`;
|
||||
4. сохранение причины `HTF_NOT_ENOUGH_CANDLES`;
|
||||
5. отклонение не конечной close price;
|
||||
6. сохранение причины `HTF_ATR_UNAVAILABLE`;
|
||||
7. успешный HTF volatility payload;
|
||||
8. поддержку canonical `Candle` с `Decimal`.
|
||||
|
||||
---
|
||||
|
||||
## Покрытие htf_trend_context()
|
||||
|
||||
Тесты проверяют:
|
||||
|
||||
1. пропуск запроса при одинаковом base и HTF interval;
|
||||
2. точные аргументы `get_candles()`;
|
||||
3. сохранение причины `HTF_KLINES_ERROR`;
|
||||
4. сохранение причины `HTF_NOT_ENOUGH_CANDLES`;
|
||||
5. отклонение не конечной close price;
|
||||
6. сохранение причины `HTF_INDICATORS_UNAVAILABLE`;
|
||||
7. преобразование `Decimal` closes в `float`;
|
||||
8. успешный HTF trend payload;
|
||||
9. неизменность структуры результата.
|
||||
|
||||
---
|
||||
|
||||
## Targeted tests
|
||||
|
||||
Выполнена команда:
|
||||
|
||||
```bash
|
||||
python -m pytest -q \
|
||||
tests/unit/trading/market_analysis/test_htf_candles.py
|
||||
```
|
||||
|
||||
Результат:
|
||||
|
||||
```text
|
||||
11 passed in 0.12s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Regression-набор Market Analysis и стратегий
|
||||
|
||||
Выполнена команда:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
Результат:
|
||||
|
||||
```text
|
||||
38 passed in 0.14s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Полный regression suite
|
||||
|
||||
Выполнена команда:
|
||||
|
||||
```bash
|
||||
python -m pytest -q
|
||||
```
|
||||
|
||||
Результат:
|
||||
|
||||
```text
|
||||
805 passed in 2.72s
|
||||
```
|
||||
|
||||
Регрессий не обнаружено.
|
||||
|
||||
---
|
||||
|
||||
## Контроль отсутствия get_klines() в Market Analysis
|
||||
|
||||
Выполнена команда:
|
||||
|
||||
```bash
|
||||
grep -RIn \
|
||||
--exclude-dir="__pycache__" \
|
||||
--exclude="*.pyc" \
|
||||
"\.get_klines(" \
|
||||
src/trading/market_analysis
|
||||
```
|
||||
|
||||
Результат:
|
||||
|
||||
```text
|
||||
пусто
|
||||
```
|
||||
|
||||
Это подтверждает, что в активном production-коде `Market Analysis` больше нет вызовов:
|
||||
|
||||
```text
|
||||
ExchangeService.get_klines()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Контроль использования get_candles()
|
||||
|
||||
Выполнена команда:
|
||||
|
||||
```bash
|
||||
grep -RIn \
|
||||
--exclude-dir="__pycache__" \
|
||||
--exclude="*.pyc" \
|
||||
"\.get_candles(" \
|
||||
src/trading/market_analysis
|
||||
```
|
||||
|
||||
Результат:
|
||||
|
||||
```text
|
||||
src/trading/market_analysis/service.py:244: candles = ExchangeService().get_candles(
|
||||
src/trading/market_analysis/htf.py:57: candles = ExchangeService().get_candles(
|
||||
src/trading/market_analysis/htf.py:157: candles = ExchangeService().get_candles(
|
||||
```
|
||||
|
||||
После Build 051 в `Market Analysis` существует ровно три production-вызова канонического API.
|
||||
|
||||
---
|
||||
|
||||
## Контроль legacy batch-зависимостей
|
||||
|
||||
Выполнена команда:
|
||||
|
||||
```bash
|
||||
grep -RIn \
|
||||
--exclude-dir="__pycache__" \
|
||||
--exclude="*.pyc" \
|
||||
"batch\.candles\|batch\.symbol\|KlineBatch\|Kline" \
|
||||
src/trading/market_analysis
|
||||
```
|
||||
|
||||
Результат:
|
||||
|
||||
```text
|
||||
src/trading/market_analysis/indicators_legacy.py:5:from src.integrations.exchange.models import Kline
|
||||
src/trading/market_analysis/indicators_legacy.py:21:def atr(candles: list[Kline], period: int = 14) -> float | None:
|
||||
```
|
||||
|
||||
Следовательно:
|
||||
|
||||
- активные production-компоненты `Market Analysis` больше не зависят от `Kline`;
|
||||
- единственная оставшаяся зависимость находится в подтверждённом неиспользуемом legacy-файле;
|
||||
- `indicators_legacy.py` намеренно не изменяется в Build 051.
|
||||
|
||||
---
|
||||
|
||||
## Проверка форматирования
|
||||
|
||||
Выполнена команда:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Вывод отсутствует.
|
||||
|
||||
Whitespace-ошибок не обнаружено.
|
||||
|
||||
---
|
||||
|
||||
## Архитектурный результат
|
||||
|
||||
После Build 051 весь активный production-путь `Market Analysis` использует канонические свечи:
|
||||
|
||||
```text
|
||||
MarketAnalysisService
|
||||
↓
|
||||
ExchangeService.get_candles()
|
||||
↓
|
||||
tuple[Candle, ...]
|
||||
|
||||
HTF volatility
|
||||
↓
|
||||
ExchangeService.get_candles()
|
||||
↓
|
||||
tuple[Candle, ...]
|
||||
|
||||
HTF trend
|
||||
↓
|
||||
ExchangeService.get_candles()
|
||||
↓
|
||||
tuple[Candle, ...]
|
||||
```
|
||||
|
||||
Legacy API:
|
||||
|
||||
```text
|
||||
ExchangeService.get_klines()
|
||||
```
|
||||
|
||||
больше не используется активным `Market Analysis`.
|
||||
|
||||
---
|
||||
|
||||
## Что намеренно не выполнено
|
||||
|
||||
Build 051 намеренно не включает:
|
||||
|
||||
- удаление `ExchangeService.get_klines()`;
|
||||
- удаление `Kline`;
|
||||
- удаление `KlineBatch`;
|
||||
- удаление `_kline_from_candle()`;
|
||||
- удаление `indicators_legacy.py`;
|
||||
- изменение диагностической причины `HTF_KLINES_ERROR`;
|
||||
- изменение алгоритмов HTF;
|
||||
- изменение торговой логики;
|
||||
- изменение порогов;
|
||||
- изменение scoring;
|
||||
- изменение Market Data Acquisition;
|
||||
- изменение структуры каталогов;
|
||||
- удаление рабочего compatibility-кода.
|
||||
|
||||
---
|
||||
|
||||
## Критерии завершения
|
||||
|
||||
Build 051 считается завершённым, поскольку:
|
||||
|
||||
- оба HTF-пути используют `get_candles()`;
|
||||
- зависимости `batch.candles` удалены;
|
||||
- `Decimal` close prices безопасно преобразуются в `float`;
|
||||
- `NaN` и бесконечности отклоняются;
|
||||
- существующие диагностические причины сохранены;
|
||||
- HTF payload не изменён;
|
||||
- специализированные тесты проходят;
|
||||
- regression-набор проходит;
|
||||
- полный suite проходит;
|
||||
- `get_klines()` отсутствует во всём активном `Market Analysis`;
|
||||
- legacy `Kline` остаётся только в неиспользуемом `indicators_legacy.py`;
|
||||
- `git diff --check` чистый.
|
||||
|
||||
---
|
||||
|
||||
## Итог
|
||||
|
||||
**Build 051 завершён успешно.**
|
||||
|
||||
Текущее состояние:
|
||||
|
||||
```text
|
||||
MarketAnalysisService → get_candles()
|
||||
HTF volatility → get_candles()
|
||||
HTF trend → get_candles()
|
||||
```
|
||||
|
||||
Результаты:
|
||||
|
||||
```text
|
||||
Targeted HTF tests: 11 passed
|
||||
Market Analysis + strategies: 38 passed
|
||||
Full test suite: 805 passed
|
||||
git diff --check: clean
|
||||
```
|
||||
|
||||
Следующий безопасный этап — общая проверка всех оставшихся production-потребителей:
|
||||
|
||||
```text
|
||||
ExchangeService.get_klines()
|
||||
Kline
|
||||
KlineBatch
|
||||
_kline_from_candle()
|
||||
```
|
||||
|
||||
Только после этой проверки можно определять отдельный Build по удалению compatibility bridge.
|
||||
Reference in New Issue
Block a user