# app/src/trading/diagnostics/formatter.py from __future__ import annotations from typing import Any class SemanticDiagnosticFormatter: def format(self, snapshot: dict[str, Any]) -> str: status = snapshot.get("status", {}) signal = snapshot.get("signal", {}) market = snapshot.get("market", {}) momentum = snapshot.get("momentum", {}) execution = snapshot.get("execution", {}) adaptive = snapshot.get("adaptive_size", {}) runtime = snapshot.get("runtime_health", {}) summary = snapshot.get("summary", {}) position = snapshot.get("position", {}) mode = str(summary.get("mode") or "EXPANDED") has_position = self._has_position(position) if has_position: mode = "EXPANDED" if str(status.get("status") or "").upper() == "OFF": sections = [ self._diagnostics_title(status), self._status_block(status), ] return "\n\n".join( section.strip() for section in sections if section and section.strip() ).strip() sections = [ self._headline_block(summary, status), self._execution_block(execution), self._signal_block(signal), self._market_block(market), self._momentum_block(momentum), ] if mode != "COMPACT": if has_position: sections.append(self._position_block(position)) if self._has_adaptive_size(adaptive): sections.append(self._adaptive_block(adaptive)) sections.append(self._analytics_block(summary, runtime, execution)) sections.append(self._status_block(status)) return "\n\n".join( section.strip() for section in sections if section and section.strip() ).strip() def _diagnostics_title(self, status: dict[str, Any]) -> str: symbol = self._asset_symbol(status.get("symbol")) return f"🔬 Диагностика · {symbol}" def build_notification_reason_lines( self, snapshot: dict[str, Any], *, limit: int = 2, ) -> list[str]: signal = snapshot.get("signal", {}) market = snapshot.get("market", {}) summary = snapshot.get("summary", {}) lines: list[str] = [] def add_many(text: str) -> None: for line in str(text or "").splitlines(): cleaned = line.strip().rstrip(".") if cleaned and cleaned not in lines: lines.append(cleaned) add_many(self._signal_explanation(signal)) has_breakout = any( "Пробой вверх" in line or "Пробой вниз" in line for line in lines ) if len(lines) < limit and not has_breakout: add_many(self._market_explanation(market)) if len(lines) < limit: blockers = summary.get("blockers") or [] for blocker in blockers: add_many(self._short_reason(str(blocker))) return lines[:limit] def _headline_block( self, data: dict[str, Any], status: dict[str, Any], ) -> str: severity = data.get("severity") assessment = data.get("assessment") or self._human(severity) symbol = self._asset_symbol(status.get("symbol")) headline_mode = str(data.get("headline_mode") or "ENTRY") severity_icon = self._severity_icon(severity) if headline_mode == "POSITION": return self._position_headline( data=data, severity_icon=severity_icon, assessment=assessment, ) return self._entry_headline( data=data, symbol=symbol, severity_icon=severity_icon, assessment=assessment, ) def _entry_headline( self, *, data: dict[str, Any], symbol: str, severity_icon: str, assessment: str, ) -> str: blockers = data.get("blockers") or [] lines = [ f"🔬 Диагностика · {symbol}", "", self._headline_status_line( severity=data.get("severity"), assessment=assessment, ), ] reasons = self._headline_reasons(blockers) if not reasons: reasons = ["Ограничений нет"] lines.extend(reasons[:2]) return "\n".join(lines) def _headline_status_line( self, *, severity: object, assessment: str, ) -> str: text = str(severity or "") if text == "RED": return "⛔️ Вход заблокирован" if text == "WAITING": return "⚪️ Ожидание" if text == "YELLOW": return "🟡 Осторожно" if text == "GREEN": return "🟢 Готово" return f"{self._severity_icon(severity)} {assessment.capitalize()}" def _headline_reasons(self, blockers: list[object]) -> list[str]: reasons: list[str] = [] def add(text: str) -> None: normalized = text.strip() if normalized and normalized not in reasons: reasons.append(normalized) for blocker in blockers: text = self._short_reason(str(blocker)).strip() if not text: continue add(text[:1].upper() + text[1:]) if len(reasons) >= 2: break return reasons def _position_headline( self, *, data: dict[str, Any], severity_icon: str, assessment: str, ) -> str: side = self._human(data.get("position")) symbol = self._asset_symbol(data.get("symbol")) lines = [ f"🔬 Диагностика · {symbol}", ] if assessment == "стабильно": lines.append(f"{severity_icon} Позиция открыта") lines.append(f"• {side.capitalize()}") lines.append("• Сопровождение активно") elif assessment == "осторожно": lines.append(f"{severity_icon} Позиция под риском") lines.append(f"• {side.capitalize()}") lines.append("• Требуется контроль") else: lines.append(f"{severity_icon} Риск позиции") lines.append(f"• {side.capitalize()}") lines.append("• Возможен выход") return "\n".join(lines) def _analytics_block( self, summary: dict[str, Any], runtime: dict[str, Any], execution: dict[str, Any], ) -> str: market_age = runtime.get("market_age_seconds") signal_age = runtime.get("signal_age_seconds") market_state = self._market_live_state(market_age) execution_state = self._execution_data_state( execution.get("snapshot_age_seconds") ) if market_state == "live" and execution_state == "live": icon = "🟢" elif market_state == "устарели" or execution_state == "устарели": icon = "⛔️" else: icon = "🟡" lines = [ f"{icon} Runtime", f"• Рынок: {market_state}", f"• Стакан: {execution_state}", f"• Сигнал: {self._duration(signal_age)}", ] expired_reason = runtime.get("runtime_expired_reason") if expired_reason: lines.append(self._human(expired_reason)) return "\n".join(lines) def _execution_block(self, data: dict[str, Any]) -> str: quality = str(data.get("quality") or "") reason = str( data.get("quality_reason") or data.get("semantic_reason") or "" ) title = self._entry_conditions_title( quality=quality, semantic_status=data.get("semantic_status"), ) lines = [title] lines.append( "• Данные: " f"{self._execution_data_state(data.get('snapshot_age_seconds'))}" ) spread_line = self._spread_status_line( quality=quality, reason=reason, spread_percent=data.get("spread_percent"), ) if spread_line: lines.append(spread_line) explanation = self._execution_explanation(data) if explanation: lines.append(explanation) return "\n".join(lines) def _entry_conditions_title( self, *, quality: str, semantic_status: str | None = None, ) -> str: if semantic_status == "POSITION_OPEN": return "🟢 Вход · выполнен" if semantic_status == "READY": return "🟢 Условия входа · готовы" if quality == "BLOCKED": return "⛔️ Условия входа · заблокированы" if quality == "WARNING": return "🟡 Условия входа · риск" if quality == "GOOD": return "🟢 Условия входа · нормальные" return "⚪ Условия входа · не готовы" def _spread_status_line( self, *, quality: str, reason: str, spread_percent: object, ) -> str: if reason == "HIGH_SPREAD": return "• Спред: блокирует вход" if reason == "WIDE_SPREAD": return "• Спред: повышен" try: spread = float(spread_percent) except Exception: return "" if spread < 0.1: return "• Спред: низкий" return f"• Спред: {spread:.3f}%" def _execution_data_state(self, value: object) -> str: if value is None: return "нет данных" try: seconds = int(float(value)) except Exception: return "неясно" if seconds <= 2: return "live" if seconds <= 10: return f"задержка {seconds}с" return "устарели" def _signal_block(self, data: dict[str, Any]) -> str: signal = str(data.get("signal") or "").upper() try: progress = float( data.get("confirmation_progress") or 0.0 ) except Exception: progress = 0.0 lines = [ ( f"{self._signal_icon(signal, progress)} " f"Сигнал · " f"{self._signal_title(signal, progress)}" ), ( f"• Длительность: " f"{self._duration(data.get('age_seconds'))}" ), ] # HOLD if signal == "HOLD": if progress > 0: lines.append( f"• Формирование: " f"{self._percent(progress)}" ) # BUY / SELL elif signal in {"BUY", "SELL"}: lines.append( f"• Готовность: " f"{self._percent(progress)}" ) explanation = self._signal_explanation(data) if explanation: lines.append(explanation) return "\n".join(lines) def _signal_title( self, value: object, progress: object = None, ) -> str: text = str(value or "").upper() try: progress_value = float(progress or 0.0) except Exception: progress_value = 0.0 if text == "BUY": return "покупка" if text == "SELL": return "продажа" if text == "HOLD": if progress_value > 0: return "формируется" return "ожидание" return "нет" def _signal_explanation(self, data: dict[str, Any]) -> str: signal = str(data.get("signal") or "").upper() reason = str(data.get("reason") or "").strip() reason_upper = reason.upper() progress = data.get("confirmation_progress") reasons: list[str] = [] def add(text: str) -> None: if text not in reasons: reasons.append(text) if not reason: if signal == "BUY": return "Есть сигнал вверх." if signal == "SELL": return "Есть сигнал вниз." if signal == "HOLD": return "Точки входа нет." return "Сигнал не определён." # 1. Критичные причины if "NOT_ENOUGH_LIVE_DATA" in reason_upper or "МАЛО" in reason_upper: add("Недостаточно live-данных") if "SIGNAL_TTL_EXPIRED" in reason_upper or "УСТАР" in reason_upper: add("Сигнал устарел") if "MARKET_ANALYSIS_TTL_EXPIRED" in reason_upper: add("Анализ рынка устарел") if "HIGH_SPREAD" in reason_upper or "СПРЕД" in reason_upper: add("Спред мешает входу") if "MARKET_FILTER_BLOCKED" in reason_upper: add("Рынок не готов") # 2. Рыночный контекст if ( "WEAK_MARKET_TREND" in reason_upper or ("СЛАБ" in reason_upper and "ТРЕНД" in reason_upper) ): add("Тренд слабый") if "NOISY_MARKET_TREND" in reason_upper or "ШУМ" in reason_upper: add("Рынок шумный") if "MARKET_PULLBACK" in reason_upper or "ОТКАТ" in reason_upper: add("Рынок в откате") if "RANGE" in reason_upper or "ФЛЭТ" in reason_upper: add("Рынок во флэте") if "SQUEEZE" in reason_upper or "СЖАТ" in reason_upper: add("Рынок в сжатии") # 3. Импульс if ( "WEAK_UP_IMPULSE" in reason_upper or "LIVE-ИМПУЛЬС ВВЕРХ НЕДОСТАТОЧНО" in reason_upper or ("ИМПУЛЬС ВВЕРХ" in reason_upper and "СЛАБ" in reason_upper) ): add("Импульс вверх слабый") if ( "WEAK_DOWN_IMPULSE" in reason_upper or "LIVE-ИМПУЛЬС ВНИЗ НЕДОСТАТОЧНО" in reason_upper or ("ИМПУЛЬС ВНИЗ" in reason_upper and "СЛАБ" in reason_upper) ): add("Импульс вниз слабый") if "NO_SIGNIFICANT_MOMENTUM" in reason_upper: add("Сильного импульса нет") if "BREAKOUT_UP" in reason_upper or "ПРОБОЙ ВВЕРХ" in reason_upper: add("Пробой вверх") if "BREAKOUT_DOWN" in reason_upper or "ПРОБОЙ ВНИЗ" in reason_upper: add("Пробой вниз") if "FAST_UP_MOVE" in reason_upper or "БЫСТРЫЙ РОСТ" in reason_upper: add("Быстрый рост") if "FAST_DOWN_MOVE" in reason_upper or "БЫСТРОЕ СНИЖЕНИЕ" in reason_upper: add("Быстрое снижение") # 4. Тренд if "TREND_UP" in reason_upper: if signal == "BUY": add("Тренд вверх подтверждает покупку") elif signal == "SELL": add("Рост против продажи") else: add("Рост есть, вход не подтверждён") if "TREND_DOWN" in reason_upper: if signal == "SELL": add("Тренд вниз подтверждает продажу") elif signal == "BUY": add("Снижение против покупки") else: add("Снижение есть, вход не подтверждён") # 5. Fallback по состоянию сигнала if not reasons: if signal in {"BUY", "SELL"}: try: progress_value = float(progress or 0.0) except Exception: progress_value = 0.0 if progress_value >= 1.0: add("Сигнал подтверждён") elif progress_value > 0: add("Сигнал подтверждается") elif signal == "BUY": add("Есть сигнал вверх") else: add("Есть сигнал вниз") elif signal == "HOLD": add("Точки входа нет") else: add(self._human(reason)) return "\n".join(reasons[:2]) def _market_block(self, data: dict[str, Any]) -> str: state = data.get("state") trend = data.get("trend") strength = data.get("trend_strength") phase = data.get("phase") phase_direction = data.get("phase_direction") quality = data.get("trend_quality") volatility = data.get("volatility") lines = [ ( f"{self._market_icon(data)} " f"Рынок · " f"{self._market_title(data)}" ), ( f"• Данные: " f"{self._market_live_state(data.get('age_seconds'))}" ), ] if state == "RANGE" or phase == "RANGE": lines.append("• Вход: ожидание") if state != "RANGE" and phase != "RANGE": trend_line = self._market_trend_line( trend=trend, strength=strength, ) if trend_line: lines.append(trend_line) current_line = self._market_current_line( state=state, phase=phase, phase_direction=phase_direction, ) if current_line: lines.append(current_line) volatility_line = self._market_volatility_line(volatility) if volatility_line: lines.append(volatility_line) quality_line = self._market_quality_line(quality) if quality_line: lines.append(quality_line) explanation = self._market_explanation(data) if explanation: lines.append(explanation) return "\n".join(lines) def _market_title(self, data: dict[str, Any]) -> str: state = str(data.get("state") or "") phase = str(data.get("phase") or "") trend = str(data.get("trend") or "") # флэт важнее всего if state == "RANGE" or phase == "RANGE": return "флэт" # откат важнее тренда if phase == "PULLBACK": return "откат" # импульс — это текущая фаза, но заголовок рынка оставляем по общему тренду if phase == "IMPULSE": if trend == "UP": return "рост" if trend == "DOWN": return "снижение" return "импульс" # базовый тренд if trend == "UP": return "рост" if trend == "DOWN": return "снижение" return self._human(state) def _market_trend_line( self, *, trend: object, strength: object, ) -> str: trend_text = self._human(trend) strength_text = self._human(strength) if trend_text in {"—", "нет", "неясно", "ровно"}: return "" if strength_text in {"—", "нет", "неясно"}: return f"• Тренд: {trend_text}" return f"• Тренд: {trend_text} · {strength_text}" def _market_current_line( self, *, state: object, phase: object, phase_direction: object, ) -> str: state_text = self._human(state) phase_text = self._human(phase) direction_text = self._human(phase_direction) if phase_text in {"—", "нет", "неясно"}: return "" if phase_text == state_text: return "" if phase_text == "флэт": return "" if direction_text in {"—", "нет", "неясно", "ровно"}: return f"• Сейчас: {phase_text}" return f"• Сейчас: {phase_text} {direction_text}" def _market_volatility_line(self, value: object) -> str: text = str(value or "") if text == "HIGH_VOLATILITY": return "• Волатильность: высокая" if text == "LOW_VOLATILITY": return "• Волатильность: низкая" return "" def _market_quality_line(self, value: object) -> str: text = str(value or "") if text == "NOISY": return "• Качество: шум" if text == "CLEAN": return "• Качество: чистый" return "" def _momentum_block(self, data: dict[str, Any]) -> str: momentum_state = data.get("state") lines = [ ( f"{self._momentum_icon(momentum_state)} " f"Импульс · " f"{self._human(momentum_state)}" ), f"• Направление: {self._human(data.get('direction'))}", f"• Пробой: {self._bool(data.get('is_breakout'))}", f"• Сила: {self._momentum_strength(data.get('strength'))}", f"• Движение: {self._percent_value(data.get('change_percent'))}", ] breakout_level = self._float(data.get("breakout_level")) if breakout_level != "—": lines.insert(3, f"• Уровень: {breakout_level}") breakout_distance = self._percent_value( data.get("breakout_distance_percent") ) if breakout_distance != "—": lines.insert(4, f"• Дистанция: {breakout_distance}") return "\n".join(lines) def _has_position(self, data: dict[str, Any]) -> bool: return str(data.get("side") or "NONE").upper() != "NONE" def _position_block(self, data: dict[str, Any]) -> str: import time side = str(data.get("side") or "NONE").upper() entry_price = data.get("entry_price") size = data.get("size") unrealized_pnl = data.get("unrealized_pnl_usd") leverage = data.get("leverage") or 2 flip_old_side = data.get("last_flip_old_side") flip_new_side = data.get("last_flip_new_side") flip_pnl = data.get("last_flip_pnl_usd") flip_reason = data.get("last_flip_reason") flip_at = data.get("last_flip_monotonic_at") cycle_pnl = float(data.get("cycle_realized_pnl_usd") or 0.0) lines = [ ( f"{self._position_icon(side)} " f"Позиция · {side} x{leverage}" ), f"• {self._pnl_line(unrealized_pnl)}", f"• Вход: $ {self._money(entry_price)}", f"• Размер: {self._size_value(size)}", f"• Объём: {self._position_notional(entry_price, size)}", ] risk_line = self._position_risk_line(data) if risk_line: lines.append(risk_line) is_recent_flip = False if flip_at is not None: try: is_recent_flip = (time.monotonic() - float(flip_at)) <= 600 except Exception: pass if ( is_recent_flip and flip_old_side and flip_new_side and flip_old_side != flip_new_side ): old_icon = self._position_icon(flip_old_side) new_icon = self._position_icon(flip_new_side) lines.append( f"Разворот {old_icon} {flip_old_side} → " f"{new_icon} {flip_new_side}" ) if self._has_closed_pnl(flip_pnl): lines.append( f"• {self._pnl_line(flip_pnl)}" ) if flip_reason: lines.append( self._short_reason(str(flip_reason)) ) else: reason = str(data.get("last_execution_reason") or "").strip() if reason: lines.append( self._short_reason(reason) ) if self._has_closed_pnl(cycle_pnl): pnl_text = self._pnl_line(cycle_pnl) if cycle_pnl > 0: lines.append( f"Предыдущая прибыль {pnl_text.replace('Прибыль ', '')}" ) else: lines.append( f"Предыдущий убыток {pnl_text.replace('Убыток ', '')}" ) return "\n".join(lines).strip() def _has_closed_pnl(value: Any) -> bool: try: return abs(float(value)) > 0.0001 except Exception: return False def _has_adaptive_size(self, data: dict[str, Any]) -> bool: multiplier_raw = data.get("multiplier") if multiplier_raw is None: return False try: value = float(multiplier_raw) except (TypeError, ValueError): return False if value < 0.95: return True if value > 1.05: return True reason = str(data.get("reason") or "").lower() return "margin" in reason def _adaptive_block(self, data: dict[str, Any]) -> str: multiplier_raw = data.get("multiplier") if multiplier_raw is None: return "" try: multiplier = float(multiplier_raw) except (TypeError, ValueError): return "" return ( f"{self._adaptive_icon(multiplier)} " f"Размер позиции · {self._adaptive_title(multiplier)}\n" f"• Риск: {self._float(data.get('effective_risk_percent'))}% депозита\n" f"• Потеря при SL: $ {self._float(data.get('effective_target_risk_usd'))}\n" f"• Коррекция: x{self._float(multiplier)}\n" f"{self._adaptive_reason_line(data)}" ).strip() def _status_block(self, data: dict[str, Any]) -> str: status = str(data.get("status") or "") if status == "RUNNING": icon = "🟢" title = "работает" elif status == "OBSERVING": icon = "🟡" title = "наблюдение" elif status == "OFF": icon = "⛔️" title = "остановлена" else: icon = "⚪" title = "не готова" return ( f"{icon} Автоторговля · {title}\n" f"• Актив: {self._format_system_symbol(data.get('symbol'))}\n" f"• Стратегия: {data.get('strategy') or '—'}\n" f"• Настроено: {self._bool(data.get('is_configured'))}" ) def _execution_explanation(self, data: dict[str, Any]) -> str: quality = str(data.get("quality") or "") reason = str( data.get("quality_reason") or data.get("semantic_reason") or "" ) semantic_status = str(data.get("semantic_status") or "") # нормальные состояния — молчим if semantic_status in {"POSITION_OPEN", "READY"}: return "" if quality == "GOOD": return "" if reason == "HIGH_SPREAD": return "Спред слишком широкий для входа." if reason == "WIDE_SPREAD": return "Цена входа может ухудшиться." if reason in { "STALE_SNAPSHOT", "AGING_SNAPSHOT", }: return "Данные исполнения устарели." if reason in { "SNAPSHOT_ERROR", "SNAPSHOT_UNAVAILABLE", }: return "Недостаточно данных для входа." if quality == "BLOCKED": return "Вход временно невозможен." if quality == "WARNING": return "Условия входа нестабильны." return "Условия входа ещё не сформированы." def _market_explanation(self, data: dict[str, Any]) -> str: state = str(data.get("state") or "") trend = str(data.get("trend") or "") strength = str(data.get("trend_strength") or "") quality = str(data.get("trend_quality") or "") phase = str(data.get("phase") or "") phase_direction = str(data.get("phase_direction") or "") volatility = str(data.get("volatility") or "") entry_block = str( data.get("entry_block_reason") or data.get("entry_block_message") or "" ) age = data.get("age_seconds") is_range = state == "RANGE" or phase == "RANGE" is_pullback = phase == "PULLBACK" is_impulse = phase == "IMPULSE" is_squeeze = phase == "SQUEEZE" reasons: list[str] = [] def add(text: str) -> None: if text not in reasons: reasons.append(text) try: age_seconds = int(float(age)) if age is not None else None except Exception: age_seconds = None if age_seconds is None: add("Нет live-данных") elif age_seconds > 60: add("Данные рынка устарели") if state == "HIGH_VOLATILITY" or volatility == "HIGH_VOLATILITY": add("Рынок перегрет") if state == "LOW_VOLATILITY" or volatility == "LOW_VOLATILITY": add("Движения мало") if "MARKET_FILTER_BLOCKED" in entry_block: if is_range: add("Рынок без направления") elif is_pullback: add("Откат блокирует вход") elif quality == "NOISY": add("Шум блокирует вход") elif strength == "WEAK": add("Слабый тренд блокирует вход") else: add("Рынок блокирует вход") elif entry_block: normalized_block = entry_block.strip().lower() if "слаб" in normalized_block and "тренд" in normalized_block: if not is_range: add("Тренд слабый") elif "откат" in normalized_block: add("Рынок в откате") elif "шум" in normalized_block: add("Движение шумное") elif "волат" in normalized_block: add("Волатильность мешает входу") elif "данн" in normalized_block: add("Недостаточно данных") else: short_reason = self._short_reason(entry_block) if short_reason == "COUNTER_TREND_BREAKOUT": short_reason = "Пробой против тренда" if not (is_range and "тренд слаб" in short_reason.lower()): add(short_reason) market_title = self._market_title(data) if is_range: if ( "флэт" not in market_title.lower() and not any("флэт" in r.lower() for r in reasons) ): add("Флэт") if is_squeeze: add("Рынок в сжатии") if is_pullback: add("Рынок в откате") if phase_direction == "UP" and trend == "DOWN": add("Откат против снижения") if phase_direction == "DOWN" and trend == "UP": add("Откат против роста") if quality == "NOISY": add("Движение шумное") if strength == "WEAK" and not is_range: add("Тренд слабый") has_weak_impulse = entry_block in { "WEAK_UP_IMPULSE", "WEAK_DOWN_IMPULSE", } if is_impulse and strength != "WEAK" and not has_weak_impulse: if phase_direction == "UP" and trend == "DOWN": add("Импульс против снижения") elif phase_direction == "DOWN" and trend == "UP": add("Импульс против роста") elif phase_direction == "UP": if not any("Пробой вверх" in r for r in reasons): add("Есть направленное движение вверх") elif phase_direction == "DOWN": if not any("Пробой вниз" in r for r in reasons): add("Есть направленное движение вниз") elif trend == "UP": if not any("Пробой вверх" in r for r in reasons): add("Есть направленное движение вверх") elif trend == "DOWN": if not any("Пробой вниз" in r for r in reasons): add("Есть направленное движение вниз") else: if not any("Пробой" in r for r in reasons): add("Есть направленное движение") if trend == "UP" and not is_impulse and not is_range: add("Рынок растёт") if trend == "DOWN" and not is_impulse and not is_range: add("Рынок снижается") if not reasons: if is_range: return "Рынок без направления" if is_squeeze: return "Рынок в сжатии" if is_pullback: return "Рынок в откате" return "Рынок анализируется" return "\n".join(reasons[:2]) def _human(self, value: object) -> str: if value is None: return "—" text = str(value) mapping = { # === ADAPTIVE SIZE === "ADAPTIVE_SIZE_INCREASED": "размер входа увеличен", "ADAPTIVE_SIZE_REDUCED": "размер входа уменьшен", "ADAPTIVE_SIZE_ZERO": "размер входа заблокирован", # === EXECUTION / SNAPSHOT === "AGING_SNAPSHOT": "snapshot стареет", "BLOCKED": "заблокировано", "GOOD": "норма", "HIGH_SPREAD": "высокий спред", "SNAPSHOT_ERROR": "нет данных", "SNAPSHOT_UNAVAILABLE": "нет стакана", "STALE_SNAPSHOT": "старый snapshot", "WARNING": "внимание", "WIDE_SPREAD": "спред повышен", # === MARKET === "BREAKOUT_DOWN": "пробой вниз", "BREAKOUT_UP": "пробой вверх", "CLEAN": "чисто", "COUNTER_TREND_BREAKOUT": "пробой против тренда", "FAST_DOWN_MOVE": "быстрое снижение", "FAST_UP_MOVE": "быстрый рост", "FLAT": "ровно", "HIGH_VOLATILITY": "перегрев", "IMPULSE": "импульс", "LOW_VOLATILITY": "сжатие", "MARKET_ANALYSIS_TTL_EXPIRED": "анализ рынка устарел", "MARKET_FILTER_BLOCKED": "рынок не готов", "MARKET_OK": "рынок готов", "MARKET_PULLBACK": "откат", "MARKET_STATE_NOT_TREND": "рынок без направления", "NOISY": "шум", "NOISY_MARKET_TREND": "рынок шумный", "NORMAL": "норма", "PRICE_ABOVE_LOOKBACK_HIGH": "выше локального хая", "PRICE_BELOW_LOOKBACK_LOW": "ниже локального лоя", "PULLBACK": "откат", "RANGE": "флэт", "SQUEEZE": "сжатие", "TREND_DOWN": "снижение", "TREND_UP": "рост", "UNKNOWN": "неясно", "WEAK_MARKET_TREND": "слабый тренд", # === MOMENTUM === "DOWN": "вниз", "EXHAUSTED": "выдохся", "MOMENTUM_DOWN": "импульс вниз", "MOMENTUM_UP": "импульс вверх", "NO_SIGNIFICANT_MOMENTUM": "импульс слабый", "STRONG": "сильная", "UP": "вверх", "WEAK": "слабая", "WEAK_DOWN_IMPULSE": "слабый импульс вниз", "WEAK_UP_IMPULSE": "слабый импульс вверх", # === RUNTIME / DATA === "CONFIRMING": "подтверждение", "EXPANDED": "подробно", "GREEN": "стабильно", "HIGH": "высокая", "LOW": "низкая", "NOT_ENOUGH_LIVE_DATA": "мало live-данных", "OFF": "выключено", "OBSERVING": "наблюдение", "READY": "готово", "RED": "вход нежелателен", "RUNNING": "работает", "SIGNAL_TTL_EXPIRED": "сигнал устарел", "WAITING": "ожидание", "YELLOW": "осторожно", # === SIGNAL === "BUY": "покупка", "HOLD": "ожидание", "SELL": "продажа", "WAITING_SIGNAL": "ждёт сигнал", # === SYSTEM / MODE === "COMPACT": "кратко", "IDLE": "пауза", # === POSITION === "LONG": "лонг", "NONE": "нет", "POSITION_OPEN": "позиция открыта", "SHORT": "шорт", } return mapping.get(text, text) def _bool(self, value: object) -> str: return "да" if bool(value) else "нет" def _float(self, value: object) -> str: if value is None: return "—" try: return f"{float(value):.4f}" except Exception: return str(value) def _percent(self, value: object) -> str: if value is None: return "—" try: return f"{float(value) * 100:.0f}%" except Exception: return str(value) def _percent_value(self, value: object) -> str: if value is None: return "—" try: return f"{float(value):.3f}%" except Exception: return str(value) def _severity_icon(self, value: object) -> str: text = str(value or "") if text == "GREEN": return "🟢" if text in {"YELLOW", "WAITING"}: return "🟡" if text == "RED": return "⛔️" return "⚪" def _signal_icon( self, value: object, progress: object = None, ) -> str: text = str(value or "").upper() try: progress_value = float(progress or 0.0) except Exception: progress_value = 0.0 if text == "BUY": return "🟢" if text == "SELL": return "🔴" if text == "HOLD": if progress_value > 0: return "🟡" return "⚪️" return "⚪️" def _position_icon(self, value: object) -> str: text = str(value or "") if text == "LONG": return "🟢" if text == "SHORT": return "🔴" if text == "NONE": return "⚪" return "⚪" def _system_icon(self, value: object) -> str: text = str(value or "") if text == "RUNNING": return "🟢" if text == "OBSERVING": return "🟡" if text == "OFF": return "⚪" return "⚪" def _money(self, value: object) -> str: if value is None: return "—" try: return f"{float(value):.2f}" except Exception: return str(value) def _size_value(self, value: object) -> str: if value is None: return "—" try: return f"{float(value):.5f}".rstrip("0").rstrip(".") except Exception: return str(value) def _position_notional(self, entry_price: object, size: object) -> str: try: value = float(entry_price) * float(size) except Exception: return "$ —" return f"$ {value:.1f}" def _position_risk_line(self, data: dict[str, Any]) -> str: items: list[str] = [] sl = data.get("stop_loss_usd") tp = data.get("take_profit_usd") ml = data.get("max_loss_usd") if sl is not None: items.append(f"SL −$ {self._money(sl)}") if tp is not None: items.append(f"TP +$ {self._money(tp)}") if ml is not None: items.append(f"ML −$ {self._money(ml)}") if not items: return "" return "• " + " · ".join(items) def _pnl_value(self, value: object) -> str: try: pnl = float(value or 0.0) except Exception: return "—" if pnl > 0: return f"🟢 +$ {abs(pnl):.2f}" if pnl < 0: return f"🔴 −$ {abs(pnl):.2f}" return "$ 0.00" def _pnl_line(self, value: object) -> str: try: pnl = float(value or 0.0) except Exception: return "PnL: —" if pnl > 0: return f"Прибыль 🟢 +$ {abs(pnl):.2f}" if pnl < 0: return f"Убыток 🔴 −$ {abs(pnl):.2f}" return "PnL: $ 0.00" def _position_action_line(self, action: str) -> str: if action in {"OPEN_LONG", "OPEN_SHORT"}: return "Новая позиция открыта" if action.startswith("FLIP_"): return "Сделка развернута" if action == "CLOSE": return "Сделка закрыта" return "" def _adaptive_icon(self, multiplier: object) -> str: try: value = float(multiplier) except Exception: return "⚠️" if value == 1: return "🟢" return "⚠️" def _adaptive_title(self, multiplier: object) -> str: try: value = float(multiplier) except Exception: return "неясен" if value < 1: return "уменьшен" if value > 1: return "увеличен" return "по настройкам" def _adaptive_reason_line(self, data: dict[str, Any]) -> str: reason = self._human(data.get("reason")) if reason in {"—", "нет"}: return "" return reason[:1].upper() + reason[1:] def _market_icon(self, data: dict[str, Any]) -> str: state = str(data.get("state") or "") strength = str(data.get("trend_strength") or "") quality = str(data.get("trend_quality") or "") phase = str(data.get("phase") or "") volatility = str(data.get("volatility") or "") entry_block_reason = str(data.get("entry_block_reason") or "") entry_block_message = str(data.get("entry_block_message") or "") entry_block_text = ( f"{entry_block_reason} {entry_block_message}" .strip() .lower() ) # Флэт сам по себе — ожидание, не блокировка. if state == "RANGE" or phase == "RANGE": return "⚪️" # Жёсткая блокировка входа рынком. if "market_filter_blocked" in entry_block_text: return "⛔️" if ( "блок" in entry_block_text or "не подходит" in entry_block_text or "высок" in entry_block_text or "перегрев" in entry_block_text ): return "⛔️" if state == "HIGH_VOLATILITY" or volatility == "HIGH_VOLATILITY": return "⛔️" if state == "UNKNOWN": return "⚪️" if phase == "SQUEEZE": return "⚪️" if entry_block_text: return "🟡" if phase == "PULLBACK" or strength == "WEAK" or quality == "NOISY": return "🟡" if state in {"TREND_UP", "TREND_DOWN"}: return "🟢" return "⚪️" def _momentum_icon(self, value: object) -> str: text = str(value or "") if text in {"BREAKOUT_UP", "BREAKOUT_DOWN"}: return "🚀" if text in {"MOMENTUM_UP", "MOMENTUM_DOWN"}: return "⚡️" if text == "EXHAUSTED": return "⛔️" return "⚪" def _momentum_strength(self, value: object) -> str: if value is None: return "—" try: strength = float(value) except Exception: return str(value) if strength < 0.3: label = "слабая" elif strength < 0.6: label = "средняя" elif strength < 1.0: label = "сильная" else: label = "резкая" return f"{label} · x{strength:.1f}" def _short_reason(self, value: str) -> str: text = value.strip() normalized = text.lower() mapping = { "анализ рынка устарел": "Анализ рынка устарел", "высокий spread": "Спред мешает входу", "низкая совокупная уверенность входа": "Вход рискованный", "откат": "Рынок в откате", "рынок сейчас не подходит для входа": "Рынок не готов", "сигнал устарел и был сброшен": "Сигнал устарел", "слабый импульс": "Слабый импульс", "слабый тренд": "Тренд слабый", "снимок устарел": "Данные рынка устарели", "snapshot устарел": "Данные рынка устарели", "spread повышен": "Спред повышен", "шумный тренд": "Рынок шумный", "counter_trend_breakout": "Пробой против тренда", "market_filter_blocked": "Рынок не подходит для входа", "market_pullback": "Рынок в откате", "market_state_not_trend": "Рынок без направления", "noisy_market_trend": "Движение шумное", "weak_down_impulse": "Импульс вниз слабый", "weak_market_trend": "Тренд слабый", "weak_up_impulse": "Импульс вверх слабый", } if normalized in mapping: return mapping[normalized] human = self._human(text) if human != text: return human[:1].upper() + human[1:] return text def _market_live_state(self, value: object) -> str: if value is None: return "нет данных" try: seconds = int(float(value)) except Exception: return str(value) if seconds <= 10: return "live" if seconds <= 60: return f"задержка {seconds}с" return "устарели" def _duration(self, value: object) -> str: if value is None: return "—" try: total_seconds = max(0, int(float(value))) except Exception: return str(value) hours = total_seconds // 3600 minutes = (total_seconds % 3600) // 60 seconds = total_seconds % 60 if hours > 0: return f"{hours}ч {minutes:02d}м" if minutes > 0: return f"{minutes}м {seconds:02d}с" return f"{seconds}с" def _asset_symbol(self, symbol: object) -> str: if symbol is None: return "—" text = str(symbol).split("_", 1)[0].upper() if "/" in text: return text.split("/", 1)[0] return text def _format_system_symbol(self, symbol: object) -> str: if symbol is None: return "—" text = str(symbol).split("_", 1)[0].upper() if "/" in text: base, quote = text.split("/", 1) return f"{base} / {quote}" return text