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

@@ -7,22 +7,19 @@ from aiogram.client.default import DefaultBotProperties
from src.bootstrap.logging import setup_logging
from src.core.config import load_settings
from src.notifications.targets import NotificationTargetRegistry
from src.storage.schema import init_schema
from src.telegram.routers import setup_routers
from src.trading.journal.service import JournalService
def create_app() -> tuple[Bot, Dispatcher]:
# загружаем настройки приложения
settings = load_settings()
# настраиваем logging
setup_logging(settings.log_level)
# сервис журнала
journal = JournalService()
# инициализация схемы БД
try:
init_schema()
except Exception as exc:
@@ -40,7 +37,6 @@ def create_app() -> tuple[Bot, Dispatcher]:
pass
raise
# лог старта приложения
try:
journal.log_info(
"app_start",
@@ -52,24 +48,17 @@ def create_app() -> tuple[Bot, Dispatcher]:
},
)
except Exception:
# журнал не должен ломать запуск приложения
pass
# здесь позже можно инициализировать stream/cache сервисы:
# init_market_cache()
# init_market_stream()
# init_auto_trade_runner()
# создаем Telegram Bot
bot = Bot(
token=settings.bot_token,
default=DefaultBotProperties(parse_mode=settings.bot_parse_mode),
)
# создаем Dispatcher
NotificationTargetRegistry.set_bot(bot)
dispatcher = Dispatcher()
# подключаем routers
setup_routers(dispatcher)
return bot, dispatcher

View File

@@ -1,3 +1,5 @@
# app/src/bootstrap/logging.py
from __future__ import annotations
import logging
@@ -7,4 +9,4 @@ def setup_logging(log_level: str) -> None:
logging.basicConfig(
level=getattr(logging, log_level.upper(), logging.INFO),
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
)

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,
)

View File

@@ -0,0 +1,9 @@
# app/src/runtime_events/__init__.py
from src.runtime_events.event_types import RuntimeEventType
from src.runtime_events.models import RuntimeEvent
__all__ = [
"RuntimeEvent",
"RuntimeEventType",
]

View File

@@ -0,0 +1,17 @@
# app/src/runtime_events/event_types.py
from __future__ import annotations
from enum import Enum
class RuntimeEventType(str, Enum):
AUTO_SIGNAL_READY = "auto_signal_ready"
POSITION_OPENED = "position_opened"
POSITION_CLOSED = "position_closed"
POSITION_FLIPPED = "position_flipped"
EXECUTION_BLOCKED = "execution_blocked"
RISK_ALERT = "risk_alert"
SYSTEM_ALERT = "system_alert"

View File

@@ -0,0 +1,20 @@
# app/src/runtime_events/models.py
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from src.runtime_events.event_types import RuntimeEventType
@dataclass(slots=True)
class RuntimeEvent:
event_type: RuntimeEventType
source: str
title: str
payload: dict[str, Any] = field(default_factory=dict)
priority: str = "normal"
dedupe_key: str | None = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

View File

@@ -0,0 +1,19 @@
# app/src/runtime_events/publisher.py
from __future__ import annotations
import asyncio
from src.notifications.service import NotificationService
from src.runtime_events.models import RuntimeEvent
class RuntimeEventPublisher:
@classmethod
def publish(cls, event: RuntimeEvent) -> None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
loop.create_task(NotificationService().handle_runtime_event(event))

View File

@@ -14,6 +14,7 @@ from src.integrations.exchange.market_data_runner import MarketDataRunner
from src.trading.auto.service import AutoTradeService
from src.trading.journal.service import JournalService
from src.telegram.ui.currency_ui import format_usd_pnl, format_usd_price
from src.notifications.targets import NotificationTargetRegistry
class AutoTradeRunner:
@@ -56,6 +57,11 @@ class AutoTradeRunner:
cls._render_markup = render_markup
cls._last_text = None
NotificationTargetRegistry.set_default_chat(
bot=bot,
chat_id=chat_id,
)
@classmethod
async def delete_registered_screen(
cls,

View File

@@ -11,6 +11,7 @@ from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter
from src.integrations.exchange.market_data_runner import MarketDataRunner
from src.trading.debug.service import DebugTradeService
from src.notifications.targets import NotificationTargetRegistry
class DebugTradeRunner:
@@ -48,6 +49,11 @@ class DebugTradeRunner:
cls._render_markup = render_markup
cls._last_text = None
NotificationTargetRegistry.set_default_chat(
bot=bot,
chat_id=chat_id,
)
@classmethod
async def delete_registered_screen(
cls,