diff --git a/app/src/integrations/exchange/models.py b/app/src/integrations/exchange/models.py
index 4d7bea9..4197d6a 100644
--- a/app/src/integrations/exchange/models.py
+++ b/app/src/integrations/exchange/models.py
@@ -67,4 +67,34 @@ class SymbolValidationResult:
@dataclass(slots=True)
class PrivateAuthHealth:
ok: bool
- message: str
\ No newline at end of file
+ message: str
+
+
+# =========================================================
+# MARKET ANALYSIS / KLINES
+# =========================================================
+
+
+@dataclass(slots=True)
+class Kline:
+ symbol: str
+ interval: str
+
+ open_time: int
+
+ open_price: float
+ high_price: float
+ low_price: float
+ close_price: float
+
+ volume: float
+
+ source: str
+
+
+@dataclass(slots=True)
+class KlineBatch:
+ symbol: str
+ interval: str
+ candles: list[Kline]
+ source: str
\ No newline at end of file
diff --git a/app/src/integrations/exchange/rest_client.py b/app/src/integrations/exchange/rest_client.py
index eba5261..b1bccc9 100644
--- a/app/src/integrations/exchange/rest_client.py
+++ b/app/src/integrations/exchange/rest_client.py
@@ -22,6 +22,60 @@ class ExchangeRestClient:
self.base_url = self.settings.exchange_base_url.rstrip("/")
self.timeout = self.settings.exchange_timeout_sec
+ def get_payload(
+ self,
+ path: str,
+ params: dict[str, str] | None = None,
+ headers: dict[str, str] | None = None,
+ ) -> object:
+ query = f"?{urlencode(params)}" if params else ""
+ url = f"{self.base_url}{path}{query}"
+
+ request_headers = {
+ "Accept": "application/json",
+ "User-Agent": "dzentra-bot/2.0.0",
+ }
+
+ if headers:
+ request_headers.update(headers)
+
+ request = Request(
+ url=url,
+ method="GET",
+ headers=request_headers,
+ )
+
+ try:
+ with urlopen(request, timeout=self.timeout) as response:
+ status = getattr(response, "status", 200)
+ body = response.read().decode("utf-8")
+ except HTTPError as exc:
+ error_body = ""
+ try:
+ error_body = exc.read().decode("utf-8")
+ except Exception:
+ pass
+
+ message = f"HTTP {exc.code} from exchange: {exc.reason}"
+ if error_body:
+ message += f" | body: {error_body}"
+
+ raise ExchangeResponseError(message) from exc
+ except URLError as exc:
+ raise ExchangeConnectionError(
+ f"Network error while calling exchange: {exc.reason}"
+ ) from exc
+ except TimeoutError as exc:
+ raise ExchangeConnectionError("Timeout while calling exchange.") from exc
+
+ if status != 200:
+ raise ExchangeResponseError(f"Unexpected HTTP status: {status}")
+
+ try:
+ return json.loads(body)
+ except json.JSONDecodeError as exc:
+ raise ExchangeResponseError("Exchange returned non-JSON response.") from exc
+
def get_json(
self,
path: str,
diff --git a/app/src/integrations/exchange/service.py b/app/src/integrations/exchange/service.py
index 5783a58..3dd8429 100644
--- a/app/src/integrations/exchange/service.py
+++ b/app/src/integrations/exchange/service.py
@@ -19,6 +19,8 @@ from src.integrations.exchange.models import (
ExchangeHealth,
ExchangeSymbol,
ExecutionPriceSnapshot,
+ Kline,
+ KlineBatch,
PrivateAuthHealth,
SymbolValidationResult,
TickerPrice,
@@ -142,6 +144,185 @@ class ExchangeService:
def _runtime_key(self, runtime_key: str | None) -> str:
return (runtime_key or self._default_runtime_key).strip().lower()
+ def get_klines(
+ self,
+ symbol: str | None = None,
+ *,
+ interval: str = "1m",
+ limit: int = 200,
+ price_type: str = "bid",
+ ) -> KlineBatch:
+ symbol_to_use = symbol or self.settings.default_symbol
+
+ if limit <= 0:
+ limit = 200
+
+ if limit > 200:
+ limit = 200
+
+ if interval not in {"1m", "5m", "15m"}:
+ raise ExchangeError(f"Unsupported kline interval: {interval}")
+
+ normalized_price_type = price_type.strip().lower()
+
+ if normalized_price_type not in {"bid", "ask"}:
+ normalized_price_type = "bid"
+
+ if not self.settings.exchange_enabled:
+ raise ExchangeError("Klines are not available in mock exchange mode.")
+
+ validation = self.validate_symbol(symbol_to_use)
+ if not validation.is_valid:
+ raise ExchangeError(validation.message)
+
+ client = ExchangeRestClient()
+
+ try:
+ payload = client.get_payload(
+ "/api/v2/klines",
+ params={
+ "symbol": validation.normalized_symbol,
+ "interval": interval,
+ "limit": str(limit),
+ "priceType": normalized_price_type,
+ },
+ )
+ except Exception as exc:
+ self._log_exchange_error(
+ endpoint="klines",
+ exc=exc,
+ symbol=validation.normalized_symbol,
+ extra_payload={
+ "interval": interval,
+ "limit": limit,
+ "price_type": normalized_price_type,
+ },
+ )
+ raise ExchangeError(str(exc)) from exc
+
+ candles = self._parse_klines_payload(
+ payload=payload,
+ symbol=validation.normalized_symbol,
+ interval=interval,
+ source=f"rest_klines:{normalized_price_type}",
+ )
+
+ return KlineBatch(
+ symbol=validation.normalized_symbol,
+ interval=interval,
+ candles=candles[-limit:],
+ source=f"rest_klines:{normalized_price_type}",
+ )
+
+ def _parse_klines_payload(
+ self,
+ *,
+ payload: object,
+ symbol: str,
+ interval: str,
+ source: str,
+ ) -> list[Kline]:
+ raw_items = self._extract_klines_items(payload)
+
+ candles: list[Kline] = []
+
+ for item in raw_items:
+ candle = self._parse_kline_item(
+ item=item,
+ symbol=symbol,
+ interval=interval,
+ source=source,
+ )
+
+ if candle is not None:
+ candles.append(candle)
+
+ candles.sort(key=lambda item: item.open_time)
+
+ return candles
+
+ def _extract_klines_items(self, payload: object) -> list:
+ if isinstance(payload, list):
+ return payload
+
+ if not isinstance(payload, dict):
+ return []
+
+ if isinstance(payload.get("klines"), list):
+ return payload["klines"]
+
+ if isinstance(payload.get("candles"), list):
+ return payload["candles"]
+
+ if isinstance(payload.get("data"), list):
+ return payload["data"]
+
+ inner = payload.get("payload")
+ if isinstance(inner, list):
+ return inner
+
+ if isinstance(inner, dict):
+ if isinstance(inner.get("klines"), list):
+ return inner["klines"]
+
+ if isinstance(inner.get("candles"), list):
+ return inner["candles"]
+
+ if isinstance(inner.get("data"), list):
+ return inner["data"]
+
+ if isinstance(payload.get("result"), list):
+ return payload["result"]
+
+ return []
+
+ def _parse_kline_item(
+ self,
+ *,
+ item: object,
+ symbol: str,
+ interval: str,
+ source: str,
+ ) -> Kline | None:
+ try:
+ if isinstance(item, dict):
+ open_time = (
+ item.get("openTime")
+ or item.get("open_time")
+ or item.get("time")
+ or item.get("timestamp")
+ )
+
+ return Kline(
+ symbol=symbol,
+ interval=interval,
+ open_time=int(open_time),
+ open_price=float(item.get("open")),
+ high_price=float(item.get("high")),
+ low_price=float(item.get("low")),
+ close_price=float(item.get("close")),
+ volume=float(item.get("volume") or 0.0),
+ source=source,
+ )
+
+ if isinstance(item, list) and len(item) >= 6:
+ return Kline(
+ symbol=symbol,
+ interval=interval,
+ open_time=int(item[0]),
+ open_price=float(item[1]),
+ high_price=float(item[2]),
+ low_price=float(item[3]),
+ close_price=float(item[4]),
+ volume=float(item[5] or 0.0),
+ source=source,
+ )
+
+ except Exception:
+ return None
+
+ return None
+
def get_health(self) -> ExchangeHealth:
if not self.settings.exchange_enabled:
return mock_exchange_health()
diff --git a/app/src/telegram/handlers/auto/ui.py b/app/src/telegram/handlers/auto/ui.py
index 8aa1640..f09505f 100644
--- a/app/src/telegram/handlers/auto/ui.py
+++ b/app/src/telegram/handlers/auto/ui.py
@@ -162,6 +162,7 @@ def _build_waiting_text(state) -> str:
f"Доступно · $ {_format_money_compact(available)}",
"",
_signal_line(state),
+ _market_state_line(state),
*_signal_confidence_lines(state),
*_execution_block_lines(state),
"",
@@ -208,6 +209,7 @@ def _build_active_position_text(state) -> str:
f"Доступно · $ {_format_money_compact(available)}",
f"Зарезервировано · $ {_format_money_compact(reserved)}",
f"P&L {_format_signed_usd_with_direction(pnl)}",
+ _market_state_line(state),
*_execution_block_lines(state),
"",
(
@@ -235,6 +237,22 @@ def _build_active_position_text(state) -> str:
return "\n".join(parts)
+def _market_state_line(state) -> str:
+ market_state = getattr(state, "market_state", None)
+
+ labels = {
+ "TREND_UP": "📈 Рынок · Рост",
+ "TREND_DOWN": "📉 Рынок · Падение",
+ "RANGE": "🟰 Рынок · Флэт",
+ "HIGH_VOLATILITY": "⚠️ Рынок · Волатильность",
+ "LOW_VOLATILITY": "🟰 Рынок · Спокойный",
+ "UNKNOWN": "⏳ Рынок · Анализ",
+ None: "⏳ Рынок · Анализ",
+ }
+
+ return labels.get(market_state, "⏳ Рынок · Анализ")
+
+
def _execution_block_lines(state) -> list[str]:
lines: list[str] = []
diff --git a/app/src/trading/auto/service.py b/app/src/trading/auto/service.py
index ce583a7..db935c4 100644
--- a/app/src/trading/auto/service.py
+++ b/app/src/trading/auto/service.py
@@ -388,7 +388,7 @@ class AutoTradeService:
confidence: float,
payload: dict | None,
) -> None:
- signal_key = f"{state.status}:{state.symbol}:{strategy_name}:{signal}:{reason}"
+ signal_key = f"{state.status}:{state.symbol}:{strategy_name}:{signal}"
previous_signal = self._last_signal_value
previous_count = self._same_signal_count
is_same_signal = signal_key == self._last_signal_key
@@ -396,6 +396,10 @@ class AutoTradeService:
if is_same_signal:
self._same_signal_count += 1
+ self._last_signal_reason = reason
+ self._last_signal_confidence = confidence
+ self._last_signal_payload = payload
+
self._update_signal_state_fields(
state=state,
signal=signal,
@@ -404,7 +408,7 @@ class AutoTradeService:
)
return
- if previous_signal is not None:
+ if previous_signal is not None and previous_signal != signal:
if previous_count > 1:
self._log_signal_summary(
strategy_name=strategy_name,
@@ -636,7 +640,21 @@ class AutoTradeService:
except Exception:
pass
- # выполнить один цикл анализа рынка
+ def _sync_market_analysis_state(
+ self,
+ *,
+ state: AutoTradeState,
+ payload: dict | None,
+ ) -> None:
+ if not isinstance(payload, dict):
+ return
+
+ state.market_state = payload.get("market_state")
+ state.market_trend = payload.get("market_trend")
+ state.market_volatility = payload.get("market_volatility")
+ state.market_analysis_interval = payload.get("market_analysis_interval")
+ state.market_analysis_reason = payload.get("market_analysis_reason")
+
def run_cycle(self) -> AutoTradeState:
state = self.get_state()
@@ -647,6 +665,11 @@ class AutoTradeService:
context = self._build_strategy_context()
result = strategy.analyze(context)
+ self._sync_market_analysis_state(
+ state=state,
+ payload=result.payload,
+ )
+
state.last_check_at = datetime.now().strftime("%H:%M:%S")
self._log_signal_if_changed(
diff --git a/app/src/trading/auto/state.py b/app/src/trading/auto/state.py
index c9e4cc5..46154eb 100644
--- a/app/src/trading/auto/state.py
+++ b/app/src/trading/auto/state.py
@@ -104,4 +104,19 @@ class AutoTradeState:
last_flip_block_reason: str | None = None
# время последнего успешного flip
- last_flip_at: str | None = None
\ No newline at end of file
+ last_flip_at: str | None = None
+
+ # состояние рынка по Market State Engine
+ market_state: str | None = None
+
+ # направление тренда: UP / DOWN / FLAT / UNKNOWN
+ market_trend: str | None = None
+
+ # волатильность: LOW / NORMAL / HIGH / UNKNOWN
+ market_volatility: str | None = None
+
+ # таймфрейм анализа рынка
+ market_analysis_interval: str | None = None
+
+ # объяснение последнего анализа рынка
+ market_analysis_reason: str | None = None
\ No newline at end of file
diff --git a/app/src/trading/market_analysis/__init__.py b/app/src/trading/market_analysis/__init__.py
new file mode 100644
index 0000000..38a2866
--- /dev/null
+++ b/app/src/trading/market_analysis/__init__.py
@@ -0,0 +1,3 @@
+# app/src/trading/market_analysis/__init__.py
+
+from __future__ import annotations
\ No newline at end of file
diff --git a/app/src/trading/market_analysis/indicators.py b/app/src/trading/market_analysis/indicators.py
new file mode 100644
index 0000000..feb3cef
--- /dev/null
+++ b/app/src/trading/market_analysis/indicators.py
@@ -0,0 +1,67 @@
+# app/src/trading/market_analysis/indicators.py
+
+from __future__ import annotations
+
+from src.integrations.exchange.models import Kline
+
+
+def ema(values: list[float], period: int) -> float | None:
+ if period <= 0 or len(values) < period:
+ return None
+
+ multiplier = 2 / (period + 1)
+ current = sum(values[:period]) / period
+
+ for value in values[period:]:
+ current = (value - current) * multiplier + current
+
+ return current
+
+
+def atr(candles: list[Kline], period: int = 14) -> float | None:
+ if period <= 0 or len(candles) < period + 1:
+ return None
+
+ true_ranges: list[float] = []
+
+ for previous, current in zip(candles, candles[1:]):
+ high_low = current.high_price - current.low_price
+ high_close = abs(current.high_price - previous.close_price)
+ low_close = abs(current.low_price - previous.close_price)
+
+ true_ranges.append(max(high_low, high_close, low_close))
+
+ if len(true_ranges) < period:
+ return None
+
+ recent = true_ranges[-period:]
+ return sum(recent) / period
+
+
+def rsi(values: list[float], period: int = 14) -> float | None:
+ if period <= 0 or len(values) < period + 1:
+ return None
+
+ gains: list[float] = []
+ losses: list[float] = []
+
+ recent = values[-(period + 1):]
+
+ for previous, current in zip(recent, recent[1:]):
+ change = current - previous
+
+ if change > 0:
+ gains.append(change)
+ losses.append(0.0)
+ else:
+ gains.append(0.0)
+ losses.append(abs(change))
+
+ average_gain = sum(gains) / period
+ average_loss = sum(losses) / period
+
+ if average_loss == 0:
+ return 100.0
+
+ rs = average_gain / average_loss
+ return 100 - (100 / (1 + rs))
\ No newline at end of file
diff --git a/app/src/trading/market_analysis/models.py b/app/src/trading/market_analysis/models.py
new file mode 100644
index 0000000..6a92928
--- /dev/null
+++ b/app/src/trading/market_analysis/models.py
@@ -0,0 +1,52 @@
+# app/src/trading/market_analysis/models.py
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import StrEnum
+
+
+class MarketState(StrEnum):
+ TREND_UP = "TREND_UP"
+ TREND_DOWN = "TREND_DOWN"
+ RANGE = "RANGE"
+ HIGH_VOLATILITY = "HIGH_VOLATILITY"
+ LOW_VOLATILITY = "LOW_VOLATILITY"
+ UNKNOWN = "UNKNOWN"
+
+
+class TrendDirection(StrEnum):
+ UP = "UP"
+ DOWN = "DOWN"
+ FLAT = "FLAT"
+ UNKNOWN = "UNKNOWN"
+
+
+class VolatilityState(StrEnum):
+ LOW = "LOW"
+ NORMAL = "NORMAL"
+ HIGH = "HIGH"
+ UNKNOWN = "UNKNOWN"
+
+
+@dataclass(slots=True)
+class MarketAnalysisResult:
+ symbol: str
+ interval: str
+
+ state: MarketState
+ trend: TrendDirection
+ volatility: VolatilityState
+
+ close_price: float | None
+ ema_fast: float | None
+ ema_slow: float | None
+ atr: float | None
+ atr_percent: float | None
+ rsi: float | None
+
+ candles_count: int
+ reason: str
+ is_trade_allowed: bool
+
+ payload: dict
\ No newline at end of file
diff --git a/app/src/trading/market_analysis/service.py b/app/src/trading/market_analysis/service.py
new file mode 100644
index 0000000..0ec6a28
--- /dev/null
+++ b/app/src/trading/market_analysis/service.py
@@ -0,0 +1,256 @@
+# app/src/trading/market_analysis/service.py
+
+from __future__ import annotations
+
+from src.integrations.exchange.service import ExchangeService
+from src.trading.market_analysis.indicators import atr, ema, rsi
+from src.trading.market_analysis.models import (
+ MarketAnalysisResult,
+ MarketState,
+ TrendDirection,
+ VolatilityState,
+)
+
+
+class MarketAnalysisService:
+ _fast_ema_period = 20
+ _slow_ema_period = 50
+ _atr_period = 14
+ _rsi_period = 14
+
+ _min_candles = 60
+
+ _low_volatility_atr_percent = 0.05
+ _high_volatility_atr_percent = 1.8
+
+ _trend_gap_percent = 0.03
+
+ def analyze(
+ self,
+ symbol: str,
+ *,
+ interval: str = "5m",
+ limit: int = 200,
+ ) -> MarketAnalysisResult:
+ try:
+ batch = ExchangeService().get_klines(
+ symbol=symbol,
+ interval=interval,
+ limit=limit,
+ )
+ except Exception as exc:
+ return self._unknown(
+ symbol=symbol,
+ interval=interval,
+ reason=f"Не удалось получить свечи: {exc}",
+ )
+
+ candles = batch.candles
+ closes = [item.close_price for item in candles]
+
+ if len(candles) < self._min_candles:
+ return self._unknown(
+ symbol=batch.symbol,
+ interval=interval,
+ reason="Недостаточно свечей для анализа рынка.",
+ candles_count=len(candles),
+ )
+
+ close_price = closes[-1] if closes else None
+ ema_fast = ema(closes, self._fast_ema_period)
+ ema_slow = ema(closes, self._slow_ema_period)
+ atr_value = atr(candles, self._atr_period)
+ rsi_value = rsi(closes, self._rsi_period)
+
+ if (
+ close_price is None
+ or close_price <= 0
+ or ema_fast is None
+ or ema_slow is None
+ or atr_value is None
+ ):
+ return self._unknown(
+ symbol=batch.symbol,
+ interval=interval,
+ reason="Недостаточно данных для расчёта EMA / ATR.",
+ candles_count=len(candles),
+ )
+
+ atr_percent = (atr_value / close_price) * 100
+
+ volatility = self._classify_volatility(atr_percent)
+ trend = self._classify_trend(
+ ema_fast=ema_fast,
+ ema_slow=ema_slow,
+ )
+
+ state = self._classify_market_state(
+ trend=trend,
+ volatility=volatility,
+ )
+
+ is_trade_allowed = state in {
+ MarketState.TREND_UP,
+ MarketState.TREND_DOWN,
+ }
+
+ reason = self._reason(
+ state=state,
+ trend=trend,
+ volatility=volatility,
+ atr_percent=atr_percent,
+ rsi_value=rsi_value,
+ )
+
+ return MarketAnalysisResult(
+ symbol=batch.symbol,
+ interval=interval,
+ state=state,
+ trend=trend,
+ volatility=volatility,
+ close_price=close_price,
+ ema_fast=ema_fast,
+ ema_slow=ema_slow,
+ atr=atr_value,
+ atr_percent=atr_percent,
+ rsi=rsi_value,
+ candles_count=len(candles),
+ reason=reason,
+ is_trade_allowed=is_trade_allowed,
+ payload={
+ "symbol": batch.symbol,
+ "interval": interval,
+ "market_state": state.value,
+ "trend": trend.value,
+ "volatility": volatility.value,
+ "close_price": close_price,
+ "ema_fast_period": self._fast_ema_period,
+ "ema_slow_period": self._slow_ema_period,
+ "ema_fast": round(ema_fast, 8),
+ "ema_slow": round(ema_slow, 8),
+ "atr_period": self._atr_period,
+ "atr": round(atr_value, 8),
+ "atr_percent": round(atr_percent, 4),
+ "rsi_period": self._rsi_period,
+ "rsi": round(rsi_value, 2) if rsi_value is not None else None,
+ "candles_count": len(candles),
+ "is_trade_allowed": is_trade_allowed,
+ },
+ )
+
+ def _classify_trend(
+ self,
+ *,
+ ema_fast: float,
+ ema_slow: float,
+ ) -> TrendDirection:
+ if ema_slow <= 0:
+ return TrendDirection.UNKNOWN
+
+ gap_percent = ((ema_fast - ema_slow) / ema_slow) * 100
+
+ if gap_percent >= self._trend_gap_percent:
+ return TrendDirection.UP
+
+ if gap_percent <= -self._trend_gap_percent:
+ return TrendDirection.DOWN
+
+ return TrendDirection.FLAT
+
+ def _classify_volatility(self, atr_percent: float) -> VolatilityState:
+ if atr_percent <= 0:
+ return VolatilityState.UNKNOWN
+
+ if atr_percent < self._low_volatility_atr_percent:
+ return VolatilityState.LOW
+
+ if atr_percent > self._high_volatility_atr_percent:
+ return VolatilityState.HIGH
+
+ return VolatilityState.NORMAL
+
+ def _classify_market_state(
+ self,
+ *,
+ trend: TrendDirection,
+ volatility: VolatilityState,
+ ) -> MarketState:
+ if volatility == VolatilityState.HIGH:
+ return MarketState.HIGH_VOLATILITY
+
+ if volatility == VolatilityState.LOW:
+ return MarketState.LOW_VOLATILITY
+
+ if trend == TrendDirection.UP:
+ return MarketState.TREND_UP
+
+ if trend == TrendDirection.DOWN:
+ return MarketState.TREND_DOWN
+
+ if trend == TrendDirection.FLAT:
+ return MarketState.RANGE
+
+ return MarketState.UNKNOWN
+
+ def _reason(
+ self,
+ *,
+ state: MarketState,
+ trend: TrendDirection,
+ volatility: VolatilityState,
+ atr_percent: float,
+ rsi_value: float | None,
+ ) -> str:
+ rsi_text = f", RSI={rsi_value:.2f}" if rsi_value is not None else ""
+
+ if state == MarketState.TREND_UP:
+ return f"Рынок в восходящем тренде. ATR={atr_percent:.2f}%{rsi_text}."
+
+ if state == MarketState.TREND_DOWN:
+ return f"Рынок в нисходящем тренде. ATR={atr_percent:.2f}%{rsi_text}."
+
+ if state == MarketState.RANGE:
+ return f"Рынок в боковике. Тренд не подтверждён. ATR={atr_percent:.2f}%{rsi_text}."
+
+ if state == MarketState.HIGH_VOLATILITY:
+ return f"Рынок слишком волатильный. ATR={atr_percent:.2f}%{rsi_text}."
+
+ if state == MarketState.LOW_VOLATILITY:
+ return f"Рынок слишком спокойный. ATR={atr_percent:.2f}%{rsi_text}."
+
+ return f"Состояние рынка не определено. Trend={trend}, volatility={volatility}."
+
+ def _unknown(
+ self,
+ *,
+ symbol: str,
+ interval: str,
+ reason: str,
+ candles_count: int = 0,
+ ) -> MarketAnalysisResult:
+ return MarketAnalysisResult(
+ symbol=symbol,
+ interval=interval,
+ state=MarketState.UNKNOWN,
+ trend=TrendDirection.UNKNOWN,
+ volatility=VolatilityState.UNKNOWN,
+ close_price=None,
+ ema_fast=None,
+ ema_slow=None,
+ atr=None,
+ atr_percent=None,
+ rsi=None,
+ candles_count=candles_count,
+ reason=reason,
+ is_trade_allowed=False,
+ payload={
+ "symbol": symbol,
+ "interval": interval,
+ "market_state": MarketState.UNKNOWN.value,
+ "trend": TrendDirection.UNKNOWN.value,
+ "volatility": VolatilityState.UNKNOWN.value,
+ "candles_count": candles_count,
+ "is_trade_allowed": False,
+ "reason": reason,
+ },
+ )
\ No newline at end of file
diff --git a/app/src/trading/strategies/trend.py b/app/src/trading/strategies/trend.py
index 93eb432..a15940a 100644
--- a/app/src/trading/strategies/trend.py
+++ b/app/src/trading/strategies/trend.py
@@ -3,6 +3,8 @@
from __future__ import annotations
from src.integrations.exchange.service import ExchangeService
+from src.trading.market_analysis.models import MarketState
+from src.trading.market_analysis.service import MarketAnalysisService
from src.trading.strategies.base import StrategyContext
from src.trading.strategies.signals import SignalResult, SignalType
@@ -12,18 +14,26 @@ class TrendStrategy:
_price_window: dict[str, list[float]] = {}
- # длиннее окно = меньше шума
+ # короткое окно оставляем как дополнительное подтверждение импульса
_window_size = 8
-
- # общий порог изменения за окно
_threshold_percent = 0.05
-
- # сколько движений внутри окна должно быть в сторону сигнала
_min_direction_ratio = 0.6
+ # основной таймфрейм анализа рынка
+ _market_interval = "5m"
+
def analyze(self, context: StrategyContext) -> SignalResult:
+ market = MarketAnalysisService().analyze(
+ context.symbol,
+ interval=self._market_interval,
+ limit=200,
+ )
+
try:
- snapshot = ExchangeService().get_market_snapshot(context.symbol)
+ snapshot = ExchangeService().get_market_snapshot(
+ context.symbol,
+ runtime_key="auto",
+ )
except Exception as exc:
return SignalResult(
signal=SignalType.HOLD,
@@ -33,6 +43,7 @@ class TrendStrategy:
"strategy": self.name,
"symbol": context.symbol,
"error": str(exc),
+ "market_analysis": market.payload,
},
)
@@ -48,6 +59,7 @@ class TrendStrategy:
"strategy": self.name,
"symbol": symbol,
"snapshot": snapshot,
+ "market_analysis": market.payload,
},
)
@@ -57,15 +69,39 @@ class TrendStrategy:
if len(prices) > self._window_size:
prices.pop(0)
+ base_payload = {
+ "strategy": self.name,
+ "symbol": symbol,
+ "analysis_price": current_price,
+ "last_price": snapshot.get("last_price"),
+ "bid_price": snapshot.get("bid_price"),
+ "ask_price": snapshot.get("ask_price"),
+ "market_state": market.state.value,
+ "market_trend": market.trend.value,
+ "market_volatility": market.volatility.value,
+ "market_analysis_interval": market.interval,
+ "market_analysis_reason": market.reason,
+ "market_analysis": market.payload,
+ }
+
+ if not market.is_trade_allowed:
+ return SignalResult(
+ signal=SignalType.HOLD,
+ reason=f"Market filter: {market.reason}",
+ confidence=0.0,
+ payload={
+ **base_payload,
+ "market_filter_blocked": True,
+ },
+ )
+
if len(prices) < self._window_size:
return SignalResult(
signal=SignalType.HOLD,
- reason="Недостаточно данных для анализа тренда.",
+ reason="Недостаточно live-данных для подтверждения TREND.",
confidence=0.0,
payload={
- "strategy": self.name,
- "symbol": symbol,
- "price": current_price,
+ **base_payload,
"window_size": len(prices),
"required_window_size": self._window_size,
},
@@ -77,11 +113,10 @@ class TrendStrategy:
if first_price <= 0:
return SignalResult(
signal=SignalType.HOLD,
- reason="Некорректная стартовая цена в окне.",
+ reason="Некорректная стартовая цена в live-окне.",
confidence=0.0,
payload={
- "strategy": self.name,
- "symbol": symbol,
+ **base_payload,
"prices": prices,
},
)
@@ -90,14 +125,9 @@ class TrendStrategy:
direction_ratio = self._direction_ratio(prices, change_percent)
payload = {
- "strategy": self.name,
- "symbol": symbol,
- "analysis_price": last_price,
+ **base_payload,
"first_price": first_price,
"current_price": last_price,
- "last_price": snapshot.get("last_price"),
- "bid_price": snapshot.get("bid_price"),
- "ask_price": snapshot.get("ask_price"),
"change_percent": round(change_percent, 5),
"direction_ratio": round(direction_ratio, 3),
"window_size": len(prices),
@@ -105,31 +135,47 @@ class TrendStrategy:
"min_direction_ratio": self._min_direction_ratio,
}
- if (
- change_percent >= self._threshold_percent
- and direction_ratio >= self._min_direction_ratio
- ):
+ if market.state == MarketState.TREND_UP:
+ if (
+ change_percent >= self._threshold_percent
+ and direction_ratio >= self._min_direction_ratio
+ ):
+ return SignalResult(
+ signal=SignalType.BUY,
+ reason="TREND_UP подтверждён market analysis и live-импульсом.",
+ confidence=self._calculate_confidence(change_percent, direction_ratio),
+ payload=payload,
+ )
+
return SignalResult(
- signal=SignalType.BUY,
- reason="Устойчивый рост цены в окне TREND.",
- confidence=self._calculate_confidence(change_percent, direction_ratio),
+ signal=SignalType.HOLD,
+ reason="TREND_UP есть, но live-импульс вверх недостаточно сильный.",
+ confidence=0.0,
payload=payload,
)
- if (
- change_percent <= -self._threshold_percent
- and direction_ratio >= self._min_direction_ratio
- ):
+ if market.state == MarketState.TREND_DOWN:
+ if (
+ change_percent <= -self._threshold_percent
+ and direction_ratio >= self._min_direction_ratio
+ ):
+ return SignalResult(
+ signal=SignalType.SELL,
+ reason="TREND_DOWN подтверждён market analysis и live-импульсом.",
+ confidence=self._calculate_confidence(change_percent, direction_ratio),
+ payload=payload,
+ )
+
return SignalResult(
- signal=SignalType.SELL,
- reason="Устойчивое снижение цены в окне TREND.",
- confidence=self._calculate_confidence(change_percent, direction_ratio),
+ signal=SignalType.HOLD,
+ reason="TREND_DOWN есть, но live-импульс вниз недостаточно сильный.",
+ confidence=0.0,
payload=payload,
)
return SignalResult(
signal=SignalType.HOLD,
- reason="Тренд недостаточно устойчивый.",
+ reason=f"Market state не подходит для TREND: {market.state.value}.",
confidence=0.0,
payload=payload,
)
diff --git a/docs/roadmap/master-roadmap.md b/docs/roadmap/master-roadmap.md
index 152ac4d..4193d97 100644
--- a/docs/roadmap/master-roadmap.md
+++ b/docs/roadmap/master-roadmap.md
@@ -396,6 +396,23 @@
- централизован EVENT_TITLES mapping
- журнал подготовлен к filters/search layer
+#### 07.4.4.1.1 ✅ Market State Human UI + HOLD Lifecycle Fix
+- добавлено короткое human-readable отображение состояния рынка
+- технические market_state значения скрыты из основного Auto UI
+- убраны `trend=...` и `volatility=...` из Telegram-экрана
+- убран timeframe анализа из основного UI как лишняя техническая деталь
+- добавлены UI-состояния `📈 Рынок · Рост`, `📉 Рынок · Падение`, `🟰 Рынок · Флэт`, `⚠️ Рынок · Волатильность`, `⏳ Рынок · Анализ`
+- подтверждена работа REST klines как основы аналитики рынка
+- Market State Engine переведён на анализ свечей OHLCV
+- добавлены EMA20 / EMA50 для определения направления тренда
+- добавлен ATR для оценки волатильности рынка
+- добавлен RSI в аналитический payload
+- TREND-стратегия стала market-aware и использует состояние рынка как фильтр входа
+- market analysis синхронизируется в AutoTradeState
+- исправлен баг ложного завершения `HOLD → HOLD`
+- HOLD summary теперь пишется только при реальной смене сигнала
+- этап подготовил основу для Market State Journal Events и BTC/ETH Relative Strength Layer
+
### 07.4.4
⏳ Grid Strategy
diff --git a/docs/roadmap/stage-07-auto-trading-roadmap.md b/docs/roadmap/stage-07-auto-trading-roadmap.md
index a7f72f5..886f10f 100644
--- a/docs/roadmap/stage-07-auto-trading-roadmap.md
+++ b/docs/roadmap/stage-07-auto-trading-roadmap.md
@@ -372,6 +372,23 @@
- централизован EVENT_TITLES mapping
- журнал подготовлен к filters/search layer
+#### 07.4.4.1.1 ✅ Market State Human UI + HOLD Lifecycle Fix
+- добавлено короткое human-readable отображение состояния рынка
+- технические market_state значения скрыты из основного Auto UI
+- убраны `trend=...` и `volatility=...` из Telegram-экрана
+- убран timeframe анализа из основного UI как лишняя техническая деталь
+- добавлены UI-состояния `📈 Рынок · Рост`, `📉 Рынок · Падение`, `🟰 Рынок · Флэт`, `⚠️ Рынок · Волатильность`, `⏳ Рынок · Анализ`
+- подтверждена работа REST klines как основы аналитики рынка
+- Market State Engine переведён на анализ свечей OHLCV
+- добавлены EMA20 / EMA50 для определения направления тренда
+- добавлен ATR для оценки волатильности рынка
+- добавлен RSI в аналитический payload
+- TREND-стратегия стала market-aware и использует состояние рынка как фильтр входа
+- market analysis синхронизируется в AutoTradeState
+- исправлен баг ложного завершения `HOLD → HOLD`
+- HOLD summary теперь пишется только при реальной смене сигнала
+- этап подготовил основу для Market State Journal Events и BTC/ETH Relative Strength Layer
+
---
### 07.4.4
diff --git a/docs/stages/stage-07_4_4_1_1-market_state_human_ui_hold_lifecycle_fix.md b/docs/stages/stage-07_4_4_1_1-market_state_human_ui_hold_lifecycle_fix.md
new file mode 100644
index 0000000..45fadcd
--- /dev/null
+++ b/docs/stages/stage-07_4_4_1_1-market_state_human_ui_hold_lifecycle_fix.md
@@ -0,0 +1,391 @@
+# 07.4.4.1.1 — Market State Human UI + HOLD Lifecycle Fix
+
+## Статус
+
+Этап завершён.
+
+## Цель этапа
+
+Цель этапа — довести первый рабочий слой Market State Engine до понятного пользовательского отображения в Auto UI, исправить некорректное журналирование HOLD-серий и подготовить аналитику рынка к дальнейшему использованию в стратегии.
+
+После этапа 07.4.4.1 в системе уже появился базовый Market State Engine:
+
+- загрузка свечей через REST `/api/v2/klines`
+- анализ 200 свечей
+- поддержка таймфреймов 1m / 5m / 15m
+- расчёт EMA / ATR / RSI
+- определение состояния рынка
+- проброс market analysis в стратегию TREND
+- блокировка входов при неподходящем рынке
+
+Но после первичной интеграции оставались проблемы:
+
+- технический вид строки рынка в Auto UI
+- отображение `UNKNOWN`, `trend=UNKNOWN`, `volatility=UNKNOWN`
+- слишком длинный формат market state в Telegram
+- лишний технический таймфрейм в основном UI
+- некорректное журналирование `HOLD → HOLD`
+- ложные события вида `HOLD 37с завершён сигналом HOLD`
+
+Этап 07.4.4.1.1 устраняет эти проблемы и делает аналитику рынка пригодной для ежедневного наблюдения.
+
+---
+
+## Что изменено
+
+### 1. Market State Engine переведён в human-readable UI
+
+Техническое отображение рынка было заменено на короткий пользовательский формат.
+
+Было:
+
+- `Рынок · TREND_UP · trend=UP · volatility=NORMAL · 5m`
+- `Рынок · UNKNOWN · trend=UNKNOWN · volatility=UNKNOWN · 5m`
+
+Стало:
+
+- `📈 Рынок · Рост`
+- `📉 Рынок · Падение`
+- `🟰 Рынок · Флэт`
+- `⚠️ Рынок · Волатильность`
+- `⏳ Рынок · Анализ`
+
+Это делает экран автоторговли понятным без знания внутренних enum-значений.
+
+---
+
+### 2. Из основного UI убран технический timeframe
+
+Таймфрейм анализа, например `5m`, оставлен во внутреннем состоянии и payload, но убран из основного Telegram UI.
+
+Причина:
+
+- для стратегии timeframe важен
+- для пользователя в основном экране он перегружает интерфейс
+- новичку важнее понять итог: рынок растёт, падает, во флэте или опасен
+
+Теперь основной Auto UI показывает торговый смысл, а не внутренние настройки движка.
+
+---
+
+### 3. Добавлена короткая интерпретация состояний рынка
+
+Состояния Market State Engine теперь отображаются так:
+
+| Внутреннее состояние | UI-отображение | Смысл |
+|---|---|---|
+| `TREND_UP` | `📈 Рынок · Рост` | Рынок находится в восходящем тренде |
+| `TREND_DOWN` | `📉 Рынок · Падение` | Рынок находится в нисходящем тренде |
+| `RANGE` | `🟰 Рынок · Флэт` | Рынок движется боком, тренда нет |
+| `HIGH_VOLATILITY` | `⚠️ Рынок · Волатильность` | Рынок слишком резкий и опасный для входа |
+| `LOW_VOLATILITY` | `🟰 Рынок · Спокойный` | Рынок слишком спокойный, импульса мало |
+| `UNKNOWN` | `⏳ Рынок · Анализ` | Данных ещё мало или анализ временно не определён |
+
+---
+
+## Что было сделано в части аналитики
+
+### 1. REST-свечи стали основой анализа рынка
+
+До этого стратегия TREND работала в основном по короткому live-окну цен.
+
+Теперь аналитический слой использует свечи биржи:
+
+- open
+- high
+- low
+- close
+- volume
+
+Простыми словами, свеча показывает, как цена вела себя за выбранный период: где открылась, куда поднималась, куда падала, где закрылась и какой был объём торгов.
+
+Для начального анализа используются:
+
+- `1m`
+- `5m`
+- `15m`
+
+В текущей стратегии основным рабочим таймфреймом выбран `5m`.
+
+---
+
+### 2. Добавлен расчёт EMA
+
+EMA — это средняя цена, которая сильнее учитывает последние значения.
+
+В системе используются:
+
+- `EMA20` — более быстрая средняя
+- `EMA50` — более медленная средняя
+
+Логика простая:
+
+- если EMA20 выше EMA50 — рынок склоняется к росту
+- если EMA20 ниже EMA50 — рынок склоняется к падению
+- если расстояние между EMA маленькое — рынок во флэте
+
+EMA помогает отличать настоящий тренд от случайного движения цены.
+
+---
+
+### 3. Добавлен расчёт ATR
+
+ATR — это показатель волатильности.
+
+Простыми словами, ATR показывает, насколько сильно рынок обычно двигается за свечу.
+
+Если ATR высокий:
+
+- рынок может резко прыгать
+- входы становятся опаснее
+- стопы может выбивать случайным шумом
+
+Если ATR слишком низкий:
+
+- рынок почти не движется
+- сигнал может быть слабым
+- сделка может долго стоять без результата
+
+На основе ATR система определяет:
+
+- нормальную волатильность
+- слишком высокую волатильность
+- слишком низкую волатильность
+
+---
+
+### 4. Добавлен расчёт RSI
+
+RSI — это индикатор силы движения.
+
+Простыми словами, он помогает понять, не перегрет ли рынок.
+
+RSI пока используется как часть аналитического payload и объяснения, но не является главным фильтром входа.
+
+Это подготовка к следующим версиям стратегии, где RSI можно будет использовать для:
+
+- фильтрации поздних входов
+- защиты от покупки на перегретом росте
+- защиты от продажи после слишком сильного падения
+
+---
+
+### 5. Добавлена классификация состояния рынка
+
+Market State Engine теперь классифицирует рынок в понятные состояния:
+
+- `TREND_UP`
+- `TREND_DOWN`
+- `RANGE`
+- `HIGH_VOLATILITY`
+- `LOW_VOLATILITY`
+- `UNKNOWN`
+
+Это важный переход от простой реакции на цену к анализу структуры рынка.
+
+Раньше стратегия могла видеть только:
+
+- цена немного выросла
+- цена немного упала
+
+Теперь стратегия получает контекст:
+
+- рынок действительно растёт
+- рынок действительно падает
+- рынок во флэте
+- рынок слишком резкий
+- рынок слишком спокойный
+- данных пока недостаточно
+
+---
+
+### 6. TREND-стратегия стала market-aware
+
+TREND теперь использует Market State Engine как фильтр.
+
+Сигнал BUY возможен только если:
+
+- Market State Engine видит `TREND_UP`
+- live-импульс подтверждает движение вверх
+
+Сигнал SELL возможен только если:
+
+- Market State Engine видит `TREND_DOWN`
+- live-импульс подтверждает движение вниз
+
+Если рынок:
+
+- `RANGE`
+- `HIGH_VOLATILITY`
+- `LOW_VOLATILITY`
+- `UNKNOWN`
+
+стратегия возвращает HOLD.
+
+Это снижает количество случайных входов.
+
+---
+
+### 7. Market analysis сохраняется в AutoTradeState
+
+В состояние автоторговли добавлены поля:
+
+- `market_state`
+- `market_trend`
+- `market_volatility`
+- `market_analysis_interval`
+- `market_analysis_reason`
+
+Теперь UI, журнал и будущие стратегии могут использовать последнее рассчитанное состояние рынка.
+
+---
+
+## Исправление HOLD lifecycle bug
+
+### Проблема
+
+В журнал попадали события вида:
+
+- `[DEMO] 🟡 HOLD 37с завершён сигналом HOLD.`
+
+Это было некорректно.
+
+Если сигнал был HOLD и следующим сигналом снова стал HOLD, это не завершение серии.
+
+Правильное завершение должно быть только при реальной смене:
+
+- `HOLD → BUY`
+- `HOLD → SELL`
+- `BUY → HOLD`
+- `SELL → HOLD`
+- `BUY → SELL`
+- `SELL → BUY`
+
+Но не:
+
+- `HOLD → HOLD`
+
+---
+
+### Причина
+
+Внутренний `signal_key` включал не только сам сигнал, но и `reason`.
+
+Из-за этого один и тот же HOLD мог считаться новым сигналом, если изменилась причина.
+
+Было логически так:
+
+- HOLD с причиной A
+- HOLD с причиной B
+
+Система воспринимала это как смену сигнала, хотя направление не изменилось.
+
+---
+
+### Исправление
+
+`signal_key` был упрощён до устойчивой идентичности сигнала:
+
+- status
+- symbol
+- strategy
+- signal
+
+Причина сигнала теперь обновляется внутри текущей серии, но не разрывает её.
+
+Дополнительно добавлена проверка:
+
+- summary пишется только если `previous_signal != signal`
+
+Теперь `HOLD → HOLD` не создаёт ложную запись в журнале.
+
+---
+
+## Что больше не должно появляться
+
+После этапа в журнале больше не должно быть:
+
+- `HOLD завершён сигналом HOLD`
+- ложных HOLD summary при изменении причины
+- технических строк `trend=UNKNOWN`
+- технических строк `volatility=UNKNOWN`
+- длинного market-state формата в основном Auto UI
+
+---
+
+## Что остаётся в системе
+
+Market State Engine продолжает сохранять технические данные внутри payload:
+
+- market_state
+- trend
+- volatility
+- interval
+- EMA20
+- EMA50
+- ATR
+- ATR percent
+- RSI
+- candles count
+- reason
+
+Но в основном UI показывается только короткий человекочитаемый итог.
+
+---
+
+## Основные изменённые файлы
+
+- app/src/telegram/handlers/auto/ui.py
+- app/src/trading/auto/service.py
+- app/src/trading/auto/state.py
+- app/src/trading/strategies/trend.py
+- app/src/trading/market_analysis/models.py
+- app/src/trading/market_analysis/indicators.py
+- app/src/trading/market_analysis/service.py
+- app/src/integrations/exchange/models.py
+- app/src/integrations/exchange/service.py
+- app/src/integrations/exchange/rest_client.py
+
+---
+
+## Проверка
+
+После правок необходимо выполнить:
+
+ python -m compileall src
+ python -m src.main
+
+После запуска проверить:
+
+1. Автоторговля запускается без ошибок.
+2. В Auto UI отображается короткая строка рынка:
+ - `📈 Рынок · Рост`
+ - `📉 Рынок · Падение`
+ - `🟰 Рынок · Флэт`
+ - `⚠️ Рынок · Волатильность`
+ - `⏳ Рынок · Анализ`
+3. В UI больше нет технических строк `trend=...` и `volatility=...`.
+4. В UI больше не показывается `5m` как техническая деталь.
+5. При `TREND_UP` стратегия может готовить BUY только после live-подтверждения.
+6. При `TREND_DOWN` стратегия может готовить SELL только после live-подтверждения.
+7. При `RANGE`, `HIGH_VOLATILITY`, `LOW_VOLATILITY`, `UNKNOWN` стратегия остаётся в HOLD.
+8. В журнале больше не появляются события `HOLD завершён сигналом HOLD`.
+9. HOLD-серия логируется только при переходе к другому сигналу.
+10. Execution logic не менялась и продолжает работать как раньше.
+
+---
+
+## Итог
+
+Этап 07.4.4.1.1 завершил первичную пользовательскую доводку Market State Engine.
+
+Система получила:
+
+- понятное отображение состояния рынка
+- скрытие технических деталей из основного UI
+- стабильный lifecycle HOLD-сигналов
+- исправление ложного журналирования HOLD-серий
+- полноценную базу аналитики на свечах
+- подготовку к следующему этапу market-state journal events и дальнейшей стратегии BTC/ETH.
+
+Это важный переход от простого signal bot к market-aware trading system.