# app/src/trading/auto/runner.py from __future__ import annotations import asyncio import time from typing import Callable from aiogram import Bot from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter 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: _task: asyncio.Task | None = None _bot: Bot | None = None _chat_id: int | None = None _message_id: int | None = None _render_text: Callable[[], str] | None = None _render_markup: Callable[[], object] | None = None _current_screen: str | None = None _analysis_interval_seconds = 5 _ui_interval_seconds = 5 _last_text: str | None = None _last_ui_refresh_at: float = 0.0 _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, *, bot: Bot, chat_id: int, message_id: int, render_text: Callable[[], str], render_markup: Callable[[], object], ) -> None: cls._bot = bot cls._chat_id = chat_id cls._message_id = message_id cls._render_text = render_text cls._render_markup = render_markup cls._last_text = None @classmethod async def delete_registered_screen( cls, *, bot: Bot, chat_id: int, ) -> None: if cls._chat_id is None or cls._message_id is None: return if cls._chat_id != chat_id: return try: await bot.delete_message( chat_id=cls._chat_id, message_id=cls._message_id, ) except Exception: pass cls._message_id = None cls._render_text = None cls._render_markup = None cls._last_text = None @classmethod def unregister_screen( cls, *, chat_id: int, message_id: int, ) -> None: if cls._chat_id != chat_id or cls._message_id != message_id: return cls._message_id = None cls._render_text = None cls._render_markup = None cls._last_text = None @classmethod def set_current_screen(cls, screen: str) -> None: cls._current_screen = screen @classmethod def start(cls) -> None: service = AutoTradeService() MarketDataRunner.start( symbol_provider=lambda: service.get_state().symbol, interval_seconds=1, runtime_key="auto", screen="auto", action="market_data", runtime_label="[AUTO]", ) if cls._task is not None and not cls._task.done(): return cls._task = asyncio.create_task(cls._worker()) @classmethod def stop(cls) -> None: MarketDataRunner.stop("auto") if cls._task is None: return cls._task.cancel() cls._task = None @classmethod async def _worker(cls) -> None: service = AutoTradeService() while True: state = service.get_state() if state.status == "OFF": cls._task = None MarketDataRunner.stop("auto") break service.run_cycle() current_event_version = EventBus.version() has_important_event = current_event_version != cls._last_event_version if has_important_event: cls._last_event_version = current_event_version await cls._handle_important_event(state) await cls._refresh_screen(force=has_important_event) await asyncio.sleep(cls._analysis_interval_seconds) @classmethod async def process_last_event_now(cls) -> None: state = AutoTradeService().get_state() await cls._handle_important_event(state) @classmethod async def _handle_important_event(cls, state) -> None: event_type, payload = EventBus.last_event() if event_type == "auto_decision_changed": if payload.get("decision_status") != "READY": return signal = str(payload.get("signal", "")).upper() if signal not in {"BUY", "SELL"}: return await cls._send_strong_signal_alert(state=state, payload=payload) return if event_type in { "paper_position_opened", "paper_position_closed", "paper_position_flipped", }: await cls._send_execution_alert( state=state, event_type=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 signal = str(payload.get("signal", "")).upper() symbol = str(payload.get("symbol") or state.symbol or "—") strategy = str(payload.get("strategy") or state.strategy or "—") repeat_count = int(payload.get("repeat_count") or state.last_signal_repeat_count or 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 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( confidence=confidence, 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", payload={ "symbol": symbol, "strategy": strategy, "signal": signal, "repeat_count": repeat_count, "confidence": confidence, "leverage": leverage, "reason": reason, "priority": priority, "position_context": position_context, }, ) except TelegramRetryAfter as exc: cls._retry_after_until = time.monotonic() + exc.retry_after + 5 except Exception: pass @classmethod async def _send_execution_alert( cls, *, state, event_type: str, payload: dict, ) -> None: if cls._bot is None or cls._chat_id is None: return alert_key = cls._execution_alert_key( event_type=event_type, payload=payload, ) 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", payload={ "source_event_type": event_type, **payload, }, ) except TelegramRetryAfter as exc: cls._retry_after_until = time.monotonic() + exc.retry_after + 5 except Exception: pass @classmethod def _execution_alert_key( cls, *, event_type: str, payload: dict, ) -> str: return ( f"{event_type}:" f"{payload.get('symbol')}:" f"{payload.get('side')}:" f"{payload.get('old_side')}:" f"{payload.get('new_side')}:" f"{payload.get('entry_price')}:" f"{payload.get('exit_price')}:" f"{payload.get('new_entry_price')}:" f"{payload.get('size')}:" f"{payload.get('old_size')}:" f"{payload.get('new_size')}:" f"{payload.get('pnl')}" f"{payload.get('risk_reason')}:" 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, *, 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" @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: JournalService().log_ui_info( event_type="auto_screen_refresh_skipped", message=f"Auto screen refresh skipped: {reason}", screen="auto", action="refresh_screen", payload=payload or {}, ) except Exception: pass @classmethod def _log_refresh_success(cls, payload: dict | None = None) -> None: try: JournalService().log_ui_info( event_type="auto_screen_refreshed", message="Auto screen refreshed.", screen="auto", action="refresh_screen", payload=payload or {}, ) except Exception: pass @classmethod def _log_refresh_error(cls, reason: str, payload: dict | None = None) -> None: try: JournalService().log_error( "auto_screen_refresh_error", f"Auto screen refresh error: {reason}", payload or {}, ) except Exception: pass @classmethod async def _refresh_screen(cls, *, force: bool = False) -> None: if cls._current_screen != "auto": cls._log_refresh_skip("current_screen_not_auto") return now = time.monotonic() if now < cls._retry_after_until: cls._log_refresh_skip( "retry_after_active", {"retry_after_until": cls._retry_after_until, "now": now}, ) return if not force and now - cls._last_ui_refresh_at < cls._ui_interval_seconds: cls._log_refresh_skip( "ui_interval_not_reached", { "elapsed": round(now - cls._last_ui_refresh_at, 2), "interval": cls._ui_interval_seconds, }, ) return if not all( [ cls._bot, cls._chat_id, cls._message_id, cls._render_text, cls._render_markup, ] ): cls._log_refresh_skip( "screen_not_registered", { "has_bot": cls._bot is not None, "chat_id": cls._chat_id, "message_id": cls._message_id, "has_render_text": cls._render_text is not None, "has_render_markup": cls._render_markup is not None, }, ) return text = cls._render_text() if text == cls._last_text: cls._log_refresh_skip("text_not_changed") return try: await cls._bot.edit_message_text( chat_id=cls._chat_id, message_id=cls._message_id, text=text, reply_markup=cls._render_markup(), ) cls._last_text = text cls._last_ui_refresh_at = now cls._log_refresh_success( { "chat_id": cls._chat_id, "message_id": cls._message_id, "text_length": len(text), } ) except TelegramRetryAfter as exc: cls._retry_after_until = time.monotonic() + exc.retry_after + 5 cls._log_refresh_error( "telegram_retry_after", { "retry_after": exc.retry_after, "retry_after_until": cls._retry_after_until, }, ) except TelegramBadRequest as exc: error_text = str(exc).lower() if "message is not modified" in error_text: cls._last_text = text cls._last_ui_refresh_at = now cls._log_refresh_skip("telegram_message_not_modified") return if "message to edit not found" in error_text: cls._message_id = None cls._render_text = None cls._render_markup = None cls._last_text = None cls._log_refresh_error( "telegram_message_to_edit_not_found", {"error": str(exc)}, ) return cls._log_refresh_error( "telegram_bad_request", {"error": str(exc)}, ) except Exception as exc: cls._log_refresh_error( "unexpected_refresh_error", {"error": str(exc)}, )