Files
dzentra_bot/app/src/trading/debug/runner.py

269 lines
6.7 KiB
Python

# 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