From 2c75f95b4694a6569808ce4456956c8dc0b1de57 Mon Sep 17 00:00:00 2001 From: Sergey Date: Sat, 16 May 2026 09:23:37 +0300 Subject: [PATCH] =?UTF-8?q?07.4.4.1.10.3=20=E2=80=94=20Telegram=20Diagnost?= =?UTF-8?q?ic=20Screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/core/event_titles.py | 2 - app/src/notifications/templates/execution.py | 241 ++- app/src/notifications/templates/signal.py | 94 +- app/src/runtime_events/event_types.py | 1 + app/src/telegram/handlers/auto/main.py | 74 +- app/src/telegram/handlers/auto/ui.py | 40 +- app/src/trading/auto/runner.py | 39 +- app/src/trading/auto/service.py | 18 + app/src/trading/auto/state.py | 12 + app/src/trading/diagnostics/formatter.py | 1683 +++++++++++++++-- app/src/trading/diagnostics/snapshot.py | 366 ++++ app/src/trading/execution/engine.py | 10 + app/src/trading/strategies/trend.py | 73 +- docs/roadmap/master-roadmap.md | 52 + docs/roadmap/stage-07-auto-trading-roadmap.md | 52 + ...7_4_4_1_10_3-telegram_diagnostic_screen.md | 388 ++++ 16 files changed, 2902 insertions(+), 243 deletions(-) create mode 100644 app/src/trading/diagnostics/snapshot.py create mode 100644 docs/stages/stage-07_4_4_1_10_3-telegram_diagnostic_screen.md 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.