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

View File

@@ -1,3 +1,5 @@
# app/src/bootstrap/logging.py
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -7,4 +9,4 @@ def setup_logging(log_level: str) -> None:
logging.basicConfig( logging.basicConfig(
level=getattr(logging, log_level.upper(), logging.INFO), level=getattr(logging, log_level.upper(), logging.INFO),
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s", 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.auto.service import AutoTradeService
from src.trading.journal.service import JournalService from src.trading.journal.service import JournalService
from src.telegram.ui.currency_ui import format_usd_pnl, format_usd_price from src.telegram.ui.currency_ui import format_usd_pnl, format_usd_price
from src.notifications.targets import NotificationTargetRegistry
class AutoTradeRunner: class AutoTradeRunner:
@@ -56,6 +57,11 @@ class AutoTradeRunner:
cls._render_markup = render_markup cls._render_markup = render_markup
cls._last_text = None cls._last_text = None
NotificationTargetRegistry.set_default_chat(
bot=bot,
chat_id=chat_id,
)
@classmethod @classmethod
async def delete_registered_screen( async def delete_registered_screen(
cls, cls,

View File

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

View File

@@ -315,6 +315,22 @@
- подготовлена архитектура для Telegram push-уведомлений - подготовлена архитектура для Telegram push-уведомлений
- подготовлена база для runtime event notifications - подготовлена база для 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 ### 07.4.4
⏳ Grid Strategy ⏳ Grid Strategy

View File

@@ -299,6 +299,22 @@
- подготовлена архитектура для Telegram push-уведомлений - подготовлена архитектура для Telegram push-уведомлений
- подготовлена база для runtime event notifications - подготовлена база для 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 ### 07.4.4

View File

@@ -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.

View File

@@ -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 слой.