# 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(".")