diff --git a/app/src/telegram/handlers/market.py b/app/src/telegram/handlers/market.py index 948ccf5..5516c11 100644 --- a/app/src/telegram/handlers/market.py +++ b/app/src/telegram/handlers/market.py @@ -9,19 +9,21 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.service import ExchangeService -from src.telegram.ui.common import mode_line +from src.telegram.live.runner import LiveScreen, LiveScreenRunner +from src.telegram.ui.common import mode_line, now_line from src.telegram.ui.exchange_error import ( classify_exchange_error, show_callback_exchange_error, show_message_exchange_error, ) -from src.trading.journal.service import JournalService from src.trading.auto.runner import AutoTradeRunner +from src.trading.journal.service import JournalService router = Router(name="market") +# клавиатура экрана рынка def _market_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="🏠 К торговле", callback_data="trade:home") @@ -29,6 +31,7 @@ def _market_keyboard() -> InlineKeyboardMarkup: return builder.as_markup() +# собрать текст рынка по готовым данным def _build_market_text( *, ticker_price: float, @@ -39,6 +42,8 @@ def _build_market_text( quote_asset: str, tick_size: str, ) -> str: + from src.telegram.ui.common import now_line + status_map = { "TRADING": "доступен для торговли", "HALT": "торги остановлены", @@ -61,10 +66,68 @@ def _build_market_text( f"Тип инструмента: {market_type_ru}\n" f"Базовый актив: {base_asset}\n" f"Валюта котировки: {quote_asset}\n" - f"Шаг цены: {tick_size} {quote_asset}" + f"Шаг цены: {tick_size} {quote_asset}\n\n" + f"{now_line()}" ) +# собрать актуальный live-текст рынка +def _build_market_live_text() -> str: + service = ExchangeService() + requested_symbol = service.settings.default_symbol + + validation = service.validate_symbol(requested_symbol) + + if not validation.is_valid: + return ( + "📈 Рынок\n" + f"{mode_line()}" + "⚠️ Ошибка инструмента\n\n" + "Инструмент недоступен." + ) + + ticker = service.get_price(validation.normalized_symbol) + + symbol_info = validation.symbol_info + symbol_status = symbol_info.status if symbol_info else "n/a" + market_type = symbol_info.market_type if symbol_info else "n/a" + tick_size = ( + f"{symbol_info.tick_size}" + if symbol_info and symbol_info.tick_size is not None + else "n/a" + ) + base_asset = symbol_info.base_asset if symbol_info and symbol_info.base_asset else "n/a" + quote_asset = symbol_info.quote_asset if symbol_info and symbol_info.quote_asset else "n/a" + name = symbol_info.name if symbol_info and symbol_info.name else ticker.symbol + + return _build_market_text( + ticker_price=ticker.price, + name=name, + symbol_status=symbol_status, + market_type=market_type, + base_asset=base_asset, + quote_asset=quote_asset, + tick_size=tick_size, + ) + + +# зарегистрировать сообщение как live-экран рынка +def _register_market_live_screen(message: Message) -> None: + LiveScreenRunner.register_screen( + LiveScreen( + screen="market", + bot=message.bot, + chat_id=message.chat.id, + message_id=message.message_id, + render_text=_build_market_live_text, + render_markup=_market_keyboard, + interval_seconds=5, + ) + ) + LiveScreenRunner.start("market") + + +# отрисовать экран рынка async def _render_market_screen( target_message: Message, *, @@ -74,7 +137,8 @@ async def _render_market_screen( action: str, ) -> None: AutoTradeRunner.set_current_screen("market") - + LiveScreenRunner.set_current_screen("market") + service = ExchangeService() journal = JournalService() requested_symbol = service.settings.default_symbol @@ -114,8 +178,11 @@ async def _render_market_screen( if edit_mode: await target_message.edit_text(text, reply_markup=_market_keyboard()) + _register_market_live_screen(target_message) else: - await target_message.answer(text, reply_markup=_market_keyboard()) + sent_message = await target_message.answer(text, reply_markup=_market_keyboard()) + _register_market_live_screen(sent_message) + return ticker = service.get_price(validation.normalized_symbol) @@ -157,14 +224,23 @@ async def _render_market_screen( if edit_mode: await target_message.edit_text(text, reply_markup=_market_keyboard()) + _register_market_live_screen(target_message) else: - await target_message.answer(text, reply_markup=_market_keyboard()) + sent_message = await target_message.answer(text, reply_markup=_market_keyboard()) + _register_market_live_screen(sent_message) +# открыть рынок из главного меню @router.message(F.text == "📈 Рынок") async def open_market(message: Message, state: FSMContext) -> None: await state.clear() + await LiveScreenRunner.delete_screen( + screen="market", + bot=message.bot, + chat_id=message.chat.id, + ) + user_id = message.from_user.id if message.from_user else None chat_id = message.chat.id if message.chat else None @@ -198,6 +274,7 @@ async def open_market(message: Message, state: FSMContext) -> None: ) +# обновить рынок вручную @router.callback_query(F.data == "market:retry") async def retry_market(callback: CallbackQuery, state: FSMContext) -> None: await state.clear() @@ -217,7 +294,7 @@ async def retry_market(callback: CallbackQuery, state: FSMContext) -> None: edit_mode=True, action="retry", ) - await callback.answer() + await callback.answer() except ExchangeError as exc: JournalService().log_ui_error( event_type="market_retry_error", diff --git a/app/src/telegram/handlers/portfolio.py b/app/src/telegram/handlers/portfolio.py index 40b3f34..6d405a1 100644 --- a/app/src/telegram/handlers/portfolio.py +++ b/app/src/telegram/handlers/portfolio.py @@ -10,6 +10,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.models import BalanceSummary from src.integrations.exchange.service import ExchangeService +from src.telegram.live.runner import LiveScreen, LiveScreenRunner from src.telegram.ui.common import mode_line, now_line from src.telegram.ui.currency_ui import ( balance_total, @@ -24,8 +25,8 @@ from src.telegram.ui.exchange_error import ( show_message_exchange_error, ) from src.trading.accounts.service import AccountsService -from src.trading.journal.service import JournalService from src.trading.auto.runner import AutoTradeRunner +from src.trading.journal.service import JournalService router = Router(name="portfolio") @@ -39,6 +40,7 @@ PINNED_ORDER = { } +# клавиатура портфеля def _portfolio_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="🏠 К торговле", callback_data="trade:home") @@ -46,6 +48,7 @@ def _portfolio_keyboard() -> InlineKeyboardMarkup: return builder.as_markup() +# клавиатура портфеля при частичной загрузке def _portfolio_warning_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="🔁 Обновить", callback_data="portfolio:retry") @@ -54,6 +57,7 @@ def _portfolio_warning_keyboard() -> InlineKeyboardMarkup: return builder.as_markup() +# сортировка активов в портфеле def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]: def sort_key(item: BalanceSummary) -> tuple[int, str]: currency = item.currency.upper() @@ -63,78 +67,33 @@ def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]: return sorted(items, key=sort_key) -async def _render_portfolio_screen( - target_message: Message, - *, - user_id: int | None, - chat_id: int | None, - edit_mode: bool, - action: str, -) -> None: - AutoTradeRunner.set_current_screen("portfolio") - +# собрать актуальный live-текст портфеля +def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]: service = AccountsService() exchange_service = ExchangeService() - journal = JournalService() - - journal.log_ui_info( - event_type="portfolio_open_requested", - message="Запрошено открытие экрана портфеля.", - screen="portfolio", - action=action, - user_id=user_id, - chat_id=chat_id, - ) balances = service.get_live_balance_summary() if not balances: - journal.log_ui_warning( - event_type="portfolio_empty", - message="Нет данных по балансу.", - screen="portfolio", - action=action, - user_id=user_id, - chat_id=chat_id, - ) - text = ( "💼 Портфель\n" f"{mode_line()}" - "Нет данных по балансу." + "Нет данных по балансу.\n\n" + f"{now_line()}" ) - - if edit_mode: - await target_message.edit_text(text, reply_markup=_portfolio_keyboard()) - else: - await target_message.answer(text, reply_markup=_portfolio_keyboard()) - return + return text, _portfolio_keyboard() visible_balances = [item for item in balances if not is_zero_balance(item)] visible_balances = sort_balances(visible_balances) if not visible_balances: - journal.log_ui_warning( - event_type="portfolio_zero_balances", - message="Нет активов с балансом.", - screen="portfolio", - action=action, - user_id=user_id, - chat_id=chat_id, - payload={"assets_count": len(balances)}, - ) - text = ( "💼 Портфель\n" f"{mode_line()}" - "Нет активов с балансом." + "Нет активов с балансом.\n\n" + f"{now_line()}" ) - - if edit_mode: - await target_message.edit_text(text, reply_markup=_portfolio_keyboard()) - else: - await target_message.answer(text, reply_markup=_portfolio_keyboard()) - return + return text, _portfolio_keyboard() price_cache: dict[str, float | None] = {} total_estimated_usd = 0.0 @@ -180,7 +139,7 @@ async def _render_portfolio_screen( lines.append("🟡 Данные загружены частично") if has_any_estimate: - lines.append(f"ОЦЕНКА · ~${format_usd_amount(total_estimated_usd)}") + lines.append(f"Оценка: ~${format_usd_amount(total_estimated_usd)}") if missing_estimate_assets: lines.append(f"Нет оценки: {', '.join(missing_estimate_assets)}") @@ -188,6 +147,70 @@ async def _render_portfolio_screen( for block in asset_blocks: lines.extend(block) + lines.extend(["", now_line()]) + + reply_markup = ( + _portfolio_warning_keyboard() + if has_partial_data + else _portfolio_keyboard() + ) + + return "\n".join(lines).rstrip(), reply_markup + + +# текст live-экрана портфеля +def _portfolio_live_text() -> str: + text, _ = _build_portfolio_live_text() + return text + + +# клавиатура live-экрана портфеля +def _portfolio_live_markup() -> InlineKeyboardMarkup: + _, markup = _build_portfolio_live_text() + return markup + + +# зарегистрировать сообщение как live-экран портфеля +def _register_portfolio_live_screen(message: Message) -> None: + LiveScreenRunner.register_screen( + LiveScreen( + screen="portfolio", + bot=message.bot, + chat_id=message.chat.id, + message_id=message.message_id, + render_text=_portfolio_live_text, + render_markup=_portfolio_live_markup, + interval_seconds=10, + ) + ) + LiveScreenRunner.start("portfolio") + + +# отрисовать экран портфеля +async def _render_portfolio_screen( + target_message: Message, + *, + user_id: int | None, + chat_id: int | None, + edit_mode: bool, + action: str, +) -> None: + AutoTradeRunner.set_current_screen("portfolio") + LiveScreenRunner.set_current_screen("portfolio") + + journal = JournalService() + + journal.log_ui_info( + event_type="portfolio_open_requested", + message="Запрошено открытие экрана портфеля.", + screen="portfolio", + action=action, + user_id=user_id, + chat_id=chat_id, + ) + + text, reply_markup = _build_portfolio_live_text() + journal.log_ui_info( event_type="portfolio_open_success", message="Портфель загружен.", @@ -195,49 +218,27 @@ async def _render_portfolio_screen( action=action, user_id=user_id, chat_id=chat_id, - payload={ - "assets_count": len(visible_balances), - "estimated_usd": round(total_estimated_usd, 2) if has_any_estimate else None, - "missing_estimate_assets": missing_estimate_assets, - }, - ) - - if missing_estimate_assets: - journal.log_ui_warning( - event_type="portfolio_partial_estimate", - message="Портфель загружен частично.", - screen="portfolio", - action=action, - user_id=user_id, - chat_id=chat_id, - payload={ - "assets_count": len(visible_balances), - "estimated_assets": len(visible_balances) - len(missing_estimate_assets), - "failed_assets": missing_estimate_assets, - }, - ) - - if has_partial_data: - lines.extend(["", now_line()]) - - text = "\n".join(lines).rstrip() - - reply_markup = ( - _portfolio_warning_keyboard() - if has_partial_data - else _portfolio_keyboard() ) if edit_mode: await target_message.edit_text(text, reply_markup=reply_markup) + _register_portfolio_live_screen(target_message) else: - await target_message.answer(text, reply_markup=reply_markup) + sent_message = await target_message.answer(text, reply_markup=reply_markup) + _register_portfolio_live_screen(sent_message) +# открыть портфель из меню @router.message(F.text == "💼 Портфель") async def open_portfolio(message: Message, state: FSMContext) -> None: await state.clear() + await LiveScreenRunner.delete_screen( + screen="portfolio", + bot=message.bot, + chat_id=message.chat.id, + ) + user_id = message.from_user.id if message.from_user else None chat_id = message.chat.id if message.chat else None @@ -271,6 +272,7 @@ async def open_portfolio(message: Message, state: FSMContext) -> None: ) +# обновить портфель вручную @router.callback_query(F.data == "portfolio:retry") async def retry_portfolio(callback: CallbackQuery, state: FSMContext) -> None: await state.clear() diff --git a/app/src/telegram/live/runner.py b/app/src/telegram/live/runner.py new file mode 100644 index 0000000..ba14e84 --- /dev/null +++ b/app/src/telegram/live/runner.py @@ -0,0 +1,133 @@ +# app/src/telegram/live/runner.py + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Callable + +from aiogram import Bot + + +@dataclass(slots=True) +class LiveScreen: + # имя live-экрана: market / portfolio / journal + screen: str + + # Telegram bot instance + bot: Bot + + # чат, где находится live-экран + chat_id: int + + # сообщение, которое нужно автообновлять + message_id: int + + # функция сборки текста экрана + render_text: Callable[[], str] + + # функция сборки клавиатуры экрана + render_markup: Callable[[], object] + + # интервал обновления в секундах + interval_seconds: int = 5 + + +class LiveScreenRunner: + _screens: dict[str, LiveScreen] = {} + _tasks: dict[str, asyncio.Task] = {} + _current_screen: str | None = None + + # переключить активный экран + @classmethod + def set_current_screen(cls, screen: str) -> None: + cls._current_screen = screen + + # зарегистрировать live-экран + @classmethod + def register_screen(cls, live_screen: LiveScreen) -> None: + cls._screens[live_screen.screen] = live_screen + + # удалить старый live-экран из Telegram + @classmethod + async def delete_screen( + cls, + *, + screen: str, + bot: Bot, + chat_id: int, + ) -> None: + live_screen = cls._screens.get(screen) + + if live_screen is None: + return + + if live_screen.chat_id != chat_id: + return + + try: + await bot.delete_message( + chat_id=live_screen.chat_id, + message_id=live_screen.message_id, + ) + except Exception: + pass + + cls._screens.pop(screen, None) + + # запустить автообновление экрана + @classmethod + def start(cls, screen: str) -> None: + task = cls._tasks.get(screen) + + if task is not None and not task.done(): + return + + cls._tasks[screen] = asyncio.create_task(cls._worker(screen)) + + # остановить автообновление экрана + @classmethod + def stop(cls, screen: str) -> None: + task = cls._tasks.get(screen) + + if task is None: + return + + task.cancel() + cls._tasks.pop(screen, None) + + # фоновый цикл обновления одного экрана + @classmethod + async def _worker(cls, screen: str) -> None: + while True: + await cls._refresh_screen(screen) + await asyncio.sleep(cls._screen_interval(screen)) + + # получить интервал обновления экрана + @classmethod + def _screen_interval(cls, screen: str) -> int: + live_screen = cls._screens.get(screen) + if live_screen is None: + return 5 + + return live_screen.interval_seconds + + # обновить Telegram-сообщение live-экрана + @classmethod + async def _refresh_screen(cls, screen: str) -> None: + if cls._current_screen != screen: + return + + live_screen = cls._screens.get(screen) + if live_screen is None: + return + + try: + await live_screen.bot.edit_message_text( + chat_id=live_screen.chat_id, + message_id=live_screen.message_id, + text=live_screen.render_text(), + reply_markup=live_screen.render_markup(), + ) + except Exception: + pass \ No newline at end of file