From c3cf446143767b9e2cc5c9845a5c40417bea1aeb Mon Sep 17 00:00:00 2001 From: Sergey Date: Sat, 9 May 2026 21:46:58 +0300 Subject: [PATCH] =?UTF-8?q?07.4.3.18.2=20=E2=80=94=20Runtime=20Notificatio?= =?UTF-8?q?n=20Migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/notifications/service.py | 107 ++++- app/src/notifications/templates/execution.py | 186 +++++++-- app/src/notifications/templates/signal.py | 92 ++++- app/src/runtime_events/publisher.py | 5 +- app/src/telegram/handlers/auto/risk.py | 16 + app/src/trading/auto/runner.py | 376 ++++-------------- docs/roadmap/master-roadmap.md | 21 + docs/roadmap/stage-07-auto-trading-roadmap.md | 22 + ...4_3_18_2-runtime_notification_migration.md | 210 ++++++++++ 9 files changed, 665 insertions(+), 370 deletions(-) create mode 100644 docs/stages/stage-07_4_3_18_2-runtime_notification_migration.md 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