diff --git a/app/src/telegram/handlers/market.py b/app/src/telegram/handlers/market.py index 5516c11..a7e7d20 100644 --- a/app/src/telegram/handlers/market.py +++ b/app/src/telegram/handlers/market.py @@ -11,6 +11,7 @@ from src.integrations.exchange.exceptions import ExchangeError 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 format_usd_amount from src.telegram.ui.exchange_error import ( classify_exchange_error, show_callback_exchange_error, @@ -21,6 +22,8 @@ from src.trading.journal.service import JournalService router = Router(name="market") +_last_market_prices: dict[str, float] = {} +_last_market_directions: dict[str, str] = {} # клавиатура экрана рынка @@ -36,20 +39,21 @@ def _build_market_text( *, ticker_price: float, name: str, - symbol_status: str, market_type: str, base_asset: str, quote_asset: str, - tick_size: str, ) -> str: - from src.telegram.ui.common import now_line + previous_price = _last_market_prices.get(name) + price_direction = _last_market_directions.get(name, "▲") - status_map = { - "TRADING": "доступен для торговли", - "HALT": "торги остановлены", - "BREAK": "перерыв", - } - status_ru = status_map.get(symbol_status.upper(), symbol_status.lower()) + if previous_price is not None: + if ticker_price > previous_price: + price_direction = "▲" + elif ticker_price < previous_price: + price_direction = "▼" + + _last_market_prices[name] = ticker_price + _last_market_directions[name] = price_direction type_map = { "LEVERAGE": "leverage", @@ -60,13 +64,9 @@ def _build_market_text( return ( "📈 Рынок\n" f"{mode_line()}" - f"Пара: {name}\n" - f"Цена: {ticker_price:.2f} {quote_asset}\n" - f"Статус: {status_ru}\n" - f"Тип инструмента: {market_type_ru}\n" - f"Базовый актив: {base_asset}\n" - f"Валюта котировки: {quote_asset}\n" - f"Шаг цены: {tick_size} {quote_asset}\n\n" + "\n" + f"{base_asset} / {quote_asset} ({market_type_ru})\n\n" + f"$ {format_usd_amount(ticker_price)} {price_direction}\n\n" f"{now_line()}" ) @@ -89,13 +89,7 @@ def _build_market_live_text() -> str: 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 @@ -103,11 +97,9 @@ def _build_market_live_text() -> str: 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, ) @@ -137,7 +129,6 @@ async def _render_market_screen( action: str, ) -> None: AutoTradeRunner.set_current_screen("market") - LiveScreenRunner.set_current_screen("market") service = ExchangeService() journal = JournalService() @@ -188,13 +179,7 @@ async def _render_market_screen( 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 @@ -202,11 +187,9 @@ async def _render_market_screen( text = _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, ) journal.log_ui_info( diff --git a/app/src/telegram/handlers/portfolio.py b/app/src/telegram/handlers/portfolio.py index 6d405a1..d929aae 100644 --- a/app/src/telegram/handlers/portfolio.py +++ b/app/src/telegram/handlers/portfolio.py @@ -12,6 +12,7 @@ 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 format_usd_amount from src.telegram.ui.currency_ui import ( balance_total, estimate_balance_usd, @@ -39,6 +40,30 @@ PINNED_ORDER = { "ETH": 4, } +# компактное форматирование количества +def _compact_amount(currency: str, value: float) -> str: + currency = currency.upper() + + if currency in {"USD", "USDT", "EUR"}: + return format_usd_amount(value) + + text = f"{value:.8f}" + + if "." in text: + integer, frac = text.split(".") + integer = f"{int(integer):,}".replace(",", " ") + + stripped = frac.rstrip("0") + + if stripped == "": + return f"{integer}.00" + + if len(stripped) <= 2: + return f"{integer}.{stripped.ljust(2, '0')}" + + return f"{integer}.{stripped}" + + return f"{int(text):,}".replace(",", " ") # клавиатура портфеля def _portfolio_keyboard() -> InlineKeyboardMarkup: @@ -104,7 +129,6 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]: "💼 Портфель", mode_line().rstrip(), "", - f"БАЛАНС · АКТИВЫ · {len(visible_balances)}", ] asset_blocks: list[list[str]] = [] @@ -120,18 +144,18 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]: elif total > 0: missing_estimate_assets.append(currency) - block = [ - "", - f"{currency}", - f"• доступно: {format_amount(currency, item.available)}", - f"• заблокировано: {format_amount(currency, item.locked)}", - f"• всего: {format_amount(currency, total)}", - ] + line = f"{currency}: {_compact_amount(currency, total)}" - if estimated_usd is not None and currency not in {"USD", "USDT"}: - block.append(f"≈ {format_usd_amount(estimated_usd)} USD") + if item.locked > 0: + line += f" · locked {_compact_amount(currency, item.locked)}" - asset_blocks.append(block) + if estimated_usd is not None and currency not in {'USD', 'USDT'}: + line += f" ≈ $ {format_usd_amount(estimated_usd)}" + + if currency == "BTC" and any("USD:" in x or "USDT:" in x for x in lines): + lines.append("") + + lines.append(line) has_partial_data = len(missing_estimate_assets) > 0 @@ -139,7 +163,8 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]: lines.append("🟡 Данные загружены частично") if has_any_estimate: - lines.append(f"Оценка: ~${format_usd_amount(total_estimated_usd)}") + lines.insert(3, "") + lines.insert(3, f"Оценка: ≈ $ {format_usd_amount(total_estimated_usd)}") if missing_estimate_assets: lines.append(f"Нет оценки: {', '.join(missing_estimate_assets)}") @@ -196,7 +221,6 @@ async def _render_portfolio_screen( action: str, ) -> None: AutoTradeRunner.set_current_screen("portfolio") - LiveScreenRunner.set_current_screen("portfolio") journal = JournalService() diff --git a/app/src/telegram/live/runner.py b/app/src/telegram/live/runner.py index ba14e84..32dac8d 100644 --- a/app/src/telegram/live/runner.py +++ b/app/src/telegram/live/runner.py @@ -34,21 +34,26 @@ class LiveScreen: class LiveScreenRunner: - _screens: dict[str, LiveScreen] = {} + _screens: dict[str, list[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 + screens = cls._screens.setdefault(live_screen.screen, []) - # удалить старый live-экран из Telegram + screens[:] = [ + item + for item in screens + if not ( + item.chat_id == live_screen.chat_id + and item.message_id == live_screen.message_id + ) + ] + + screens.append(live_screen) + + # удалить все live-экраны указанного типа из Telegram @classmethod async def delete_screen( cls, @@ -57,25 +62,29 @@ class LiveScreenRunner: bot: Bot, chat_id: int, ) -> None: - live_screen = cls._screens.get(screen) + screens = cls._screens.get(screen, []) - if live_screen is None: - return + remaining: list[LiveScreen] = [] - if live_screen.chat_id != chat_id: - return + for live_screen in screens: + if live_screen.chat_id != chat_id: + remaining.append(live_screen) + continue - try: - await bot.delete_message( - chat_id=live_screen.chat_id, - message_id=live_screen.message_id, - ) - except Exception: - pass + 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) + if remaining: + cls._screens[screen] = remaining + else: + cls._screens.pop(screen, None) - # запустить автообновление экрана + # запустить автообновление группы экранов @classmethod def start(cls, screen: str) -> None: task = cls._tasks.get(screen) @@ -85,7 +94,7 @@ class LiveScreenRunner: cls._tasks[screen] = asyncio.create_task(cls._worker(screen)) - # остановить автообновление экрана + # остановить автообновление группы экранов @classmethod def stop(cls, screen: str) -> None: task = cls._tasks.get(screen) @@ -96,38 +105,44 @@ class LiveScreenRunner: 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: + screens = cls._screens.get(screen, []) + if not screens: return 5 - return live_screen.interval_seconds + return screens[0].interval_seconds - # обновить Telegram-сообщение live-экрана + # обновить все Telegram-сообщения live-экрана @classmethod async def _refresh_screen(cls, screen: str) -> None: - if cls._current_screen != screen: + screens = cls._screens.get(screen, []) + if not screens: return - live_screen = cls._screens.get(screen) - if live_screen is None: - return + alive_screens: list[LiveScreen] = [] - 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 + for live_screen in screens: + 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(), + ) + alive_screens.append(live_screen) + except Exception: + pass + + if alive_screens: + cls._screens[screen] = alive_screens + else: + cls._screens.pop(screen, None) \ No newline at end of file