# 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 from src.core.numbers import safe_float def build_execution_notification(event: RuntimeEvent) -> NotificationMessage | 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) if event.event_type == RuntimeEventType.POSITION_FLIP_BLOCKED: return _build_flip_blocked(event) return None def _build_position_opened(event: RuntimeEvent) -> NotificationMessage: payload = event.payload symbol = _format_symbol(payload.get("symbol")) strategy = str(payload.get("strategy") or "—").title() side_raw = str(payload.get("side") or "—").upper() side = side_raw.title() leverage = _format_leverage(payload.get("leverage")) entry_price = _format_price(payload.get("entry_price")) size = _format_size(payload.get("size")) confidence = float(payload.get("confidence") or 0.0) priority = _alert_priority( confidence=confidence, repeat_count=int(payload.get("repeat_count") or 0), ) semantic_lines = payload.get("semantic_lines") or [] side_icon = "🟢" if side_raw == "LONG" else "🔴" lines = [ "🧾 Позиция открыта", "", f"{side_icon} {symbol} · {strategy} · {side} {leverage}", f"Вход: ${entry_price}", f"Размер: {size}", f"Объём: {_format_notional(entry_price=payload.get('entry_price'), size=payload.get('size'))}", "", f"{_strength_bar(priority)} Сигнал {_strength_label(priority).lower()} · {confidence:.2f}", ] if semantic_lines: lines.extend( str(line).strip().rstrip(".") for line in semantic_lines if str(line).strip() ) return NotificationMessage( title=event.title, text="\n".join(lines), priority=event.priority, dedupe_key=event.dedupe_key, ) def _build_position_closed(event: RuntimeEvent) -> NotificationMessage: payload = event.payload symbol = _format_symbol(payload.get("symbol")) side = str(payload.get("side") or "—").title() 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_value = float(payload.get("pnl") or 0.0) pnl_text = _format_pnl_amount(pnl_value) risk_reason = _human_close_reason(payload.get("risk_reason")) pnl_icon = "🟢" if pnl_value >= 0 else "🔴" pnl_label = "Прибыль" if pnl_value >= 0 else "Убыток" lines = [ "🧾 Сделка закрыта", f"{pnl_icon} {pnl_label} · {pnl_text}", "", f"{symbol} · {side} {leverage}", f"Вход: ${entry_price}", f"Выход: ${exit_price}", f"Размер: {size}", ] if risk_reason: lines.extend([ "", f"Закрытие по {risk_reason}", ]) return NotificationMessage( title=event.title, text="\n".join(lines), priority=event.priority, dedupe_key=event.dedupe_key, ) def _format_pnl_amount(value: float) -> str: amount = f"$ {abs(value):,.2f}".replace(",", " ").rstrip("0").rstrip(".") if value > 0: return f"+{amount}" if value < 0: return f"−{amount}" return "$ 0" def _human_close_reason(value: object) -> str: mapping = { "STOP_LOSS": "Stop Loss", "TAKE_PROFIT": "Take Profit", "MAX_LOSS": "Max Loss", } return mapping.get(str(value or ""), "") def _build_position_flipped(event: RuntimeEvent) -> NotificationMessage: payload = event.payload symbol = _format_symbol(payload.get("symbol")) strategy = str(payload.get("strategy") or "—").title() old_side_raw = str(payload.get("old_side") or "—").upper() new_side_raw = str( payload.get("new_side") or payload.get("side") or "—" ).upper() old_side = old_side_raw.title() new_side = new_side_raw.title() old_leverage = _format_leverage( payload.get("old_leverage") if payload.get("old_leverage") is not None else payload.get("leverage") ) new_leverage = _format_leverage(payload.get("leverage")) 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_value = float(payload.get("pnl") or 0.0) pnl_text = _format_pnl_amount(pnl_value) pnl_icon = "🟢" if pnl_value >= 0 else "🔴" pnl_label = "Прибыль" if pnl_value >= 0 else "Убыток" old_icon = "🟢" if old_side_raw == "LONG" else "🔴" new_icon = "🟢" if new_side_raw == "LONG" else "🔴" confidence = float(payload.get("confidence") or 0.0) repeat_count = int(payload.get("repeat_count") or 0) priority = _alert_priority( confidence=confidence, repeat_count=repeat_count, ) semantic_lines = payload.get("semantic_lines") or [] lines = [ "🧾 Сделка развернута", f"{pnl_label} {pnl_icon} {pnl_text}", f"{symbol} · {strategy} {old_icon} {old_side} → {new_icon} {new_side}", "", f"Закрыта {old_side} {old_leverage}", f"Вход: ${entry_price}", f"Выход: ${exit_price}", f"Размер: {old_size}", "", f"Открыта {new_side} {new_leverage}", f"Вход: ${new_entry_price}", f"Размер: {new_size}", ( "Объём: " f"{_format_notional(entry_price=payload.get('new_entry_price'), size=payload.get('new_size'))}" ), "", f"{_strength_bar(priority)} Сигнал {_strength_label(priority).lower()} · {confidence:.2f}", ] if semantic_lines: lines.extend( str(line).strip().rstrip(".") for line in semantic_lines if str(line).strip() ) return NotificationMessage( title=event.title, text="\n".join(lines), priority=event.priority, dedupe_key=event.dedupe_key, ) def _build_flip_blocked(event: RuntimeEvent) -> NotificationMessage: payload = event.payload symbol = _format_symbol(payload.get("symbol")) signal = str(payload.get("signal") or "").upper() confidence = float(payload.get("confidence") or 0.0) reason = str(payload.get("reason") or "Flip заблокирован") position_side = str(payload.get("position_side") or "—").title() target_side = "Long" if signal == "BUY" else "Short" if signal == "SELL" else "—" icon = "🟢" if target_side == "LONG" else "🔴" if target_side == "SHORT" else "" text = ( f"⚠️ Flip отменён\n\n" f"{icon} {symbol} · {target_side}\n" f"Текущая позиция: {position_side}\n\n" f"Недостаточно условий для разворота\n" f"{reason}\n" f"Сила сигнала: {confidence:.2f}" ) 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 "—" return symbol.split("_", 1)[0].split("/", 1)[0].upper() def _format_leverage(value: object) -> str: number = safe_float(value) if number is None: return "—" return f"x{number:g}" def _format_price(value: object) -> str: number = safe_float(value) if number is None: return "—" return f"{number:,.2f}".replace(",", " ") def _format_size(value: object) -> str: number = safe_float(value) if number is None: return "—" return f"{number:.8f}".rstrip("0").rstrip(".") 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 _strength_label(priority: str) -> str: mapping = { "HIGH": "Сильный", "MEDIUM": "Средний", "LOW": "Слабый", } return mapping.get(priority.upper(), priority) def _strength_bar(priority: str) -> str: mapping = { "HIGH": "●●●", "MEDIUM": "●●○", "LOW": "●○○", } return mapping.get(priority.upper(), "●○○") def _format_notional( *, entry_price: object, size: object, ) -> str: entry = safe_float(entry_price) amount = safe_float(size) if entry is None or amount is None: return "—" value = entry * amount return f"$ {value:,.2f}".replace(",", " ").rstrip("0").rstrip(".")