07.4.3.18.1 — Runtime Event Skeleton Architecture

This commit is contained in:
2026-05-09 18:21:02 +03:00
parent 3181ac680c
commit 7e5ecc2ed9
22 changed files with 641 additions and 99 deletions

View File

@@ -0,0 +1,9 @@
# app/src/notifications/__init__.py
from src.notifications.models import NotificationMessage
from src.notifications.targets import NotificationTargetRegistry
__all__ = [
"NotificationMessage",
"NotificationTargetRegistry",
]

View File

@@ -0,0 +1,5 @@
# app/src/notifications/channels/__init__.py
from src.notifications.channels.telegram import TelegramNotificationChannel
__all__ = ["TelegramNotificationChannel"]

View File

@@ -0,0 +1,62 @@
# app/src/notifications/channels/telegram.py
from __future__ import annotations
from aiogram.exceptions import TelegramRetryAfter
from src.notifications.models import NotificationMessage
from src.notifications.targets import NotificationTargetRegistry
from src.trading.journal.service import JournalService
class TelegramNotificationChannel:
async def send(self, message: NotificationMessage) -> bool:
bot = NotificationTargetRegistry.get_bot()
chat_id = NotificationTargetRegistry.get_default_chat_id()
if bot is None or chat_id is None:
JournalService().log_warning(
"notification_target_missing",
"Telegram notification target is not registered.",
{
"title": message.title,
"priority": message.priority,
"dedupe_key": message.dedupe_key,
},
)
return False
try:
await bot.send_message(
chat_id=chat_id,
text=message.text,
parse_mode=message.parse_mode,
)
return True
except TelegramRetryAfter as exc:
JournalService().log_warning(
"notification_telegram_retry_after",
"Telegram notification delayed by retry-after.",
{
"title": message.title,
"retry_after": exc.retry_after,
"priority": message.priority,
"dedupe_key": message.dedupe_key,
},
)
return False
except Exception as exc:
JournalService().log_error(
"notification_telegram_error",
"Telegram notification failed.",
{
"title": message.title,
"error": str(exc),
"error_type": type(exc).__name__,
"priority": message.priority,
"dedupe_key": message.dedupe_key,
},
)
return False

View File

@@ -0,0 +1,23 @@
# app/src/notifications/dedupe.py
from __future__ import annotations
import time
class NotificationDedupe:
_sent_at_by_key: dict[str, float] = {}
@classmethod
def should_send(cls, key: str | None, *, ttl_seconds: int = 120) -> bool:
if not key:
return True
now = time.monotonic()
last_sent_at = cls._sent_at_by_key.get(key)
if last_sent_at is not None and now - last_sent_at < ttl_seconds:
return False
cls._sent_at_by_key[key] = now
return True

View File

@@ -0,0 +1,14 @@
# app/src/notifications/models.py
from __future__ import annotations
from dataclasses import dataclass
@dataclass(slots=True)
class NotificationMessage:
title: str
text: str
priority: str = "normal"
parse_mode: str = "HTML"
dedupe_key: str | None = None

View File

@@ -0,0 +1,65 @@
# app/src/notifications/service.py
from __future__ import annotations
from src.notifications.channels.telegram import TelegramNotificationChannel
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.models import RuntimeEvent
from src.trading.journal.service import JournalService
class NotificationService:
async def handle_runtime_event(self, event: RuntimeEvent) -> None:
message = self._build_message(event)
if message is None:
JournalService().log_info(
"runtime_event_ignored",
"Runtime event has no notification template.",
{
"event_type": event.event_type.value,
"source": event.source,
"title": event.title,
"priority": event.priority,
"dedupe_key": event.dedupe_key,
},
)
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,
},
)
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,
},
)
def _build_message(self, event: RuntimeEvent) -> NotificationMessage | None:
return (
build_signal_notification(event)
or build_execution_notification(event)
)

View File

@@ -0,0 +1,33 @@
# app/src/notifications/targets.py
from __future__ import annotations
from aiogram import Bot
class NotificationTargetRegistry:
_bot: Bot | None = None
_chat_id: int | None = None
@classmethod
def set_bot(cls, bot: Bot) -> None:
cls._bot = bot
@classmethod
def set_default_chat(cls, *, bot: Bot | None = None, chat_id: int) -> None:
if bot is not None:
cls._bot = bot
cls._chat_id = chat_id
@classmethod
def get_bot(cls) -> Bot | None:
return cls._bot
@classmethod
def get_default_chat_id(cls) -> int | None:
return cls._chat_id
@classmethod
def is_ready(cls) -> bool:
return cls._bot is not None and cls._chat_id is not None

View File

@@ -0,0 +1,9 @@
# app/src/notifications/templates/__init__.py
from src.notifications.templates.execution import build_execution_notification
from src.notifications.templates.signal import build_signal_notification
__all__ = [
"build_execution_notification",
"build_signal_notification",
]

View File

@@ -0,0 +1,54 @@
# app/src/notifications/templates/execution.py
from __future__ import annotations
from src.notifications.models import NotificationMessage
from src.runtime_events.event_types import RuntimeEventType
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
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")
if event.event_type == RuntimeEventType.POSITION_OPENED:
title = "📄 Position opened"
elif event.event_type == RuntimeEventType.POSITION_CLOSED:
title = "✅ Position closed"
else:
title = "🔁 Position flipped"
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}")
return NotificationMessage(
title=event.title,
text="\n".join(lines),
priority=event.priority,
dedupe_key=event.dedupe_key,
)

View File

@@ -0,0 +1,39 @@
# app/src/notifications/templates/signal.py
from __future__ import annotations
from src.notifications.models import NotificationMessage
from src.runtime_events.event_types import RuntimeEventType
from src.runtime_events.models import RuntimeEvent
def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None:
if event.event_type != RuntimeEventType.AUTO_SIGNAL_READY:
return None
payload = event.payload
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")
reason = str(payload.get("reason") or "")
icon = "🟢" if signal == "BUY" else "🔴" if signal == "SELL" else "🟡"
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"{reason}"
)
return NotificationMessage(
title=event.title,
text=text,
priority=event.priority,
dedupe_key=event.dedupe_key,
)