397 lines
12 KiB
Python
397 lines
12 KiB
Python
# 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",
|
||
) |