07.4.3.18.2 — Runtime Notification Migration
This commit is contained in:
@@ -7,6 +7,7 @@ from src.notifications.dedupe import NotificationDedupe
|
||||
from src.notifications.models import NotificationMessage
|
||||
from src.notifications.templates.execution import build_execution_notification
|
||||
from src.notifications.templates.signal import build_signal_notification
|
||||
from src.runtime_events.event_types import RuntimeEventType
|
||||
from src.runtime_events.models import RuntimeEvent
|
||||
from src.trading.journal.service import JournalService
|
||||
|
||||
@@ -30,36 +31,98 @@ class NotificationService:
|
||||
return
|
||||
|
||||
if not NotificationDedupe.should_send(message.dedupe_key):
|
||||
JournalService().log_info(
|
||||
"notification_suppressed_duplicate",
|
||||
"Duplicate notification suppressed.",
|
||||
{
|
||||
"event_type": event.event_type.value,
|
||||
"source": event.source,
|
||||
"title": event.title,
|
||||
"priority": event.priority,
|
||||
"dedupe_key": message.dedupe_key,
|
||||
},
|
||||
)
|
||||
self._log_suppressed(event, message)
|
||||
return
|
||||
|
||||
sent = await TelegramNotificationChannel().send(message)
|
||||
|
||||
if sent:
|
||||
JournalService().log_info(
|
||||
"notification_sent",
|
||||
"Runtime notification sent.",
|
||||
{
|
||||
"event_type": event.event_type.value,
|
||||
"source": event.source,
|
||||
"title": event.title,
|
||||
"priority": event.priority,
|
||||
"dedupe_key": message.dedupe_key,
|
||||
},
|
||||
)
|
||||
self._log_sent(event, message)
|
||||
|
||||
def _build_message(self, event: RuntimeEvent) -> NotificationMessage | None:
|
||||
return (
|
||||
build_signal_notification(event)
|
||||
or build_execution_notification(event)
|
||||
)
|
||||
|
||||
def _log_sent(self, event: RuntimeEvent, message: NotificationMessage) -> None:
|
||||
if event.event_type == RuntimeEventType.AUTO_SIGNAL_READY:
|
||||
signal = str(event.payload.get("signal") or "—").upper()
|
||||
|
||||
JournalService().log_ui_info(
|
||||
event_type="auto_strong_signal_alert_sent",
|
||||
message=f"Отправлено уведомление о сильном сигнале {signal}.",
|
||||
screen="auto",
|
||||
action="strong_signal_alert",
|
||||
payload={
|
||||
**event.payload,
|
||||
"runtime_event_type": event.event_type.value,
|
||||
"runtime_source": event.source,
|
||||
"priority": message.priority.upper(),
|
||||
"dedupe_key": message.dedupe_key,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if event.event_type in {
|
||||
RuntimeEventType.POSITION_OPENED,
|
||||
RuntimeEventType.POSITION_CLOSED,
|
||||
RuntimeEventType.POSITION_FLIPPED,
|
||||
}:
|
||||
JournalService().log_ui_info(
|
||||
event_type="auto_execution_alert_sent",
|
||||
message="Отправлено Telegram-уведомление по paper execution.",
|
||||
screen="auto",
|
||||
action="execution_alert",
|
||||
payload={
|
||||
**event.payload,
|
||||
"runtime_event_type": event.event_type.value,
|
||||
"runtime_source": event.source,
|
||||
"priority": message.priority.upper(),
|
||||
"dedupe_key": message.dedupe_key,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
JournalService().log_info(
|
||||
"notification_sent",
|
||||
"Runtime notification sent.",
|
||||
{
|
||||
"event_type": event.event_type.value,
|
||||
"source": event.source,
|
||||
"title": event.title,
|
||||
"priority": event.priority,
|
||||
"dedupe_key": message.dedupe_key,
|
||||
},
|
||||
)
|
||||
|
||||
def _log_suppressed(self, event: RuntimeEvent, message: NotificationMessage) -> None:
|
||||
if event.event_type == RuntimeEventType.AUTO_SIGNAL_READY:
|
||||
signal = str(event.payload.get("signal") or "—").upper()
|
||||
|
||||
JournalService().log_ui_info(
|
||||
event_type="auto_strong_signal_alert_suppressed",
|
||||
message=f"Повторное уведомление о сильном сигнале {signal} подавлено.",
|
||||
screen="auto",
|
||||
action="strong_signal_alert",
|
||||
payload={
|
||||
**event.payload,
|
||||
"runtime_event_type": event.event_type.value,
|
||||
"runtime_source": event.source,
|
||||
"priority": message.priority.upper(),
|
||||
"dedupe_key": message.dedupe_key,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
JournalService().log_info(
|
||||
"notification_suppressed_duplicate",
|
||||
"Duplicate notification suppressed.",
|
||||
{
|
||||
"event_type": event.event_type.value,
|
||||
"source": event.source,
|
||||
"title": event.title,
|
||||
"priority": event.priority,
|
||||
"dedupe_key": message.dedupe_key,
|
||||
},
|
||||
)
|
||||
@@ -8,47 +8,165 @@ from src.runtime_events.models import RuntimeEvent
|
||||
|
||||
|
||||
def build_execution_notification(event: RuntimeEvent) -> NotificationMessage | None:
|
||||
if event.event_type not in {
|
||||
RuntimeEventType.POSITION_OPENED,
|
||||
RuntimeEventType.POSITION_CLOSED,
|
||||
RuntimeEventType.POSITION_FLIPPED,
|
||||
}:
|
||||
return None
|
||||
if event.event_type == RuntimeEventType.POSITION_OPENED:
|
||||
return _build_position_opened(event)
|
||||
|
||||
if event.event_type == RuntimeEventType.POSITION_CLOSED:
|
||||
return _build_position_closed(event)
|
||||
|
||||
if event.event_type == RuntimeEventType.POSITION_FLIPPED:
|
||||
return _build_position_flipped(event)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _build_position_opened(event: RuntimeEvent) -> NotificationMessage:
|
||||
payload = event.payload
|
||||
|
||||
symbol = str(payload.get("symbol") or "—")
|
||||
side = str(payload.get("side") or payload.get("new_side") or "—")
|
||||
entry_price = payload.get("entry_price") or payload.get("new_entry_price")
|
||||
exit_price = payload.get("exit_price")
|
||||
pnl = payload.get("pnl")
|
||||
symbol = _format_symbol(payload.get("symbol"))
|
||||
side = str(payload.get("side") or "—")
|
||||
leverage = _format_leverage(payload.get("leverage"))
|
||||
entry_price = _format_price(payload.get("entry_price"))
|
||||
size = _format_size(payload.get("size"))
|
||||
|
||||
if event.event_type == RuntimeEventType.POSITION_OPENED:
|
||||
title = "📄 Position opened"
|
||||
elif event.event_type == RuntimeEventType.POSITION_CLOSED:
|
||||
title = "✅ Position closed"
|
||||
else:
|
||||
title = "🔁 Position flipped"
|
||||
side_icon = "🟢" if side == "LONG" else "🔴"
|
||||
|
||||
lines = [
|
||||
f"<b>{title}</b>",
|
||||
"",
|
||||
f"{symbol} · {side}",
|
||||
]
|
||||
|
||||
if entry_price is not None:
|
||||
lines.append(f"Entry: $ {entry_price}")
|
||||
|
||||
if exit_price is not None:
|
||||
lines.append(f"Exit: $ {exit_price}")
|
||||
|
||||
if pnl is not None:
|
||||
lines.append("")
|
||||
lines.append(f"PnL: {pnl}")
|
||||
text = (
|
||||
f"<b>📄 Paper position opened {side_icon} {side}</b>\n\n"
|
||||
f"{symbol} · {leverage}\n"
|
||||
f"Entry: $ {entry_price}\n"
|
||||
f"Size: {size}"
|
||||
)
|
||||
|
||||
return NotificationMessage(
|
||||
title=event.title,
|
||||
text="\n".join(lines),
|
||||
text=text,
|
||||
priority=event.priority,
|
||||
dedupe_key=event.dedupe_key,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _build_position_closed(event: RuntimeEvent) -> NotificationMessage:
|
||||
payload = event.payload
|
||||
|
||||
symbol = _format_symbol(payload.get("symbol"))
|
||||
side = str(payload.get("side") or "—")
|
||||
leverage = _format_leverage(payload.get("leverage"))
|
||||
entry_price = _format_price(payload.get("entry_price"))
|
||||
exit_price = _format_price(payload.get("exit_price"))
|
||||
size = _format_size(payload.get("size"))
|
||||
pnl = _format_pnl(payload.get("pnl"))
|
||||
risk_reason = payload.get("risk_reason")
|
||||
|
||||
risk_line = f"\nRisk: {risk_reason}" if risk_reason else ""
|
||||
|
||||
text = (
|
||||
f"<b>✅ Paper position closed</b>\n\n"
|
||||
f"{side} · {symbol} · {leverage}\n"
|
||||
f"Entry: $ {entry_price}\n"
|
||||
f"Exit: $ {exit_price}\n"
|
||||
f"Size: {size}\n\n"
|
||||
f"PnL: {pnl}"
|
||||
f"{risk_line}"
|
||||
)
|
||||
|
||||
return NotificationMessage(
|
||||
title=event.title,
|
||||
text=text,
|
||||
priority=event.priority,
|
||||
dedupe_key=event.dedupe_key,
|
||||
)
|
||||
|
||||
|
||||
def _build_position_flipped(event: RuntimeEvent) -> NotificationMessage:
|
||||
payload = event.payload
|
||||
|
||||
symbol = _format_symbol(payload.get("symbol"))
|
||||
leverage = _format_leverage(payload.get("leverage"))
|
||||
|
||||
old_side = str(payload.get("old_side") or "—")
|
||||
new_side = str(payload.get("new_side") or payload.get("side") or "—")
|
||||
|
||||
entry_price = _format_price(payload.get("entry_price"))
|
||||
exit_price = _format_price(payload.get("exit_price"))
|
||||
new_entry_price = _format_price(payload.get("new_entry_price"))
|
||||
old_size = _format_size(payload.get("old_size"))
|
||||
new_size = _format_size(payload.get("new_size"))
|
||||
pnl = _format_pnl(payload.get("pnl"))
|
||||
|
||||
old_icon = "🟢" if old_side == "LONG" else "🔴"
|
||||
new_icon = "🟢" if new_side == "LONG" else "🔴"
|
||||
|
||||
text = (
|
||||
f"<b>🔁 Paper position flipped {old_icon} {old_side} → "
|
||||
f"{new_icon} {new_side}</b>\n\n"
|
||||
f"{symbol} · {leverage}\n\n"
|
||||
f"Old entry: $ {entry_price}\n"
|
||||
f"Exit: $ {exit_price}\n"
|
||||
f"Old size: {old_size}\n\n"
|
||||
f"New entry: $ {new_entry_price}\n"
|
||||
f"New size: {new_size}\n\n"
|
||||
f"PnL: {pnl}"
|
||||
)
|
||||
|
||||
return NotificationMessage(
|
||||
title=event.title,
|
||||
text=text,
|
||||
priority=event.priority,
|
||||
dedupe_key=event.dedupe_key,
|
||||
)
|
||||
|
||||
|
||||
def _format_symbol(value: object) -> str:
|
||||
symbol = str(value or "—")
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _format_leverage(value: object) -> str:
|
||||
try:
|
||||
return f"x{float(value):g}"
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
|
||||
|
||||
def _format_price(value: object) -> str:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
|
||||
return f"{number:,.2f}".replace(",", " ")
|
||||
|
||||
|
||||
def _format_size(value: object) -> str:
|
||||
try:
|
||||
return f"{float(value):.8f}".rstrip("0").rstrip(".")
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
|
||||
|
||||
def _format_pnl(value: object) -> str:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
|
||||
amount = f"$ {abs(number):,.2f}".replace(",", " ").rstrip("0").rstrip(".")
|
||||
|
||||
if number > 0:
|
||||
return f"🟢 +{amount}"
|
||||
|
||||
if number < 0:
|
||||
return f"🔴 −{amount}"
|
||||
|
||||
return "$ 0"
|
||||
@@ -16,24 +16,96 @@ def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None
|
||||
signal = str(payload.get("signal") or "—").upper()
|
||||
symbol = str(payload.get("symbol") or "—")
|
||||
strategy = str(payload.get("strategy") or "—")
|
||||
confidence = payload.get("confidence")
|
||||
repeat_count = payload.get("repeat_count")
|
||||
confidence = float(payload.get("confidence") or 0.0)
|
||||
repeat_count = int(payload.get("repeat_count") or 0)
|
||||
leverage = payload.get("leverage")
|
||||
reason = str(payload.get("reason") or "—")
|
||||
position_context = str(payload.get("position_context") or "NONE")
|
||||
|
||||
icon = "🟢" if signal == "BUY" else "🔴" if signal == "SELL" else "🟡"
|
||||
priority = str(event.priority or _alert_priority(
|
||||
confidence=confidence,
|
||||
repeat_count=repeat_count,
|
||||
)).upper()
|
||||
|
||||
icon = _signal_icon(signal)
|
||||
symbol_text = _format_symbol(symbol)
|
||||
leverage_text = _format_leverage(leverage)
|
||||
priority_text = _priority_label(priority)
|
||||
|
||||
text = (
|
||||
f"<b>🚨 Runtime Signal · {icon} {signal}</b>\n\n"
|
||||
f"{symbol} · {strategy}\n"
|
||||
f"Confidence: {confidence}\n"
|
||||
f"Repeats: {repeat_count}\n\n"
|
||||
f"Причина:\n"
|
||||
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}"
|
||||
)
|
||||
|
||||
return NotificationMessage(
|
||||
title=event.title,
|
||||
text=text,
|
||||
priority=event.priority,
|
||||
dedupe_key=event.dedupe_key,
|
||||
priority=priority.lower(),
|
||||
dedupe_key=event.dedupe_key or _dedupe_key(payload),
|
||||
)
|
||||
|
||||
|
||||
def _alert_priority(*, 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"
|
||||
|
||||
|
||||
def _priority_label(priority: str) -> str:
|
||||
mapping = {
|
||||
"HIGH": "🚨 HIGH",
|
||||
"MEDIUM": "⚡ MEDIUM",
|
||||
"LOW": "ℹ️ LOW",
|
||||
}
|
||||
return mapping.get(priority.upper(), priority)
|
||||
|
||||
|
||||
def _signal_icon(signal: str) -> str:
|
||||
mapping = {
|
||||
"BUY": "🟢",
|
||||
"SELL": "🔴",
|
||||
}
|
||||
return mapping.get(signal, "⚪")
|
||||
|
||||
|
||||
def _format_symbol(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
|
||||
|
||||
|
||||
def _format_leverage(leverage: object) -> str:
|
||||
if isinstance(leverage, (int, float)):
|
||||
return f"x{leverage:g}"
|
||||
|
||||
return "—"
|
||||
|
||||
|
||||
def _dedupe_key(payload: dict) -> str:
|
||||
return (
|
||||
f"auto_signal_ready:"
|
||||
f"{payload.get('position_context')}:"
|
||||
f"{payload.get('symbol')}:"
|
||||
f"{payload.get('strategy')}:"
|
||||
f"{payload.get('signal')}:"
|
||||
f"{payload.get('repeat_count')}:"
|
||||
f"{float(payload.get('confidence') or 0.0):.2f}:"
|
||||
f"{payload.get('decision_status')}:"
|
||||
f"{payload.get('reason')}"
|
||||
)
|
||||
Reference in New Issue
Block a user