feat(live): add live screens for market and portfolio

This commit is contained in:
2026-04-28 23:56:14 +03:00
parent b2801d8a19
commit 93cdd164ae
3 changed files with 306 additions and 94 deletions

View File

@@ -9,19 +9,21 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.exceptions import ExchangeError
from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.service import ExchangeService
from src.telegram.ui.common import mode_line from src.telegram.live.runner import LiveScreen, LiveScreenRunner
from src.telegram.ui.common import mode_line, now_line
from src.telegram.ui.exchange_error import ( from src.telegram.ui.exchange_error import (
classify_exchange_error, classify_exchange_error,
show_callback_exchange_error, show_callback_exchange_error,
show_message_exchange_error, show_message_exchange_error,
) )
from src.trading.journal.service import JournalService
from src.trading.auto.runner import AutoTradeRunner from src.trading.auto.runner import AutoTradeRunner
from src.trading.journal.service import JournalService
router = Router(name="market") router = Router(name="market")
# клавиатура экрана рынка
def _market_keyboard() -> InlineKeyboardMarkup: def _market_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.button(text="🏠 К торговле", callback_data="trade:home") builder.button(text="🏠 К торговле", callback_data="trade:home")
@@ -29,6 +31,7 @@ def _market_keyboard() -> InlineKeyboardMarkup:
return builder.as_markup() return builder.as_markup()
# собрать текст рынка по готовым данным
def _build_market_text( def _build_market_text(
*, *,
ticker_price: float, ticker_price: float,
@@ -39,6 +42,8 @@ def _build_market_text(
quote_asset: str, quote_asset: str,
tick_size: str, tick_size: str,
) -> str: ) -> str:
from src.telegram.ui.common import now_line
status_map = { status_map = {
"TRADING": "доступен для торговли", "TRADING": "доступен для торговли",
"HALT": "торги остановлены", "HALT": "торги остановлены",
@@ -61,10 +66,68 @@ def _build_market_text(
f"Тип инструмента: {market_type_ru}\n" f"Тип инструмента: {market_type_ru}\n"
f"Базовый актив: {base_asset}\n" f"Базовый актив: {base_asset}\n"
f"Валюта котировки: {quote_asset}\n" f"Валюта котировки: {quote_asset}\n"
f"Шаг цены: {tick_size} {quote_asset}" f"Шаг цены: {tick_size} {quote_asset}\n\n"
f"{now_line()}"
) )
# собрать актуальный live-текст рынка
def _build_market_live_text() -> str:
service = ExchangeService()
requested_symbol = service.settings.default_symbol
validation = service.validate_symbol(requested_symbol)
if not validation.is_valid:
return (
"<b>📈 Рынок</b>\n"
f"{mode_line()}"
"⚠️ Ошибка инструмента\n\n"
"Инструмент недоступен."
)
ticker = service.get_price(validation.normalized_symbol)
symbol_info = validation.symbol_info
symbol_status = symbol_info.status if symbol_info else "n/a"
market_type = symbol_info.market_type if symbol_info else "n/a"
tick_size = (
f"{symbol_info.tick_size}"
if symbol_info and symbol_info.tick_size is not None
else "n/a"
)
base_asset = symbol_info.base_asset if symbol_info and symbol_info.base_asset else "n/a"
quote_asset = symbol_info.quote_asset if symbol_info and symbol_info.quote_asset else "n/a"
name = symbol_info.name if symbol_info and symbol_info.name else ticker.symbol
return _build_market_text(
ticker_price=ticker.price,
name=name,
symbol_status=symbol_status,
market_type=market_type,
base_asset=base_asset,
quote_asset=quote_asset,
tick_size=tick_size,
)
# зарегистрировать сообщение как live-экран рынка
def _register_market_live_screen(message: Message) -> None:
LiveScreenRunner.register_screen(
LiveScreen(
screen="market",
bot=message.bot,
chat_id=message.chat.id,
message_id=message.message_id,
render_text=_build_market_live_text,
render_markup=_market_keyboard,
interval_seconds=5,
)
)
LiveScreenRunner.start("market")
# отрисовать экран рынка
async def _render_market_screen( async def _render_market_screen(
target_message: Message, target_message: Message,
*, *,
@@ -74,7 +137,8 @@ async def _render_market_screen(
action: str, action: str,
) -> None: ) -> None:
AutoTradeRunner.set_current_screen("market") AutoTradeRunner.set_current_screen("market")
LiveScreenRunner.set_current_screen("market")
service = ExchangeService() service = ExchangeService()
journal = JournalService() journal = JournalService()
requested_symbol = service.settings.default_symbol requested_symbol = service.settings.default_symbol
@@ -114,8 +178,11 @@ async def _render_market_screen(
if edit_mode: if edit_mode:
await target_message.edit_text(text, reply_markup=_market_keyboard()) await target_message.edit_text(text, reply_markup=_market_keyboard())
_register_market_live_screen(target_message)
else: else:
await target_message.answer(text, reply_markup=_market_keyboard()) sent_message = await target_message.answer(text, reply_markup=_market_keyboard())
_register_market_live_screen(sent_message)
return return
ticker = service.get_price(validation.normalized_symbol) ticker = service.get_price(validation.normalized_symbol)
@@ -157,14 +224,23 @@ async def _render_market_screen(
if edit_mode: if edit_mode:
await target_message.edit_text(text, reply_markup=_market_keyboard()) await target_message.edit_text(text, reply_markup=_market_keyboard())
_register_market_live_screen(target_message)
else: else:
await target_message.answer(text, reply_markup=_market_keyboard()) sent_message = await target_message.answer(text, reply_markup=_market_keyboard())
_register_market_live_screen(sent_message)
# открыть рынок из главного меню
@router.message(F.text == "📈 Рынок") @router.message(F.text == "📈 Рынок")
async def open_market(message: Message, state: FSMContext) -> None: async def open_market(message: Message, state: FSMContext) -> None:
await state.clear() await state.clear()
await LiveScreenRunner.delete_screen(
screen="market",
bot=message.bot,
chat_id=message.chat.id,
)
user_id = message.from_user.id if message.from_user else None user_id = message.from_user.id if message.from_user else None
chat_id = message.chat.id if message.chat else None chat_id = message.chat.id if message.chat else None
@@ -198,6 +274,7 @@ async def open_market(message: Message, state: FSMContext) -> None:
) )
# обновить рынок вручную
@router.callback_query(F.data == "market:retry") @router.callback_query(F.data == "market:retry")
async def retry_market(callback: CallbackQuery, state: FSMContext) -> None: async def retry_market(callback: CallbackQuery, state: FSMContext) -> None:
await state.clear() await state.clear()
@@ -217,7 +294,7 @@ async def retry_market(callback: CallbackQuery, state: FSMContext) -> None:
edit_mode=True, edit_mode=True,
action="retry", action="retry",
) )
await callback.answer() await callback.answer()
except ExchangeError as exc: except ExchangeError as exc:
JournalService().log_ui_error( JournalService().log_ui_error(
event_type="market_retry_error", event_type="market_retry_error",

View File

@@ -10,6 +10,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.exceptions import ExchangeError
from src.integrations.exchange.models import BalanceSummary from src.integrations.exchange.models import BalanceSummary
from src.integrations.exchange.service import ExchangeService 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.common import mode_line, now_line
from src.telegram.ui.currency_ui import ( from src.telegram.ui.currency_ui import (
balance_total, balance_total,
@@ -24,8 +25,8 @@ from src.telegram.ui.exchange_error import (
show_message_exchange_error, show_message_exchange_error,
) )
from src.trading.accounts.service import AccountsService from src.trading.accounts.service import AccountsService
from src.trading.journal.service import JournalService
from src.trading.auto.runner import AutoTradeRunner from src.trading.auto.runner import AutoTradeRunner
from src.trading.journal.service import JournalService
router = Router(name="portfolio") router = Router(name="portfolio")
@@ -39,6 +40,7 @@ PINNED_ORDER = {
} }
# клавиатура портфеля
def _portfolio_keyboard() -> InlineKeyboardMarkup: def _portfolio_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.button(text="🏠 К торговле", callback_data="trade:home") builder.button(text="🏠 К торговле", callback_data="trade:home")
@@ -46,6 +48,7 @@ def _portfolio_keyboard() -> InlineKeyboardMarkup:
return builder.as_markup() return builder.as_markup()
# клавиатура портфеля при частичной загрузке
def _portfolio_warning_keyboard() -> InlineKeyboardMarkup: def _portfolio_warning_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.button(text="🔁 Обновить", callback_data="portfolio:retry") builder.button(text="🔁 Обновить", callback_data="portfolio:retry")
@@ -54,6 +57,7 @@ def _portfolio_warning_keyboard() -> InlineKeyboardMarkup:
return builder.as_markup() return builder.as_markup()
# сортировка активов в портфеле
def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]: def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
def sort_key(item: BalanceSummary) -> tuple[int, str]: def sort_key(item: BalanceSummary) -> tuple[int, str]:
currency = item.currency.upper() currency = item.currency.upper()
@@ -63,78 +67,33 @@ def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
return sorted(items, key=sort_key) return sorted(items, key=sort_key)
async def _render_portfolio_screen( # собрать актуальный live-текст портфеля
target_message: Message, def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
*,
user_id: int | None,
chat_id: int | None,
edit_mode: bool,
action: str,
) -> None:
AutoTradeRunner.set_current_screen("portfolio")
service = AccountsService() service = AccountsService()
exchange_service = ExchangeService() 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() balances = service.get_live_balance_summary()
if not balances: 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 = ( text = (
"<b>💼 Портфель</b>\n" "<b>💼 Портфель</b>\n"
f"{mode_line()}" f"{mode_line()}"
"Нет данных по балансу." "Нет данных по балансу.\n\n"
f"{now_line()}"
) )
return text, _portfolio_keyboard()
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)] visible_balances = [item for item in balances if not is_zero_balance(item)]
visible_balances = sort_balances(visible_balances) visible_balances = sort_balances(visible_balances)
if not 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 = ( text = (
"<b>💼 Портфель</b>\n" "<b>💼 Портфель</b>\n"
f"{mode_line()}" f"{mode_line()}"
"Нет активов с балансом." "Нет активов с балансом.\n\n"
f"{now_line()}"
) )
return text, _portfolio_keyboard()
if edit_mode:
await target_message.edit_text(text, reply_markup=_portfolio_keyboard())
else:
await target_message.answer(text, reply_markup=_portfolio_keyboard())
return
price_cache: dict[str, float | None] = {} price_cache: dict[str, float | None] = {}
total_estimated_usd = 0.0 total_estimated_usd = 0.0
@@ -180,7 +139,7 @@ async def _render_portfolio_screen(
lines.append("🟡 <b>Данные загружены частично</b>") lines.append("🟡 <b>Данные загружены частично</b>")
if has_any_estimate: 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: if missing_estimate_assets:
lines.append(f"Нет оценки: {', '.join(missing_estimate_assets)}") lines.append(f"Нет оценки: {', '.join(missing_estimate_assets)}")
@@ -188,6 +147,70 @@ async def _render_portfolio_screen(
for block in asset_blocks: for block in asset_blocks:
lines.extend(block) 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( journal.log_ui_info(
event_type="portfolio_open_success", event_type="portfolio_open_success",
message="Портфель загружен.", message="Портфель загружен.",
@@ -195,49 +218,27 @@ async def _render_portfolio_screen(
action=action, action=action,
user_id=user_id, user_id=user_id,
chat_id=chat_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: if edit_mode:
await target_message.edit_text(text, reply_markup=reply_markup) await target_message.edit_text(text, reply_markup=reply_markup)
_register_portfolio_live_screen(target_message)
else: 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 == "💼 Портфель") @router.message(F.text == "💼 Портфель")
async def open_portfolio(message: Message, state: FSMContext) -> None: async def open_portfolio(message: Message, state: FSMContext) -> None:
await state.clear() 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 user_id = message.from_user.id if message.from_user else None
chat_id = message.chat.id if message.chat 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") @router.callback_query(F.data == "portfolio:retry")
async def retry_portfolio(callback: CallbackQuery, state: FSMContext) -> None: async def retry_portfolio(callback: CallbackQuery, state: FSMContext) -> None:
await state.clear() await state.clear()

View File

@@ -0,0 +1,133 @@
# app/src/telegram/live/runner.py
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import Callable
from aiogram import Bot
@dataclass(slots=True)
class LiveScreen:
# имя live-экрана: market / portfolio / journal
screen: str
# Telegram bot instance
bot: Bot
# чат, где находится live-экран
chat_id: int
# сообщение, которое нужно автообновлять
message_id: int
# функция сборки текста экрана
render_text: Callable[[], str]
# функция сборки клавиатуры экрана
render_markup: Callable[[], object]
# интервал обновления в секундах
interval_seconds: int = 5
class LiveScreenRunner:
_screens: dict[str, LiveScreen] = {}
_tasks: dict[str, asyncio.Task] = {}
_current_screen: str | None = None
# переключить активный экран
@classmethod
def set_current_screen(cls, screen: str) -> None:
cls._current_screen = screen
# зарегистрировать live-экран
@classmethod
def register_screen(cls, live_screen: LiveScreen) -> None:
cls._screens[live_screen.screen] = live_screen
# удалить старый live-экран из Telegram
@classmethod
async def delete_screen(
cls,
*,
screen: str,
bot: Bot,
chat_id: int,
) -> None:
live_screen = cls._screens.get(screen)
if live_screen is None:
return
if live_screen.chat_id != chat_id:
return
try:
await bot.delete_message(
chat_id=live_screen.chat_id,
message_id=live_screen.message_id,
)
except Exception:
pass
cls._screens.pop(screen, None)
# запустить автообновление экрана
@classmethod
def start(cls, screen: str) -> None:
task = cls._tasks.get(screen)
if task is not None and not task.done():
return
cls._tasks[screen] = asyncio.create_task(cls._worker(screen))
# остановить автообновление экрана
@classmethod
def stop(cls, screen: str) -> None:
task = cls._tasks.get(screen)
if task is None:
return
task.cancel()
cls._tasks.pop(screen, None)
# фоновый цикл обновления одного экрана
@classmethod
async def _worker(cls, screen: str) -> None:
while True:
await cls._refresh_screen(screen)
await asyncio.sleep(cls._screen_interval(screen))
# получить интервал обновления экрана
@classmethod
def _screen_interval(cls, screen: str) -> int:
live_screen = cls._screens.get(screen)
if live_screen is None:
return 5
return live_screen.interval_seconds
# обновить Telegram-сообщение live-экрана
@classmethod
async def _refresh_screen(cls, screen: str) -> None:
if cls._current_screen != screen:
return
live_screen = cls._screens.get(screen)
if live_screen is None:
return
try:
await live_screen.bot.edit_message_text(
chat_id=live_screen.chat_id,
message_id=live_screen.message_id,
text=live_screen.render_text(),
reply_markup=live_screen.render_markup(),
)
except Exception:
pass