diff --git a/app/src/core/event_titles.py b/app/src/core/event_titles.py
index 4b240ae..97e8fcc 100644
--- a/app/src/core/event_titles.py
+++ b/app/src/core/event_titles.py
@@ -1,7 +1,5 @@
# app/src/core/event_titles.py
-# app/src/core/event_titles.py
-
from __future__ import annotations
diff --git a/app/src/notifications/templates/execution.py b/app/src/notifications/templates/execution.py
index 48d0a6d..82ab96c 100644
--- a/app/src/notifications/templates/execution.py
+++ b/app/src/notifications/templates/execution.py
@@ -16,6 +16,9 @@ def build_execution_notification(event: RuntimeEvent) -> NotificationMessage | N
if event.event_type == RuntimeEventType.POSITION_FLIPPED:
return _build_position_flipped(event)
+
+ if event.event_type == RuntimeEventType.POSITION_FLIP_BLOCKED:
+ return _build_flip_blocked(event)
return None
@@ -24,23 +27,41 @@ def _build_position_opened(event: RuntimeEvent) -> NotificationMessage:
payload = event.payload
symbol = _format_symbol(payload.get("symbol"))
- side = str(payload.get("side") or "—")
+ strategy = str(payload.get("strategy") or "—")
+ side = str(payload.get("side") or "—").upper()
leverage = _format_leverage(payload.get("leverage"))
entry_price = _format_price(payload.get("entry_price"))
size = _format_size(payload.get("size"))
+ confidence = float(payload.get("confidence") or 0.0)
+ priority = _alert_priority(
+ confidence=confidence,
+ repeat_count=int(payload.get("repeat_count") or 0),
+ )
+ semantic_lines = payload.get("semantic_lines") or []
side_icon = "🟢" if side == "LONG" else "🔴"
- text = (
- f"📄 Paper position opened {side_icon} {side}\n\n"
- f"{symbol} · {leverage}\n"
- f"Entry: $ {entry_price}\n"
- f"Size: {size}"
- )
+ lines = [
+ "🧾 Позиция открыта",
+ "",
+ f"{side_icon} {symbol} · {strategy} · {side} {leverage}",
+ f"Вход: {entry_price}",
+ f"Размер: {size}",
+ f"Объём: {_format_notional(entry_price=payload.get('entry_price'), size=payload.get('size'))}",
+ "",
+ f"{_strength_bar(priority)} Сигнал {_strength_label(priority).lower()} · {confidence:.2f}",
+ ]
+
+ if semantic_lines:
+ lines.extend(
+ str(line).strip().rstrip(".")
+ for line in semantic_lines
+ if str(line).strip()
+ )
return NotificationMessage(
title=event.title,
- text=text,
+ text="\n".join(lines),
priority=event.priority,
dedupe_key=event.dedupe_key,
)
@@ -50,63 +71,162 @@ def _build_position_closed(event: RuntimeEvent) -> NotificationMessage:
payload = event.payload
symbol = _format_symbol(payload.get("symbol"))
- side = str(payload.get("side") or "—")
+ side = str(payload.get("side") or "—").upper()
leverage = _format_leverage(payload.get("leverage"))
+
entry_price = _format_price(payload.get("entry_price"))
exit_price = _format_price(payload.get("exit_price"))
size = _format_size(payload.get("size"))
- pnl = _format_pnl(payload.get("pnl"))
- risk_reason = payload.get("risk_reason")
- risk_line = f"\nRisk: {risk_reason}" if risk_reason else ""
+ pnl_value = float(payload.get("pnl") or 0.0)
+ pnl_text = _format_pnl_amount(pnl_value)
- text = (
- f"✅ Paper position closed\n\n"
- f"{side} · {symbol} · {leverage}\n"
- f"Entry: $ {entry_price}\n"
- f"Exit: $ {exit_price}\n"
- f"Size: {size}\n\n"
- f"PnL: {pnl}"
- f"{risk_line}"
- )
+ risk_reason = _human_close_reason(payload.get("risk_reason"))
+
+ pnl_icon = "🟢" if pnl_value >= 0 else "🔴"
+ pnl_label = "Прибыль" if pnl_value >= 0 else "Убыток"
+
+ lines = [
+ "🧾 Сделка закрыта",
+ f"{pnl_icon} {pnl_label} · {pnl_text}",
+ "",
+ f"{symbol} · {side} {leverage}",
+ f"Вход: $ {entry_price}",
+ f"Выход: $ {exit_price}",
+ f"Размер: {size}",
+ ]
+
+ if risk_reason:
+ lines.extend([
+ "",
+ f"Закрытие по {risk_reason}",
+ ])
return NotificationMessage(
title=event.title,
- text=text,
+ text="\n".join(lines),
priority=event.priority,
dedupe_key=event.dedupe_key,
)
+def _format_pnl_amount(value: float) -> str:
+ amount = f"$ {abs(value):,.2f}".replace(",", " ").rstrip("0").rstrip(".")
+
+ if value > 0:
+ return f"+{amount}"
+
+ if value < 0:
+ return f"−{amount}"
+
+ return "$ 0"
+
+
+def _human_close_reason(value: object) -> str:
+ mapping = {
+ "STOP_LOSS": "Stop Loss",
+ "TAKE_PROFIT": "Take Profit",
+ "MAX_LOSS": "Max Loss",
+ }
+
+ return mapping.get(str(value or ""), "")
+
+
def _build_position_flipped(event: RuntimeEvent) -> NotificationMessage:
payload = event.payload
symbol = _format_symbol(payload.get("symbol"))
- leverage = _format_leverage(payload.get("leverage"))
+ strategy = str(payload.get("strategy") or "—").title()
- old_side = str(payload.get("old_side") or "—")
- new_side = str(payload.get("new_side") or payload.get("side") or "—")
+ old_side = str(payload.get("old_side") or "—").upper()
+ new_side = str(payload.get("new_side") or payload.get("side") or "—").upper()
+
+ old_leverage = _format_leverage(
+ payload.get("old_leverage")
+ if payload.get("old_leverage") is not None
+ else payload.get("leverage")
+ )
+ new_leverage = _format_leverage(payload.get("leverage"))
entry_price = _format_price(payload.get("entry_price"))
exit_price = _format_price(payload.get("exit_price"))
new_entry_price = _format_price(payload.get("new_entry_price"))
+
old_size = _format_size(payload.get("old_size"))
new_size = _format_size(payload.get("new_size"))
- pnl = _format_pnl(payload.get("pnl"))
+
+ pnl_value = float(payload.get("pnl") or 0.0)
+ pnl_text = _format_pnl_amount(pnl_value)
+
+ pnl_icon = "🟢" if pnl_value >= 0 else "🔴"
+ pnl_label = "Прибыль" if pnl_value >= 0 else "Убыток"
old_icon = "🟢" if old_side == "LONG" else "🔴"
new_icon = "🟢" if new_side == "LONG" else "🔴"
+ confidence = float(payload.get("confidence") or 0.0)
+ repeat_count = int(payload.get("repeat_count") or 0)
+ priority = _alert_priority(
+ confidence=confidence,
+ repeat_count=repeat_count,
+ )
+ semantic_lines = payload.get("semantic_lines") or []
+
+ lines = [
+ "🧾 Сделка развернута",
+ f"{pnl_label} {pnl_icon} {pnl_text}",
+ f"{symbol} · {strategy} {old_icon} {old_side} → {new_icon} {new_side}",
+ "",
+ f"Закрыта {old_side} {old_leverage}",
+ f"Вход: $ {entry_price}",
+ f"Выход: $ {exit_price}",
+ f"Размер: {old_size}",
+ "",
+ f"Открыта {new_side} {new_leverage}",
+ f"Вход: $ {new_entry_price}",
+ f"Размер: {new_size}",
+ (
+ "Объём: "
+ f"{_format_notional(entry_price=payload.get('new_entry_price'), size=payload.get('new_size'))}"
+ ),
+ "",
+ f"{_strength_bar(priority)} Сигнал {_strength_label(priority).lower()} · {confidence:.2f}",
+ ]
+
+ if semantic_lines:
+ lines.extend(
+ str(line).strip().rstrip(".")
+ for line in semantic_lines
+ if str(line).strip()
+ )
+
+ return NotificationMessage(
+ title=event.title,
+ text="\n".join(lines),
+ priority=event.priority,
+ dedupe_key=event.dedupe_key,
+ )
+
+
+def _build_flip_blocked(event: RuntimeEvent) -> NotificationMessage:
+ payload = event.payload
+
+ symbol = _format_symbol(payload.get("symbol"))
+ signal = str(payload.get("signal") or "").upper()
+ confidence = float(payload.get("confidence") or 0.0)
+ reason = str(payload.get("reason") or "Flip заблокирован")
+ position_side = str(payload.get("position_side") or "—").upper()
+
+ target_side = "LONG" if signal == "BUY" else "SHORT" if signal == "SELL" else "—"
+ icon = "🟢" if target_side == "LONG" else "🔴" if target_side == "SHORT" else ""
+
text = (
- f"🔁 Paper position flipped {old_icon} {old_side} → "
- f"{new_icon} {new_side}\n\n"
- f"{symbol} · {leverage}\n\n"
- f"Old entry: $ {entry_price}\n"
- f"Exit: $ {exit_price}\n"
- f"Old size: {old_size}\n\n"
- f"New entry: $ {new_entry_price}\n"
- f"New size: {new_size}\n\n"
- f"PnL: {pnl}"
+ f"⚠️ Flip отменён\n\n"
+ f"{icon} {symbol} · {target_side}\n"
+ f"Текущая позиция: {position_side}\n\n"
+ f"Недостаточно условий для разворота\n"
+ f"{reason}\n"
+ f"Сила сигнала: {confidence:.2f}"
)
return NotificationMessage(
@@ -123,13 +243,7 @@ def _format_symbol(value: object) -> str:
if not symbol or symbol == "—":
return "—"
- base_symbol = symbol.split("_", 1)[0]
- parts = base_symbol.split("/", 1)
-
- if len(parts) == 2:
- return f"{parts[0]} / {parts[1]}"
-
- return base_symbol
+ return symbol.split("_", 1)[0].split("/", 1)[0].upper()
def _format_leverage(value: object) -> str:
@@ -169,4 +283,45 @@ def _format_pnl(value: object) -> str:
if number < 0:
return f"🔴 −{amount}"
- return "$ 0"
\ No newline at end of file
+ return "$ 0"
+
+
+def _alert_priority(*, confidence: float, repeat_count: int) -> str:
+ if confidence >= 0.8 and repeat_count >= 3:
+ return "HIGH"
+
+ if confidence >= 0.6 or repeat_count >= 2:
+ return "MEDIUM"
+
+ return "LOW"
+
+
+def _strength_label(priority: str) -> str:
+ mapping = {
+ "HIGH": "Сильный",
+ "MEDIUM": "Средний",
+ "LOW": "Слабый",
+ }
+ return mapping.get(priority.upper(), priority)
+
+
+def _strength_bar(priority: str) -> str:
+ mapping = {
+ "HIGH": "●●●",
+ "MEDIUM": "●●○",
+ "LOW": "●○○",
+ }
+ return mapping.get(priority.upper(), "●○○")
+
+
+def _format_notional(
+ *,
+ entry_price: object,
+ size: object,
+) -> str:
+ try:
+ value = float(entry_price) * float(size)
+ except (TypeError, ValueError):
+ return "—"
+
+ return f"$ {value:,.2f}".replace(",", " ").rstrip("0").rstrip(".")
\ No newline at end of file
diff --git a/app/src/notifications/templates/signal.py b/app/src/notifications/templates/signal.py
index a48f218..8872474 100644
--- a/app/src/notifications/templates/signal.py
+++ b/app/src/notifications/templates/signal.py
@@ -14,37 +14,44 @@ def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None
payload = event.payload
signal = str(payload.get("signal") or "—").upper()
- symbol = str(payload.get("symbol") or "—")
- strategy = str(payload.get("strategy") or "—")
+ symbol = _format_symbol(str(payload.get("symbol") or "—"))
confidence = float(payload.get("confidence") or 0.0)
- repeat_count = int(payload.get("repeat_count") or 0)
- leverage = payload.get("leverage")
- reason = str(payload.get("reason") or "—")
- position_context = str(payload.get("position_context") or "NONE")
+ position_context = str(payload.get("position_context") or "NONE").upper()
+ semantic_lines = payload.get("semantic_lines") or []
priority = str(event.priority or _alert_priority(
confidence=confidence,
- repeat_count=repeat_count,
+ repeat_count=int(payload.get("repeat_count") or 0),
)).upper()
- icon = _signal_icon(signal)
- symbol_text = _format_symbol(symbol)
- leverage_text = _format_leverage(leverage)
- priority_text = _priority_label(priority)
+ direction = _signal_direction(signal)
+ icon = _direction_icon(direction)
+ strength = _strength_label(priority)
+ strength_bar = _strength_bar(priority)
- text = (
- f"{priority_text} · {icon} {signal}\n\n"
- f"{symbol_text} · {strategy} · {leverage_text}\n"
- f"Position: {position_context}\n\n"
- f"🧠 Confidence: {confidence:.2f}\n"
- f"🔁 Repeats: {repeat_count}\n\n"
- f"💡 Причина:\n"
- f"{reason}"
- )
+ lines = [
+ f"Сигнал {icon} {symbol} · {direction}",
+ "",
+ ]
+
+ if position_context not in {"NONE", "—", ""} and position_context != direction:
+ lines.extend([
+ "⚠️ ПРОТИВ ПОЗИЦИИ",
+ "",
+ ])
+
+ lines.append(f"{strength_bar} {strength} · {confidence:.2f}")
+
+ if semantic_lines:
+ lines.extend(
+ str(line).strip().rstrip(".")
+ for line in semantic_lines
+ if str(line).strip()
+ )
return NotificationMessage(
title=event.title,
- text=text,
+ text="\n".join(lines),
priority=priority.lower(),
dedupe_key=event.dedupe_key or _dedupe_key(payload),
)
@@ -60,34 +67,49 @@ def _alert_priority(*, confidence: float, repeat_count: int) -> str:
return "LOW"
-def _priority_label(priority: str) -> str:
+def _strength_label(priority: str) -> str:
mapping = {
- "HIGH": "🚨 HIGH",
- "MEDIUM": "⚡ MEDIUM",
- "LOW": "ℹ️ LOW",
+ "HIGH": "Сильный",
+ "MEDIUM": "Средний",
+ "LOW": "Слабый",
}
return mapping.get(priority.upper(), priority)
-def _signal_icon(signal: str) -> str:
+def _strength_bar(priority: str) -> str:
mapping = {
- "BUY": "🟢",
- "SELL": "🔴",
+ "HIGH": "●●●",
+ "MEDIUM": "●●○",
+ "LOW": "●○○",
}
- return mapping.get(signal, "⚪")
+ return mapping.get(priority.upper(), "●○○")
+
+
+def _signal_direction(signal: str) -> str:
+ if signal == "BUY":
+ return "LONG"
+
+ if signal == "SELL":
+ return "SHORT"
+
+ return "—"
+
+
+def _direction_icon(direction: str) -> str:
+ if direction == "LONG":
+ return "🟢"
+
+ if direction == "SHORT":
+ return "🔴"
+
+ return ""
def _format_symbol(symbol: str) -> str:
if not symbol or symbol == "—":
return "—"
- base_symbol = symbol.split("_", 1)[0]
- parts = base_symbol.split("/", 1)
-
- if len(parts) == 2:
- return f"{parts[0]} / {parts[1]}"
-
- return base_symbol
+ return symbol.split("_", 1)[0].split("/", 1)[0].upper()
def _format_leverage(leverage: object) -> str:
diff --git a/app/src/runtime_events/event_types.py b/app/src/runtime_events/event_types.py
index ad1ad1c..5d2addc 100644
--- a/app/src/runtime_events/event_types.py
+++ b/app/src/runtime_events/event_types.py
@@ -11,6 +11,7 @@ class RuntimeEventType(str, Enum):
POSITION_OPENED = "position_opened"
POSITION_CLOSED = "position_closed"
POSITION_FLIPPED = "position_flipped"
+ POSITION_FLIP_BLOCKED = "position_flip_blocked"
EXECUTION_BLOCKED = "execution_blocked"
RISK_ALERT = "risk_alert"
diff --git a/app/src/telegram/handlers/auto/main.py b/app/src/telegram/handlers/auto/main.py
index b4603d6..d20ed4f 100644
--- a/app/src/telegram/handlers/auto/main.py
+++ b/app/src/telegram/handlers/auto/main.py
@@ -8,6 +8,7 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from src.telegram.handlers.auto.ui import (
+ auto_diagnostics_keyboard,
auto_keyboard,
build_auto_text,
is_auto_configured,
@@ -16,6 +17,8 @@ from src.telegram.handlers.system import open_auto_settings
from src.telegram.live.active_screen import ActiveScreenManager
from src.trading.auto.runner import AutoTradeRunner
from src.trading.auto.service import AutoTradeService
+from src.trading.diagnostics.formatter import SemanticDiagnosticFormatter
+from src.trading.diagnostics.snapshot import SemanticDiagnosticSnapshotBuilder
router = Router(name="auto")
@@ -88,6 +91,58 @@ async def _prepare_auto_from_callback(callback: CallbackQuery) -> bool:
return True
+
+def build_auto_diagnostics_text() -> str:
+ service = AutoTradeService()
+ state = service.get_state()
+
+ snapshot = SemanticDiagnosticSnapshotBuilder().build(
+ state,
+ is_configured=is_auto_configured(state),
+ )
+
+ return SemanticDiagnosticFormatter().format(snapshot)
+
+
+async def render_auto_diagnostics_screen(
+ target_message: Message,
+) -> None:
+ text = build_auto_diagnostics_text()
+
+ try:
+ await target_message.edit_text(
+ text,
+ reply_markup=auto_diagnostics_keyboard(),
+ )
+ except TelegramBadRequest as exc:
+ error_text = str(exc).lower()
+
+ if "message to edit not found" in error_text:
+ await target_message.answer(
+ text,
+ reply_markup=auto_diagnostics_keyboard(),
+ )
+ return
+
+ if "message is not modified" in error_text:
+ return
+
+ raise
+
+ AutoTradeRunner.register_screen(
+ bot=target_message.bot,
+ chat_id=target_message.chat.id,
+ message_id=target_message.message_id,
+ render_text=build_auto_diagnostics_text,
+ render_markup=auto_diagnostics_keyboard,
+ )
+
+ ActiveScreenManager.register(
+ screen="auto_diagnostics",
+ message=target_message,
+ )
+
+
@router.message(F.text.in_({"🤖 Автоторговля", "🤖 Авто"}))
async def open_auto(message: Message, state: FSMContext) -> None:
await state.clear()
@@ -173,4 +228,21 @@ async def auto_stop(callback: CallbackQuery) -> None:
await _prepare_auto_from_callback(callback)
await render_auto_screen(callback.message, edit_mode=True)
- await callback.answer(message)
\ No newline at end of file
+ await callback.answer(message)
+
+
+@router.callback_query(F.data == "auto:diagnostics")
+async def open_auto_diagnostics(callback: CallbackQuery) -> None:
+ if callback.message is None:
+ await callback.answer("Сообщение не найдено", show_alert=True)
+ return
+
+ await ActiveScreenManager.prepare_new_screen(
+ screen="auto_diagnostics",
+ bot=callback.message.bot,
+ chat_id=callback.message.chat.id,
+ keep_message_id=callback.message.message_id,
+ )
+
+ await render_auto_diagnostics_screen(callback.message)
+ await callback.answer()
\ No newline at end of file
diff --git a/app/src/telegram/handlers/auto/ui.py b/app/src/telegram/handlers/auto/ui.py
index 832d9b4..2f7ca5e 100644
--- a/app/src/telegram/handlers/auto/ui.py
+++ b/app/src/telegram/handlers/auto/ui.py
@@ -15,15 +15,45 @@ from src.trading.auto.service import AutoTradeService
def auto_keyboard() -> InlineKeyboardMarkup:
+ state = AutoTradeService().get_state()
+
builder = InlineKeyboardBuilder()
- builder.button(text="▶️ Start", callback_data="auto:start")
- builder.button(text="👀 Watch", callback_data="auto:observe")
- builder.button(text="🛑 Stop", callback_data="auto:stop")
+ status = (state.status or "").upper()
+
+ if status == "OFF":
+ builder.button(text="▶️ Start", callback_data="auto:start")
+ builder.button(text="👀 Watch", callback_data="auto:observe")
+
+ elif status == "RUNNING":
+ builder.button(text="👀 Watch", callback_data="auto:observe")
+ builder.button(text="🛑 Stop", callback_data="auto:stop")
+
+ elif status == "OBSERVING":
+ builder.button(text="▶️ Start", callback_data="auto:start")
+ builder.button(text="🛑 Stop", callback_data="auto:stop")
+
+ else:
+ builder.button(text="▶️ Start", callback_data="auto:start")
+ builder.button(text="👀 Watch", callback_data="auto:observe")
+ builder.button(text="🛑 Stop", callback_data="auto:stop")
+
builder.button(text="🛠️ Настройки", callback_data="settings:auto")
builder.button(text="🧯 Защита", callback_data="auto:risk")
+ builder.button(text="🔬 Диагностика", callback_data="auto:diagnostics")
- builder.adjust(3, 2)
+ builder.adjust(2, 2, 1)
+
+ return builder.as_markup()
+
+
+def auto_diagnostics_keyboard() -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+
+ builder.button(text="🔄 Обновить", callback_data="auto:diagnostics")
+ builder.button(text="⬅️ Назад", callback_data="auto:home")
+
+ builder.adjust(2)
return builder.as_markup()
@@ -281,7 +311,6 @@ def _build_waiting_text(state) -> str:
_order_header_line(state),
f"{_price_label_for_signal(state)} · {_format_plain_or_dash(price)}",
_estimated_size_text(state, price),
- _adaptive_size_line(state),
_max_reserved_line(state, price),
_effective_risk_line(state),
]
@@ -346,7 +375,6 @@ def _build_active_position_text(state) -> str:
"",
f"Размер · {_format_crypto_size(size)}",
f"Позиция · {_format_money_compact(notional)}",
- _adaptive_size_line(state),
f"Вход · {_format_plain_or_dash(state.entry_price)}",
f"Цена · {_format_plain_or_dash(price_for_calc)}",
"",
diff --git a/app/src/trading/auto/runner.py b/app/src/trading/auto/runner.py
index 59f3b84..01dc0ba 100644
--- a/app/src/trading/auto/runner.py
+++ b/app/src/trading/auto/runner.py
@@ -18,6 +18,8 @@ from src.runtime_events.publisher import RuntimeEventPublisher
from src.trading.auto.service import AutoTradeService
from src.trading.journal.service import JournalService
from src.telegram.handlers.auto.ui import build_auto_semantic_text
+from src.trading.diagnostics.formatter import SemanticDiagnosticFormatter
+from src.trading.diagnostics.snapshot import SemanticDiagnosticSnapshotBuilder
class AutoTradeRunner:
@@ -269,13 +271,15 @@ class AutoTradeRunner:
if signal not in {"BUY", "SELL"}:
return
- if cls._is_position_aligned_signal(state=state, signal=signal):
- cls._log_position_aligned_signal_suppressed(
- state=state,
- payload=payload,
- signal=signal,
- )
- return
+ # Если сигнал совпадает с открытой позицией, не публикуем событие,
+ # чтобы не создавать избыточные уведомления
+ #if cls._is_position_aligned_signal(state=state, signal=signal):
+ # cls._log_position_aligned_signal_suppressed(
+ # state=state,
+ # payload=payload,
+ # signal=signal,
+ # )
+ # return
cls._publish_strong_signal_event(state=state, payload=payload)
return
@@ -284,6 +288,7 @@ class AutoTradeRunner:
"paper_position_opened",
"paper_position_closed",
"paper_position_flipped",
+ "paper_flip_blocked",
}:
cls._publish_execution_event(
state=state,
@@ -292,6 +297,18 @@ class AutoTradeRunner:
)
return
+ @classmethod
+ def _notification_reason_lines(cls, state) -> list[str]:
+ snapshot = SemanticDiagnosticSnapshotBuilder().build(
+ state,
+ is_configured=True,
+ )
+
+ return SemanticDiagnosticFormatter().build_notification_reason_lines(
+ snapshot,
+ limit=2,
+ )
+
@classmethod
def _is_position_aligned_signal(cls, *, state, signal: str) -> bool:
position_side = str(getattr(state, "position_side", "NONE") or "NONE").upper()
@@ -390,6 +407,8 @@ class AutoTradeRunner:
"reason": reason,
"position_context": position_context,
"decision_status": state.decision_status,
+ "semantic_lines": cls._notification_reason_lines(state),
+ "position_side": position_context,
},
priority=priority.lower(),
dedupe_key=(
@@ -423,6 +442,8 @@ class AutoTradeRunner:
old_side = str(payload.get("old_side") or "—")
new_side = str(payload.get("new_side") or side or "—")
+ semantic_lines = cls._notification_reason_lines(state)
+
RuntimeEventPublisher.publish(
RuntimeEvent(
event_type=runtime_event_type,
@@ -436,6 +457,8 @@ class AutoTradeRunner:
"new_side": new_side,
"leverage": payload.get("leverage") if payload.get("leverage") is not None else state.leverage,
**payload,
+ "strategy": state.strategy,
+ "semantic_lines": semantic_lines,
},
priority="normal",
dedupe_key=cls._execution_dedupe_key(
@@ -451,6 +474,7 @@ class AutoTradeRunner:
"paper_position_opened": RuntimeEventType.POSITION_OPENED,
"paper_position_closed": RuntimeEventType.POSITION_CLOSED,
"paper_position_flipped": RuntimeEventType.POSITION_FLIPPED,
+ "paper_flip_blocked": RuntimeEventType.POSITION_FLIP_BLOCKED,
}
return mapping.get(event_type)
@@ -460,6 +484,7 @@ class AutoTradeRunner:
RuntimeEventType.POSITION_OPENED: "Paper position opened",
RuntimeEventType.POSITION_CLOSED: "Paper position closed",
RuntimeEventType.POSITION_FLIPPED: "Paper position flipped",
+ RuntimeEventType.POSITION_FLIP_BLOCKED: "Flip blocked",
}
return mapping.get(event_type, "Paper execution event")
diff --git a/app/src/trading/auto/service.py b/app/src/trading/auto/service.py
index 465d5b6..7bc1cda 100644
--- a/app/src/trading/auto/service.py
+++ b/app/src/trading/auto/service.py
@@ -230,6 +230,12 @@ class AutoTradeService:
state.status = "RUNNING"
self._reset_signal_tracking()
+ state.cycle_realized_pnl_usd = 0.0
+ state.last_flip_old_side = None
+ state.last_flip_new_side = None
+ state.last_flip_pnl_usd = None
+ state.last_flip_reason = None
+ state.last_flip_monotonic_at = None
state.last_signal = "HOLD"
state.signal_started_at = time.monotonic()
@@ -261,6 +267,12 @@ class AutoTradeService:
)
if previous_status == "OFF":
+ state.cycle_realized_pnl_usd = 0.0
+ state.last_flip_old_side = None
+ state.last_flip_new_side = None
+ state.last_flip_pnl_usd = None
+ state.last_flip_reason = None
+ state.last_flip_monotonic_at = None
return state, "Включён режим наблюдения."
return state, "Автоторговля переведена в режим наблюдения."
@@ -275,6 +287,12 @@ class AutoTradeService:
return state, "Автоторговля уже выключена."
state.status = "OFF"
+ state.cycle_realized_pnl_usd = 0.0
+ state.last_flip_old_side = None
+ state.last_flip_new_side = None
+ state.last_flip_pnl_usd = None
+ state.last_flip_reason = None
+ state.last_flip_monotonic_at = None
self.stop_loop()
EventBus.emit(
diff --git a/app/src/trading/auto/state.py b/app/src/trading/auto/state.py
index 2fd210a..1dbe6cc 100644
--- a/app/src/trading/auto/state.py
+++ b/app/src/trading/auto/state.py
@@ -94,6 +94,18 @@ class AutoTradeState:
# зафиксированный результат закрытых paper-сделок
realized_pnl_usd: float = 0.0
+ # cumulative realized pnl за текущий цикл автоторговли
+ cycle_realized_pnl_usd: float = 0.0
+
+ # данные последнего flip
+ last_flip_old_side: str | None = None
+ last_flip_new_side: str | None = None
+ last_flip_pnl_usd: float | None = None
+ last_flip_reason: str | None = None
+
+ # monotonic timestamp последнего flip
+ last_flip_monotonic_at: float | None = None
+
# последнее execution-действие
last_execution_action: str | None = None
diff --git a/app/src/trading/diagnostics/formatter.py b/app/src/trading/diagnostics/formatter.py
index dff2e56..56dc6ad 100644
--- a/app/src/trading/diagnostics/formatter.py
+++ b/app/src/trading/diagnostics/formatter.py
@@ -7,8 +7,6 @@ from typing import Any
class SemanticDiagnosticFormatter:
def format(self, snapshot: dict[str, Any]) -> str:
- sections: list[str] = []
-
status = snapshot.get("status", {})
signal = snapshot.get("signal", {})
market = snapshot.get("market", {})
@@ -19,158 +17,1176 @@ class SemanticDiagnosticFormatter:
summary = snapshot.get("summary", {})
position = snapshot.get("position", {})
- sections.extend(
- [
- "🧠 Semantic Runtime Diagnostics",
- "",
+ 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),
- self._signal_block(signal),
- self._market_block(market),
- self._momentum_block(momentum),
- self._execution_block(execution),
- self._adaptive_block(adaptive),
- self._position_block(position),
- self._runtime_block(runtime),
- self._summary_block(summary),
]
+
+ 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
)
- return "\n".join(
- line
- for line in sections
- if line is not None
+ 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:
- return (
- "📦 Runtime\n"
- f"• Status: {data.get('status')}\n"
- f"• Symbol: {data.get('symbol')}\n"
- f"• Strategy: {data.get('strategy')}\n"
- f"• Configured: {self._bool(data.get('is_configured'))}\n"
- )
+ status = str(data.get("status") or "")
- def _signal_block(self, data: dict[str, Any]) -> str:
- return (
- "📡 Signal\n"
- f"• Signal: {data.get('signal')}\n"
- f"• Confidence: {self._float(data.get('confidence'))}\n"
- f"• Decision: {data.get('decision_status')}\n"
- f"• Confirmed: {self._bool(data.get('is_confirmed'))}\n"
- f"• Ready: {self._bool(data.get('is_ready'))}\n"
- f"• Repeats: {data.get('repeat_count')}\n"
- f"• Progress: {self._percent(data.get('confirmation_progress'))}\n"
- f"• Age: {self._seconds(data.get('age_seconds'))}\n"
- f"• Reason: {data.get('reason')}\n"
- )
-
- def _market_block(self, data: dict[str, Any]) -> str:
- return (
- "📈 Market\n"
- f"• State: {data.get('state')}\n"
- f"• Trend: {data.get('trend')}\n"
- f"• Volatility: {data.get('volatility')}\n"
- f"• Strength: {data.get('trend_strength')}\n"
- f"• Quality: {data.get('trend_quality')}\n"
- f"• Phase: {data.get('phase')}\n"
- f"• Phase Direction: {data.get('phase_direction')}\n"
- f"• Entry Block: {data.get('entry_block_message')}\n"
- f"• Analysis Age: {self._seconds(data.get('age_seconds'))}\n"
- )
-
- def _momentum_block(self, data: dict[str, Any]) -> str:
- return (
- "⚡ Momentum / Breakout\n"
- f"• State: {data.get('state')}\n"
- f"• Direction: {data.get('direction')}\n"
- f"• Strength: {self._float(data.get('strength'))}\n"
- f"• Change %: {self._float(data.get('change_percent'))}\n"
- f"• Breakout Level: {self._float(data.get('breakout_level'))}\n"
- f"• Breakout Distance %: "
- f"{self._float(data.get('breakout_distance_percent'))}\n"
- f"• Is Breakout: {self._bool(data.get('is_breakout'))}\n"
- f"• Reason: {data.get('breakout_reason')}\n"
- )
-
- def _execution_block(self, data: dict[str, Any]) -> str:
- return (
- "🛡 Execution\n"
- f"• Quality: {data.get('quality')}\n"
- f"• Semantic Status: {data.get('semantic_status')}\n"
- f"• Confidence Score: "
- f"{self._float(data.get('confidence_score'))}\n"
- f"• Confidence Level: {data.get('confidence_level')}\n"
- f"• Spread %: {self._float(data.get('spread_percent'))}\n"
- f"• Snapshot Age: "
- f"{self._seconds(data.get('snapshot_age_seconds'))}\n"
- f"• Runtime Degraded: "
- f"{self._bool(data.get('market_runtime_degraded'))}\n"
- f"• Reason: {data.get('semantic_reason')}\n"
- )
-
- def _adaptive_block(self, data: dict[str, Any]) -> str:
- return (
- "🧮 Adaptive Sizing\n"
- f"• Multiplier: {self._float(data.get('multiplier'))}\n"
- f"• Effective Risk %: "
- f"{self._float(data.get('effective_risk_percent'))}\n"
- f"• Effective Risk USD: "
- f"{self._float(data.get('effective_target_risk_usd'))}\n"
- f"• Reason: {data.get('reason')}\n"
- )
-
- def _position_block(self, data: dict[str, Any]) -> str:
- return (
- "📌 Position\n"
- f"• Side: {data.get('side')}\n"
- f"• Entry: {self._float(data.get('entry_price'))}\n"
- f"• Size: {self._float(data.get('size'))}\n"
- f"• Unrealized PnL: "
- f"{self._float(data.get('unrealized_pnl_usd'))}\n"
- f"• Realized PnL: "
- f"{self._float(data.get('realized_pnl_usd'))}\n"
- f"• Last Action: {data.get('last_execution_action')}\n"
- )
-
- def _runtime_block(self, data: dict[str, Any]) -> str:
- return (
- "🧬 Runtime Health\n"
- f"• Runtime Degraded: "
- f"{self._bool(data.get('is_runtime_degraded'))}\n"
- f"• Signal Age: "
- f"{self._seconds(data.get('signal_age_seconds'))}\n"
- f"• Market Age: "
- f"{self._seconds(data.get('market_age_seconds'))}\n"
- f"• Expired Reason: "
- f"{data.get('runtime_expired_reason')}\n"
- f"• Has Market Data: "
- f"{self._bool(data.get('has_market_data'))}\n"
- f"• Has Momentum Data: "
- f"{self._bool(data.get('has_momentum_data'))}\n"
- )
-
- def _summary_block(self, data: dict[str, Any]) -> str:
- blockers = data.get("blockers") or []
-
- if blockers:
- blockers_text = ", ".join(str(item) for item in blockers)
+ if status == "RUNNING":
+ icon = "🟢"
+ title = "работает"
+ elif status == "OBSERVING":
+ icon = "🟡"
+ title = "наблюдение"
+ elif status == "OFF":
+ icon = "⛔️"
+ title = "остановлена"
else:
- blockers_text = "none"
+ icon = "⚪"
+ title = "не готова"
return (
- "🧾 Summary\n"
- f"• Market: {data.get('market')}\n"
- f"• Phase: {data.get('phase')}\n"
- f"• Momentum: {data.get('momentum')}\n"
- f"• Execution: {data.get('execution')}\n"
- f"• Position: {data.get('position')}\n"
- f"• Ready: {self._bool(data.get('is_ready'))}\n"
- f"• Blocked: {self._bool(data.get('is_blocked'))}\n"
- f"• Blockers: {blockers_text}\n"
+ 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 "YES" if bool(value) else "NO"
+ return "да" if bool(value) else "нет"
def _float(self, value: object) -> str:
if value is None:
@@ -186,15 +1202,394 @@ class SemanticDiagnosticFormatter:
return "—"
try:
- return f"{float(value) * 100:.1f}%"
+ return f"{float(value) * 100:.0f}%"
except Exception:
return str(value)
- def _seconds(self, value: object) -> str:
+ def _percent_value(self, value: object) -> str:
if value is None:
return "—"
try:
- return f"{int(float(value))}s"
+ return f"{float(value):.3f}%"
except Exception:
- return str(value)
\ No newline at end of file
+ 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
\ No newline at end of file
diff --git a/app/src/trading/diagnostics/snapshot.py b/app/src/trading/diagnostics/snapshot.py
new file mode 100644
index 0000000..b9e4634
--- /dev/null
+++ b/app/src/trading/diagnostics/snapshot.py
@@ -0,0 +1,366 @@
+# app/src/trading/diagnostics/snapshot.py
+
+from __future__ import annotations
+
+import time
+from typing import Any
+
+from src.trading.auto.state import AutoTradeState
+
+
+class SemanticDiagnosticSnapshotBuilder:
+ def build(self, state: AutoTradeState, *, is_configured: bool) -> dict[str, Any]:
+ now = time.monotonic()
+
+ signal_age_seconds = self._age_seconds(
+ now=now,
+ started_at=state.signal_started_at,
+ )
+
+ market_age_seconds = self._age_seconds(
+ now=now,
+ started_at=state.market_analysis_updated_at,
+ )
+
+ blockers = self._blockers(state)
+ health_score = self._health_score(state=state, blockers=blockers)
+ severity = self._severity(
+ state=state,
+ health_score=health_score,
+ blockers=blockers,
+ )
+
+ return {
+ "status": {
+ "status": state.status,
+ "symbol": state.symbol,
+ "strategy": state.strategy,
+ "is_configured": is_configured,
+ },
+ "signal": {
+ "signal": state.last_signal,
+ "confidence": state.last_signal_confidence,
+ "decision_status": state.decision_status,
+ "is_confirmed": state.is_signal_confirmed,
+ "is_ready": state.is_signal_ready,
+ "repeat_count": state.last_signal_repeat_count,
+ "confirmation_progress": state.signal_confirmation_progress,
+ "age_seconds": signal_age_seconds,
+ "reason": state.last_signal_reason,
+ },
+ "market": {
+ "state": state.market_state,
+ "trend": state.market_trend,
+ "volatility": state.market_volatility,
+ "trend_strength": state.market_trend_strength,
+ "trend_quality": state.market_trend_quality,
+ "phase": state.market_phase,
+ "phase_direction": state.market_phase_direction,
+ "entry_block_reason": state.entry_block_reason,
+ "entry_block_message": state.entry_block_message,
+ "age_seconds": market_age_seconds,
+ },
+ "momentum": {
+ "state": getattr(state, "momentum_state", None),
+ "direction": getattr(state, "momentum_direction", None),
+ "strength": getattr(state, "momentum_strength", None),
+ "change_percent": getattr(state, "momentum_change_percent", None),
+ "breakout_level": getattr(state, "breakout_level", None),
+ "breakout_distance_percent": getattr(
+ state,
+ "breakout_distance_percent",
+ None,
+ ),
+ "is_breakout": getattr(state, "momentum_state", None)
+ in {"BREAKOUT_UP", "BREAKOUT_DOWN"},
+ "breakout_reason": getattr(state, "breakout_reason", None),
+ },
+ "execution": {
+ "quality": state.execution_quality,
+ "quality_reason": state.execution_quality_reason,
+ "quality_message": state.execution_quality_message,
+ "semantic_status": state.execution_semantic_status,
+ "semantic_message": state.execution_semantic_message,
+ "semantic_reason": state.execution_semantic_reason,
+ "confidence_score": state.execution_confidence_score,
+ "confidence_level": state.execution_confidence_level,
+ "confidence_reason": state.execution_confidence_reason,
+ "spread_percent": state.spread_percent,
+ "snapshot_age_seconds": state.snapshot_age_seconds,
+ "market_runtime_degraded": state.market_runtime_degraded,
+ },
+ "adaptive_size": {
+ "base": state.adaptive_size_base,
+ "final": state.adaptive_size_final,
+ "multiplier": state.adaptive_size_multiplier,
+ "effective_risk_percent": state.effective_risk_percent,
+ "effective_target_risk_usd": state.effective_target_risk_usd,
+ "reason": state.adaptive_size_reason,
+ "factors": state.adaptive_size_factors,
+ },
+ "position": {
+ "side": state.position_side,
+ "entry_price": state.entry_price,
+ "size": state.position_size,
+ "leverage": state.leverage,
+ "unrealized_pnl_usd": state.unrealized_pnl_usd,
+ "realized_pnl_usd": state.realized_pnl_usd,
+ "cycle_realized_pnl_usd": state.cycle_realized_pnl_usd,
+ "last_execution_action": state.last_execution_action,
+ "last_execution_reason": state.last_execution_reason,
+ "last_flip_old_side": state.last_flip_old_side,
+ "last_flip_new_side": state.last_flip_new_side,
+ "last_flip_pnl_usd": state.last_flip_pnl_usd,
+ "last_flip_reason": state.last_flip_reason,
+ "last_flip_monotonic_at": state.last_flip_monotonic_at,
+ },
+ "runtime_health": {
+ "health_score": health_score,
+ "severity": severity,
+ "is_runtime_degraded": self._is_runtime_degraded(state),
+ "signal_age_seconds": signal_age_seconds,
+ "market_age_seconds": market_age_seconds,
+ "runtime_expired_reason": state.runtime_expired_reason,
+ "runtime_expired_message": state.runtime_expired_message,
+ "has_market_data": state.market_state is not None,
+ "has_momentum_data": getattr(state, "momentum_state", None) is not None,
+ },
+ "summary": {
+ "health_score": health_score,
+ "severity": severity,
+ "assessment": self._assessment(severity),
+ "mode": self._display_mode(
+ severity=severity,
+ blockers=blockers,
+ state=state,
+ ),
+
+ "headline_mode": (
+ "POSITION"
+ if state.position_side != "NONE"
+ else "ENTRY"
+ ),
+
+ "main_message": self._main_message(state=state, blockers=blockers),
+
+ "market": state.market_state,
+ "phase": state.market_phase,
+ "momentum": getattr(state, "momentum_state", None),
+ "execution": state.execution_semantic_status,
+ "position": state.position_side,
+ "is_ready": state.is_signal_ready,
+ "is_blocked": bool(blockers),
+ "blockers": blockers,
+ },
+ }
+
+ def _age_seconds(
+ self,
+ *,
+ now: float,
+ started_at: float | None,
+ ) -> int | None:
+ if started_at is None:
+ return None
+
+ return max(0, int(now - float(started_at)))
+
+ def _is_runtime_degraded(self, state: AutoTradeState) -> bool:
+ return bool(
+ state.market_runtime_degraded
+ or state.execution_quality == "BLOCKED"
+ or state.runtime_expired_reason
+ )
+
+ def _health_score(
+ self,
+ *,
+ state: AutoTradeState,
+ blockers: list[str],
+ ) -> int:
+ score = 100
+
+ if state.status != "RUNNING":
+ score -= 10
+
+ if blockers:
+ score -= min(35, len(blockers) * 12)
+
+ if state.execution_quality == "BLOCKED":
+ score -= 30
+ elif state.execution_quality == "WARNING":
+ score -= 15
+
+ if state.market_state in {"RANGE", "HIGH_VOLATILITY", "LOW_VOLATILITY"}:
+ score -= 15
+
+ if state.market_trend_strength == "WEAK":
+ score -= 10
+
+ if state.market_trend_quality == "NOISY":
+ score -= 10
+
+ if state.market_phase in {"RANGE", "SQUEEZE", "PULLBACK"}:
+ score -= 10
+
+ if state.market_runtime_degraded:
+ score -= 15
+
+ if state.runtime_expired_reason:
+ score -= 20
+
+ if state.is_signal_ready:
+ score += 10
+
+ return max(0, min(100, score))
+
+ def _severity(
+ self,
+ *,
+ state: AutoTradeState,
+ health_score: int,
+ blockers: list[str],
+ ) -> str:
+ signal = str(state.last_signal or "HOLD").upper()
+ has_ready_signal = bool(state.is_signal_ready)
+ has_position = state.position_side != "NONE"
+
+ has_waiting_data_blocker = any(
+ str(item).strip().lower()
+ in {
+ "мало данных",
+ "мало live-данных",
+ "недостаточно live-данных",
+ }
+ for item in blockers
+ )
+
+ if has_waiting_data_blocker:
+ return "WAITING"
+
+ if (
+ state.execution_quality == "BLOCKED"
+ or state.decision_status == "BLOCKED"
+ or state.runtime_expired_reason
+ ):
+ return "RED"
+
+ if has_position:
+ if health_score < 45:
+ return "RED"
+
+ if blockers or state.execution_quality == "WARNING" or health_score < 75:
+ return "YELLOW"
+
+ return "GREEN"
+
+ if signal == "HOLD" and not has_ready_signal:
+ return "WAITING"
+
+ if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
+ return "YELLOW"
+
+ if health_score < 45:
+ return "YELLOW"
+
+ if blockers or state.execution_quality == "WARNING" or health_score < 75:
+ return "YELLOW"
+
+ return "GREEN"
+
+ def _assessment(self, severity: str) -> str:
+ if severity == "GREEN":
+ return "стабильно"
+
+ if severity == "WAITING":
+ return "ожидание"
+
+ if severity == "YELLOW":
+ return "осторожно"
+
+ return "вход нежелателен"
+
+ def _display_mode(
+ self,
+ *,
+ severity: str,
+ blockers: list[str],
+ state: AutoTradeState | None = None,
+ ) -> str:
+ if state is not None and state.position_side != "NONE":
+ return "EXPANDED"
+
+ if severity == "GREEN" and not blockers:
+ return "COMPACT"
+
+ return "EXPANDED"
+
+ def _main_message(
+ self,
+ *,
+ state: AutoTradeState,
+ blockers: list[str],
+ ) -> str:
+ if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
+ if state.market_state == "RANGE" or state.market_phase == "RANGE":
+ return "Ожидание: рынок без направления."
+
+ return "Осторожно: рынок не подходит."
+
+ if state.execution_quality == "BLOCKED":
+ reason = str(state.execution_quality_reason or "")
+
+ if reason == "HIGH_SPREAD":
+ return "Вход нежелателен: спред мешает входу."
+
+ if reason == "STALE_SNAPSHOT":
+ return "Вход нежелателен: данные рынка устарели."
+
+ if reason in {"SNAPSHOT_ERROR", "SNAPSHOT_UNAVAILABLE"}:
+ return "Вход нежелателен: нет надёжных данных рынка."
+
+ return "Вход нежелателен: исполнение заблокировано."
+
+ if state.entry_block_message:
+ return f"Рынок не готов: {state.entry_block_message}."
+
+ if state.execution_quality == "WARNING":
+ return "Вход рискованный: качество исполнения снижено."
+
+ if state.is_signal_ready:
+ return "Сигнал готов, вход разрешён."
+
+ if state.last_signal in {"BUY", "SELL"}:
+ return "Сигнал есть, идёт подтверждение."
+
+ if blockers:
+ return f"Есть ограничения: {', '.join(blockers)}."
+
+ return "Критичных ограничений нет."
+
+ def _blockers(self, state: AutoTradeState) -> list[str]:
+ blockers: list[str] = []
+
+ if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
+ if state.market_state == "RANGE" or state.market_phase == "RANGE":
+ blockers.append("рынок без направления")
+ elif state.entry_block_message:
+ blockers.append(str(state.entry_block_message))
+ else:
+ blockers.append("рынок не подходит")
+
+ return blockers
+
+ if state.entry_block_message:
+ blockers.append(str(state.entry_block_message))
+
+ if state.execution_quality == "BLOCKED":
+ blockers.append(str(state.execution_quality_message or "исполнение заблокировано"))
+
+ if state.decision_status == "BLOCKED":
+ blockers.append(str(state.decision_reason or "решение заблокировано"))
+
+ if state.runtime_expired_message:
+ blockers.append(str(state.runtime_expired_message))
+
+ return blockers
\ No newline at end of file
diff --git a/app/src/trading/execution/engine.py b/app/src/trading/execution/engine.py
index 1792aa9..04f9d1a 100644
--- a/app/src/trading/execution/engine.py
+++ b/app/src/trading/execution/engine.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import time
import math
from dataclasses import dataclass
from datetime import datetime
@@ -227,6 +228,13 @@ class ExecutionEngine:
)
state.realized_pnl_usd += pnl
+ state.cycle_realized_pnl_usd += pnl
+
+ state.last_flip_old_side = old_side
+ state.last_flip_new_side = new_side
+ state.last_flip_pnl_usd = pnl
+ state.last_flip_reason = state.last_signal_reason
+ state.last_flip_monotonic_at = time.monotonic()
old_side = position.side
old_entry_price = position.entry_price
@@ -341,6 +349,7 @@ class ExecutionEngine:
pnl = forced_pnl if forced_pnl is not None else self._calculate_pnl(exit_price)
state.realized_pnl_usd += pnl
+ state.cycle_realized_pnl_usd += pnl
now = self._now_time()
@@ -404,6 +413,7 @@ class ExecutionEngine:
f"Позиция закрыта по правилу защиты: {forced_reason}.",
)
+
return ExecutionDecision("CLOSE", True, "Позиция закрыта.")
def _risk_close_decision(self, state: AutoTradeState) -> ExecutionDecision | None:
diff --git a/app/src/trading/strategies/trend.py b/app/src/trading/strategies/trend.py
index e95a397..15e0eed 100644
--- a/app/src/trading/strategies/trend.py
+++ b/app/src/trading/strategies/trend.py
@@ -110,6 +110,9 @@ class TrendStrategy:
if len(prices) > self._window_size:
prices.pop(0)
+ market_phase = self._normalized_market_phase(market)
+ market_phase_direction = self._normalized_market_phase_direction(market)
+
base_payload = {
"strategy": self.name,
"symbol": symbol,
@@ -125,8 +128,8 @@ class TrendStrategy:
"market_analysis": market.payload,
"market_trend_strength": market.trend_strength.value,
"market_trend_quality": market.trend_quality.value,
- "market_phase": market.market_phase.value,
- "market_phase_direction": market.phase_direction.value,
+ "market_phase": market_phase,
+ "market_phase_direction": market_phase_direction,
"market_phase_change_percent": market.phase_change_percent,
"market_phase_direction_consistency": market.payload.get("market_phase_direction_consistency"),
"market_phase_reason": market.phase_reason,
@@ -305,7 +308,10 @@ class TrendStrategy:
momentum_direction = getattr(market, "momentum_direction", TrendDirection.UNKNOWN)
momentum_strength = float(getattr(market, "momentum_strength", 0.0) or 0.0)
- if momentum_state == MomentumState.BREAKOUT_UP:
+ if (
+ momentum_state == MomentumState.BREAKOUT_UP
+ and market.state == MarketState.TREND_UP
+ ):
return SignalResult(
signal=SignalType.BUY,
reason="BREAKOUT_UP подтверждён momentum/breakout semantic layer.",
@@ -319,7 +325,10 @@ class TrendStrategy:
},
)
- if momentum_state == MomentumState.BREAKOUT_DOWN:
+ if (
+ momentum_state == MomentumState.BREAKOUT_DOWN
+ and market.state == MarketState.TREND_DOWN
+ ):
return SignalResult(
signal=SignalType.SELL,
reason="BREAKOUT_DOWN подтверждён momentum/breakout semantic layer.",
@@ -333,6 +342,37 @@ class TrendStrategy:
},
)
+ if (
+ momentum_state == MomentumState.BREAKOUT_DOWN
+ and market.state == MarketState.TREND_UP
+ ):
+ return SignalResult(
+ signal=SignalType.HOLD,
+ reason="Пробой вниз против TREND_UP считается коррекцией, вход в SHORT запрещён.",
+ confidence=0.0,
+ payload={
+ **base_payload,
+ "entry_block_reason": "COUNTER_TREND_BREAKOUT",
+ "entry_block_message": "пробой против тренда",
+ "expected_direction": "BUY",
+ },
+ )
+
+ if (
+ momentum_state == MomentumState.BREAKOUT_UP
+ and market.state == MarketState.TREND_DOWN
+ ):
+ return SignalResult(
+ signal=SignalType.HOLD,
+ reason="Пробой вверх против TREND_DOWN считается откатом, вход в LONG запрещён.",
+ confidence=0.0,
+ payload={
+ **base_payload,
+ "entry_block_reason": "COUNTER_TREND_BREAKOUT",
+ "entry_block_message": "пробой против тренда",
+ "expected_direction": "SELL",
+ },
+ )
return None
def _calculate_breakout_confidence(self, momentum_strength: float) -> float:
@@ -384,6 +424,31 @@ class TrendStrategy:
return down_moves / total_moves
+ def _normalized_market_phase(self, market) -> str:
+ phase = market.market_phase.value
+ momentum_state = market.momentum_state.value
+
+ active_momentum_states = {
+ "MOMENTUM_UP",
+ "MOMENTUM_DOWN",
+ "BREAKOUT_UP",
+ "BREAKOUT_DOWN",
+ }
+
+ if phase == "IMPULSE" and momentum_state not in active_momentum_states:
+ return "UNKNOWN"
+
+ return phase
+
+
+ def _normalized_market_phase_direction(self, market) -> str:
+ phase = self._normalized_market_phase(market)
+
+ if phase == "UNKNOWN":
+ return "UNKNOWN"
+
+ return market.phase_direction.value
+
def _calculate_confidence(
self,
change_percent: float,
diff --git a/docs/roadmap/master-roadmap.md b/docs/roadmap/master-roadmap.md
index 2a92d02..6d5ab04 100644
--- a/docs/roadmap/master-roadmap.md
+++ b/docs/roadmap/master-roadmap.md
@@ -1205,6 +1205,58 @@
- diagnostic layer подготовлен к Diagnostic Journal Layer
- diagnostic layer подготовлен к Auto-refresh Diagnostic UI
+#### 07.4.4.1.10.3 ✅ Telegram Diagnostic Screen
+- реализирован полноценный Telegram Diagnostic Screen
+- реализован отдельный diagnostics Telegram UI layer
+- реализован auto-refresh diagnostics screen
+- реализована интеграция diagnostics screen с AutoTradeRunner
+- реализована интеграция diagnostics screen с ActiveScreenManager
+- реализован отдельный diagnostic navigation flow
+- реализована отдельная diagnostics keyboard
+- реализовано безопасное обновление diagnostic messages
+- реализована защита Telegram diagnostics UI от TelegramBadRequest
+- реализован explainable runtime diagnostic screen
+- реализован explainable semantic diagnostic UI
+- реализован explainable market diagnostics UI
+- реализован explainable momentum diagnostics UI
+- реализован explainable breakout diagnostics UI
+- реализован explainable execution diagnostics UI
+- реализован explainable adaptive sizing diagnostics UI
+- реализован explainable runtime health diagnostics UI
+- реализован explainable position diagnostics UI
+- реализован explainable severity system
+- реализована semantic severity hierarchy
+- реализовано разделение WAITING / YELLOW / RED runtime states
+- реализована логика semantic waiting state
+- реализована логика runtime freshness interpretation
+- реализована логика execution readiness interpretation
+- реализована логика signal confirmation interpretation
+- реализована логика market noise interpretation
+- реализована логика market phase interpretation
+- реализована логика breakout explanation
+- реализована логика execution quality explanation
+- реализована логика adaptive sizing explanation
+- реализована логика runtime degradation explanation
+- исключены ложные warning состояния при HOLD signal
+- исключены ложные yellow состояния без momentum
+- реализован semantic OFF diagnostics mode
+- реализован lightweight diagnostics режим для OFF состояния
+- реализована корректная diagnostics логика без RUNNING state
+- реализована подготовка cycle pnl diagnostics
+- реализована подготовка flip diagnostics
+- реализована подготовка cumulative realized pnl diagnostics
+- реализована подготовка old/new side flip diagnostics
+- реализована подготовка flip pnl diagnostics
+- реализована подготовка position cycle analytics
+- semantic analytics layer стал explainable
+- semantic analytics layer стал user-readable
+- semantic analytics layer стал Telegram-ready
+- diagnostics layer подготовлен к Diagnostic Journal Layer
+- diagnostics layer подготовлен к persistent runtime analytics
+- diagnostics layer подготовлен к advanced cycle analytics
+- diagnostics layer подготовлен к semantic trade analytics
+- diagnostics layer подготовлен к auto-refresh runtime dashboard
+
---
### 07.4.5
diff --git a/docs/roadmap/stage-07-auto-trading-roadmap.md b/docs/roadmap/stage-07-auto-trading-roadmap.md
index 798e6bb..9b9b38f 100644
--- a/docs/roadmap/stage-07-auto-trading-roadmap.md
+++ b/docs/roadmap/stage-07-auto-trading-roadmap.md
@@ -1131,6 +1131,58 @@
- execution runtime подготовлен к semantic breakout routing
- execution runtime подготовлен к AI-driven momentum interpretation
+#### 07.4.4.1.10.3 ✅ Telegram Diagnostic Screen
+- реализирован полноценный Telegram Diagnostic Screen
+- реализован отдельный diagnostics Telegram UI layer
+- реализован auto-refresh diagnostics screen
+- реализована интеграция diagnostics screen с AutoTradeRunner
+- реализована интеграция diagnostics screen с ActiveScreenManager
+- реализован отдельный diagnostic navigation flow
+- реализована отдельная diagnostics keyboard
+- реализовано безопасное обновление diagnostic messages
+- реализована защита Telegram diagnostics UI от TelegramBadRequest
+- реализован explainable runtime diagnostic screen
+- реализован explainable semantic diagnostic UI
+- реализован explainable market diagnostics UI
+- реализован explainable momentum diagnostics UI
+- реализован explainable breakout diagnostics UI
+- реализован explainable execution diagnostics UI
+- реализован explainable adaptive sizing diagnostics UI
+- реализован explainable runtime health diagnostics UI
+- реализован explainable position diagnostics UI
+- реализован explainable severity system
+- реализована semantic severity hierarchy
+- реализовано разделение WAITING / YELLOW / RED runtime states
+- реализована логика semantic waiting state
+- реализована логика runtime freshness interpretation
+- реализована логика execution readiness interpretation
+- реализована логика signal confirmation interpretation
+- реализована логика market noise interpretation
+- реализована логика market phase interpretation
+- реализована логика breakout explanation
+- реализована логика execution quality explanation
+- реализована логика adaptive sizing explanation
+- реализована логика runtime degradation explanation
+- исключены ложные warning состояния при HOLD signal
+- исключены ложные yellow состояния без momentum
+- реализован semantic OFF diagnostics mode
+- реализован lightweight diagnostics режим для OFF состояния
+- реализована корректная diagnostics логика без RUNNING state
+- реализована подготовка cycle pnl diagnostics
+- реализована подготовка flip diagnostics
+- реализована подготовка cumulative realized pnl diagnostics
+- реализована подготовка old/new side flip diagnostics
+- реализована подготовка flip pnl diagnostics
+- реализована подготовка position cycle analytics
+- semantic analytics layer стал explainable
+- semantic analytics layer стал user-readable
+- semantic analytics layer стал Telegram-ready
+- diagnostics layer подготовлен к Diagnostic Journal Layer
+- diagnostics layer подготовлен к persistent runtime analytics
+- diagnostics layer подготовлен к advanced cycle analytics
+- diagnostics layer подготовлен к semantic trade analytics
+- diagnostics layer подготовлен к auto-refresh runtime dashboard
+
---
### 07.4.4.1.10 Semantic Runtime Diagnostics & Observability
diff --git a/docs/stages/stage-07_4_4_1_10_3-telegram_diagnostic_screen.md b/docs/stages/stage-07_4_4_1_10_3-telegram_diagnostic_screen.md
new file mode 100644
index 0000000..86d962b
--- /dev/null
+++ b/docs/stages/stage-07_4_4_1_10_3-telegram_diagnostic_screen.md
@@ -0,0 +1,388 @@
+# 07.4.4.1.10.3 — Telegram Diagnostic Screen
+
+## Статус
+
+✅ Этап реализован.
+
+---
+
+## Назначение этапа
+
+Этап **07.4.4.1.10.3 Telegram Diagnostic Screen** добавляет полноценный Telegram diagnostic interface поверх ранее реализованных:
+
+- semantic diagnostic snapshot builder
+- human-readable formatter
+- runtime semantic analytics layer
+
+Главная цель этапа — превратить внутреннюю runtime-аналитику автоторговли в explainable Telegram UI.
+
+Теперь бот умеет не только анализировать рынок, но и подробно объяснять:
+
+- почему вход разрешён или запрещён
+- почему execution считается рискованным
+- почему рынок считается шумным
+- почему signal находится в HOLD
+- почему flip разрешён или заблокирован
+- почему adaptive sizing изменил размер позиции
+- почему runtime считается degraded
+- почему severity имеет WAITING / YELLOW / RED состояние
+
+---
+
+## Что было реализовано
+
+### Telegram Diagnostic Screen
+
+Реализован отдельный экран диагностики:
+
+```text
+🔬 Диагностика · BTC
+```
+
+Экран отображает explainable runtime diagnostics прямо внутри Telegram.
+
+---
+
+### Auto-refresh diagnostics
+
+Реализован auto-refresh diagnostics screen через:
+
+- AutoTradeRunner
+- ActiveScreenManager
+- render callbacks
+
+Теперь diagnostics screen автоматически обновляется во время работы автоторговли.
+
+---
+
+### Navigation Layer
+
+Реализован отдельный navigation flow:
+
+```text
+Auto Screen
+ ↓
+Diagnostic Screen
+ ↓
+Back
+```
+
+Diagnostics screen теперь существует как полноценный Telegram UI layer.
+
+---
+
+### Separate Diagnostic Keyboard
+
+Реализована отдельная diagnostics keyboard:
+
+- Обновить
+- Назад
+
+Diagnostics screen больше не зависит от main auto keyboard.
+
+---
+
+## Архитектура
+
+После этапа структура diagnostics стала следующей:
+
+```text
+AutoTradeState
+ ↓
+SemanticDiagnosticSnapshotBuilder
+ ↓
+SemanticDiagnosticFormatter
+ ↓
+Telegram Diagnostic Screen
+```
+
+Теперь Telegram UI отображает не raw runtime state, а explainable semantic representation.
+
+---
+
+# Что было сделано в части аналитики
+
+## Полностью переработана severity logic
+
+До этого этапа headline severity могла конфликтовать с runtime состоянием.
+
+Например:
+
+- HOLD signal
+- отсутствие momentum
+- отсутствие breakout
+
+могли одновременно отображаться вместе с:
+
+```text
+🟡 Осторожно
+```
+
+что создавало логические противоречия.
+
+После переработки:
+
+- severity учитывает signal readiness
+- severity учитывает execution readiness
+- severity учитывает наличие momentum
+- severity учитывает HOLD state
+- severity учитывает runtime degradation
+- severity учитывает execution blockers
+- severity учитывает market semantic state
+
+---
+
+## Реализована WAITING semantic category
+
+Добавлено отдельное semantic состояние:
+
+```text
+WAITING
+```
+
+Оно используется когда:
+
+- сигнала ещё нет
+- momentum отсутствует
+- breakout отсутствует
+- рынок просто наблюдается
+- execution ещё не готов
+
+Это устраняет ложные WARNING состояния.
+
+---
+
+## Разделены WAITING / YELLOW / RED
+
+Теперь semantic severity hierarchy выглядит так:
+
+### WAITING
+
+Используется когда:
+
+- рынок просто анализируется
+- signal ещё не подтверждён
+- HOLD является нормальным состоянием
+- momentum отсутствует
+- execution не готов, но не заблокирован
+
+### YELLOW
+
+Используется когда:
+
+- execution рискованный
+- рынок шумный
+- есть unstable runtime conditions
+- есть WARNING execution quality
+- есть weak / noisy trend
+
+### RED
+
+Используется когда:
+
+- execution заблокирован
+- snapshot устарел
+- runtime expired
+- spread блокирует execution
+- runtime критически degraded
+
+---
+
+## Реализована explainable runtime logic
+
+Diagnostics screen теперь умеет объяснять:
+
+- почему рынок считается noisy
+- почему рынок считается trend/range/pullback
+- почему execution blocked
+- почему execution warning
+- почему spread считается высоким
+- почему signal confirmation ещё не завершён
+- почему momentum слабый
+- почему breakout не подтверждён
+
+---
+
+## Реализована explainable signal interpretation
+
+Signal block теперь отображает:
+
+- signal state
+- confirmation progress
+- confirmation duration
+- semantic signal explanation
+- breakout explanation
+- trend confirmation explanation
+
+Теперь HOLD перестал выглядеть как ошибка.
+
+---
+
+## Реализована explainable market interpretation
+
+Market block теперь отображает:
+
+- trend direction
+- trend strength
+- trend quality
+- market phase
+- volatility state
+- current movement context
+- market blockers
+- semantic market explanation
+
+Теперь можно визуально понимать:
+
+- тренд ли это
+- флэт ли это
+- squeeze ли это
+- pullback ли это
+- noisy ли рынок
+- есть ли directional movement
+
+---
+
+## Реализована explainable execution diagnostics
+
+Execution diagnostics теперь объясняет:
+
+- почему execution GOOD
+- почему execution WARNING
+- почему execution BLOCKED
+- почему spread опасен
+- почему snapshot считается stale
+- почему execution confidence низкий
+
+---
+
+## Реализована explainable adaptive sizing diagnostics
+
+Adaptive sizing diagnostics теперь объясняет:
+
+- почему размер позиции уменьшен
+- почему размер увеличен
+- почему adaptive size заблокировал вход
+- какие runtime factors повлияли на multiplier
+
+---
+
+## Реализована explainable runtime health diagnostics
+
+Runtime diagnostics теперь отображает:
+
+- freshness market data
+- freshness snapshot data
+- runtime degradation
+- stale analysis detection
+- signal age
+- runtime expiration
+
+---
+
+# Position Diagnostic Layer
+
+## Реализован semantic position block
+
+Добавлен полноценный explainable position diagnostics block.
+
+---
+
+## Реализовано отображение unrealized pnl
+
+Теперь diagnostics screen показывает:
+
+- текущую прибыль
+- текущий убыток
+- semantic pnl state
+
+---
+
+## Реализована подготовка flip diagnostics
+
+Подготовлена структура для:
+
+- old_side
+- new_side
+- flip pnl
+- cumulative cycle pnl
+- flip semantic rendering
+
+---
+
+## Реализована подготовка cycle pnl analytics
+
+Добавлена подготовка данных для:
+
+- cumulative realized pnl
+- cycle analytics
+- multi-trade cycle statistics
+
+---
+
+# Runtime & UX
+
+## Реализован OFF diagnostics mode
+
+Когда автоторговля выключена:
+
+```text
+⛔️ Автоторговля · остановлена
+```
+
+Diagnostics screen теперь не показывает misleading runtime analytics.
+
+---
+
+## Реализован lightweight OFF state
+
+В OFF режиме diagnostics screen больше не отображает:
+
+- signal diagnostics
+- market diagnostics
+- momentum diagnostics
+- execution diagnostics
+
+Это устраняет ложное ощущение активной аналитики.
+
+---
+
+## Исключены misleading runtime states
+
+Теперь:
+
+- HOLD больше не выглядит как warning
+- отсутствие momentum больше не выглядит как ошибка
+- отсутствие breakout больше не выглядит как degraded runtime
+- noisy market больше не вызывает ложный RED severity
+
+---
+
+# Подготовка к следующим этапам
+
+Этап подготавливает систему к:
+
+- Diagnostic Journal Layer
+- Persistent Runtime Diagnostics
+- Semantic Trade Analytics
+- Cycle Analytics
+- Flip Analytics
+- Trade History Diagnostics
+- Runtime Dashboard
+- Auto-refresh Runtime Monitoring
+
+---
+
+# Итог
+
+Этап **07.4.4.1.10.3 Telegram Diagnostic Screen** завершает превращение semantic diagnostics в полноценный explainable Telegram runtime interface.
+
+Теперь автоторговля умеет:
+
+- анализировать рынок
+- анализировать execution
+- анализировать momentum
+- анализировать runtime health
+- анализировать adaptive sizing
+- анализировать position state
+
+и одновременно подробно объяснять всё это пользователю внутри Telegram UI.