Stage 07.4.3.7 — Alert Priority & UX Improvements
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user