# 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