07.4.3.18.1 — Runtime Event Skeleton Architecture
This commit is contained in:
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
)
|
||||
9
app/src/notifications/__init__.py
Normal file
9
app/src/notifications/__init__.py
Normal 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",
|
||||
]
|
||||
5
app/src/notifications/channels/__init__.py
Normal file
5
app/src/notifications/channels/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# app/src/notifications/channels/__init__.py
|
||||
|
||||
from src.notifications.channels.telegram import TelegramNotificationChannel
|
||||
|
||||
__all__ = ["TelegramNotificationChannel"]
|
||||
62
app/src/notifications/channels/telegram.py
Normal file
62
app/src/notifications/channels/telegram.py
Normal 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
|
||||
23
app/src/notifications/dedupe.py
Normal file
23
app/src/notifications/dedupe.py
Normal 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
|
||||
14
app/src/notifications/models.py
Normal file
14
app/src/notifications/models.py
Normal 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
|
||||
65
app/src/notifications/service.py
Normal file
65
app/src/notifications/service.py
Normal 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)
|
||||
)
|
||||
33
app/src/notifications/targets.py
Normal file
33
app/src/notifications/targets.py
Normal 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
|
||||
9
app/src/notifications/templates/__init__.py
Normal file
9
app/src/notifications/templates/__init__.py
Normal 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",
|
||||
]
|
||||
54
app/src/notifications/templates/execution.py
Normal file
54
app/src/notifications/templates/execution.py
Normal 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,
|
||||
)
|
||||
39
app/src/notifications/templates/signal.py
Normal file
39
app/src/notifications/templates/signal.py
Normal 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,
|
||||
)
|
||||
9
app/src/runtime_events/__init__.py
Normal file
9
app/src/runtime_events/__init__.py
Normal 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",
|
||||
]
|
||||
17
app/src/runtime_events/event_types.py
Normal file
17
app/src/runtime_events/event_types.py
Normal 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"
|
||||
20
app/src/runtime_events/models.py
Normal file
20
app/src/runtime_events/models.py
Normal 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))
|
||||
19
app/src/runtime_events/publisher.py
Normal file
19
app/src/runtime_events/publisher.py
Normal 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))
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user