diff --git a/app/src/telegram/handlers/market.py b/app/src/telegram/handlers/market.py
index 948ccf5..5516c11 100644
--- a/app/src/telegram/handlers/market.py
+++ b/app/src/telegram/handlers/market.py
@@ -9,19 +9,21 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.integrations.exchange.exceptions import ExchangeError
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 (
classify_exchange_error,
show_callback_exchange_error,
show_message_exchange_error,
)
-from src.trading.journal.service import JournalService
from src.trading.auto.runner import AutoTradeRunner
+from src.trading.journal.service import JournalService
router = Router(name="market")
+# клавиатура экрана рынка
def _market_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🏠 К торговле", callback_data="trade:home")
@@ -29,6 +31,7 @@ def _market_keyboard() -> InlineKeyboardMarkup:
return builder.as_markup()
+# собрать текст рынка по готовым данным
def _build_market_text(
*,
ticker_price: float,
@@ -39,6 +42,8 @@ def _build_market_text(
quote_asset: str,
tick_size: str,
) -> str:
+ from src.telegram.ui.common import now_line
+
status_map = {
"TRADING": "доступен для торговли",
"HALT": "торги остановлены",
@@ -61,10 +66,68 @@ def _build_market_text(
f"Тип инструмента: {market_type_ru}\n"
f"Базовый актив: {base_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 (
+ "📈 Рынок\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(
target_message: Message,
*,
@@ -74,7 +137,8 @@ async def _render_market_screen(
action: str,
) -> None:
AutoTradeRunner.set_current_screen("market")
-
+ LiveScreenRunner.set_current_screen("market")
+
service = ExchangeService()
journal = JournalService()
requested_symbol = service.settings.default_symbol
@@ -114,8 +178,11 @@ async def _render_market_screen(
if edit_mode:
await target_message.edit_text(text, reply_markup=_market_keyboard())
+ _register_market_live_screen(target_message)
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
ticker = service.get_price(validation.normalized_symbol)
@@ -157,14 +224,23 @@ async def _render_market_screen(
if edit_mode:
await target_message.edit_text(text, reply_markup=_market_keyboard())
+ _register_market_live_screen(target_message)
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 == "📈 Рынок")
async def open_market(message: Message, state: FSMContext) -> None:
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
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")
async def retry_market(callback: CallbackQuery, state: FSMContext) -> None:
await state.clear()
@@ -217,7 +294,7 @@ async def retry_market(callback: CallbackQuery, state: FSMContext) -> None:
edit_mode=True,
action="retry",
)
- await callback.answer()
+ await callback.answer()
except ExchangeError as exc:
JournalService().log_ui_error(
event_type="market_retry_error",
diff --git a/app/src/telegram/handlers/portfolio.py b/app/src/telegram/handlers/portfolio.py
index 40b3f34..6d405a1 100644
--- a/app/src/telegram/handlers/portfolio.py
+++ b/app/src/telegram/handlers/portfolio.py
@@ -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 = (
"💼 Портфель\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 = (
"💼 Портфель\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("🟡 Данные загружены частично")
if has_any_estimate:
- lines.append(f"ОЦЕНКА · ~${format_usd_amount(total_estimated_usd)}")
+ lines.append(f"Оценка: ~${format_usd_amount(total_estimated_usd)}")
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()
diff --git a/app/src/telegram/live/runner.py b/app/src/telegram/live/runner.py
new file mode 100644
index 0000000..ba14e84
--- /dev/null
+++ b/app/src/telegram/live/runner.py
@@ -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
\ No newline at end of file