Stage 07.3.3: multi live screens and UI optimization

This commit is contained in:
2026-04-29 12:38:29 +03:00
parent 93cdd164ae
commit 861f98024c
3 changed files with 111 additions and 89 deletions

View File

@@ -11,6 +11,7 @@ from src.integrations.exchange.exceptions import ExchangeError
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.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 format_usd_amount
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,
@@ -21,6 +22,8 @@ from src.trading.journal.service import JournalService
router = Router(name="market") 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, ticker_price: float,
name: str, name: str,
symbol_status: str,
market_type: str, market_type: str,
base_asset: str, base_asset: str,
quote_asset: str, quote_asset: str,
tick_size: str,
) -> 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 = { if previous_price is not None:
"TRADING": "доступен для торговли", if ticker_price > previous_price:
"HALT": "торги остановлены", price_direction = ""
"BREAK": "перерыв", elif ticker_price < previous_price:
} price_direction = ""
status_ru = status_map.get(symbol_status.upper(), symbol_status.lower())
_last_market_prices[name] = ticker_price
_last_market_directions[name] = price_direction
type_map = { type_map = {
"LEVERAGE": "leverage", "LEVERAGE": "leverage",
@@ -60,13 +64,9 @@ def _build_market_text(
return ( return (
"<b>📈 Рынок</b>\n" "<b>📈 Рынок</b>\n"
f"{mode_line()}" f"{mode_line()}"
f"Пара: <b>{name}</b>\n" "\n"
f"Цена: <b>{ticker_price:.2f} {quote_asset}</b>\n" f"<b>{base_asset} / {quote_asset}</b> ({market_type_ru})\n\n"
f"Статус: {status_ru}\n" f"<b>$ {format_usd_amount(ticker_price)}</b> {price_direction}\n\n"
f"Тип инструмента: {market_type_ru}\n"
f"Базовый актив: {base_asset}\n"
f"Валюта котировки: {quote_asset}\n"
f"Шаг цены: {tick_size} {quote_asset}\n\n"
f"{now_line()}" f"{now_line()}"
) )
@@ -89,13 +89,7 @@ def _build_market_live_text() -> str:
ticker = service.get_price(validation.normalized_symbol) ticker = service.get_price(validation.normalized_symbol)
symbol_info = validation.symbol_info 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" 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" 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" 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 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( return _build_market_text(
ticker_price=ticker.price, ticker_price=ticker.price,
name=name, name=name,
symbol_status=symbol_status,
market_type=market_type, market_type=market_type,
base_asset=base_asset, base_asset=base_asset,
quote_asset=quote_asset, quote_asset=quote_asset,
tick_size=tick_size,
) )
@@ -137,7 +129,6 @@ 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()
@@ -188,13 +179,7 @@ async def _render_market_screen(
ticker = service.get_price(validation.normalized_symbol) ticker = service.get_price(validation.normalized_symbol)
symbol_info = validation.symbol_info 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" 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" 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" 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 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( text = _build_market_text(
ticker_price=ticker.price, ticker_price=ticker.price,
name=name, name=name,
symbol_status=symbol_status,
market_type=market_type, market_type=market_type,
base_asset=base_asset, base_asset=base_asset,
quote_asset=quote_asset, quote_asset=quote_asset,
tick_size=tick_size,
) )
journal.log_ui_info( journal.log_ui_info(

View File

@@ -12,6 +12,7 @@ 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.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 format_usd_amount
from src.telegram.ui.currency_ui import ( from src.telegram.ui.currency_ui import (
balance_total, balance_total,
estimate_balance_usd, estimate_balance_usd,
@@ -39,6 +40,30 @@ PINNED_ORDER = {
"ETH": 4, "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: def _portfolio_keyboard() -> InlineKeyboardMarkup:
@@ -104,7 +129,6 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
"<b>💼 Портфель</b>", "<b>💼 Портфель</b>",
mode_line().rstrip(), mode_line().rstrip(),
"", "",
f"<b>БАЛАНС · АКТИВЫ · {len(visible_balances)}</b>",
] ]
asset_blocks: list[list[str]] = [] asset_blocks: list[list[str]] = []
@@ -120,18 +144,18 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
elif total > 0: elif total > 0:
missing_estimate_assets.append(currency) missing_estimate_assets.append(currency)
block = [ line = f"{currency}: {_compact_amount(currency, total)}"
"",
f"<b>{currency}</b>",
f"• доступно: {format_amount(currency, item.available)}",
f"• заблокировано: {format_amount(currency, item.locked)}",
f"• всего: {format_amount(currency, total)}",
]
if estimated_usd is not None and currency not in {"USD", "USDT"}: if item.locked > 0:
block.append(f"{format_usd_amount(estimated_usd)} USD") 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 has_partial_data = len(missing_estimate_assets) > 0
@@ -139,7 +163,8 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
lines.append("🟡 <b>Данные загружены частично</b>") lines.append("🟡 <b>Данные загружены частично</b>")
if has_any_estimate: if has_any_estimate:
lines.append(f"Оценка: <b>~${format_usd_amount(total_estimated_usd)}</b>") lines.insert(3, "")
lines.insert(3, 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)}")
@@ -196,7 +221,6 @@ async def _render_portfolio_screen(
action: str, action: str,
) -> None: ) -> None:
AutoTradeRunner.set_current_screen("portfolio") AutoTradeRunner.set_current_screen("portfolio")
LiveScreenRunner.set_current_screen("portfolio")
journal = JournalService() journal = JournalService()

View File

@@ -34,21 +34,26 @@ class LiveScreen:
class LiveScreenRunner: class LiveScreenRunner:
_screens: dict[str, LiveScreen] = {} _screens: dict[str, list[LiveScreen]] = {}
_tasks: dict[str, asyncio.Task] = {} _tasks: dict[str, asyncio.Task] = {}
_current_screen: str | None = None
# переключить активный экран
@classmethod
def set_current_screen(cls, screen: str) -> None:
cls._current_screen = screen
# зарегистрировать live-экран # зарегистрировать live-экран
@classmethod @classmethod
def register_screen(cls, live_screen: LiveScreen) -> None: 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 @classmethod
async def delete_screen( async def delete_screen(
cls, cls,
@@ -57,13 +62,14 @@ class LiveScreenRunner:
bot: Bot, bot: Bot,
chat_id: int, chat_id: int,
) -> None: ) -> None:
live_screen = cls._screens.get(screen) screens = cls._screens.get(screen, [])
if live_screen is None: remaining: list[LiveScreen] = []
return
for live_screen in screens:
if live_screen.chat_id != chat_id: if live_screen.chat_id != chat_id:
return remaining.append(live_screen)
continue
try: try:
await bot.delete_message( await bot.delete_message(
@@ -73,9 +79,12 @@ class LiveScreenRunner:
except Exception: except Exception:
pass pass
if remaining:
cls._screens[screen] = remaining
else:
cls._screens.pop(screen, None) cls._screens.pop(screen, None)
# запустить автообновление экрана # запустить автообновление группы экранов
@classmethod @classmethod
def start(cls, screen: str) -> None: def start(cls, screen: str) -> None:
task = cls._tasks.get(screen) task = cls._tasks.get(screen)
@@ -85,7 +94,7 @@ class LiveScreenRunner:
cls._tasks[screen] = asyncio.create_task(cls._worker(screen)) cls._tasks[screen] = asyncio.create_task(cls._worker(screen))
# остановить автообновление экрана # остановить автообновление группы экранов
@classmethod @classmethod
def stop(cls, screen: str) -> None: def stop(cls, screen: str) -> None:
task = cls._tasks.get(screen) task = cls._tasks.get(screen)
@@ -96,32 +105,32 @@ class LiveScreenRunner:
task.cancel() task.cancel()
cls._tasks.pop(screen, None) cls._tasks.pop(screen, None)
# фоновый цикл обновления одного экрана # фоновый цикл обновления группы экранов
@classmethod @classmethod
async def _worker(cls, screen: str) -> None: async def _worker(cls, screen: str) -> None:
while True: while True:
await cls._refresh_screen(screen) await cls._refresh_screen(screen)
await asyncio.sleep(cls._screen_interval(screen)) await asyncio.sleep(cls._screen_interval(screen))
# получить интервал обновления экрана # получить интервал обновления группы экранов
@classmethod @classmethod
def _screen_interval(cls, screen: str) -> int: def _screen_interval(cls, screen: str) -> int:
live_screen = cls._screens.get(screen) screens = cls._screens.get(screen, [])
if live_screen is None: if not screens:
return 5 return 5
return live_screen.interval_seconds return screens[0].interval_seconds
# обновить Telegram-сообщение live-экрана # обновить все Telegram-сообщения live-экрана
@classmethod @classmethod
async def _refresh_screen(cls, screen: str) -> None: async def _refresh_screen(cls, screen: str) -> None:
if cls._current_screen != screen: screens = cls._screens.get(screen, [])
if not screens:
return return
live_screen = cls._screens.get(screen) alive_screens: list[LiveScreen] = []
if live_screen is None:
return
for live_screen in screens:
try: try:
await live_screen.bot.edit_message_text( await live_screen.bot.edit_message_text(
chat_id=live_screen.chat_id, chat_id=live_screen.chat_id,
@@ -129,5 +138,11 @@ class LiveScreenRunner:
text=live_screen.render_text(), text=live_screen.render_text(),
reply_markup=live_screen.render_markup(), reply_markup=live_screen.render_markup(),
) )
alive_screens.append(live_screen)
except Exception: except Exception:
pass pass
if alive_screens:
cls._screens[screen] = alive_screens
else:
cls._screens.pop(screen, None)