Stage 07.4.3.7 — Alert Priority & UX Improvements

This commit is contained in:
2026-05-04 14:47:50 +03:00
parent 75ba87c6d1
commit d8c077d066
5 changed files with 622 additions and 47 deletions

View File

@@ -8,8 +8,8 @@ from aiogram.types import Message
from src.core.config import load_settings from src.core.config import load_settings
from src.trading.auto.runner import AutoTradeRunner from src.trading.auto.runner import AutoTradeRunner
from src.trading.auto.service import AutoTradeService from src.trading.auto.service import AutoTradeService
from src.trading.journal.service import JournalService
from src.trading.execution.engine import ExecutionEngine from src.trading.execution.engine import ExecutionEngine
from src.trading.journal.service import JournalService
router = Router(name="debug") router = Router(name="debug")
@@ -19,21 +19,81 @@ def _debug_enabled() -> bool:
return load_settings().debug_enabled 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 (
"<b>🧪 Debug commands</b>\n\n"
"<b>Основная команда:</b>\n"
"/debug_signal BUY 0.95 3\n"
"/debug_signal SELL 0.70 2\n"
"/debug_signal HOLD 0.00 1\n\n"
"<b>Быстрые команды:</b>\n"
"/debug_signal — BUY 0.90 2\n"
"/debug_ready — READY BUY\n"
"/debug_state — текущее состояние\n"
"/debug_help — список команд\n\n"
"<b>Priority тест:</b>\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")) @router.message(F.text.startswith("/debug_signal"))
async def debug_signal(message: Message) -> None: async def debug_signal(message: Message) -> None:
if not _debug_enabled(): if not _debug_enabled():
await message.answer("Debug mode выключен.") await message.answer("Debug mode выключен.")
return return
parts = (message.text or "").split() signal, confidence, repeat_count, error = _parse_debug_signal_args(message.text)
signal = parts[1].upper() if len(parts) > 1 else "BUY"
if error is not None:
await message.answer(
f"⛔️ {error}\n\n"
f"{_debug_help_text()}"
)
return
service = AutoTradeService() service = AutoTradeService()
state = service.debug_force_signal( state = service.debug_force_signal(
signal=signal, signal=signal,
confidence=0.9, confidence=confidence,
repeat_count=2, repeat_count=repeat_count,
reason=f"DEBUG FORCE {signal}", reason=f"DEBUG FORCE {signal} {confidence:.2f} ×{repeat_count}",
) )
if state.status == "OFF": if state.status == "OFF":
@@ -41,7 +101,9 @@ async def debug_signal(message: Message) -> None:
await AutoTradeRunner._handle_important_event(state) await AutoTradeRunner._handle_important_event(state)
ExecutionEngine().process(state) execution_result = ExecutionEngine().process(state)
await AutoTradeRunner.process_last_event_now()
AutoTradeRunner.start() AutoTradeRunner.start()
@@ -57,6 +119,9 @@ async def debug_signal(message: Message) -> None:
"decision_status": state.decision_status, "decision_status": state.decision_status,
"confidence": state.last_signal_confidence, "confidence": state.last_signal_confidence,
"repeat_count": state.last_signal_repeat_count, "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"Signal: {state.last_signal}\n"
f"Decision: {state.decision_status}\n" f"Decision: {state.decision_status}\n"
f"Confidence: {state.last_signal_confidence:.2f}\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( state = service.debug_force_signal(
signal="BUY", signal="BUY",
confidence=0.95, confidence=0.95,
repeat_count=2, repeat_count=3,
reason="DEBUG READY BUY", reason="DEBUG READY BUY 0.95 ×3",
) )
if state.status == "OFF": if state.status == "OFF":
@@ -88,14 +156,18 @@ async def debug_ready(message: Message) -> None:
await AutoTradeRunner._handle_important_event(state) await AutoTradeRunner._handle_important_event(state)
ExecutionEngine().process(state) execution_result = ExecutionEngine().process(state)
await AutoTradeRunner.process_last_event_now()
AutoTradeRunner.start() AutoTradeRunner.start()
await message.answer( await message.answer(
"✅ Debug READY создан\n\n" "✅ Debug READY создан\n\n"
f"Signal: {state.last_signal}\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}"
) )

View File

@@ -24,22 +24,20 @@ class AutoTradeRunner:
_render_markup: Callable[[], object] | None = None _render_markup: Callable[[], object] | None = None
_current_screen: str | None = None _current_screen: str | None = None
# анализ стратегии — часто
_analysis_interval_seconds = 5 _analysis_interval_seconds = 5
# Telegram UI — редко
_ui_interval_seconds = 60 _ui_interval_seconds = 60
_last_text: str | None = None _last_text: str | None = None
_last_ui_refresh_at: float = 0.0 _last_ui_refresh_at: float = 0.0
_last_event_version: int = 0 _last_event_version: int = 0
_retry_after_until: float = 0.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 _strong_alert_cooldown_seconds = 120
_last_strong_alert_at_by_key: dict[str, float] = {} _last_strong_alert_at_by_key: dict[str, float] = {}
_last_execution_alert_key: str | None = None
@classmethod @classmethod
def register_screen( def register_screen(
cls, cls,
@@ -160,18 +158,25 @@ class AutoTradeRunner:
async def _handle_important_event(cls, state) -> None: async def _handle_important_event(cls, state) -> None:
event_type, payload = EventBus.last_event() 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 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 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 @classmethod
async def _send_strong_signal_alert(cls, *, state, payload: dict) -> None: async def _send_strong_signal_alert(cls, *, state, payload: dict) -> None:
if cls._bot is None or cls._chat_id is 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 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 "") 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 = ( alert_key = (
f"{symbol}:{strategy}:{signal}:" f"signal:{position_context}:{symbol}:{strategy}:{signal}:"
f"{repeat_count}:{confidence:.2f}:" f"{repeat_count}:{confidence:.2f}:"
f"{state.decision_status}:{reason}" f"{state.decision_status}:{reason}"
) )
@@ -207,25 +219,23 @@ class AutoTradeRunner:
leverage=leverage, leverage=leverage,
reason=reason, reason=reason,
cooldown_left=round(cls._strong_alert_cooldown_seconds - elapsed, 2), cooldown_left=round(cls._strong_alert_cooldown_seconds - elapsed, 2),
position_context=position_context,
) )
return return
cls._last_strong_alert_key = alert_key cls._last_strong_alert_key = alert_key
cls._last_strong_alert_at_by_key[alert_key] = now cls._last_strong_alert_at_by_key[alert_key] = now
signal_icon = { text = cls._build_strong_signal_alert_text(
"BUY": "🟢", signal=signal,
"SELL": "🔴", symbol=symbol,
}.get(signal, "🚨") strategy=strategy,
repeat_count=repeat_count,
leverage_text = f"x{leverage:g}" if isinstance(leverage, (int, float)) else "" confidence=confidence,
leverage=leverage,
text = ( reason=reason,
f"<b>🚨 Сильный сигнал {signal_icon} {signal}</b>\n\n" priority=priority,
f"{symbol} · {strategy} · {leverage_text}\n" position_context=position_context,
f"Confidence: {confidence:.2f}\n"
f"Repeats: {repeat_count}\n\n"
f"Причина: {reason}"
) )
try: try:
@@ -247,6 +257,8 @@ class AutoTradeRunner:
"confidence": confidence, "confidence": confidence,
"leverage": leverage, "leverage": leverage,
"reason": reason, "reason": reason,
"priority": priority,
"position_context": position_context,
}, },
) )
@@ -256,6 +268,117 @@ class AutoTradeRunner:
except Exception: except Exception:
pass 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"<b>📄 Paper position opened {side_icon} {side}</b>\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"<b>✅ Paper position closed</b>\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 "<b>📄 Paper execution event</b>"
@classmethod @classmethod
def _log_suppressed_strong_alert( def _log_suppressed_strong_alert(
cls, cls,
@@ -268,6 +391,7 @@ class AutoTradeRunner:
leverage: object, leverage: object,
reason: str, reason: str,
cooldown_left: float, cooldown_left: float,
position_context: str,
) -> None: ) -> None:
try: try:
JournalService().log_ui_info( JournalService().log_ui_info(
@@ -284,11 +408,117 @@ class AutoTradeRunner:
"leverage": leverage, "leverage": leverage,
"reason": reason, "reason": reason,
"cooldown_left": cooldown_left, "cooldown_left": cooldown_left,
"position_context": position_context,
}, },
) )
except Exception: except Exception:
pass 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"<b>{priority_text} · {icon} {signal}</b>\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 @classmethod
async def _refresh_screen(cls, *, force: bool = False) -> None: async def _refresh_screen(cls, *, force: bool = False) -> None:
now = time.monotonic() now = time.monotonic()

View File

@@ -172,6 +172,14 @@
- journal logging suppressed событий - journal logging suppressed событий
- не влияет на execution pipeline - не влияет на 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 ### 07.4.4
⏳ Grid Strategy ⏳ Grid Strategy

View File

@@ -102,8 +102,6 @@
✔ убраны дубли (WAITING / HOLD и т.д.) ✔ убраны дубли (WAITING / HOLD и т.д.)
✔ оптимизация под mobile ✔ оптимизация под mobile
---
#### 07.4.3.2 — Engine Decoupling (NEXT) #### 07.4.3.2 — Engine Decoupling (NEXT)
⏳ разделение: ⏳ разделение:
- analysis loop (частый) - analysis loop (частый)
@@ -117,8 +115,6 @@
- обновление только при изменении состояния - обновление только при изменении состояния
- защита от flood control - защита от flood control
---
#### Stage 07.4.3.3 — Paper Position & Execution Engine #### Stage 07.4.3.3 — Paper Position & Execution Engine
- добавлен ExecutionEngine - добавлен ExecutionEngine
- реализованы paper-позиции (LONG / SHORT) - реализованы paper-позиции (LONG / SHORT)
@@ -127,8 +123,6 @@
- логирование paper execution - логирование paper execution
- EventBus события (paper_position_opened) - EventBus события (paper_position_opened)
---
#### Stage 07.4.3.4 — Telegram Strong Signal Alerts #### Stage 07.4.3.4 — Telegram Strong Signal Alerts
- EventBus-driven уведомления - EventBus-driven уведомления
- Фильтрация READY сигналов - Фильтрация READY сигналов
@@ -142,8 +136,6 @@
- Execution lifecycle - Execution lifecycle
- Real trading notifications - Real trading notifications
---
#### Stage 07.4.3.5 — Debug Commands & Test Mode ✅ #### Stage 07.4.3.5 — Debug Commands & Test Mode ✅
- DEBUG_ENABLED env flag - DEBUG_ENABLED env flag
@@ -161,6 +153,14 @@
- journal logging suppressed событий - journal logging suppressed событий
- не влияет на execution pipeline - не влияет на 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 ### 07.4.4

View File

@@ -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.