diff --git a/app/src/bootstrap/app_factory.py b/app/src/bootstrap/app_factory.py index bbe0d7b..facaaaf 100644 --- a/app/src/bootstrap/app_factory.py +++ b/app/src/bootstrap/app_factory.py @@ -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 \ No newline at end of file diff --git a/app/src/bootstrap/logging.py b/app/src/bootstrap/logging.py index 86540e7..b1c1bfb 100644 --- a/app/src/bootstrap/logging.py +++ b/app/src/bootstrap/logging.py @@ -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", - ) + ) \ No newline at end of file diff --git a/app/src/notifications/__init__.py b/app/src/notifications/__init__.py new file mode 100644 index 0000000..d430467 --- /dev/null +++ b/app/src/notifications/__init__.py @@ -0,0 +1,9 @@ +# app/src/notifications/__init__.py + +from src.notifications.models import NotificationMessage +from src.notifications.targets import NotificationTargetRegistry + +__all__ = [ + "NotificationMessage", + "NotificationTargetRegistry", +] \ No newline at end of file diff --git a/app/src/notifications/channels/__init__.py b/app/src/notifications/channels/__init__.py new file mode 100644 index 0000000..95c5dfa --- /dev/null +++ b/app/src/notifications/channels/__init__.py @@ -0,0 +1,5 @@ +# app/src/notifications/channels/__init__.py + +from src.notifications.channels.telegram import TelegramNotificationChannel + +__all__ = ["TelegramNotificationChannel"] \ No newline at end of file diff --git a/app/src/notifications/channels/telegram.py b/app/src/notifications/channels/telegram.py new file mode 100644 index 0000000..cefea86 --- /dev/null +++ b/app/src/notifications/channels/telegram.py @@ -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 \ No newline at end of file diff --git a/app/src/notifications/dedupe.py b/app/src/notifications/dedupe.py new file mode 100644 index 0000000..1ab82fc --- /dev/null +++ b/app/src/notifications/dedupe.py @@ -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 \ No newline at end of file diff --git a/app/src/notifications/models.py b/app/src/notifications/models.py new file mode 100644 index 0000000..0a03b4a --- /dev/null +++ b/app/src/notifications/models.py @@ -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 \ No newline at end of file diff --git a/app/src/notifications/service.py b/app/src/notifications/service.py new file mode 100644 index 0000000..5de6c5b --- /dev/null +++ b/app/src/notifications/service.py @@ -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) + ) \ No newline at end of file diff --git a/app/src/notifications/targets.py b/app/src/notifications/targets.py new file mode 100644 index 0000000..e4f99a3 --- /dev/null +++ b/app/src/notifications/targets.py @@ -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 \ No newline at end of file diff --git a/app/src/notifications/templates/__init__.py b/app/src/notifications/templates/__init__.py new file mode 100644 index 0000000..1afa6f3 --- /dev/null +++ b/app/src/notifications/templates/__init__.py @@ -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", +] \ No newline at end of file diff --git a/app/src/notifications/templates/execution.py b/app/src/notifications/templates/execution.py new file mode 100644 index 0000000..5a2188e --- /dev/null +++ b/app/src/notifications/templates/execution.py @@ -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"{title}", + "", + 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, + ) \ No newline at end of file diff --git a/app/src/notifications/templates/signal.py b/app/src/notifications/templates/signal.py new file mode 100644 index 0000000..83470e4 --- /dev/null +++ b/app/src/notifications/templates/signal.py @@ -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"🚨 Runtime Signal · {icon} {signal}\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, + ) \ No newline at end of file diff --git a/app/src/runtime_events/__init__.py b/app/src/runtime_events/__init__.py new file mode 100644 index 0000000..a33044b --- /dev/null +++ b/app/src/runtime_events/__init__.py @@ -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", +] \ No newline at end of file diff --git a/app/src/runtime_events/event_types.py b/app/src/runtime_events/event_types.py new file mode 100644 index 0000000..ad1ad1c --- /dev/null +++ b/app/src/runtime_events/event_types.py @@ -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" \ No newline at end of file diff --git a/app/src/runtime_events/models.py b/app/src/runtime_events/models.py new file mode 100644 index 0000000..b3591c6 --- /dev/null +++ b/app/src/runtime_events/models.py @@ -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)) \ No newline at end of file diff --git a/app/src/runtime_events/publisher.py b/app/src/runtime_events/publisher.py new file mode 100644 index 0000000..767137d --- /dev/null +++ b/app/src/runtime_events/publisher.py @@ -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)) \ No newline at end of file diff --git a/app/src/trading/auto/runner.py b/app/src/trading/auto/runner.py index 8cb657f..e3ffaa3 100644 --- a/app/src/trading/auto/runner.py +++ b/app/src/trading/auto/runner.py @@ -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, diff --git a/app/src/trading/debug/runner.py b/app/src/trading/debug/runner.py index d3f5ca9..737508b 100644 --- a/app/src/trading/debug/runner.py +++ b/app/src/trading/debug/runner.py @@ -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, diff --git a/docs/roadmap/master-roadmap.md b/docs/roadmap/master-roadmap.md index 6f15499..b34f107 100644 --- a/docs/roadmap/master-roadmap.md +++ b/docs/roadmap/master-roadmap.md @@ -315,6 +315,22 @@ - подготовлена архитектура для Telegram push-уведомлений - подготовлена база для runtime event notifications +#### 07.4.3.18.1 — Runtime Event Skeleton Architecture +- добавлен слой runtime_events +- добавлен слой notifications +- создана модель RuntimeEvent +- создана модель NotificationMessage +- добавлен RuntimeEventPublisher +- добавлен NotificationService skeleton +- добавлен TelegramNotificationChannel +- добавлен NotificationTargetRegistry +- добавлена базовая дедупликация уведомлений +- добавлены шаблоны signal/execution notifications +- зарегистрирован Telegram bot в notification target registry +- зарегистрирован default chat из AUTO/DEBUG runners +- исправлен circular import в package init файлах +- подготовлена архитектура для переноса strong signal alerts + ### 07.4.4 ⏳ Grid Strategy diff --git a/docs/roadmap/stage-07-auto-trading-roadmap.md b/docs/roadmap/stage-07-auto-trading-roadmap.md index 495ede4..18e7a56 100644 --- a/docs/roadmap/stage-07-auto-trading-roadmap.md +++ b/docs/roadmap/stage-07-auto-trading-roadmap.md @@ -299,6 +299,22 @@ - подготовлена архитектура для Telegram push-уведомлений - подготовлена база для runtime event notifications +#### 07.4.3.18.1 — Runtime Event Skeleton Architecture +- добавлен слой runtime_events +- добавлен слой notifications +- создана модель RuntimeEvent +- создана модель NotificationMessage +- добавлен RuntimeEventPublisher +- добавлен NotificationService skeleton +- добавлен TelegramNotificationChannel +- добавлен NotificationTargetRegistry +- добавлена базовая дедупликация уведомлений +- добавлены шаблоны signal/execution notifications +- зарегистрирован Telegram bot в notification target registry +- зарегистрирован default chat из AUTO/DEBUG runners +- исправлен circular import в package init файлах +- подготовлена архитектура для переноса strong signal alerts + --- ### 07.4.4 diff --git a/docs/stages/ROADMAP_UPDATE_07.4.3.17.md b/docs/stages/ROADMAP_UPDATE_07.4.3.17.md deleted file mode 100644 index d115ff5..0000000 --- a/docs/stages/ROADMAP_UPDATE_07.4.3.17.md +++ /dev/null @@ -1,84 +0,0 @@ -# ROADMAP UPDATE — Этап 07.4.3.17 - -## Завершено - -### Active Screen Lifecycle -- внедрён единый lifecycle основных экранов; -- реализовано автоматическое закрытие предыдущего экрана; -- устранено накопление Telegram UI-сообщений; -- унифицировано поведение всех основных экранов. - ---- - -## Обновлённая архитектура - -### ActiveScreenManager -Теперь отвечает за: -- регистрацию активного экрана; -- удаление предыдущего экрана; -- переключение UI. - -### LiveScreenRunner -Теперь отвечает только за: -- автообновление live-данных; -- worker loop; -- refresh Telegram message. - -### Trading Runtime -Теперь полностью независим от UI: -- AutoTradeRunner; -- DebugTradeRunner; -- future push-events. - ---- - -## Исправленные разделы - -### Основные экраны -- Главная -- Мониторинг -- Рынок -- Портфель -- Журнал -- Торговля -- Система -- Автоторговля -- Debug Auto - ---- - -## Новое поведение системы - -### UI -- всегда существует только один основной экран; -- при открытии нового старый удаляется автоматически. - -### Background Runtime -- продолжает работать независимо от UI; -- готов к Telegram push-событиям. - ---- - -# Следующий этап - -## Planned - -### Telegram Event Push Layer -Планируется внедрение: -- уведомлений об открытии позиции; -- уведомлений о закрытии позиции; -- уведомлений о сигналах; -- runtime alerts; -- system alerts; -- execution events. - ---- - -## Архитектурная готовность - -Система теперь готова к: -- background runtime; -- multi-event notifications; -- persistent runtime state; -- restart-safe workflows; -- production lifecycle management. diff --git a/docs/stages/stage-07_4_3_18_1-runtime_event_skeleton_architecture.md b/docs/stages/stage-07_4_3_18_1-runtime_event_skeleton_architecture.md new file mode 100644 index 0000000..1f9ecc3 --- /dev/null +++ b/docs/stages/stage-07_4_3_18_1-runtime_event_skeleton_architecture.md @@ -0,0 +1,213 @@ +# 07.4.3.18.1 — Skeleton Architecture + +## Цель этапа + +Заложить базовую архитектуру слоя runtime-событий и Telegram-уведомлений без переноса существующей бизнес-логики уведомлений. + +Главная задача этапа — подготовить ядро, чтобы в следующих шагах можно было переносить уведомления из `AutoTradeRunner`, `DebugTradeRunner`, `ExecutionEngine` и других runtime-компонентов без переписывания архитектуры. + +## Что добавлено + +### Runtime Events Layer + +Создан новый слой: + +```text +app/src/runtime_events/ + __init__.py + event_types.py + models.py + publisher.py +``` + +Назначение слоя: + +```text +runtime logic + ↓ +RuntimeEvent + ↓ +RuntimeEventPublisher +``` + +Этот слой отвечает за публикацию событий runtime-уровня, но не знает деталей Telegram, UI или конкретных шаблонов сообщений. + +### Notification Layer + +Создан новый слой: + +```text +app/src/notifications/ + __init__.py + models.py + service.py + targets.py + dedupe.py + templates/ + __init__.py + signal.py + execution.py + channels/ + __init__.py + telegram.py +``` + +Назначение слоя: + +```text +RuntimeEvent + ↓ +NotificationService + ↓ +Notification template + ↓ +TelegramNotificationChannel +``` + +Этот слой отвечает за: +- выбор шаблона уведомления; +- дедупликацию уведомлений; +- отправку в Telegram; +- логирование результата доставки; +- централизованное хранение Telegram target. + +## Добавленные runtime event types + +Добавлен enum `RuntimeEventType`: + +```text +AUTO_SIGNAL_READY +POSITION_OPENED +POSITION_CLOSED +POSITION_FLIPPED +EXECUTION_BLOCKED +RISK_ALERT +SYSTEM_ALERT +``` + +На этапе 07.4.3.18.1 эти события только описаны архитектурно. Массовый перенос runtime-логики на них будет сделан следующими подэтапами. + +## Добавлена модель RuntimeEvent + +Добавлена единая модель события: + +```text +RuntimeEvent + event_type + source + title + payload + priority + dedupe_key + created_at +``` + +Это будущий контракт между торговым runtime и системой уведомлений. + +## Добавлена модель NotificationMessage + +Добавлена единая модель готового уведомления: + +```text +NotificationMessage + title + text + priority + parse_mode + dedupe_key +``` + +Эта модель отделяет runtime-событие от конкретного Telegram-сообщения. + +## Добавлен NotificationTargetRegistry + +Создан in-memory registry для хранения активной Telegram-цели уведомлений: + +```text +NotificationTargetRegistry + set_bot() + set_default_chat() + get_bot() + get_default_chat_id() + is_ready() +``` + +Пока target хранится в памяти. В будущем его можно перенести в БД без изменения runtime-слоя. + +## Интеграция с app bootstrap + +В `app/src/bootstrap/app_factory.py` добавлена регистрация `Bot` в `NotificationTargetRegistry` сразу после создания Telegram Bot. + +Это даёт notification layer доступ к bot instance без прямой зависимости от UI handler-ов. + +## Интеграция с AUTO/DEBUG runner + +В `AutoTradeRunner.register_screen()` и `DebugTradeRunner.register_screen()` добавлена регистрация текущего `chat_id` как default notification target. + +Это позволяет будущим runtime-уведомлениям знать, куда отправлять сообщения. + +## Что не менялось на этом этапе + +На этом этапе намеренно не переносились: + +- strong signal alerts; +- paper execution alerts; +- risk alerts; +- Telegram send_message из существующих runner-ов; +- cooldown-логика strong signal; +- execution alert dedupe. + +Эти изменения будут выполняться поэтапно, чтобы не сломать текущую рабочую логику. + +## Исправление circular import + +После первичной интеграции был найден circular import: + +```text +notifications.__init__ + → NotificationService + → runtime_events + → RuntimeEventPublisher + → NotificationService +``` + +Исправление: + +- из `runtime_events/__init__.py` убран экспорт `RuntimeEventPublisher`; +- из `notifications/__init__.py` убран экспорт `NotificationService`. + +Теперь прямые импорты выполняются из конкретных модулей: + +```python +from src.runtime_events.publisher import RuntimeEventPublisher +from src.notifications.service import NotificationService +``` + +## Проверка + +Проверено: + +```bash +python -m compileall src +python -m src.main +``` + +Ожидаемое поведение после этапа: + +- бот запускается без ImportError; +- основные экраны открываются; +- текущая UI lifecycle логика не меняется; +- новых runtime Telegram-уведомлений пока не появляется; +- notification target регистрируется при открытии AUTO/DEBUG экранов. + +## Итог + +Этап 07.4.3.18.1 завершает подготовку архитектурного skeleton для будущего runtime notification pipeline. + +Следующий этап: + +```text +07.4.3.18.2 — Move strong signal alerts +``` + +На нём strong signal alerts будут перенесены из `AutoTradeRunner` в новый runtime event / notification слой.