# app/src/trading/debug/runner.py from __future__ import annotations import asyncio import time from collections.abc import Callable from typing import ClassVar, Protocol from aiogram import Bot from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter from aiogram.types import InlineKeyboardMarkup from src.core.telegram_errors import ( is_message_not_modified, is_message_to_edit_not_found, ) from src.integrations.exchange.market_data_runner import MarketDataRunner from src.notifications.targets import NotificationTargetRegistry from src.trading.debug.service import DebugTradeService class RenderText(Protocol): def __call__(self) -> str: ... class RenderMarkup(Protocol): def __call__(self) -> InlineKeyboardMarkup | None: ... class DebugTradeRunner: _task: ClassVar[asyncio.Task[None] | None] = None _bot: ClassVar[Bot | None] = None _chat_id: ClassVar[int | None] = None _message_id: ClassVar[int | None] = None _text_renderer: ClassVar[RenderText | None] = None _markup_renderer: ClassVar[RenderMarkup | None] = None _current_screen: ClassVar[str | None] = None _interval_seconds: ClassVar[int] = 5 _market_interval_seconds: ClassVar[int] = 1 _last_text: ClassVar[str | None] = None _last_refresh_at: ClassVar[float] = 0.0 _retry_after_until: ClassVar[float] = 0.0 @classmethod def register_screen( cls, *, bot: Bot, chat_id: int, message_id: int, render_text: RenderText, render_markup: RenderMarkup, ) -> None: cls._bot = bot cls._chat_id = chat_id cls._message_id = message_id cls._text_renderer = render_text cls._markup_renderer = render_markup cls._last_text = None NotificationTargetRegistry.set_default_chat( bot=bot, chat_id=chat_id, ) @classmethod def _reset_screen(cls) -> None: cls._message_id = None cls._text_renderer = None cls._markup_renderer = None cls._last_text = None @classmethod def _reset_runtime(cls) -> None: cls._bot = None cls._chat_id = None cls._current_screen = None cls._reset_screen() @classmethod def _is_screen_ready(cls) -> bool: return ( cls._bot is not None and cls._chat_id is not None and cls._message_id is not None and cls._text_renderer is not None and cls._markup_renderer is not 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._reset_screen() @classmethod async def detach_screen( cls, *, delete_message: bool = False, bot: Bot | None = None, chat_id: int | None = None, keep_message_id: int | None = None, ) -> None: if ( delete_message and bot is not None and cls._chat_id is not None and cls._message_id is not None and cls._chat_id == chat_id and cls._message_id != keep_message_id ): try: await bot.delete_message( chat_id=cls._chat_id, message_id=cls._message_id, ) except Exception: pass cls._reset_runtime() @classmethod def set_current_screen(cls, screen: str) -> None: cls._current_screen = screen @classmethod def start(cls) -> None: service = DebugTradeService() state = service.get_state() state.status = "RUNNING" MarketDataRunner.start( symbol_provider=lambda: DebugTradeService().get_state().symbol, interval_seconds=cls._market_interval_seconds, runtime_key="debug_auto", screen="debug_auto", action="market_data", runtime_label="[DEBUG]", ) cls._last_text = None 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("debug_auto") if cls._task is None: return cls._task.cancel() cls._task = None @classmethod async def _worker(cls) -> None: service = DebugTradeService() while True: state = service.get_state() if state.status == "OFF": cls._task = None MarketDataRunner.stop("debug_auto") break service.process() await cls.refresh_screen(force=False) await asyncio.sleep(cls._interval_seconds) @classmethod async def refresh_screen( cls, *, force: bool = False, ) -> None: if cls._current_screen != "debug_auto": return now = time.monotonic() if now < cls._retry_after_until: return if ( not force and now - cls._last_refresh_at < cls._interval_seconds ): return if not cls._is_screen_ready(): return bot = cls._bot chat_id = cls._chat_id message_id = cls._message_id text_renderer = cls._text_renderer markup_renderer = cls._markup_renderer if ( bot is None or chat_id is None or message_id is None or text_renderer is None or markup_renderer is None ): return text = text_renderer() if text == cls._last_text: return try: await bot.edit_message_text( chat_id=chat_id, message_id=message_id, text=text, reply_markup=markup_renderer(), ) cls._last_text = text cls._last_refresh_at = now except TelegramRetryAfter as exc: cls._retry_after_until = time.monotonic() + exc.retry_after + 5 except TelegramBadRequest as exc: if is_message_not_modified(exc): cls._last_text = text cls._last_refresh_at = now return if is_message_to_edit_not_found(exc): cls._reset_screen() return except Exception: pass