Stage 03.5 - private account balance and portfolio UI

This commit is contained in:
2026-04-14 18:12:41 +03:00
parent 96998ee998
commit 1deb676585
9 changed files with 532 additions and 48 deletions

View File

@@ -1,12 +1,143 @@
from __future__ import annotations
from aiogram import F, Router
from aiogram.types import Message
from src.telegram.menus import PORTFOLIO_TEXT
from src.integrations.exchange.exceptions import ExchangeError
from src.integrations.exchange.models import BalanceSummary
from src.integrations.exchange.service import ExchangeService
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,
"BTC": 3,
"ETH": 4,
}
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 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)}",
"",
]
@router.message(F.text == "💼 Портфель")
async def open_portfolio(message: Message) -> None:
await message.answer(PORTFOLIO_TEXT)
service = ExchangeService()
try:
balances = service.get_balance_summary()
except ExchangeError as exc:
await message.answer(
"<b>💼 Портфель</b>\n\n"
"Не удалось получить баланс с private API.\n"
f"Ошибка: {exc}"
)
return
if not balances:
await message.answer(
"<b>💼 Портфель</b>\n\n"
"Баланс пуст."
)
return
visible_balances = [item for item in balances if not is_zero_balance(item)]
visible_balances = sort_balances(visible_balances)
if not visible_balances:
await message.answer(
"<b>💼 Портфель</b>\n\n"
"Все балансы нулевые."
)
return
major_balances, other_balances = split_balances(visible_balances)
lines: list[str] = ["<b>💼 Портфель</b>", "", "<b>Баланс аккаунта</b>", ""]
if major_balances:
lines.append("<b>Основные активы</b>")
lines.append("")
for item in major_balances:
lines.extend(render_balance_block(item))
if other_balances:
lines.append("<b>Прочие активы</b>")
lines.append("")
for item in other_balances:
lines.extend(render_balance_block(item))
lines.extend(
[
"<b>Итого</b>",
f"• активов с ненулевым балансом: {len(visible_balances)}",
]
)
text = "\n".join(lines).rstrip()
await message.answer(text)