feat: unify market/portfolio/system UI, improve exchange errors and asset valuation
This commit is contained in:
@@ -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",
|
||||
)
|
||||
Reference in New Issue
Block a user