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.