feat: unify market/portfolio/system UI, improve exchange errors and asset valuation

This commit is contained in:
2026-04-22 18:21:34 +03:00
parent 2a9ef16524
commit 1fb72ced58
13 changed files with 2034 additions and 822 deletions

View File

@@ -4,10 +4,24 @@ from __future__ import annotations
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import Message
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.ui.common import mode_line, now_line
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 (
show_callback_exchange_error,
show_message_exchange_error,
)
from src.trading.accounts.service import AccountsService
from src.trading.journal.service import JournalService
@@ -15,23 +29,6 @@ from src.trading.journal.service import JournalService
router = Router(name="portfolio")
FIAT_CURRENCIES = {"USD", "USDT", "EUR", "RUB", "BYN"}
CURRENCY_ICONS = {
"USD": "💵",
"USDT": "💵",
"EUR": "💶",
"RUB": "",
"BYN": "Br",
"BTC": "",
"ETH": "Ξ",
"BNB": "🟡",
"SOL": "",
"ADA": "🔵",
"XRP": "",
"DOGE": "🐶",
}
PINNED_ORDER = {
"USD": 1,
"USDT": 2,
@@ -40,55 +37,19 @@ PINNED_ORDER = {
}
def format_amount(currency: str, value: float) -> str:
if currency.upper() in FIAT_CURRENCIES:
return f"{value:,.2f}".replace(",", " ")
return f"{value:,.8f}".replace(",", " ")
def _portfolio_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🏠 К торговле", callback_data="trade:home")
builder.adjust(1)
return builder.as_markup()
def get_currency_label(currency: str) -> str:
icon = CURRENCY_ICONS.get(currency.upper(), "💰")
return f"{icon} {currency.upper()}"
def is_zero_balance(item: BalanceSummary) -> bool:
return abs(item.available) < 1e-12 and abs(item.locked) < 1e-12
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)
def split_balances(
items: list[BalanceSummary],
) -> tuple[list[BalanceSummary], list[BalanceSummary]]:
major: list[BalanceSummary] = []
other: list[BalanceSummary] = []
for item in items:
if item.currency.upper() in PINNED_ORDER:
major.append(item)
else:
other.append(item)
return major, other
def render_balance_block(item: BalanceSummary) -> list[str]:
total = item.available + item.locked
return [
f"<b>{get_currency_label(item.currency)}</b>",
f"• доступно: {format_amount(item.currency, item.available)}",
f"• заблокировано: {format_amount(item.currency, item.locked)}",
f"• всего: {format_amount(item.currency, total)}",
"",
]
def _portfolio_warning_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🔁 Обновить", callback_data="portfolio:retry")
builder.button(text="🏠 К торговле", callback_data="trade:home")
builder.adjust(1, 1)
return builder.as_markup()
def _safe_log_info(
@@ -127,17 +88,26 @@ def _safe_log_error(
pass
@router.message(F.text == "💼 Портфель")
async def open_portfolio(message: Message, state: FSMContext) -> None:
# Глобальный экран: всегда выходим из текущего FSM-сценария.
await state.clear()
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)
async def _render_portfolio_screen(
target_message: Message,
*,
user_id: int | None,
chat_id: int | None,
edit_mode: bool,
) -> None:
service = AccountsService()
exchange_service = ExchangeService()
journal = JournalService()
user_id = message.from_user.id if message.from_user else None
chat_id = message.chat.id if message.chat else None
_safe_log_info(
journal,
"user_open_portfolio",
@@ -148,24 +118,7 @@ async def open_portfolio(message: Message, state: FSMContext) -> None:
},
)
try:
balances = service.get_live_balance_summary()
except ExchangeError as exc:
_safe_log_error(
journal,
"portfolio_open_error",
f"Не удалось открыть портфель: {exc}",
{
"user_id": user_id,
"chat_id": chat_id,
},
)
await message.answer(
"<b>💼 Портфель</b>\n\n"
"Не удалось получить баланс с private API.\n"
f"Ошибка: {exc}"
)
return
balances = service.get_live_balance_summary()
if not balances:
_safe_log_warning(
@@ -177,10 +130,17 @@ async def open_portfolio(message: Message, state: FSMContext) -> None:
"chat_id": chat_id,
},
)
await message.answer(
"<b>💼 Портфель</b>\n\n"
"Баланс пуст."
text = (
"<b>💼 Портфель</b>\n"
f"{mode_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
visible_balances = [item for item in balances if not is_zero_balance(item)]
@@ -197,34 +157,76 @@ async def open_portfolio(message: Message, state: FSMContext) -> None:
"assets_count": len(balances),
},
)
await message.answer(
"<b>💼 Портфель</b>\n\n"
"Все балансы нулевые."
text = (
"<b>💼 Портфель</b>\n"
f"{mode_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
major_balances, other_balances = split_balances(visible_balances)
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>", "", "<b>Баланс аккаунта</b>", ""]
lines: list[str] = [
"<b>💼 Портфель</b>",
mode_line().rstrip(),
"",
f"<b>БАЛАНС · АКТИВЫ · {len(visible_balances)}</b>",
]
if major_balances:
lines.append("<b>Основные активы</b>")
lines.append("")
for item in major_balances:
lines.extend(render_balance_block(item))
asset_blocks: list[list[str]] = []
if other_balances:
lines.append("<b>Прочие активы</b>")
lines.append("")
for item in other_balances:
lines.extend(render_balance_block(item))
for item in visible_balances:
currency = item.currency.upper()
total = balance_total(item)
estimated_usd = estimate_balance_usd(item, exchange_service, price_cache)
lines.extend(
[
"<b>Итого</b>",
f"• активов с ненулевым балансом: {len(visible_balances)}",
if estimated_usd is not None:
total_estimated_usd += estimated_usd
has_any_estimate = True
elif total > 0:
missing_estimate_assets.append(currency)
block = [
"",
f"<b>{currency}</b>",
f"• доступно: {format_amount(currency, item.available)}",
f"• заблокировано: {format_amount(currency, item.locked)}",
f"• всего: {format_amount(currency, total)}",
]
)
if estimated_usd is not None and currency not in {"USD", "USDT"}:
block.append(f"{format_usd_amount(estimated_usd)} USD")
asset_blocks.append(block)
has_partial_data = len(missing_estimate_assets) > 0
if missing_estimate_assets:
lines.extend(
[
"🟡 <b>Данные загружены частично</b>",
]
)
if has_any_estimate:
lines.append(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)
_safe_log_info(
journal,
@@ -234,8 +236,115 @@ async def open_portfolio(message: Message, state: FSMContext) -> None:
"user_id": user_id,
"chat_id": chat_id,
"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:
_safe_log_warning(
journal,
"portfolio_partial_estimate",
"Портфель показан частично: не для всех активов доступна USD-оценка.",
{
"user_id": user_id,
"chat_id": chat_id,
"missing_estimate_assets": missing_estimate_assets,
},
)
if has_partial_data:
lines.extend(
[
"",
now_line(),
]
)
text = "\n".join(lines).rstrip()
await message.answer(text)
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)
else:
await target_message.answer(text, reply_markup=reply_markup)
@router.message(F.text == "💼 Портфель")
async def open_portfolio(message: Message, state: FSMContext) -> None:
await state.clear()
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,
)
except ExchangeError as exc:
journal = JournalService()
_safe_log_error(
journal,
"portfolio_open_error",
f"Не удалось открыть портфель: {exc}",
{
"user_id": user_id,
"chat_id": chat_id,
},
)
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 == "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,
)
await callback.answer()
except ExchangeError as exc:
journal = JournalService()
_safe_log_error(
journal,
"portfolio_retry_error",
f"Не удалось повторно открыть портфель: {exc}",
{
"user_id": user_id,
"chat_id": chat_id,
},
)
await show_callback_exchange_error(
callback,
title="<b>💼 Портфель</b>",
exc=exc,
network_details="Не загружен баланс аккаунта.\nОбнови экран.",
auth_details="Не загружен баланс аккаунта.\nПроверь API ключи.",
retry_callback_data="portfolio:retry",
)