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.