# app/src/telegram/handlers/portfolio.py from __future__ import annotations from aiogram import F, Router from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message 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, ScreenRegistry from src.telegram.ui.common import mode_line, now_line from src.telegram.ui.currency_ui import format_usd_amount from src.telegram.ui.currency_ui import ( balance_total, estimate_balance_usd, format_amount, format_usd_amount, is_zero_balance, ) from src.telegram.ui.exchange_error import ( classify_exchange_error, show_callback_exchange_error, show_message_exchange_error, ) from src.trading.accounts.service import AccountsService from src.trading.auto.runner import AutoTradeRunner from src.trading.journal.service import JournalService router = Router(name="portfolio") PINNED_ORDER = { "USD": 1, "USDT": 2, "BTC": 3, "ETH": 4, } # компактное форматирование количества def _compact_amount(currency: str, value: float) -> str: currency = currency.upper() if currency in {"USD", "USDT", "EUR"}: return format_usd_amount(value) text = f"{value:.8f}" if "." in text: integer, frac = text.split(".") integer = f"{int(integer):,}".replace(",", " ") stripped = frac.rstrip("0") if stripped == "": return f"{integer}.00" if len(stripped) <= 2: return f"{integer}.{stripped.ljust(2, '0')}" return f"{integer}.{stripped}" return f"{int(text):,}".replace(",", " ") # клавиатура портфеля def _portfolio_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="📊 К мониторингу", callback_data="monitoring:home") builder.adjust(1) return builder.as_markup() # клавиатура портфеля при частичной загрузке def _portfolio_warning_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="🔁 Обновить", callback_data="portfolio:retry") builder.button(text="📊 К мониторингу", callback_data="monitoring:home") builder.adjust(1, 1) return builder.as_markup() # сортировка активов в портфеле def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]: def sort_key(item: BalanceSummary) -> tuple[int, str]: currency = item.currency.upper() priority = PINNED_ORDER.get(currency, 999) return (priority, currency) return sorted(items, key=sort_key) # собрать актуальный live-текст портфеля def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]: service = AccountsService() exchange_service = ExchangeService() balances = service.get_live_balance_summary() if not balances: text = ( "💼 Портфель\n" f"{mode_line()}" "Нет данных по балансу.\n\n" f"{now_line()}" ) 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: text = ( "💼 Портфель\n" f"{mode_line()}" "Нет активов с балансом.\n\n" f"{now_line()}" ) return text, _portfolio_keyboard() price_cache: dict[str, float | None] = {} total_estimated_usd = 0.0 has_any_estimate = False missing_estimate_assets: list[str] = [] lines: list[str] = [ "💼 Портфель", mode_line().rstrip(), "", ] asset_blocks: list[list[str]] = [] for item in visible_balances: currency = item.currency.upper() total = balance_total(item) estimated_usd = estimate_balance_usd(item, exchange_service, price_cache) if estimated_usd is not None: total_estimated_usd += estimated_usd has_any_estimate = True elif total > 0: missing_estimate_assets.append(currency) line = f"{currency}: {_compact_amount(currency, total)}" if item.locked > 0: line += f" · locked {_compact_amount(currency, item.locked)}" if estimated_usd is not None and currency not in {'USD', 'USDT'}: line += f" ≈ $ {format_usd_amount(estimated_usd)}" if currency == "BTC" and any("USD:" in x or "USDT:" in x for x in lines): lines.append("") lines.append(line) has_partial_data = len(missing_estimate_assets) > 0 if missing_estimate_assets: lines.append("🟡 Данные загружены частично") if has_any_estimate: lines.insert(3, "") lines.insert(3, f"Оценка: ≈ $ {format_usd_amount(total_estimated_usd)}") if missing_estimate_assets: lines.append(f"Нет оценки: {', '.join(missing_estimate_assets)}") 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.unregister_message( chat_id=message.chat.id, message_id=message.message_id, ) ScreenRegistry.unregister_message( chat_id=message.chat.id, message_id=message.message_id, ) 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") 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="Портфель загружен.", screen="portfolio", action=action, user_id=user_id, chat_id=chat_id, ) if edit_mode: await target_message.edit_text(text, reply_markup=reply_markup) _register_portfolio_live_screen(target_message) else: 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 try: await _render_portfolio_screen( message, user_id=user_id, chat_id=chat_id, edit_mode=False, action="open", ) except ExchangeError as exc: JournalService().log_ui_error( event_type="portfolio_open_error", message="Не удалось загрузить портфель.", screen="portfolio", action="open", user_id=user_id, chat_id=chat_id, error_type=classify_exchange_error(exc), raw_error=str(exc), ) await show_message_exchange_error( message, title="💼 Портфель", exc=exc, network_details="Не загружен баланс аккаунта.\nОбнови экран.", auth_details="Не загружен баланс аккаунта.\nПроверь API ключи.", retry_callback_data="portfolio:retry", ) # открыть портфель из экрана мониторинга @router.callback_query(F.data == "monitoring:portfolio") async def open_portfolio_from_monitoring(callback: CallbackQuery, state: FSMContext) -> None: await state.clear() if callback.message is None: await callback.answer("Сообщение не найдено", show_alert=True) return await LiveScreenRunner.delete_screen( screen="portfolio", bot=callback.message.bot, chat_id=callback.message.chat.id, ) user_id = callback.from_user.id if callback.from_user else None chat_id = callback.message.chat.id if callback.message.chat else None try: await _render_portfolio_screen( callback.message, user_id=user_id, chat_id=chat_id, edit_mode=True, action="open_from_monitoring", ) await callback.answer() except ExchangeError as exc: JournalService().log_ui_error( event_type="portfolio_open_error", message="Не удалось загрузить портфель из мониторинга.", screen="portfolio", action="open_from_monitoring", user_id=user_id, chat_id=chat_id, error_type=classify_exchange_error(exc), raw_error=str(exc), ) await show_callback_exchange_error( callback, title="💼 Портфель", exc=exc, network_details="Не загружен баланс аккаунта.\nОбнови экран.", auth_details="Не загружен баланс аккаунта.\nПроверь API ключи.", retry_callback_data="portfolio:retry", ) # обновить портфель вручную @router.callback_query(F.data == "portfolio:retry") async def retry_portfolio(callback: CallbackQuery, state: FSMContext) -> None: await state.clear() if callback.message is None: await callback.answer("Сообщение не найдено", show_alert=True) return user_id = callback.from_user.id if callback.from_user else None chat_id = callback.message.chat.id if callback.message.chat else None try: await _render_portfolio_screen( callback.message, user_id=user_id, chat_id=chat_id, edit_mode=True, action="retry", ) await callback.answer() except ExchangeError as exc: JournalService().log_ui_error( event_type="portfolio_retry_error", message="Не удалось обновить портфель.", screen="portfolio", action="retry", user_id=user_id, chat_id=chat_id, error_type=classify_exchange_error(exc), raw_error=str(exc), ) await show_callback_exchange_error( callback, title="💼 Портфель", exc=exc, network_details="Не загружен баланс аккаунта.\nОбнови экран.", auth_details="Не загружен баланс аккаунта.\nПроверь API ключи.", retry_callback_data="portfolio:retry", )