Files
dzentra_bot/app/src/trading/diagnostics/formatter.py

1595 lines
49 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"🔬 Диагностика · <b>{symbol}</b>"
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"🔬 Диагностика · <b>{symbol}</b>",
"",
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