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.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 (
"<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"))
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}"
)

View File

@@ -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"<b>🚨 Сильный сигнал {signal_icon} {signal}</b>\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"<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
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"<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
async def _refresh_screen(cls, *, force: bool = False) -> None:
now = time.monotonic()