feat(live): add live screens for market and portfolio
This commit is contained in:
@@ -10,6 +10,7 @@ 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
|
||||
from src.telegram.ui.common import mode_line, now_line
|
||||
from src.telegram.ui.currency_ui import (
|
||||
balance_total,
|
||||
@@ -24,8 +25,8 @@ from src.telegram.ui.exchange_error import (
|
||||
show_message_exchange_error,
|
||||
)
|
||||
from src.trading.accounts.service import AccountsService
|
||||
from src.trading.journal.service import JournalService
|
||||
from src.trading.auto.runner import AutoTradeRunner
|
||||
from src.trading.journal.service import JournalService
|
||||
|
||||
|
||||
router = Router(name="portfolio")
|
||||
@@ -39,6 +40,7 @@ PINNED_ORDER = {
|
||||
}
|
||||
|
||||
|
||||
# клавиатура портфеля
|
||||
def _portfolio_keyboard() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🏠 К торговле", callback_data="trade:home")
|
||||
@@ -46,6 +48,7 @@ def _portfolio_keyboard() -> InlineKeyboardMarkup:
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
# клавиатура портфеля при частичной загрузке
|
||||
def _portfolio_warning_keyboard() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🔁 Обновить", callback_data="portfolio:retry")
|
||||
@@ -54,6 +57,7 @@ def _portfolio_warning_keyboard() -> InlineKeyboardMarkup:
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
# сортировка активов в портфеле
|
||||
def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
|
||||
def sort_key(item: BalanceSummary) -> tuple[int, str]:
|
||||
currency = item.currency.upper()
|
||||
@@ -63,78 +67,33 @@ def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
|
||||
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,
|
||||
action: str,
|
||||
) -> None:
|
||||
AutoTradeRunner.set_current_screen("portfolio")
|
||||
|
||||
# собрать актуальный live-текст портфеля
|
||||
def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
|
||||
service = AccountsService()
|
||||
exchange_service = ExchangeService()
|
||||
journal = JournalService()
|
||||
|
||||
journal.log_ui_info(
|
||||
event_type="portfolio_open_requested",
|
||||
message="Запрошено открытие экрана портфеля.",
|
||||
screen="portfolio",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
|
||||
balances = service.get_live_balance_summary()
|
||||
|
||||
if not balances:
|
||||
journal.log_ui_warning(
|
||||
event_type="portfolio_empty",
|
||||
message="Нет данных по балансу.",
|
||||
screen="portfolio",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
|
||||
text = (
|
||||
"<b>💼 Портфель</b>\n"
|
||||
f"{mode_line()}"
|
||||
"Нет данных по балансу."
|
||||
"Нет данных по балансу.\n\n"
|
||||
f"{now_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
|
||||
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:
|
||||
journal.log_ui_warning(
|
||||
event_type="portfolio_zero_balances",
|
||||
message="Нет активов с балансом.",
|
||||
screen="portfolio",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload={"assets_count": len(balances)},
|
||||
)
|
||||
|
||||
text = (
|
||||
"<b>💼 Портфель</b>\n"
|
||||
f"{mode_line()}"
|
||||
"Нет активов с балансом."
|
||||
"Нет активов с балансом.\n\n"
|
||||
f"{now_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
|
||||
return text, _portfolio_keyboard()
|
||||
|
||||
price_cache: dict[str, float | None] = {}
|
||||
total_estimated_usd = 0.0
|
||||
@@ -180,7 +139,7 @@ async def _render_portfolio_screen(
|
||||
lines.append("🟡 <b>Данные загружены частично</b>")
|
||||
|
||||
if has_any_estimate:
|
||||
lines.append(f"ОЦЕНКА · ~${format_usd_amount(total_estimated_usd)}")
|
||||
lines.append(f"Оценка: <b>~${format_usd_amount(total_estimated_usd)}</b>")
|
||||
|
||||
if missing_estimate_assets:
|
||||
lines.append(f"Нет оценки: {', '.join(missing_estimate_assets)}")
|
||||
@@ -188,6 +147,70 @@ async def _render_portfolio_screen(
|
||||
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.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")
|
||||
LiveScreenRunner.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="Портфель загружен.",
|
||||
@@ -195,49 +218,27 @@ async def _render_portfolio_screen(
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload={
|
||||
"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:
|
||||
journal.log_ui_warning(
|
||||
event_type="portfolio_partial_estimate",
|
||||
message="Портфель загружен частично.",
|
||||
screen="portfolio",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload={
|
||||
"assets_count": len(visible_balances),
|
||||
"estimated_assets": len(visible_balances) - len(missing_estimate_assets),
|
||||
"failed_assets": missing_estimate_assets,
|
||||
},
|
||||
)
|
||||
|
||||
if has_partial_data:
|
||||
lines.extend(["", now_line()])
|
||||
|
||||
text = "\n".join(lines).rstrip()
|
||||
|
||||
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)
|
||||
_register_portfolio_live_screen(target_message)
|
||||
else:
|
||||
await target_message.answer(text, reply_markup=reply_markup)
|
||||
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
|
||||
|
||||
@@ -271,6 +272,7 @@ async def open_portfolio(message: Message, state: FSMContext) -> None:
|
||||
)
|
||||
|
||||
|
||||
# обновить портфель вручную
|
||||
@router.callback_query(F.data == "portfolio:retry")
|
||||
async def retry_portfolio(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
await state.clear()
|
||||
|
||||
Reference in New Issue
Block a user