Stage 07.3.3: multi live screens and UI optimization
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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,25 +62,29 @@ 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
|
|
||||||
|
|
||||||
if live_screen.chat_id != chat_id:
|
for live_screen in screens:
|
||||||
return
|
if live_screen.chat_id != chat_id:
|
||||||
|
remaining.append(live_screen)
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await bot.delete_message(
|
await bot.delete_message(
|
||||||
chat_id=live_screen.chat_id,
|
chat_id=live_screen.chat_id,
|
||||||
message_id=live_screen.message_id,
|
message_id=live_screen.message_id,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
cls._screens.pop(screen, None)
|
if remaining:
|
||||||
|
cls._screens[screen] = remaining
|
||||||
|
else:
|
||||||
|
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,38 +105,44 @@ 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
|
|
||||||
|
|
||||||
try:
|
for live_screen in screens:
|
||||||
await live_screen.bot.edit_message_text(
|
try:
|
||||||
chat_id=live_screen.chat_id,
|
await live_screen.bot.edit_message_text(
|
||||||
message_id=live_screen.message_id,
|
chat_id=live_screen.chat_id,
|
||||||
text=live_screen.render_text(),
|
message_id=live_screen.message_id,
|
||||||
reply_markup=live_screen.render_markup(),
|
text=live_screen.render_text(),
|
||||||
)
|
reply_markup=live_screen.render_markup(),
|
||||||
except Exception:
|
)
|
||||||
pass
|
alive_screens.append(live_screen)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if alive_screens:
|
||||||
|
cls._screens[screen] = alive_screens
|
||||||
|
else:
|
||||||
|
cls._screens.pop(screen, None)
|
||||||
Reference in New Issue
Block a user