07.4.3.18.2 — Runtime Notification Migration
This commit is contained in:
@@ -7,6 +7,7 @@ from src.notifications.dedupe import NotificationDedupe
|
|||||||
from src.notifications.models import NotificationMessage
|
from src.notifications.models import NotificationMessage
|
||||||
from src.notifications.templates.execution import build_execution_notification
|
from src.notifications.templates.execution import build_execution_notification
|
||||||
from src.notifications.templates.signal import build_signal_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.runtime_events.models import RuntimeEvent
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
@@ -30,36 +31,98 @@ class NotificationService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not NotificationDedupe.should_send(message.dedupe_key):
|
if not NotificationDedupe.should_send(message.dedupe_key):
|
||||||
JournalService().log_info(
|
self._log_suppressed(event, message)
|
||||||
"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
|
return
|
||||||
|
|
||||||
sent = await TelegramNotificationChannel().send(message)
|
sent = await TelegramNotificationChannel().send(message)
|
||||||
|
|
||||||
if sent:
|
if sent:
|
||||||
JournalService().log_info(
|
self._log_sent(event, message)
|
||||||
"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:
|
def _build_message(self, event: RuntimeEvent) -> NotificationMessage | None:
|
||||||
return (
|
return (
|
||||||
build_signal_notification(event)
|
build_signal_notification(event)
|
||||||
or build_execution_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,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
@@ -8,47 +8,165 @@ from src.runtime_events.models import RuntimeEvent
|
|||||||
|
|
||||||
|
|
||||||
def build_execution_notification(event: RuntimeEvent) -> NotificationMessage | None:
|
def build_execution_notification(event: RuntimeEvent) -> NotificationMessage | None:
|
||||||
if event.event_type not in {
|
if event.event_type == RuntimeEventType.POSITION_OPENED:
|
||||||
RuntimeEventType.POSITION_OPENED,
|
return _build_position_opened(event)
|
||||||
RuntimeEventType.POSITION_CLOSED,
|
|
||||||
RuntimeEventType.POSITION_FLIPPED,
|
|
||||||
}:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
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
|
payload = event.payload
|
||||||
|
|
||||||
symbol = str(payload.get("symbol") or "—")
|
symbol = _format_symbol(payload.get("symbol"))
|
||||||
side = str(payload.get("side") or payload.get("new_side") or "—")
|
side = str(payload.get("side") or "—")
|
||||||
entry_price = payload.get("entry_price") or payload.get("new_entry_price")
|
leverage = _format_leverage(payload.get("leverage"))
|
||||||
exit_price = payload.get("exit_price")
|
entry_price = _format_price(payload.get("entry_price"))
|
||||||
pnl = payload.get("pnl")
|
size = _format_size(payload.get("size"))
|
||||||
|
|
||||||
if event.event_type == RuntimeEventType.POSITION_OPENED:
|
side_icon = "🟢" if side == "LONG" else "🔴"
|
||||||
title = "📄 Position opened"
|
|
||||||
elif event.event_type == RuntimeEventType.POSITION_CLOSED:
|
|
||||||
title = "✅ Position closed"
|
|
||||||
else:
|
|
||||||
title = "🔁 Position flipped"
|
|
||||||
|
|
||||||
lines = [
|
text = (
|
||||||
f"<b>{title}</b>",
|
f"<b>📄 Paper position opened {side_icon} {side}</b>\n\n"
|
||||||
"",
|
f"{symbol} · {leverage}\n"
|
||||||
f"{symbol} · {side}",
|
f"Entry: $ {entry_price}\n"
|
||||||
]
|
f"Size: {size}"
|
||||||
|
)
|
||||||
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(
|
return NotificationMessage(
|
||||||
title=event.title,
|
title=event.title,
|
||||||
text="\n".join(lines),
|
text=text,
|
||||||
priority=event.priority,
|
priority=event.priority,
|
||||||
dedupe_key=event.dedupe_key,
|
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 "—")
|
||||||
|
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"<b>✅ Paper position closed</b>\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"<b>🔁 Paper position flipped {old_icon} {old_side} → "
|
||||||
|
f"{new_icon} {new_side}</b>\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"
|
||||||
@@ -16,24 +16,96 @@ def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None
|
|||||||
signal = str(payload.get("signal") or "—").upper()
|
signal = str(payload.get("signal") or "—").upper()
|
||||||
symbol = str(payload.get("symbol") or "—")
|
symbol = str(payload.get("symbol") or "—")
|
||||||
strategy = str(payload.get("strategy") or "—")
|
strategy = str(payload.get("strategy") or "—")
|
||||||
confidence = payload.get("confidence")
|
confidence = float(payload.get("confidence") or 0.0)
|
||||||
repeat_count = payload.get("repeat_count")
|
repeat_count = int(payload.get("repeat_count") or 0)
|
||||||
|
leverage = payload.get("leverage")
|
||||||
reason = str(payload.get("reason") or "—")
|
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 = (
|
text = (
|
||||||
f"<b>🚨 Runtime Signal · {icon} {signal}</b>\n\n"
|
f"<b>{priority_text} · {icon} {signal}</b>\n\n"
|
||||||
f"{symbol} · {strategy}\n"
|
f"{symbol_text} · {strategy} · {leverage_text}\n"
|
||||||
f"Confidence: {confidence}\n"
|
f"Position: {position_context}\n\n"
|
||||||
f"Repeats: {repeat_count}\n\n"
|
f"🧠 Confidence: {confidence:.2f}\n"
|
||||||
f"Причина:\n"
|
f"🔁 Repeats: {repeat_count}\n\n"
|
||||||
|
f"💡 Причина:\n"
|
||||||
f"{reason}"
|
f"{reason}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return NotificationMessage(
|
return NotificationMessage(
|
||||||
title=event.title,
|
title=event.title,
|
||||||
text=text,
|
text=text,
|
||||||
priority=event.priority,
|
priority=priority.lower(),
|
||||||
dedupe_key=event.dedupe_key,
|
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')}"
|
||||||
)
|
)
|
||||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from src.notifications.service import NotificationService
|
|
||||||
from src.runtime_events.models import RuntimeEvent
|
from src.runtime_events.models import RuntimeEvent
|
||||||
|
|
||||||
|
|
||||||
@@ -16,4 +15,8 @@ class RuntimeEventPublisher:
|
|||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# lazy import, чтобы не ловить circular import:
|
||||||
|
# runtime_events -> notifications -> runtime_events
|
||||||
|
from src.notifications.service import NotificationService
|
||||||
|
|
||||||
loop.create_task(NotificationService().handle_runtime_event(event))
|
loop.create_task(NotificationService().handle_runtime_event(event))
|
||||||
@@ -103,6 +103,8 @@ async def _render_risk_screen(callback: CallbackQuery) -> None:
|
|||||||
await callback.answer("Сообщение не найдено", show_alert=True)
|
await callback.answer("Сообщение не найдено", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
_unregister_auto_screen_message(callback)
|
||||||
|
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
_risk_text(),
|
_risk_text(),
|
||||||
reply_markup=_risk_keyboard(),
|
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:
|
def _parse_positive_or_none(raw_text: str | None) -> float | None:
|
||||||
value_text = (raw_text or "").strip().replace(",", ".")
|
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")
|
@router.callback_query(F.data == "auto:risk:set_sl")
|
||||||
async def ask_stop_loss(callback: CallbackQuery, state: FSMContext) -> None:
|
async def ask_stop_loss(callback: CallbackQuery, state: FSMContext) -> None:
|
||||||
AutoTradeRunner.set_current_screen("auto_risk")
|
AutoTradeRunner.set_current_screen("auto_risk")
|
||||||
|
_unregister_auto_screen_message(callback)
|
||||||
await state.set_state(AutoRiskStates.waiting_stop_loss)
|
await state.set_state(AutoRiskStates.waiting_stop_loss)
|
||||||
await _remember_risk_screen(callback, state)
|
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")
|
@router.callback_query(F.data == "auto:risk:set_tp")
|
||||||
async def ask_take_profit(callback: CallbackQuery, state: FSMContext) -> None:
|
async def ask_take_profit(callback: CallbackQuery, state: FSMContext) -> None:
|
||||||
AutoTradeRunner.set_current_screen("auto_risk")
|
AutoTradeRunner.set_current_screen("auto_risk")
|
||||||
|
_unregister_auto_screen_message(callback)
|
||||||
await state.set_state(AutoRiskStates.waiting_take_profit)
|
await state.set_state(AutoRiskStates.waiting_take_profit)
|
||||||
await _remember_risk_screen(callback, state)
|
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")
|
@router.callback_query(F.data == "auto:risk:set_ml")
|
||||||
async def ask_max_loss(callback: CallbackQuery, state: FSMContext) -> None:
|
async def ask_max_loss(callback: CallbackQuery, state: FSMContext) -> None:
|
||||||
AutoTradeRunner.set_current_screen("auto_risk")
|
AutoTradeRunner.set_current_screen("auto_risk")
|
||||||
|
_unregister_auto_screen_message(callback)
|
||||||
await state.set_state(AutoRiskStates.waiting_max_loss)
|
await state.set_state(AutoRiskStates.waiting_max_loss)
|
||||||
await _remember_risk_screen(callback, state)
|
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:
|
async def reset_risk(callback: CallbackQuery, state: FSMContext) -> None:
|
||||||
await state.clear()
|
await state.clear()
|
||||||
AutoTradeRunner.set_current_screen("auto_risk")
|
AutoTradeRunner.set_current_screen("auto_risk")
|
||||||
|
_unregister_auto_screen_message(callback)
|
||||||
|
|
||||||
service = AutoTradeService()
|
service = AutoTradeService()
|
||||||
service.set_stop_loss_percent(None)
|
service.set_stop_loss_percent(None)
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter
|
|||||||
|
|
||||||
from src.core.event_bus import EventBus
|
from src.core.event_bus import EventBus
|
||||||
from src.integrations.exchange.market_data_runner import MarketDataRunner
|
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.auto.service import AutoTradeService
|
||||||
from src.trading.journal.service import JournalService
|
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:
|
class AutoTradeRunner:
|
||||||
@@ -34,12 +36,6 @@ class AutoTradeRunner:
|
|||||||
_last_event_version: int = 0
|
_last_event_version: int = 0
|
||||||
_retry_after_until: float = 0.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
|
@classmethod
|
||||||
def register_screen(
|
def register_screen(
|
||||||
cls,
|
cls,
|
||||||
@@ -210,7 +206,7 @@ class AutoTradeRunner:
|
|||||||
if signal not in {"BUY", "SELL"}:
|
if signal not in {"BUY", "SELL"}:
|
||||||
return
|
return
|
||||||
|
|
||||||
await cls._send_strong_signal_alert(state=state, payload=payload)
|
cls._publish_strong_signal_event(state=state, payload=payload)
|
||||||
return
|
return
|
||||||
|
|
||||||
if event_type in {
|
if event_type in {
|
||||||
@@ -218,18 +214,15 @@ class AutoTradeRunner:
|
|||||||
"paper_position_closed",
|
"paper_position_closed",
|
||||||
"paper_position_flipped",
|
"paper_position_flipped",
|
||||||
}:
|
}:
|
||||||
await cls._send_execution_alert(
|
cls._publish_execution_event(
|
||||||
state=state,
|
state=state,
|
||||||
event_type=event_type,
|
event_type=str(event_type),
|
||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _send_strong_signal_alert(cls, *, state, payload: dict) -> None:
|
def _publish_strong_signal_event(cls, *, state, payload: dict) -> None:
|
||||||
if cls._bot is None or cls._chat_id is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
signal = str(payload.get("signal", "")).upper()
|
signal = str(payload.get("signal", "")).upper()
|
||||||
symbol = str(payload.get("symbol") or state.symbol or "—")
|
symbol = str(payload.get("symbol") or state.symbol or "—")
|
||||||
strategy = str(payload.get("strategy") or state.strategy 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)
|
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
|
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 "—")
|
reason = str(payload.get("reason") or state.last_signal_reason or "—")
|
||||||
|
|
||||||
position_context = str(getattr(state, "position_side", "NONE") or "NONE")
|
position_context = str(getattr(state, "position_side", "NONE") or "NONE")
|
||||||
|
|
||||||
priority = cls._alert_priority(
|
priority = cls._alert_priority(
|
||||||
@@ -245,58 +237,11 @@ class AutoTradeRunner:
|
|||||||
repeat_count=repeat_count,
|
repeat_count=repeat_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
alert_key = (
|
RuntimeEventPublisher.publish(
|
||||||
f"signal:{position_context}:{symbol}:{strategy}:{signal}:"
|
RuntimeEvent(
|
||||||
f"{repeat_count}:{confidence:.2f}:"
|
event_type=RuntimeEventType.AUTO_SIGNAL_READY,
|
||||||
f"{state.decision_status}:{reason}"
|
source="auto_trade_runner",
|
||||||
)
|
title=f"Auto strong signal {signal}",
|
||||||
|
|
||||||
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",
|
|
||||||
payload={
|
payload={
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"strategy": strategy,
|
"strategy": strategy,
|
||||||
@@ -305,76 +250,90 @@ class AutoTradeRunner:
|
|||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
"leverage": leverage,
|
"leverage": leverage,
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
"priority": priority,
|
|
||||||
"position_context": position_context,
|
"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
|
@classmethod
|
||||||
async def _send_execution_alert(
|
def _publish_execution_event(
|
||||||
cls,
|
cls,
|
||||||
*,
|
*,
|
||||||
state,
|
state,
|
||||||
event_type: str,
|
event_type: str,
|
||||||
payload: dict,
|
payload: dict,
|
||||||
) -> None:
|
) -> 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
|
return
|
||||||
|
|
||||||
alert_key = cls._execution_alert_key(
|
symbol = str(payload.get("symbol") or state.symbol or "—")
|
||||||
event_type=event_type,
|
side = str(payload.get("side") or getattr(state, "position_side", "—") or "—")
|
||||||
payload=payload,
|
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:
|
RuntimeEventPublisher.publish(
|
||||||
return
|
RuntimeEvent(
|
||||||
|
event_type=runtime_event_type,
|
||||||
cls._last_execution_alert_key = alert_key
|
source="auto_trade_runner",
|
||||||
|
title=cls._execution_event_title(runtime_event_type),
|
||||||
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",
|
|
||||||
payload={
|
payload={
|
||||||
"source_event_type": event_type,
|
"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,
|
**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
|
@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,
|
cls,
|
||||||
*,
|
*,
|
||||||
event_type: str,
|
runtime_event_type: RuntimeEventType,
|
||||||
payload: dict,
|
payload: dict,
|
||||||
) -> str:
|
) -> str:
|
||||||
return (
|
return (
|
||||||
f"{event_type}:"
|
f"{runtime_event_type.value}:"
|
||||||
f"{payload.get('symbol')}:"
|
f"{payload.get('symbol')}:"
|
||||||
f"{payload.get('side')}:"
|
f"{payload.get('side')}:"
|
||||||
f"{payload.get('old_side')}:"
|
f"{payload.get('old_side')}:"
|
||||||
@@ -385,119 +344,11 @@ class AutoTradeRunner:
|
|||||||
f"{payload.get('size')}:"
|
f"{payload.get('size')}:"
|
||||||
f"{payload.get('old_size')}:"
|
f"{payload.get('old_size')}:"
|
||||||
f"{payload.get('new_size')}:"
|
f"{payload.get('new_size')}:"
|
||||||
f"{payload.get('pnl')}"
|
f"{payload.get('pnl')}:"
|
||||||
f"{payload.get('risk_reason')}:"
|
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"<b>📄 Paper position opened {side_icon} {side}</b>\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"<b>✅ Paper position closed</b>\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"<b>🔁 Paper position flipped {old_icon} {old_side} → "
|
|
||||||
f"{new_icon} {new_side}</b>\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 "<b>📄 Paper execution event</b>"
|
|
||||||
|
|
||||||
@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
|
@classmethod
|
||||||
def _alert_priority(
|
def _alert_priority(
|
||||||
cls,
|
cls,
|
||||||
@@ -513,87 +364,6 @@ class AutoTradeRunner:
|
|||||||
|
|
||||||
return "LOW"
|
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"<b>{priority_text} · {icon} {signal}</b>\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
|
@classmethod
|
||||||
def _log_refresh_skip(cls, reason: str, payload: dict | None = None) -> None:
|
def _log_refresh_skip(cls, reason: str, payload: dict | None = None) -> None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -331,6 +331,27 @@
|
|||||||
- исправлен circular import в package init файлах
|
- исправлен circular import в package init файлах
|
||||||
- подготовлена архитектура для переноса strong signal alerts
|
- подготовлена архитектура для переноса 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
|
### 07.4.4
|
||||||
⏳ Grid Strategy
|
⏳ Grid Strategy
|
||||||
|
|
||||||
|
|||||||
@@ -315,6 +315,28 @@
|
|||||||
- исправлен circular import в package init файлах
|
- исправлен circular import в package init файлах
|
||||||
- подготовлена архитектура для переноса strong signal alerts
|
- подготовлена архитектура для переноса 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
|
### 07.4.4
|
||||||
|
|||||||
210
docs/stages/stage-07_4_3_18_2-runtime_notification_migration.md
Normal file
210
docs/stages/stage-07_4_3_18_2-runtime_notification_migration.md
Normal file
@@ -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.
|
||||||
Reference in New Issue
Block a user