Files
dzentra_bot/app/src/telegram/handlers/portfolio.py

397 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 = (
"<b>💼 Портфель</b>\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 = (
"<b>💼 Портфель</b>\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] = [
"<b>💼 Портфель</b>",
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("🟡 <b>Данные загружены частично</b>")
if has_any_estimate:
lines.insert(3, "")
lines.insert(3, f"Оценка: <b>≈ $ {format_usd_amount(total_estimated_usd)}</b>")
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="<b>💼 Портфель</b>",
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="<b>💼 Портфель</b>",
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="<b>💼 Портфель</b>",
exc=exc,
network_details="Не загружен баланс аккаунта.\nОбнови экран.",
auth_details="Не загружен баланс аккаунта.\nПроверь API ключи.",
retry_callback_data="portfolio:retry",
)