diff --git a/app/src/telegram/handlers/debug.py b/app/src/telegram/handlers/debug.py index ff7a1ae..70b0235 100644 --- a/app/src/telegram/handlers/debug.py +++ b/app/src/telegram/handlers/debug.py @@ -8,8 +8,8 @@ from aiogram.types import Message from src.core.config import load_settings from src.trading.auto.runner import AutoTradeRunner from src.trading.auto.service import AutoTradeService -from src.trading.journal.service import JournalService from src.trading.execution.engine import ExecutionEngine +from src.trading.journal.service import JournalService router = Router(name="debug") @@ -19,21 +19,81 @@ def _debug_enabled() -> bool: return load_settings().debug_enabled +def _parse_debug_signal_args(raw_text: str | None) -> tuple[str, float, int, str | None]: + parts = (raw_text or "").split() + + signal = parts[1].upper() if len(parts) > 1 else "BUY" + if signal not in {"BUY", "SELL", "HOLD"}: + return "BUY", 0.9, 2, "SIGNAL должен быть BUY, SELL или HOLD." + + try: + confidence = float(parts[2]) if len(parts) > 2 else 0.9 + except ValueError: + return "BUY", 0.9, 2, "CONFIDENCE должен быть числом от 0.00 до 1.00." + + if confidence < 0 or confidence > 1: + return "BUY", 0.9, 2, "CONFIDENCE должен быть от 0.00 до 1.00." + + try: + repeat_count = int(parts[3]) if len(parts) > 3 else 2 + except ValueError: + return "BUY", 0.9, 2, "REPEATS должен быть целым числом." + + if repeat_count < 1: + return "BUY", 0.9, 2, "REPEATS должен быть больше или равен 1." + + return signal, confidence, repeat_count, None + + +def _debug_help_text() -> str: + return ( + "🧪 Debug commands\n\n" + "Основная команда:\n" + "/debug_signal BUY 0.95 3\n" + "/debug_signal SELL 0.70 2\n" + "/debug_signal HOLD 0.00 1\n\n" + "Быстрые команды:\n" + "/debug_signal — BUY 0.90 2\n" + "/debug_ready — READY BUY\n" + "/debug_state — текущее состояние\n" + "/debug_help — список команд\n\n" + "Priority тест:\n" + "HIGH: confidence >= 0.80 и repeats >= 3\n" + "MEDIUM: confidence >= 0.60 или repeats >= 2\n" + "LOW: всё остальное" + ) + + +@router.message(F.text == "/debug_help") +async def debug_help(message: Message) -> None: + if not _debug_enabled(): + await message.answer("Debug mode выключен.") + return + + await message.answer(_debug_help_text()) + + @router.message(F.text.startswith("/debug_signal")) async def debug_signal(message: Message) -> None: if not _debug_enabled(): await message.answer("Debug mode выключен.") return - parts = (message.text or "").split() - signal = parts[1].upper() if len(parts) > 1 else "BUY" + signal, confidence, repeat_count, error = _parse_debug_signal_args(message.text) + + if error is not None: + await message.answer( + f"⛔️ {error}\n\n" + f"{_debug_help_text()}" + ) + return service = AutoTradeService() state = service.debug_force_signal( signal=signal, - confidence=0.9, - repeat_count=2, - reason=f"DEBUG FORCE {signal}", + confidence=confidence, + repeat_count=repeat_count, + reason=f"DEBUG FORCE {signal} {confidence:.2f} ×{repeat_count}", ) if state.status == "OFF": @@ -41,7 +101,9 @@ async def debug_signal(message: Message) -> None: await AutoTradeRunner._handle_important_event(state) - ExecutionEngine().process(state) + execution_result = ExecutionEngine().process(state) + + await AutoTradeRunner.process_last_event_now() AutoTradeRunner.start() @@ -57,6 +119,9 @@ async def debug_signal(message: Message) -> None: "decision_status": state.decision_status, "confidence": state.last_signal_confidence, "repeat_count": state.last_signal_repeat_count, + "execution_action": execution_result.action, + "execution_can_execute": execution_result.can_execute, + "execution_reason": execution_result.reason, }, ) @@ -65,7 +130,10 @@ async def debug_signal(message: Message) -> None: f"Signal: {state.last_signal}\n" f"Decision: {state.decision_status}\n" f"Confidence: {state.last_signal_confidence:.2f}\n" - f"Repeats: {state.last_signal_repeat_count}" + f"Repeats: {state.last_signal_repeat_count}\n\n" + f"Execution: {execution_result.action}\n" + f"Can execute: {execution_result.can_execute}\n" + f"Reason: {execution_result.reason}" ) @@ -79,8 +147,8 @@ async def debug_ready(message: Message) -> None: state = service.debug_force_signal( signal="BUY", confidence=0.95, - repeat_count=2, - reason="DEBUG READY BUY", + repeat_count=3, + reason="DEBUG READY BUY 0.95 ×3", ) if state.status == "OFF": @@ -88,14 +156,18 @@ async def debug_ready(message: Message) -> None: await AutoTradeRunner._handle_important_event(state) - ExecutionEngine().process(state) + execution_result = ExecutionEngine().process(state) + + await AutoTradeRunner.process_last_event_now() AutoTradeRunner.start() await message.answer( "✅ Debug READY создан\n\n" f"Signal: {state.last_signal}\n" - f"Decision: {state.decision_status}" + f"Decision: {state.decision_status}\n" + f"Execution: {execution_result.action}\n" + f"Can execute: {execution_result.can_execute}" ) diff --git a/app/src/trading/auto/runner.py b/app/src/trading/auto/runner.py index 6b559eb..ebbf67d 100644 --- a/app/src/trading/auto/runner.py +++ b/app/src/trading/auto/runner.py @@ -24,22 +24,20 @@ class AutoTradeRunner: _render_markup: Callable[[], object] | None = None _current_screen: str | None = None - # анализ стратегии — часто _analysis_interval_seconds = 5 - - # Telegram UI — редко _ui_interval_seconds = 60 _last_text: str | None = None _last_ui_refresh_at: float = 0.0 _last_event_version: int = 0 _retry_after_until: float = 0.0 - _last_strong_alert_key: str | None = None - # защита от частых одинаковых alert-ов + _last_strong_alert_key: str | None = None _strong_alert_cooldown_seconds = 120 _last_strong_alert_at_by_key: dict[str, float] = {} + _last_execution_alert_key: str | None = None + @classmethod def register_screen( cls, @@ -160,18 +158,25 @@ class AutoTradeRunner: async def _handle_important_event(cls, state) -> None: event_type, payload = EventBus.last_event() - if event_type != "auto_decision_changed": + if event_type == "auto_decision_changed": + if payload.get("decision_status") != "READY": + return + + signal = str(payload.get("signal", "")).upper() + if signal not in {"BUY", "SELL"}: + return + + await cls._send_strong_signal_alert(state=state, payload=payload) return - if payload.get("decision_status") != "READY": + if event_type in {"paper_position_opened", "paper_position_closed"}: + await cls._send_execution_alert( + state=state, + event_type=event_type, + payload=payload, + ) return - signal = str(payload.get("signal", "")).upper() - if signal not in {"BUY", "SELL"}: - return - - await cls._send_strong_signal_alert(state=state, payload=payload) - @classmethod async def _send_strong_signal_alert(cls, *, state, payload: dict) -> None: if cls._bot is None or cls._chat_id is None: @@ -185,8 +190,15 @@ class AutoTradeRunner: leverage = payload.get("leverage") if payload.get("leverage") is not None else state.leverage reason = str(payload.get("reason") or state.last_signal_reason or "—") + position_context = str(getattr(state, "position_side", "NONE") or "NONE") + + priority = cls._alert_priority( + confidence=confidence, + repeat_count=repeat_count, + ) + alert_key = ( - f"{symbol}:{strategy}:{signal}:" + f"signal:{position_context}:{symbol}:{strategy}:{signal}:" f"{repeat_count}:{confidence:.2f}:" f"{state.decision_status}:{reason}" ) @@ -207,25 +219,23 @@ class AutoTradeRunner: leverage=leverage, reason=reason, cooldown_left=round(cls._strong_alert_cooldown_seconds - elapsed, 2), + position_context=position_context, ) return cls._last_strong_alert_key = alert_key cls._last_strong_alert_at_by_key[alert_key] = now - signal_icon = { - "BUY": "🟢", - "SELL": "🔴", - }.get(signal, "🚨") - - leverage_text = f"x{leverage:g}" if isinstance(leverage, (int, float)) else "—" - - text = ( - f"🚨 Сильный сигнал {signal_icon} {signal}\n\n" - f"{symbol} · {strategy} · {leverage_text}\n" - f"Confidence: {confidence:.2f}\n" - f"Repeats: {repeat_count}\n\n" - f"Причина: {reason}" + text = cls._build_strong_signal_alert_text( + signal=signal, + symbol=symbol, + strategy=strategy, + repeat_count=repeat_count, + confidence=confidence, + leverage=leverage, + reason=reason, + priority=priority, + position_context=position_context, ) try: @@ -247,6 +257,8 @@ class AutoTradeRunner: "confidence": confidence, "leverage": leverage, "reason": reason, + "priority": priority, + "position_context": position_context, }, ) @@ -256,6 +268,117 @@ class AutoTradeRunner: except Exception: pass + @classmethod + async def _send_execution_alert( + cls, + *, + state, + event_type: str, + payload: dict, + ) -> None: + if cls._bot is None or cls._chat_id is None: + return + + alert_key = cls._execution_alert_key( + event_type=event_type, + payload=payload, + ) + + if alert_key == cls._last_execution_alert_key: + return + + cls._last_execution_alert_key = alert_key + + text = cls._build_execution_alert_text( + state=state, + event_type=event_type, + payload=payload, + ) + + try: + await cls._bot.send_message( + chat_id=cls._chat_id, + text=text, + ) + + JournalService().log_ui_info( + event_type="auto_execution_alert_sent", + message="Отправлено Telegram-уведомление по paper execution.", + screen="auto", + action="execution_alert", + payload={ + "source_event_type": event_type, + **payload, + }, + ) + + except TelegramRetryAfter as exc: + cls._retry_after_until = time.monotonic() + exc.retry_after + 5 + + except Exception: + pass + + @classmethod + def _execution_alert_key( + cls, + *, + event_type: str, + payload: dict, + ) -> str: + return ( + f"{event_type}:" + f"{payload.get('symbol')}:" + f"{payload.get('side')}:" + f"{payload.get('entry_price')}:" + f"{payload.get('exit_price')}:" + f"{payload.get('size')}:" + f"{payload.get('pnl')}" + ) + + @classmethod + def _build_execution_alert_text( + cls, + *, + state, + event_type: str, + payload: dict, + ) -> str: + symbol = str(payload.get("symbol") or state.symbol or "—") + side = str(payload.get("side") or state.position_side or "—") + leverage = payload.get("leverage") if payload.get("leverage") is not None else state.leverage + + symbol_text = cls._format_alert_symbol(symbol) + leverage_text = cls._format_alert_leverage(leverage) + + if event_type == "paper_position_opened": + entry_price = cls._format_price(payload.get("entry_price")) + size = cls._format_size(payload.get("size")) + side_icon = "🟢" if side == "LONG" else "🔴" + + return ( + f"📄 Paper position opened {side_icon} {side}\n\n" + f"{symbol_text} · {leverage_text}\n" + f"Entry: $ {entry_price}\n" + f"Size: {size}" + ) + + if event_type == "paper_position_closed": + entry_price = cls._format_price(payload.get("entry_price")) + exit_price = cls._format_price(payload.get("exit_price")) + size = cls._format_size(payload.get("size")) + pnl = cls._format_pnl(payload.get("pnl")) + + return ( + f"✅ Paper position closed\n\n" + f"{side} · {symbol_text} · {leverage_text}\n" + f"Entry: $ {entry_price}\n" + f"Exit: $ {exit_price}\n" + f"Size: {size}\n\n" + f"PnL: {pnl}" + ) + + return "📄 Paper execution event" + @classmethod def _log_suppressed_strong_alert( cls, @@ -268,6 +391,7 @@ class AutoTradeRunner: leverage: object, reason: str, cooldown_left: float, + position_context: str, ) -> None: try: JournalService().log_ui_info( @@ -284,11 +408,117 @@ class AutoTradeRunner: "leverage": leverage, "reason": reason, "cooldown_left": cooldown_left, + "position_context": position_context, }, ) except Exception: pass + @classmethod + def _alert_priority( + cls, + *, + 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" + + @classmethod + def _priority_label(cls, priority: str) -> str: + mapping = { + "HIGH": "🚨 HIGH", + "MEDIUM": "⚡ MEDIUM", + "LOW": "ℹ️ LOW", + } + return mapping.get(priority, priority) + + @classmethod + def _format_alert_symbol(cls, 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 + + @classmethod + def _format_alert_leverage(cls, leverage: object) -> str: + if isinstance(leverage, (int, float)): + return f"x{leverage:g}" + + return "—" + + @classmethod + def _signal_icon(cls, signal: str) -> str: + mapping = { + "BUY": "🟢", + "SELL": "🔴", + } + return mapping.get(signal, "⚪") + + @classmethod + def _build_strong_signal_alert_text( + cls, + *, + signal: str, + symbol: str, + strategy: str, + repeat_count: int, + confidence: float, + leverage: object, + reason: str, + priority: str, + position_context: str, + ) -> str: + icon = cls._signal_icon(signal) + symbol_text = cls._format_alert_symbol(symbol) + leverage_text = cls._format_alert_leverage(leverage) + priority_text = cls._priority_label(priority) + + return ( + 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}" + ) + + @classmethod + def _format_price(cls, value: object) -> str: + try: + return f"{float(value):.2f}" + except (TypeError, ValueError): + return "—" + + @classmethod + def _format_size(cls, value: object) -> str: + try: + return f"{float(value):.8f}".rstrip("0").rstrip(".") + except (TypeError, ValueError): + return "—" + + @classmethod + def _format_pnl(cls, value: object) -> str: + try: + pnl = float(value) + except (TypeError, ValueError): + return "—" + + prefix = "+" if pnl > 0 else "" + return f"{prefix}{pnl:.4f} USD" + @classmethod async def _refresh_screen(cls, *, force: bool = False) -> None: now = time.monotonic() diff --git a/docs/roadmap/master-roadmap.md b/docs/roadmap/master-roadmap.md index 0d68381..18c3359 100644 --- a/docs/roadmap/master-roadmap.md +++ b/docs/roadmap/master-roadmap.md @@ -172,6 +172,14 @@ - journal logging suppressed событий - не влияет на execution pipeline +#### 07.4.3.7 — Alert priority & UX improvements ✅ +- priority levels: HIGH / MEDIUM / LOW +- improved Telegram alert layout +- normalized symbol & leverage formatting +- compatible with cooldown & suppression +- extended debug_signal parameters + + ### 07.4.4 ⏳ Grid Strategy diff --git a/docs/roadmap/stage-07-auto-trading-roadmap.md b/docs/roadmap/stage-07-auto-trading-roadmap.md index e06ca16..7066019 100644 --- a/docs/roadmap/stage-07-auto-trading-roadmap.md +++ b/docs/roadmap/stage-07-auto-trading-roadmap.md @@ -102,8 +102,6 @@ ✔ убраны дубли (WAITING / HOLD и т.д.) ✔ оптимизация под mobile ---- - #### 07.4.3.2 — Engine Decoupling (NEXT) ⏳ разделение: - analysis loop (частый) @@ -117,8 +115,6 @@ - обновление только при изменении состояния - защита от flood control ---- - #### Stage 07.4.3.3 — Paper Position & Execution Engine - добавлен ExecutionEngine - реализованы paper-позиции (LONG / SHORT) @@ -127,8 +123,6 @@ - логирование paper execution - EventBus события (paper_position_opened) ---- - #### Stage 07.4.3.4 — Telegram Strong Signal Alerts - EventBus-driven уведомления - Фильтрация READY сигналов @@ -142,8 +136,6 @@ - Execution lifecycle - Real trading notifications ---- - #### Stage 07.4.3.5 — Debug Commands & Test Mode ✅ - DEBUG_ENABLED env flag @@ -161,6 +153,14 @@ - journal logging suppressed событий - не влияет на execution pipeline +#### 07.4.3.7 — Alert priority & UX improvements ✅ + +- priority levels: HIGH / MEDIUM / LOW +- improved Telegram alert layout +- normalized symbol & leverage formatting +- compatible with cooldown & suppression +- extended debug_signal parameters + --- ### 07.4.4 diff --git a/docs/stages/stage-07_4_3_7-alert-priority-ux.md b/docs/stages/stage-07_4_3_7-alert-priority-ux.md new file mode 100644 index 0000000..bfab19a --- /dev/null +++ b/docs/stages/stage-07_4_3_7-alert-priority-ux.md @@ -0,0 +1,265 @@ +# Stage 07.4.3.7 — Alert Priority & UX Improvements + +## Цель этапа + +Улучшить Telegram-уведомления о сильных сигналах BUY / SELL: + +- сделать alert более читаемым; +- добавить визуальный приоритет сигнала; +- унифицировать формат symbol / leverage; +- сохранить совместимость с cooldown / suppression; +- не влиять на ExecutionEngine. + +--- + +## Что изменено + +### 1. Новый формат Telegram alert + +Было: + +```text +🚨 Сильный сигнал 🟢 BUY + +BTC/USD_LEVERAGE · TREND · x2 +Confidence: 0.90 +Repeats: 2 + +Причина: DEBUG FORCE BUY +``` + +Стало: + +```text +⚡ MEDIUM · 🟢 BUY + +BTC / USD · TREND · x2 + +🧠 Confidence: 0.90 +🔁 Repeats: 2 + +💡 Причина: +DEBUG FORCE BUY +``` + +--- + +## 2. Priority layer + +Добавлена логика определения приоритета: + +```python +HIGH = confidence >= 0.80 and repeat_count >= 3 +MEDIUM = confidence >= 0.60 or repeat_count >= 2 +LOW = everything else +``` + +### Priority labels + +```text +HIGH → 🚨 HIGH +MEDIUM → ⚡ MEDIUM +LOW → ℹ️ LOW +``` + +--- + +## 3. Форматирование symbol + +Для UI alert-ов добавлено компактное отображение: + +```text +BTC/USD_LEVERAGE → BTC / USD +``` + +Это делает alert визуально единым с экраном автоторговли. + +--- + +## 4. Форматирование leverage + +Плечо выводится компактно: + +```text +2.0 → x2 +5.0 → x5 +``` + +--- + +## 5. Совместимость с throttling + +Существующая логика cooldown / suppression сохранена: + +- одинаковый alert не отправляется чаще заданного cooldown; +- suppressed alert логируется в Journal; +- priority не ломает alert key; +- debug и normal режимы используют один и тот же alert pipeline. + +--- + +## Изменённые файлы + +```text +app/src/trading/auto/runner.py +app/src/telegram/handlers/debug.py +``` + +--- + +## Проверка + +### 1. MEDIUM alert + +```text +/debug_signal BUY 0.90 2 +``` + +Ожидаемо: + +```text +⚡ MEDIUM · 🟢 BUY +``` + +--- + +### 2. HIGH alert + +```text +/debug_signal BUY 0.95 3 +``` + +Ожидаемо: + +```text +🚨 HIGH · 🟢 BUY +``` + +--- + +### 3. SELL alert + +```text +/debug_signal SELL 0.70 2 +``` + +Ожидаемо: + +```text +⚡ MEDIUM · 🔴 SELL +``` + +--- + +### 4. LOW alert + +```text +/debug_signal BUY 0.40 1 +``` + +Ожидаемо: + +```text +ℹ️ LOW · 🟢 BUY +``` + +--- + +### 5. HOLD + +```text +/debug_signal HOLD 0.00 1 +``` + +Ожидаемо: + +- decision = WAITING; +- alert не отправляется; +- execution не выполняется. + +--- + +### 6. Cooldown + +```text +/debug_signal BUY 0.95 3 +/debug_signal BUY 0.95 3 +``` + +Ожидаемо: + +- первый alert отправляется; +- второй alert подавляется; +- в Journal появляется `auto_strong_signal_alert_suppressed`. + +--- + +## Архитектурный результат + +Alert layer стал отдельным читаемым UX-слоем: + +```text +EventBus + ↓ +AutoTradeRunner + ↓ +Priority / Formatting / Cooldown + ↓ +Telegram alert +``` + +Execution layer не изменён: + +```text +Signal alert ≠ Execution alert +``` + +Это важно для будущего этапа `07.4.3.8 — Telegram execution alerts`. + +--- + +## Roadmap update + +### Stage 07 roadmap + +```text +07.4.3.7 — Alert Priority & UX Improvements ✅ +- priority labels HIGH / MEDIUM / LOW +- improved alert layout +- normalized symbol display +- normalized leverage display +- compatible with cooldown / suppression +- parametrized debug signal testing +``` + +### Master roadmap + +```text +Stage 07 — Auto Trading +✔ 07.4.3.7 — Alert Priority & UX Improvements +``` + +--- + +## Commit + +```bash +git add app/src/telegram/handlers/debug.py +git add app/src/trading/auto/runner.py +git commit -m "Stage 07.4.3.7 — Alert priority and UX improvements" +``` + +--- + +## Следующий этап + +```text +07.4.3.8 — Telegram execution alerts +``` + +План: + +- отдельные сообщения OPEN_LONG / OPEN_SHORT; +- отдельные сообщения CLOSE; +- отображение Entry / Exit / PnL; +- journal + Telegram consistency.