1595 lines
49 KiB
Python
1595 lines
49 KiB
Python
# 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 |