diff --git a/app/src/notifications/service.py b/app/src/notifications/service.py
index 5de6c5b..73cf853 100644
--- a/app/src/notifications/service.py
+++ b/app/src/notifications/service.py
@@ -7,6 +7,7 @@ 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.event_types import RuntimeEventType
from src.runtime_events.models import RuntimeEvent
from src.trading.journal.service import JournalService
@@ -30,36 +31,98 @@ class NotificationService:
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,
- },
- )
+ self._log_suppressed(event, message)
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,
- },
- )
+ self._log_sent(event, message)
def _build_message(self, event: RuntimeEvent) -> NotificationMessage | None:
return (
build_signal_notification(event)
or build_execution_notification(event)
+ )
+
+ def _log_sent(self, event: RuntimeEvent, message: NotificationMessage) -> None:
+ if event.event_type == RuntimeEventType.AUTO_SIGNAL_READY:
+ signal = str(event.payload.get("signal") or "—").upper()
+
+ JournalService().log_ui_info(
+ event_type="auto_strong_signal_alert_sent",
+ message=f"Отправлено уведомление о сильном сигнале {signal}.",
+ screen="auto",
+ action="strong_signal_alert",
+ payload={
+ **event.payload,
+ "runtime_event_type": event.event_type.value,
+ "runtime_source": event.source,
+ "priority": message.priority.upper(),
+ "dedupe_key": message.dedupe_key,
+ },
+ )
+ return
+
+ if event.event_type in {
+ RuntimeEventType.POSITION_OPENED,
+ RuntimeEventType.POSITION_CLOSED,
+ RuntimeEventType.POSITION_FLIPPED,
+ }:
+ JournalService().log_ui_info(
+ event_type="auto_execution_alert_sent",
+ message="Отправлено Telegram-уведомление по paper execution.",
+ screen="auto",
+ action="execution_alert",
+ payload={
+ **event.payload,
+ "runtime_event_type": event.event_type.value,
+ "runtime_source": event.source,
+ "priority": message.priority.upper(),
+ "dedupe_key": message.dedupe_key,
+ },
+ )
+ return
+
+ 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 _log_suppressed(self, event: RuntimeEvent, message: NotificationMessage) -> None:
+ if event.event_type == RuntimeEventType.AUTO_SIGNAL_READY:
+ signal = str(event.payload.get("signal") or "—").upper()
+
+ JournalService().log_ui_info(
+ event_type="auto_strong_signal_alert_suppressed",
+ message=f"Повторное уведомление о сильном сигнале {signal} подавлено.",
+ screen="auto",
+ action="strong_signal_alert",
+ payload={
+ **event.payload,
+ "runtime_event_type": event.event_type.value,
+ "runtime_source": event.source,
+ "priority": message.priority.upper(),
+ "dedupe_key": message.dedupe_key,
+ },
+ )
+ return
+
+ 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,
+ },
)
\ No newline at end of file
diff --git a/app/src/notifications/templates/execution.py b/app/src/notifications/templates/execution.py
index 5a2188e..48d0a6d 100644
--- a/app/src/notifications/templates/execution.py
+++ b/app/src/notifications/templates/execution.py
@@ -8,47 +8,165 @@ 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
+ if event.event_type == RuntimeEventType.POSITION_OPENED:
+ return _build_position_opened(event)
+ if event.event_type == RuntimeEventType.POSITION_CLOSED:
+ return _build_position_closed(event)
+
+ if event.event_type == RuntimeEventType.POSITION_FLIPPED:
+ return _build_position_flipped(event)
+
+ return None
+
+
+def _build_position_opened(event: RuntimeEvent) -> NotificationMessage:
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")
+ symbol = _format_symbol(payload.get("symbol"))
+ side = str(payload.get("side") or "—")
+ leverage = _format_leverage(payload.get("leverage"))
+ entry_price = _format_price(payload.get("entry_price"))
+ size = _format_size(payload.get("size"))
- if event.event_type == RuntimeEventType.POSITION_OPENED:
- title = "📄 Position opened"
- elif event.event_type == RuntimeEventType.POSITION_CLOSED:
- title = "✅ Position closed"
- else:
- title = "🔁 Position flipped"
+ side_icon = "🟢" if side == "LONG" else "🔴"
- 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}")
+ text = (
+ f"📄 Paper position opened {side_icon} {side}\n\n"
+ f"{symbol} · {leverage}\n"
+ f"Entry: $ {entry_price}\n"
+ f"Size: {size}"
+ )
return NotificationMessage(
title=event.title,
- text="\n".join(lines),
+ text=text,
priority=event.priority,
dedupe_key=event.dedupe_key,
- )
\ No newline at end of file
+ )
+
+
+def _build_position_closed(event: RuntimeEvent) -> NotificationMessage:
+ payload = event.payload
+
+ symbol = _format_symbol(payload.get("symbol"))
+ side = str(payload.get("side") or "—")
+ leverage = _format_leverage(payload.get("leverage"))
+ entry_price = _format_price(payload.get("entry_price"))
+ exit_price = _format_price(payload.get("exit_price"))
+ size = _format_size(payload.get("size"))
+ pnl = _format_pnl(payload.get("pnl"))
+ risk_reason = payload.get("risk_reason")
+
+ risk_line = f"\nRisk: {risk_reason}" if risk_reason else ""
+
+ text = (
+ f"✅ Paper position closed\n\n"
+ f"{side} · {symbol} · {leverage}\n"
+ f"Entry: $ {entry_price}\n"
+ f"Exit: $ {exit_price}\n"
+ f"Size: {size}\n\n"
+ f"PnL: {pnl}"
+ f"{risk_line}"
+ )
+
+ return NotificationMessage(
+ title=event.title,
+ text=text,
+ priority=event.priority,
+ dedupe_key=event.dedupe_key,
+ )
+
+
+def _build_position_flipped(event: RuntimeEvent) -> NotificationMessage:
+ payload = event.payload
+
+ symbol = _format_symbol(payload.get("symbol"))
+ leverage = _format_leverage(payload.get("leverage"))
+
+ old_side = str(payload.get("old_side") or "—")
+ new_side = str(payload.get("new_side") or payload.get("side") or "—")
+
+ entry_price = _format_price(payload.get("entry_price"))
+ exit_price = _format_price(payload.get("exit_price"))
+ new_entry_price = _format_price(payload.get("new_entry_price"))
+ old_size = _format_size(payload.get("old_size"))
+ new_size = _format_size(payload.get("new_size"))
+ pnl = _format_pnl(payload.get("pnl"))
+
+ old_icon = "🟢" if old_side == "LONG" else "🔴"
+ new_icon = "🟢" if new_side == "LONG" else "🔴"
+
+ text = (
+ f"🔁 Paper position flipped {old_icon} {old_side} → "
+ f"{new_icon} {new_side}\n\n"
+ f"{symbol} · {leverage}\n\n"
+ f"Old entry: $ {entry_price}\n"
+ f"Exit: $ {exit_price}\n"
+ f"Old size: {old_size}\n\n"
+ f"New entry: $ {new_entry_price}\n"
+ f"New size: {new_size}\n\n"
+ f"PnL: {pnl}"
+ )
+
+ return NotificationMessage(
+ title=event.title,
+ text=text,
+ priority=event.priority,
+ dedupe_key=event.dedupe_key,
+ )
+
+
+def _format_symbol(value: object) -> str:
+ symbol = str(value or "—")
+
+ if not symbol or symbol == "—":
+ return "—"
+
+ base_symbol = symbol.split("_", 1)[0]
+ parts = base_symbol.split("/", 1)
+
+ if len(parts) == 2:
+ return f"{parts[0]} / {parts[1]}"
+
+ return base_symbol
+
+
+def _format_leverage(value: object) -> str:
+ try:
+ return f"x{float(value):g}"
+ except (TypeError, ValueError):
+ return "—"
+
+
+def _format_price(value: object) -> str:
+ try:
+ number = float(value)
+ except (TypeError, ValueError):
+ return "—"
+
+ return f"{number:,.2f}".replace(",", " ")
+
+
+def _format_size(value: object) -> str:
+ try:
+ return f"{float(value):.8f}".rstrip("0").rstrip(".")
+ except (TypeError, ValueError):
+ return "—"
+
+
+def _format_pnl(value: object) -> str:
+ try:
+ number = float(value)
+ except (TypeError, ValueError):
+ return "—"
+
+ amount = f"$ {abs(number):,.2f}".replace(",", " ").rstrip("0").rstrip(".")
+
+ if number > 0:
+ return f"🟢 +{amount}"
+
+ if number < 0:
+ return f"🔴 −{amount}"
+
+ return "$ 0"
\ No newline at end of file
diff --git a/app/src/notifications/templates/signal.py b/app/src/notifications/templates/signal.py
index 83470e4..a48f218 100644
--- a/app/src/notifications/templates/signal.py
+++ b/app/src/notifications/templates/signal.py
@@ -16,24 +16,96 @@ def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None
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")
+ confidence = float(payload.get("confidence") or 0.0)
+ repeat_count = int(payload.get("repeat_count") or 0)
+ leverage = payload.get("leverage")
reason = str(payload.get("reason") or "—")
+ position_context = str(payload.get("position_context") or "NONE")
- icon = "🟢" if signal == "BUY" else "🔴" if signal == "SELL" else "🟡"
+ priority = str(event.priority or _alert_priority(
+ confidence=confidence,
+ repeat_count=repeat_count,
+ )).upper()
+
+ icon = _signal_icon(signal)
+ symbol_text = _format_symbol(symbol)
+ leverage_text = _format_leverage(leverage)
+ priority_text = _priority_label(priority)
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"{priority_text} · {icon} {signal}\n\n"
+ f"{symbol_text} · {strategy} · {leverage_text}\n"
+ f"Position: {position_context}\n\n"
+ f"🧠 Confidence: {confidence:.2f}\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,
+ priority=priority.lower(),
+ dedupe_key=event.dedupe_key or _dedupe_key(payload),
+ )
+
+
+def _alert_priority(*, confidence: float, repeat_count: int) -> str:
+ if confidence >= 0.8 and repeat_count >= 3:
+ return "HIGH"
+
+ if confidence >= 0.6 or repeat_count >= 2:
+ return "MEDIUM"
+
+ return "LOW"
+
+
+def _priority_label(priority: str) -> str:
+ mapping = {
+ "HIGH": "🚨 HIGH",
+ "MEDIUM": "⚡ MEDIUM",
+ "LOW": "ℹ️ LOW",
+ }
+ return mapping.get(priority.upper(), priority)
+
+
+def _signal_icon(signal: str) -> str:
+ mapping = {
+ "BUY": "🟢",
+ "SELL": "🔴",
+ }
+ return mapping.get(signal, "⚪")
+
+
+def _format_symbol(symbol: str) -> str:
+ if not symbol or symbol == "—":
+ return "—"
+
+ base_symbol = symbol.split("_", 1)[0]
+ parts = base_symbol.split("/", 1)
+
+ if len(parts) == 2:
+ return f"{parts[0]} / {parts[1]}"
+
+ return base_symbol
+
+
+def _format_leverage(leverage: object) -> str:
+ if isinstance(leverage, (int, float)):
+ return f"x{leverage:g}"
+
+ return "—"
+
+
+def _dedupe_key(payload: dict) -> str:
+ return (
+ f"auto_signal_ready:"
+ f"{payload.get('position_context')}:"
+ f"{payload.get('symbol')}:"
+ f"{payload.get('strategy')}:"
+ f"{payload.get('signal')}:"
+ f"{payload.get('repeat_count')}:"
+ f"{float(payload.get('confidence') or 0.0):.2f}:"
+ f"{payload.get('decision_status')}:"
+ f"{payload.get('reason')}"
)
\ No newline at end of file
diff --git a/app/src/runtime_events/publisher.py b/app/src/runtime_events/publisher.py
index 767137d..e1c2ccf 100644
--- a/app/src/runtime_events/publisher.py
+++ b/app/src/runtime_events/publisher.py
@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
-from src.notifications.service import NotificationService
from src.runtime_events.models import RuntimeEvent
@@ -16,4 +15,8 @@ class RuntimeEventPublisher:
except RuntimeError:
return
+ # lazy import, чтобы не ловить circular import:
+ # runtime_events -> notifications -> runtime_events
+ from src.notifications.service import NotificationService
+
loop.create_task(NotificationService().handle_runtime_event(event))
\ No newline at end of file
diff --git a/app/src/telegram/handlers/auto/risk.py b/app/src/telegram/handlers/auto/risk.py
index 671ba62..a3b4d58 100644
--- a/app/src/telegram/handlers/auto/risk.py
+++ b/app/src/telegram/handlers/auto/risk.py
@@ -103,6 +103,8 @@ async def _render_risk_screen(callback: CallbackQuery) -> None:
await callback.answer("Сообщение не найдено", show_alert=True)
return
+ _unregister_auto_screen_message(callback)
+
await callback.message.edit_text(
_risk_text(),
reply_markup=_risk_keyboard(),
@@ -164,6 +166,16 @@ async def _remember_risk_screen(callback: CallbackQuery, state: FSMContext) -> N
)
+def _unregister_auto_screen_message(callback: CallbackQuery) -> None:
+ if callback.message is None:
+ return
+
+ AutoTradeRunner.unregister_screen(
+ chat_id=callback.message.chat.id,
+ message_id=callback.message.message_id,
+ )
+
+
def _parse_positive_or_none(raw_text: str | None) -> float | None:
value_text = (raw_text or "").strip().replace(",", ".")
@@ -229,6 +241,7 @@ async def open_auto_risk_from_settings(callback: CallbackQuery, state: FSMContex
@router.callback_query(F.data == "auto:risk:set_sl")
async def ask_stop_loss(callback: CallbackQuery, state: FSMContext) -> None:
AutoTradeRunner.set_current_screen("auto_risk")
+ _unregister_auto_screen_message(callback)
await state.set_state(AutoRiskStates.waiting_stop_loss)
await _remember_risk_screen(callback, state)
@@ -247,6 +260,7 @@ async def ask_stop_loss(callback: CallbackQuery, state: FSMContext) -> None:
@router.callback_query(F.data == "auto:risk:set_tp")
async def ask_take_profit(callback: CallbackQuery, state: FSMContext) -> None:
AutoTradeRunner.set_current_screen("auto_risk")
+ _unregister_auto_screen_message(callback)
await state.set_state(AutoRiskStates.waiting_take_profit)
await _remember_risk_screen(callback, state)
@@ -265,6 +279,7 @@ async def ask_take_profit(callback: CallbackQuery, state: FSMContext) -> None:
@router.callback_query(F.data == "auto:risk:set_ml")
async def ask_max_loss(callback: CallbackQuery, state: FSMContext) -> None:
AutoTradeRunner.set_current_screen("auto_risk")
+ _unregister_auto_screen_message(callback)
await state.set_state(AutoRiskStates.waiting_max_loss)
await _remember_risk_screen(callback, state)
@@ -284,6 +299,7 @@ async def ask_max_loss(callback: CallbackQuery, state: FSMContext) -> None:
async def reset_risk(callback: CallbackQuery, state: FSMContext) -> None:
await state.clear()
AutoTradeRunner.set_current_screen("auto_risk")
+ _unregister_auto_screen_message(callback)
service = AutoTradeService()
service.set_stop_loss_percent(None)
diff --git a/app/src/trading/auto/runner.py b/app/src/trading/auto/runner.py
index e3ffaa3..d1b923f 100644
--- a/app/src/trading/auto/runner.py
+++ b/app/src/trading/auto/runner.py
@@ -11,10 +11,12 @@ from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter
from src.core.event_bus import EventBus
from src.integrations.exchange.market_data_runner import MarketDataRunner
+from src.notifications.targets import NotificationTargetRegistry
+from src.runtime_events.event_types import RuntimeEventType
+from src.runtime_events.models import RuntimeEvent
+from src.runtime_events.publisher import RuntimeEventPublisher
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:
@@ -34,12 +36,6 @@ class AutoTradeRunner:
_last_event_version: int = 0
_retry_after_until: float = 0.0
- _last_strong_alert_key: str | None = None
- _strong_alert_cooldown_seconds = 120
- _last_strong_alert_at_by_key: dict[str, float] = {}
-
- _last_execution_alert_key: str | None = None
-
@classmethod
def register_screen(
cls,
@@ -210,7 +206,7 @@ class AutoTradeRunner:
if signal not in {"BUY", "SELL"}:
return
- await cls._send_strong_signal_alert(state=state, payload=payload)
+ cls._publish_strong_signal_event(state=state, payload=payload)
return
if event_type in {
@@ -218,18 +214,15 @@ class AutoTradeRunner:
"paper_position_closed",
"paper_position_flipped",
}:
- await cls._send_execution_alert(
+ cls._publish_execution_event(
state=state,
- event_type=event_type,
+ event_type=str(event_type),
payload=payload,
)
return
@classmethod
- async def _send_strong_signal_alert(cls, *, state, payload: dict) -> None:
- if cls._bot is None or cls._chat_id is None:
- return
-
+ def _publish_strong_signal_event(cls, *, state, payload: dict) -> None:
signal = str(payload.get("signal", "")).upper()
symbol = str(payload.get("symbol") or state.symbol or "—")
strategy = str(payload.get("strategy") or state.strategy or "—")
@@ -237,7 +230,6 @@ class AutoTradeRunner:
confidence = float(payload.get("confidence") or state.last_signal_confidence or 0.0)
leverage = payload.get("leverage") if payload.get("leverage") is not None else state.leverage
reason = str(payload.get("reason") or state.last_signal_reason or "—")
-
position_context = str(getattr(state, "position_side", "NONE") or "NONE")
priority = cls._alert_priority(
@@ -245,58 +237,11 @@ class AutoTradeRunner:
repeat_count=repeat_count,
)
- alert_key = (
- f"signal:{position_context}:{symbol}:{strategy}:{signal}:"
- f"{repeat_count}:{confidence:.2f}:"
- f"{state.decision_status}:{reason}"
- )
-
- now = time.monotonic()
- last_alert_at = cls._last_strong_alert_at_by_key.get(alert_key)
-
- if last_alert_at is not None:
- elapsed = now - last_alert_at
-
- if elapsed < cls._strong_alert_cooldown_seconds:
- cls._log_suppressed_strong_alert(
- signal=signal,
- symbol=symbol,
- strategy=strategy,
- repeat_count=repeat_count,
- confidence=confidence,
- leverage=leverage,
- reason=reason,
- cooldown_left=round(cls._strong_alert_cooldown_seconds - elapsed, 2),
- position_context=position_context,
- )
- return
-
- cls._last_strong_alert_key = alert_key
- cls._last_strong_alert_at_by_key[alert_key] = now
-
- text = cls._build_strong_signal_alert_text(
- signal=signal,
- symbol=symbol,
- strategy=strategy,
- repeat_count=repeat_count,
- confidence=confidence,
- leverage=leverage,
- reason=reason,
- priority=priority,
- position_context=position_context,
- )
-
- try:
- await cls._bot.send_message(
- chat_id=cls._chat_id,
- text=text,
- )
-
- JournalService().log_ui_info(
- event_type="auto_strong_signal_alert_sent",
- message=f"Отправлено уведомление о сильном сигнале {signal}.",
- screen="auto",
- action="strong_signal_alert",
+ RuntimeEventPublisher.publish(
+ RuntimeEvent(
+ event_type=RuntimeEventType.AUTO_SIGNAL_READY,
+ source="auto_trade_runner",
+ title=f"Auto strong signal {signal}",
payload={
"symbol": symbol,
"strategy": strategy,
@@ -305,76 +250,90 @@ class AutoTradeRunner:
"confidence": confidence,
"leverage": leverage,
"reason": reason,
- "priority": priority,
"position_context": position_context,
+ "decision_status": state.decision_status,
},
+ priority=priority.lower(),
+ dedupe_key=(
+ f"auto_signal_ready:"
+ f"{position_context}:"
+ f"{symbol}:"
+ f"{strategy}:"
+ f"{signal}:"
+ f"{repeat_count}:"
+ f"{confidence:.2f}:"
+ f"{state.decision_status}:"
+ f"{reason}"
+ ),
)
-
- except TelegramRetryAfter as exc:
- cls._retry_after_until = time.monotonic() + exc.retry_after + 5
-
- except Exception:
- pass
+ )
@classmethod
- async def _send_execution_alert(
+ def _publish_execution_event(
cls,
*,
state,
event_type: str,
payload: dict,
) -> None:
- if cls._bot is None or cls._chat_id is None:
+ runtime_event_type = cls._runtime_execution_event_type(event_type)
+ if runtime_event_type is None:
return
- alert_key = cls._execution_alert_key(
- event_type=event_type,
- payload=payload,
- )
+ symbol = str(payload.get("symbol") or state.symbol or "—")
+ side = str(payload.get("side") or getattr(state, "position_side", "—") or "—")
+ old_side = str(payload.get("old_side") or "—")
+ new_side = str(payload.get("new_side") or side or "—")
- if alert_key == cls._last_execution_alert_key:
- return
-
- cls._last_execution_alert_key = alert_key
-
- text = cls._build_execution_alert_text(
- state=state,
- event_type=event_type,
- payload=payload,
- )
-
- try:
- await cls._bot.send_message(
- chat_id=cls._chat_id,
- text=text,
- )
-
- JournalService().log_ui_info(
- event_type="auto_execution_alert_sent",
- message="Отправлено Telegram-уведомление по paper execution.",
- screen="auto",
- action="execution_alert",
+ RuntimeEventPublisher.publish(
+ RuntimeEvent(
+ event_type=runtime_event_type,
+ source="auto_trade_runner",
+ title=cls._execution_event_title(runtime_event_type),
payload={
"source_event_type": event_type,
+ "symbol": symbol,
+ "side": side,
+ "old_side": old_side,
+ "new_side": new_side,
+ "leverage": payload.get("leverage") if payload.get("leverage") is not None else state.leverage,
**payload,
},
+ priority="normal",
+ dedupe_key=cls._execution_dedupe_key(
+ runtime_event_type=runtime_event_type,
+ payload=payload,
+ ),
)
-
- except TelegramRetryAfter as exc:
- cls._retry_after_until = time.monotonic() + exc.retry_after + 5
-
- except Exception:
- pass
+ )
@classmethod
- def _execution_alert_key(
+ def _runtime_execution_event_type(cls, event_type: str) -> RuntimeEventType | None:
+ mapping = {
+ "paper_position_opened": RuntimeEventType.POSITION_OPENED,
+ "paper_position_closed": RuntimeEventType.POSITION_CLOSED,
+ "paper_position_flipped": RuntimeEventType.POSITION_FLIPPED,
+ }
+ return mapping.get(event_type)
+
+ @classmethod
+ def _execution_event_title(cls, event_type: RuntimeEventType) -> str:
+ mapping = {
+ RuntimeEventType.POSITION_OPENED: "Paper position opened",
+ RuntimeEventType.POSITION_CLOSED: "Paper position closed",
+ RuntimeEventType.POSITION_FLIPPED: "Paper position flipped",
+ }
+ return mapping.get(event_type, "Paper execution event")
+
+ @classmethod
+ def _execution_dedupe_key(
cls,
*,
- event_type: str,
+ runtime_event_type: RuntimeEventType,
payload: dict,
) -> str:
return (
- f"{event_type}:"
+ f"{runtime_event_type.value}:"
f"{payload.get('symbol')}:"
f"{payload.get('side')}:"
f"{payload.get('old_side')}:"
@@ -385,119 +344,11 @@ class AutoTradeRunner:
f"{payload.get('size')}:"
f"{payload.get('old_size')}:"
f"{payload.get('new_size')}:"
- f"{payload.get('pnl')}"
+ f"{payload.get('pnl')}:"
f"{payload.get('risk_reason')}:"
- f"{payload.get('is_forced')}:"
+ f"{payload.get('is_forced')}"
)
- @classmethod
- def _build_execution_alert_text(
- cls,
- *,
- state,
- event_type: str,
- payload: dict,
- ) -> str:
- symbol = str(payload.get("symbol") or state.symbol or "—")
- side = str(payload.get("side") or state.position_side or "—")
- leverage = payload.get("leverage") if payload.get("leverage") is not None else state.leverage
-
- symbol_text = cls._format_alert_symbol(symbol)
- leverage_text = cls._format_alert_leverage(leverage)
-
- if event_type == "paper_position_opened":
- entry_price = cls._format_price(payload.get("entry_price"))
- size = cls._format_size(payload.get("size"))
- side_icon = "🟢" if side == "LONG" else "🔴"
-
- return (
- f"📄 Paper position opened {side_icon} {side}\n\n"
- f"{symbol_text} · {leverage_text}\n"
- f"Entry: $ {entry_price}\n"
- f"Size: {size}"
- )
-
- if event_type == "paper_position_closed":
- entry_price = cls._format_price(payload.get("entry_price"))
- exit_price = cls._format_price(payload.get("exit_price"))
- size = cls._format_size(payload.get("size"))
- pnl = cls._format_pnl(payload.get("pnl"))
- risk_reason = payload.get("risk_reason")
- risk_line = f"\nRisk: {risk_reason}" if risk_reason else ""
-
- return (
- f"✅ Paper position closed\n\n"
- f"{side} · {symbol_text} · {leverage_text}\n"
- f"Entry: $ {entry_price}\n"
- f"Exit: $ {exit_price}\n"
- f"Size: {size}\n\n"
- f"PnL: {pnl}"
- f"{risk_line}"
- )
-
- if event_type == "paper_position_flipped":
- old_side = str(payload.get("old_side") or "—")
- new_side = str(payload.get("new_side") or side or "—")
-
- entry_price = cls._format_price(payload.get("entry_price"))
- exit_price = cls._format_price(payload.get("exit_price"))
- new_entry_price = cls._format_price(payload.get("new_entry_price"))
- old_size = cls._format_size(payload.get("old_size"))
- new_size = cls._format_size(payload.get("new_size"))
- pnl = cls._format_pnl(payload.get("pnl"))
-
- old_icon = "🟢" if old_side == "LONG" else "🔴"
- new_icon = "🟢" if new_side == "LONG" else "🔴"
-
- return (
- f"🔁 Paper position flipped {old_icon} {old_side} → "
- f"{new_icon} {new_side}\n\n"
- f"{symbol_text} · {leverage_text}\n\n"
- f"Old entry: $ {entry_price}\n"
- f"Exit: $ {exit_price}\n"
- f"Old size: {old_size}\n\n"
- f"New entry: $ {new_entry_price}\n"
- f"New size: {new_size}\n\n"
- f"PnL: {pnl}"
- )
-
- return "📄 Paper execution event"
-
- @classmethod
- def _log_suppressed_strong_alert(
- cls,
- *,
- signal: str,
- symbol: str,
- strategy: str,
- repeat_count: int,
- confidence: float,
- leverage: object,
- reason: str,
- cooldown_left: float,
- position_context: str,
- ) -> None:
- try:
- JournalService().log_ui_info(
- event_type="auto_strong_signal_alert_suppressed",
- message=f"Повторное уведомление о сильном сигнале {signal} подавлено.",
- screen="auto",
- action="strong_signal_alert",
- payload={
- "symbol": symbol,
- "strategy": strategy,
- "signal": signal,
- "repeat_count": repeat_count,
- "confidence": confidence,
- "leverage": leverage,
- "reason": reason,
- "cooldown_left": cooldown_left,
- "position_context": position_context,
- },
- )
- except Exception:
- pass
-
@classmethod
def _alert_priority(
cls,
@@ -513,87 +364,6 @@ class AutoTradeRunner:
return "LOW"
- @classmethod
- def _priority_label(cls, priority: str) -> str:
- mapping = {
- "HIGH": "🚨 HIGH",
- "MEDIUM": "⚡ MEDIUM",
- "LOW": "ℹ️ LOW",
- }
- return mapping.get(priority, priority)
-
- @classmethod
- def _format_alert_symbol(cls, symbol: str) -> str:
- if not symbol or symbol == "—":
- return "—"
-
- base_symbol = symbol.split("_", 1)[0]
- parts = base_symbol.split("/", 1)
-
- if len(parts) == 2:
- return f"{parts[0]} / {parts[1]}"
-
- return base_symbol
-
- @classmethod
- def _format_alert_leverage(cls, leverage: object) -> str:
- if isinstance(leverage, (int, float)):
- return f"x{leverage:g}"
-
- return "—"
-
- @classmethod
- def _signal_icon(cls, signal: str) -> str:
- mapping = {
- "BUY": "🟢",
- "SELL": "🔴",
- }
- return mapping.get(signal, "⚪")
-
- @classmethod
- def _build_strong_signal_alert_text(
- cls,
- *,
- signal: str,
- symbol: str,
- strategy: str,
- repeat_count: int,
- confidence: float,
- leverage: object,
- reason: str,
- priority: str,
- position_context: str,
- ) -> str:
- icon = cls._signal_icon(signal)
- symbol_text = cls._format_alert_symbol(symbol)
- leverage_text = cls._format_alert_leverage(leverage)
- priority_text = cls._priority_label(priority)
-
- return (
- f"{priority_text} · {icon} {signal}\n\n"
- f"{symbol_text} · {strategy} · {leverage_text}\n"
- f"Position: {position_context}\n\n"
- f"🧠 Confidence: {confidence:.2f}\n"
- f"🔁 Repeats: {repeat_count}\n\n"
- f"💡 Причина:\n"
- f"{reason}"
- )
-
- @classmethod
- def _format_price(cls, value: object) -> str:
- return format_usd_price(value)
-
- @classmethod
- def _format_pnl(cls, value: object) -> str:
- return format_usd_pnl(value)
-
- @classmethod
- def _format_size(cls, value: object) -> str:
- try:
- return f"{float(value):.8f}".rstrip("0").rstrip(".")
- except (TypeError, ValueError):
- return "—"
-
@classmethod
def _log_refresh_skip(cls, reason: str, payload: dict | None = None) -> None:
try:
diff --git a/docs/roadmap/master-roadmap.md b/docs/roadmap/master-roadmap.md
index b34f107..332222e 100644
--- a/docs/roadmap/master-roadmap.md
+++ b/docs/roadmap/master-roadmap.md
@@ -331,6 +331,27 @@
- исправлен circular import в package init файлах
- подготовлена архитектура для переноса strong signal alerts
+#### 07.4.3.18.2 ✅ Runtime Notification Migration
+- strong signal alerts migrated to RuntimeEvent pipeline
+- execution alerts migrated to RuntimeEvent pipeline
+- detached Telegram delivery
+- centralized notification logging
+- runtime notification dedupe
+- live runtime validation on real paper execution
+- auto screen independence from notifications
+
+---
+
+#### 07.4.3.19 — Strategy Audit & Signal Quality Layer
+- audit SCALP false flip behavior
+- add position-aware signal handling
+- prevent weak/medium signal flips
+- add min hold time before flip
+- add flip cooldown
+- add spread/slippage buffer
+- classify signals as ENTRY / HOLD / EXIT / FLIP
+- tune SCALP thresholds
+
### 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 18e7a56..b4a99b6 100644
--- a/docs/roadmap/stage-07-auto-trading-roadmap.md
+++ b/docs/roadmap/stage-07-auto-trading-roadmap.md
@@ -315,6 +315,28 @@
- исправлен circular import в package init файлах
- подготовлена архитектура для переноса strong signal alerts
+#### 07.4.3.18.2 ✅ Runtime Notification Migration
+- strong signal alerts migrated to RuntimeEvent pipeline
+- execution alerts migrated to RuntimeEvent pipeline
+- detached Telegram delivery
+- centralized notification logging
+- runtime notification dedupe
+- live runtime validation on real paper execution
+- auto screen independence from notifications
+
+---
+
+#### 07.4.3.19 — Strategy Audit & Signal Quality Layer
+
+- audit SCALP false flip behavior
+- add position-aware signal handling
+- prevent weak/medium signal flips
+- add min hold time before flip
+- add flip cooldown
+- add spread/slippage buffer
+- classify signals as ENTRY / HOLD / EXIT / FLIP
+- tune SCALP thresholds
+
---
### 07.4.4
diff --git a/docs/stages/stage-07_4_3_18_2-runtime_notification_migration.md b/docs/stages/stage-07_4_3_18_2-runtime_notification_migration.md
new file mode 100644
index 0000000..467e2e1
--- /dev/null
+++ b/docs/stages/stage-07_4_3_18_2-runtime_notification_migration.md
@@ -0,0 +1,210 @@
+# 07.4.3.18.2 — Runtime Notification Migration
+
+## Stage Goal
+
+Перевести runtime Telegram-уведомления
+со старого inline-alert подхода
+на отдельную Runtime Notification Architecture.
+
+---
+
+# Что было реализовано
+
+## 1. Runtime Notification Pipeline
+
+В проект введён полноценный runtime notification layer:
+
+~~~text
+RuntimeEvent
+ ↓
+RuntimeEventPublisher
+ ↓
+NotificationService
+ ↓
+TelegramNotificationChannel
+~~~
+
+---
+
+## 2. Strong Signal Alerts Migration
+
+Сильные сигналы автоторговли были вынесены
+из прямого Telegram send flow
+в RuntimeEvent pipeline.
+
+Ранее:
+
+~~~text
+AutoTradeRunner
+ -> bot.send_message()
+~~~
+
+Теперь:
+
+~~~text
+AutoTradeRunner
+ -> RuntimeEventPublisher.publish()
+ -> NotificationService
+ -> TelegramNotificationChannel
+~~~
+
+---
+
+## 3. Execution Alerts Migration
+
+Paper execution alerts также переведены
+в RuntimeEvent pipeline:
+
+- POSITION_OPENED
+- POSITION_CLOSED
+- POSITION_FLIPPED
+
+---
+
+## 4. Notification Dedupe Layer
+
+Добавлен NotificationDedupe:
+
+~~~python
+NotificationDedupe.should_send(...)
+~~~
+
+Поддерживает:
+- anti-spam
+- cooldown suppression
+- duplicate protection
+
+---
+
+## 5. Notification Templates
+
+Добавлены отдельные шаблоны:
+
+~~~text
+notifications/templates/signal.py
+notifications/templates/execution.py
+~~~
+
+Теперь presentation layer отделён от business logic.
+
+---
+
+## 6. Telegram Channel Abstraction
+
+Добавлен:
+
+~~~text
+TelegramNotificationChannel
+~~~
+
+Теперь NotificationService не зависит напрямую от aiogram Bot API.
+
+---
+
+## 7. Notification Target Registry
+
+Добавлен:
+
+~~~text
+NotificationTargetRegistry
+~~~
+
+Registry хранит:
+- active bot
+- target chat_id
+
+Позволяет:
+- отправлять уведомления без открытого auto screen
+- поддерживать detached runtime notifications
+
+---
+
+## 8. Circular Import Protection
+
+Устранён circular import:
+
+~~~text
+runtime_events
+ ↔ notifications
+~~~
+
+Через lazy import внутри RuntimeEventPublisher.
+
+---
+
+# Runtime Validation
+
+Система была протестирована
+на реальном paper execution.
+
+Подтверждено:
+
+- runtime events публикуются
+- Telegram delivery работает
+- execution alerts приходят
+- journal logging работает
+- dedupe layer работает
+- detached delivery работает
+
+---
+
+# Ограничения текущего этапа
+
+Signal escalation layer ещё не реализован.
+
+Сейчас RuntimeEvent публикуется только при:
+
+~~~text
+decision_status transition
+~~~
+
+Из-за этого:
+- repeated READY signals
+- stronger confirmations
+- confidence escalation
+
+пока не создают новые RuntimeEvent.
+
+Это переносится в:
+
+~~~text
+07.4.3.19 — Strategy Audit & Signal Quality Layer
+~~~
+
+---
+
+# Modified Components
+
+## Runtime Events
+
+~~~text
+runtime_events/event_types.py
+runtime_events/models.py
+runtime_events/publisher.py
+~~~
+
+## Notifications
+
+~~~text
+notifications/service.py
+notifications/models.py
+notifications/dedupe.py
+notifications/targets.py
+notifications/channels/telegram.py
+notifications/templates/signal.py
+notifications/templates/execution.py
+~~~
+
+## Auto Trading
+
+~~~text
+trading/auto/runner.py
+~~~
+
+---
+
+# Result
+
+Система перешла
+от inline Telegram alerts
+к полноценной event-driven runtime notification architecture.
\ No newline at end of file