From 1253cda0036d9a9786ef6916dc3023956d28fec2 Mon Sep 17 00:00:00 2001 From: Sergey Date: Mon, 4 May 2026 19:52:35 +0300 Subject: [PATCH] =?UTF-8?q?Stage=2007.4.3.8=20=E2=80=94=20Telegram=20execu?= =?UTF-8?q?tion=20alerts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/telegram/ui/currency_ui.py | 28 ++ app/src/trading/auto/runner.py | 20 +- app/src/trading/execution/engine.py | 159 ++++------- docs/roadmap/master-roadmap.md | 7 + docs/roadmap/stage-07-auto-trading-roadmap.md | 8 + ...tage-07_4_3_8-telegram-execution-alerts.md | 258 ++++++++++++++++++ 6 files changed, 358 insertions(+), 122 deletions(-) create mode 100644 docs/stages/stage-07_4_3_8-telegram-execution-alerts.md diff --git a/app/src/telegram/ui/currency_ui.py b/app/src/telegram/ui/currency_ui.py index 76fef05..81bb168 100644 --- a/app/src/telegram/ui/currency_ui.py +++ b/app/src/telegram/ui/currency_ui.py @@ -52,6 +52,34 @@ def format_usd_amount(value: float) -> str: return f"{value:,.2f}".replace(",", " ") +def format_usd_price(value: float | int | str | None) -> str: + if value is None: + return "—" + + try: + return f"{float(value):,.2f}".replace(",", " ") + except (TypeError, ValueError): + return "—" + + +def format_usd_pnl(value: float | int | str | None) -> str: + if value is None: + return "—" + + try: + pnl = float(value) + except (TypeError, ValueError): + return "—" + + if pnl > 0: + return f"🟢 +{format_usd_price(pnl)} USD" + + if pnl < 0: + return f"🔴 -{format_usd_price(abs(pnl))} USD" + + return f"⚪ {format_usd_price(0)} USD" + + def render_currency_line( *, currency: str, diff --git a/app/src/trading/auto/runner.py b/app/src/trading/auto/runner.py index ebbf67d..dfa4480 100644 --- a/app/src/trading/auto/runner.py +++ b/app/src/trading/auto/runner.py @@ -13,6 +13,7 @@ from src.core.event_bus import EventBus from src.integrations.exchange.market_data_runner import MarketDataRunner 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 class AutoTradeRunner: @@ -497,10 +498,11 @@ class AutoTradeRunner: @classmethod def _format_price(cls, value: object) -> str: - try: - return f"{float(value):.2f}" - except (TypeError, ValueError): - return "—" + 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: @@ -509,16 +511,6 @@ class AutoTradeRunner: except (TypeError, ValueError): return "—" - @classmethod - def _format_pnl(cls, value: object) -> str: - try: - pnl = float(value) - except (TypeError, ValueError): - return "—" - - prefix = "+" if pnl > 0 else "" - return f"{prefix}{pnl:.4f} USD" - @classmethod async def _refresh_screen(cls, *, force: bool = False) -> None: now = time.monotonic() diff --git a/app/src/trading/execution/engine.py b/app/src/trading/execution/engine.py index 83f5ab2..0099a3d 100644 --- a/app/src/trading/execution/engine.py +++ b/app/src/trading/execution/engine.py @@ -15,54 +15,31 @@ from src.trading.position.state import PositionState class ExecutionEngine: _position = PositionState() - # получить текущую paper-позицию def get_position(self) -> PositionState: return type(self)._position - # обработать состояние автоторговли и принять paper-execution решение def process(self, state: AutoTradeState) -> ExecutionDecision: self._sync_state_from_position(state) if state.status != "RUNNING": - return ExecutionDecision( - action="NONE", - can_execute=False, - reason="Execution доступен только в режиме RUNNING.", - ) + return ExecutionDecision("NONE", False, "Execution доступен только в режиме RUNNING.") self._update_unrealized_pnl(state) if state.decision_status != "READY" or not state.is_signal_ready: - return ExecutionDecision( - action="NONE", - can_execute=False, - reason="Сигнал ещё не готов к execution.", - ) + return ExecutionDecision("NONE", False, "Сигнал ещё не готов к execution.") if self._should_close_position(state): return self._close_position(state) if state.last_signal == "BUY": - return self._open_position_if_empty( - state=state, - side="LONG", - action="OPEN_LONG", - ) + return self._open_position_if_empty(state=state, side="LONG", action="OPEN_LONG") if state.last_signal == "SELL": - return self._open_position_if_empty( - state=state, - side="SHORT", - action="OPEN_SHORT", - ) + return self._open_position_if_empty(state=state, side="SHORT", action="OPEN_SHORT") - return ExecutionDecision( - action="NONE", - can_execute=False, - reason="Нет торгового действия.", - ) + return ExecutionDecision("NONE", False, "Нет торгового действия.") - # открыть paper-позицию, если позиции ещё нет def _open_position_if_empty( self, *, @@ -74,21 +51,13 @@ class ExecutionEngine: if position.side != "NONE": self._sync_state_from_position(state) - return ExecutionDecision( - action="NONE", - can_execute=False, - reason="Позиция уже открыта.", - ) + return ExecutionDecision("NONE", False, "Позиция уже открыта.") try: ticker = ExchangeService().get_price(state.symbol) entry_price = ticker.price except Exception as exc: - return ExecutionDecision( - action="NONE", - can_execute=False, - reason=f"Не удалось получить цену для paper execution: {exc}", - ) + return ExecutionDecision("NONE", False, f"Не удалось получить цену для paper execution: {exc}") now = self._now_time() size = self._calculate_position_size(state) @@ -106,102 +75,82 @@ class ExecutionEngine: self._sync_state_from_position(state) + payload = { + "execution_type": "ENTRY", + "action": action, + "symbol": state.symbol, + "side": side, + "entry_price": entry_price, + "size": size, + "leverage": state.leverage, + "signal": state.last_signal, + "confidence": state.last_signal_confidence, + "repeat_count": state.last_signal_repeat_count, + "reason": state.last_signal_reason, + "opened_at": now, + } + JournalService().log_ui_info( event_type="paper_position_opened", - message=f"Paper-позиция открыта: {side} {state.symbol}", + message=f"Paper ENTRY открыта: {side} {state.symbol}", screen="auto", action="paper_execution", - payload={ - "symbol": state.symbol, - "side": side, - "entry_price": entry_price, - "size": size, - "leverage": state.leverage, - "signal": state.last_signal, - "confidence": state.last_signal_confidence, - }, + payload=payload, ) - EventBus.emit( - "paper_position_opened", - { - "symbol": state.symbol, - "side": side, - "entry_price": entry_price, - "size": size, - "leverage": state.leverage, - }, - ) + EventBus.emit("paper_position_opened", payload) - return ExecutionDecision( - action=action, - can_execute=True, - reason=f"Paper-позиция {side} открыта.", - ) + return ExecutionDecision(action, True, f"Paper ENTRY {side} открыта.") - # закрыть текущую paper-позицию def _close_position(self, state: AutoTradeState) -> ExecutionDecision: position = type(self)._position if position.side == "NONE": self._sync_state_from_position(state) - return ExecutionDecision( - action="NONE", - can_execute=False, - reason="Нет открытой позиции для закрытия.", - ) + return ExecutionDecision("NONE", False, "Нет открытой позиции для закрытия.") try: ticker = ExchangeService().get_price(state.symbol) exit_price = ticker.price except Exception as exc: - return ExecutionDecision( - action="NONE", - can_execute=False, - reason=f"Ошибка получения цены для закрытия: {exc}", - ) + return ExecutionDecision("NONE", False, f"Ошибка получения цены для закрытия: {exc}") pnl = self._calculate_pnl(exit_price) + now = self._now_time() + + payload = { + "execution_type": "EXIT", + "action": "CLOSE", + "symbol": state.symbol, + "side": position.side, + "entry_price": position.entry_price, + "exit_price": exit_price, + "size": position.size, + "leverage": position.leverage, + "pnl": pnl, + "signal": state.last_signal, + "confidence": state.last_signal_confidence, + "repeat_count": state.last_signal_repeat_count, + "reason": state.last_signal_reason, + "opened_at": position.opened_at, + "closed_at": now, + } JournalService().log_ui_info( event_type="paper_position_closed", - message=f"Позиция закрыта: {position.side} {state.symbol}", + message=f"Paper EXIT закрыта: {position.side} {state.symbol}", screen="auto", action="paper_execution", - payload={ - "symbol": state.symbol, - "side": position.side, - "entry_price": position.entry_price, - "exit_price": exit_price, - "size": position.size, - "leverage": position.leverage, - "pnl": pnl, - }, + payload=payload, ) - EventBus.emit( - "paper_position_closed", - { - "symbol": state.symbol, - "side": position.side, - "entry_price": position.entry_price, - "exit_price": exit_price, - "size": position.size, - "leverage": position.leverage, - "pnl": pnl, - }, - ) + EventBus.emit("paper_position_closed", payload) type(self)._position = PositionState() self._sync_state_from_position(state) - return ExecutionDecision( - action="CLOSE", - can_execute=True, - reason="Позиция закрыта.", - ) + return ExecutionDecision("CLOSE", True, "Paper EXIT выполнена.") - # проверить, нужно ли закрывать позицию по противоположному сигналу def _should_close_position(self, state: AutoTradeState) -> bool: position = type(self)._position @@ -216,7 +165,6 @@ class ExecutionEngine: return False - # обновить unrealized PnL по текущей цене def _update_unrealized_pnl(self, state: AutoTradeState) -> None: position = type(self)._position @@ -236,14 +184,11 @@ class ExecutionEngine: self._sync_state_from_position(state) - # временный расчёт размера позиции для paper mode def _calculate_position_size(self, state: AutoTradeState) -> float: risk_percent = state.risk_percent or 0.0 leverage = state.leverage or 1.0 - return round((risk_percent * leverage) / 100, 8) - # расчёт PnL для paper-позиции def _calculate_pnl(self, current_price: float) -> float: position = type(self)._position @@ -258,7 +203,6 @@ class ExecutionEngine: return 0.0 - # синхронизировать AutoTradeState с текущей paper-позицией def _sync_state_from_position(self, state: AutoTradeState) -> None: position = type(self)._position @@ -267,6 +211,5 @@ class ExecutionEngine: state.position_size = position.size state.unrealized_pnl_usd = position.unrealized_pnl_usd - # текущее время для paper execution def _now_time(self) -> str: return datetime.now().strftime("%H:%M:%S") \ No newline at end of file diff --git a/docs/roadmap/master-roadmap.md b/docs/roadmap/master-roadmap.md index 18c3359..ce28020 100644 --- a/docs/roadmap/master-roadmap.md +++ b/docs/roadmap/master-roadmap.md @@ -179,6 +179,13 @@ - compatible with cooldown & suppression - extended debug_signal parameters +#### 07.4.3.8 — Telegram Execution Alerts ✅ +- Telegram alerts for paper position opened +- Telegram alerts for paper position closed +- Entry / Exit / Size / PnL rendering +- readable USD formatting +- signal alerts separated from execution alerts + ### 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 7066019..9fc061c 100644 --- a/docs/roadmap/stage-07-auto-trading-roadmap.md +++ b/docs/roadmap/stage-07-auto-trading-roadmap.md @@ -161,6 +161,14 @@ - compatible with cooldown & suppression - extended debug_signal parameters +#### 07.4.3.8 — Telegram Execution Alerts ✅ + +- Telegram alerts for paper position opened +- Telegram alerts for paper position closed +- Entry / Exit / Size / PnL rendering +- readable USD formatting +- signal alerts separated from execution alerts + --- ### 07.4.4 diff --git a/docs/stages/stage-07_4_3_8-telegram-execution-alerts.md b/docs/stages/stage-07_4_3_8-telegram-execution-alerts.md new file mode 100644 index 0000000..6705dad --- /dev/null +++ b/docs/stages/stage-07_4_3_8-telegram-execution-alerts.md @@ -0,0 +1,258 @@ +# Stage 07.4.3.8 — Telegram Execution Alerts + +## Цель этапа + +Добавить отдельные Telegram-уведомления для paper execution событий: + +- открытие LONG / SHORT позиции; +- закрытие позиции; +- отображение Entry / Exit; +- отображение Size; +- отображение PnL; +- отделить signal alerts от execution alerts. + +--- + +## Что изменено + +### 1. Отдельный alert-слой для execution + +Теперь Telegram-уведомления разделены по смыслу: + +```text +Strong signal alert → рынок дал BUY / SELL +Execution alert → система открыла / закрыла paper-позицию +``` + +Это устраняет конфликт между: + +```text +SELL для закрытия LONG +SELL для открытия SHORT +``` + +--- + +## 2. Обработка EventBus событий execution + +В `AutoTradeRunner` добавлена обработка событий: + +```text +paper_position_opened +paper_position_closed +``` + +Теперь runner реагирует не только на: + +```text +auto_decision_changed +``` + +но и на paper execution события. + +--- + +## 3. Telegram alert при открытии позиции + +Пример сообщения: + +```text +📄 Paper position opened 🟢 LONG + +BTC / USD · x2 +Entry: $ 79 710.10 +Size: 0.01 +``` + +Для SHORT: + +```text +📄 Paper position opened 🔴 SHORT + +BTC / USD · x2 +Entry: $ 79 710.10 +Size: 0.01 +``` + +--- + +## 4. Telegram alert при закрытии позиции + +Пример сообщения: + +```text +✅ Paper position closed + +LONG · BTC / USD · x2 +Entry: $ 79 710.10 +Exit: $ 79 720.15 +Size: 0.01 + +PnL: 🟢 +0.10 USD +``` + +Для отрицательного PnL: + +```text +PnL: 🔴 -0.02 USD +``` + +--- + +## 5. Улучшено форматирование валют + +Добавлены UI helper-функции: + +```python +format_usd_price(...) +format_usd_pnl(...) +``` + +Теперь цены выводятся в читаемом формате: + +```text +$ 79 710.10 +``` + +а не: + +```text +$ 79710.10 +``` + +PnL отображается с направлением: + +```text +🟢 +1.25 USD +🔴 -0.02 USD +⚪ 0.00 USD +``` + +--- + +## 6. Dedup для execution alerts + +Добавлен отдельный ключ для execution alert: + +```text +event_type + symbol + side + entry_price + exit_price + size + pnl +``` + +Это защищает от повторной отправки одного и того же execution-события, но не блокирует новые реальные события. + +--- + +## Изменённые файлы + +```text +app/src/trading/auto/runner.py +app/src/trading/execution/engine.py +app/src/telegram/ui/currency_ui.py +``` + +--- + +## Проверка + +### 1. Открытие LONG + +```text +/debug_signal BUY 0.95 3 +``` + +Ожидаемо: + +```text +🚨 HIGH · 🟢 BUY +📄 Paper position opened 🟢 LONG +``` + +--- + +### 2. Закрытие LONG + +```text +/debug_signal SELL 0.95 3 +``` + +Ожидаемо: + +```text +🚨 HIGH · 🔴 SELL +✅ Paper position closed +``` + +--- + +### 3. Открытие SHORT + +После закрытия LONG: + +```text +/debug_signal SELL 0.96 3 +``` + +Ожидаемо: + +```text +🚨 HIGH · 🔴 SELL +📄 Paper position opened 🔴 SHORT +``` + +--- + +### 4. Закрытие SHORT + +```text +/debug_signal BUY 0.96 3 +``` + +Ожидаемо: + +```text +🚨 HIGH · 🟢 BUY +✅ Paper position closed +``` + +--- + +## Архитектурный результат + +Теперь система имеет отдельные слои: + +```text +Strategy + ↓ +AutoTradeService + ↓ +EventBus + ↓ +Signal Alert Layer + ↓ +ExecutionEngine + ↓ +Execution Alert Layer +``` + +Главное разделение: + +```text +Signal alert ≠ Execution alert +``` + +Это делает поведение более предсказуемым и готовит систему к следующему этапу — position flip flow. + +--- + +## Следующий этап + +```text +07.4.3.9 — Position flip flow +``` + +План: + +- SELL при LONG → закрыть LONG и открыть SHORT в одном цикле; +- BUY при SHORT → закрыть SHORT и открыть LONG в одном цикле; +- отдельные alerts для flip-сценариев; +- подготовка к реальному execution.