From 1fb72ced58f9165e9765489cfe67c1dcd87e2770 Mon Sep 17 00:00:00 2001 From: Sergey Date: Wed, 22 Apr 2026 18:21:34 +0300 Subject: [PATCH] feat: unify market/portfolio/system UI, improve exchange errors and asset valuation --- app/src/core/system_status.py | 90 +- app/src/integrations/exchange/exceptions.py | 40 + app/src/integrations/exchange/symbol_utils.py | 2 + app/src/telegram/handlers/market.py | 211 ++-- app/src/telegram/handlers/portfolio.py | 337 ++++--- app/src/telegram/handlers/system.py | 58 +- .../telegram/handlers/trade/new_order_flow.py | 950 ++++++++++-------- .../handlers/trade/new_order_navigation.py | 424 ++++---- .../telegram/handlers/trade/new_order_ui.py | 115 ++- app/src/telegram/ui/common.py | 20 +- app/src/telegram/ui/currency_ui.py | 189 ++++ app/src/telegram/ui/exchange_error.py | 247 +++++ .../stage-05_9-trading_ui_milestone_notes.md | 173 ++++ 13 files changed, 2034 insertions(+), 822 deletions(-) create mode 100644 app/src/telegram/ui/currency_ui.py create mode 100644 app/src/telegram/ui/exchange_error.py create mode 100644 docs/stages/stage-05_9-trading_ui_milestone_notes.md diff --git a/app/src/core/system_status.py b/app/src/core/system_status.py index beb0f59..3d36b1d 100644 --- a/app/src/core/system_status.py +++ b/app/src/core/system_status.py @@ -1,7 +1,11 @@ +# app/src/core/system_status.py + from __future__ import annotations import re from dataclasses import dataclass +from datetime import datetime +from zoneinfo import ZoneInfo from src.core.config import load_settings from src.core.constants import APP_NAME, APP_VERSION @@ -40,7 +44,8 @@ def _extract_postgres_version(raw: str) -> str: def _build_exchange_status( - exchange_service: ExchangeService, default_symbol: str + exchange_service: ExchangeService, + default_symbol: str, ) -> ComponentStatus: try: symbol_validation = exchange_service.validate_symbol(default_symbol) @@ -48,7 +53,7 @@ def _build_exchange_status( return ComponentStatus( name="Биржа", state="🔴", - details=f"Не удалось проверить инструмент: {exc}", + details=_humanize_error_message(str(exc)), ) exchange_health = exchange_service.get_health() @@ -60,7 +65,7 @@ def _build_exchange_status( return ComponentStatus( name="Биржа", state="🔴", - details=exchange_health.message or "Ошибка подключения к API биржи.", + details=_humanize_error_message(exchange_health.message or ""), ) return ComponentStatus( @@ -78,7 +83,7 @@ def _build_account_status(exchange_service: ExchangeService) -> ComponentStatus: return ComponentStatus( name="Аккаунт", state="🔴", - details=private_auth_health.message or "Ошибка private API.", + details=_humanize_error_message(private_auth_health.message or ""), ) @@ -145,26 +150,75 @@ def get_system_snapshot() -> SystemSnapshot: ) +def has_system_alerts(snapshot: SystemSnapshot) -> bool: + return any(component.state != "🟢" for component in snapshot.components) + + def _render_component(component: ComponentStatus) -> str: - if component.state == "🟢": - return f"{component.state} {component.name}" + line = f"{component.state} {component.name}" - return f"{component.state} {component.name}\n— {component.details}" + if component.state == "🟢" or not component.details: + return line + + return f"{line}\n— {component.details}" -def build_system_text() -> str: +def _now_hhmmss() -> str: + settings = load_settings() + tz_name = settings.tz or "UTC" + + try: + local_dt = datetime.now(ZoneInfo(tz_name)) + except Exception: + local_dt = datetime.utcnow() + + return local_dt.strftime("%H:%M:%S") + + +def build_system_text(*, include_updated_at: bool = False) -> str: snapshot = get_system_snapshot() components_block = "\n".join( _render_component(component) for component in snapshot.components ) - return ( - "⚙️ Система\n\n" - f"{components_block}\n\n" - "🌐 Окружение\n" - f"• приложение: {snapshot.app_name} {snapshot.app_version}\n" - f"• база данных: {snapshot.db_label}\n" - f"• часовой пояс: {snapshot.timezone_name}\n" - f"• режим: {snapshot.mode_label}\n" - f"• инструмент: {snapshot.default_symbol}" - ) \ No newline at end of file + text = ( + "⚙️ Система\n" + f"🔸 {snapshot.mode_label}\n" + f"⏱️ {snapshot.timezone_name}\n\n" + f"{components_block}" + ) + + if include_updated_at: + text += f"\n\nОбновлено: {_now_hhmmss()}" + + return text + + +def _humanize_error_message(text: str) -> str: + t = text.lower() + + # сеть + if "nodename nor servname" in t or "name or service not known" in t: + return "Нет связи с биржей" + + if "timeout" in t or "timed out" in t: + return "Биржа не отвечает (таймаут)" + + if "network error" in t or "connection error" in t: + return "Ошибка сети при обращении к бирже" + + # API / доступ + if "private api error" in t: + return "Ошибка доступа к аккаунту" + + if "invalid api key" in t or "api key" in t: + return "Неверный API ключ" + + if "forbidden" in t or "unauthorized" in t: + return "Нет доступа к аккаунту" + + # время + if "-1021" in t or "doesn't match server time" in t: + return "Ошибка времени (рассинхронизация)" + + return "Не удалось получить данные с биржи" \ No newline at end of file diff --git a/app/src/integrations/exchange/exceptions.py b/app/src/integrations/exchange/exceptions.py index 8bf9103..c73b1ff 100644 --- a/app/src/integrations/exchange/exceptions.py +++ b/app/src/integrations/exchange/exceptions.py @@ -1,3 +1,5 @@ +# app/src/integrations/exchange/exceptions.py + from __future__ import annotations @@ -11,3 +13,41 @@ class ExchangeConnectionError(ExchangeError): class ExchangeResponseError(ExchangeError): """Unexpected HTTP response or malformed JSON.""" + + +def is_exchange_time_sync_error(exc: Exception) -> bool: + text = str(exc).lower() + return ( + "-1021" in text + or "doesn't match server time" in text + or "server time" in text and "match" in text + or "рассинхрон" in text + ) + + +def format_exchange_error_for_user(exc: Exception) -> str: + if is_exchange_time_sync_error(exc): + return ( + "Биржа отклонила запрос из-за рассинхронизации времени. " + "Проверь системное время и повтори попытку." + ) + + if isinstance(exc, ExchangeConnectionError): + return ( + "Не удалось получить данные биржи: таймаут или ошибка сети. " + "Попробуй ещё раз через несколько секунд." + ) + + if isinstance(exc, ExchangeResponseError): + return ( + "Биржа вернула ошибку ответа. " + "Попробуй ещё раз через несколько секунд." + ) + + if isinstance(exc, ExchangeError): + return ( + "Не удалось получить данные биржи. " + "Попробуй ещё раз через несколько секунд." + ) + + return "Временная ошибка получения данных биржи. Попробуй ещё раз через несколько секунд." \ No newline at end of file diff --git a/app/src/integrations/exchange/symbol_utils.py b/app/src/integrations/exchange/symbol_utils.py index 2e61f38..cbb1d6f 100644 --- a/app/src/integrations/exchange/symbol_utils.py +++ b/app/src/integrations/exchange/symbol_utils.py @@ -1,3 +1,5 @@ +# app/src/integrations/exchange/symbol_utils.py + from __future__ import annotations diff --git a/app/src/telegram/handlers/market.py b/app/src/telegram/handlers/market.py index 878e0df..ba3ea85 100644 --- a/app/src/telegram/handlers/market.py +++ b/app/src/telegram/handlers/market.py @@ -4,10 +4,16 @@ from __future__ import annotations from aiogram import F, Router from aiogram.fsm.context import FSMContext -from aiogram.types import Message +from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message +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.ui.exchange_error import ( + show_callback_exchange_error, + show_message_exchange_error, +) from src.trading.journal.service import JournalService @@ -50,16 +56,58 @@ def _safe_log_error( pass -@router.message(F.text == "📈 Рынок") -async def open_market(message: Message, state: FSMContext) -> None: - # Глобальный экран: всегда выходим из текущего FSM-сценария. - await state.clear() +def _market_keyboard() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button(text="🏠 К торговле", callback_data="trade:home") + builder.adjust(1) + return builder.as_markup() + +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: + status_map = { + "TRADING": "доступен для торговли", + "HALT": "торги остановлены", + "BREAK": "перерыв", + } + status_ru = status_map.get(symbol_status.upper(), symbol_status.lower()) + + type_map = { + "LEVERAGE": "leverage", + "SPOT": "spot", + } + market_type_ru = type_map.get(market_type.upper(), market_type.lower()) + + 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}" + ) + + +async def _render_market_screen( + target_message: Message, + *, + user_id: int | None, + chat_id: int | None, + edit_mode: bool, +) -> None: service = ExchangeService() journal = JournalService() - - user_id = message.from_user.id if message.from_user else None - chat_id = message.chat.id if message.chat else None requested_symbol = service.settings.default_symbol _safe_log_info( @@ -73,52 +121,38 @@ async def open_market(message: Message, state: FSMContext) -> None: }, ) - try: - validation = service.validate_symbol(requested_symbol) - if not validation.is_valid: - _safe_log_warning( - journal, - "market_symbol_invalid", - f"Символ не прошел проверку: {validation.message}", - { - "user_id": user_id, - "chat_id": chat_id, - "symbol": requested_symbol, - }, - ) - await message.answer( - "📈 Рынок\n\n" - f"Ошибка инструмента: {validation.message}" - ) - return + validation = service.validate_symbol(requested_symbol) - ticker = service.get_price(validation.normalized_symbol) - except ExchangeError as exc: - _safe_log_error( + if not validation.is_valid: + _safe_log_warning( journal, - "market_open_error", - f"Не удалось открыть экран рынка: {exc}", + "market_symbol_invalid", + f"Символ не прошел проверку: {validation.message}", { "user_id": user_id, "chat_id": chat_id, "symbol": requested_symbol, }, ) - await message.answer( - "📈 Рынок\n\n" - "Не удалось получить цену с биржи.\n" - f"Ошибка: {exc}" + + text = ( + "📈 Рынок\n" + f"{mode_line()}" + "⚠️ Ошибка инструмента\n\n" + "Инструмент недоступен." ) + + if edit_mode: + await target_message.edit_text(text, reply_markup=_market_keyboard()) + else: + await target_message.answer(text, reply_markup=_market_keyboard()) return + 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" - market_modes = ( - ", ".join(symbol_info.market_modes) - if symbol_info and symbol_info.market_modes - else "n/a" - ) tick_size = ( f"{symbol_info.tick_size}" if symbol_info and symbol_info.tick_size is not None @@ -128,19 +162,14 @@ async def open_market(message: Message, state: FSMContext) -> None: 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 - text = ( - "📈 Рынок\n\n" - f"Символ: {ticker.symbol}\n" - f"Название: {name}\n" - f"Цена: {ticker.price:.2f}\n" - f"Статус: {symbol_status}\n" - f"Тип рынка: {market_type}\n" - f"Режимы: {market_modes}\n" - f"Base asset: {base_asset}\n" - f"Quote asset: {quote_asset}\n" - f"Tick size: {tick_size}\n" - f"Источник: {ticker.source}\n" - f"Обновлено: {ticker.updated_at}" + 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, ) _safe_log_info( @@ -152,9 +181,79 @@ async def open_market(message: Message, state: FSMContext) -> None: "chat_id": chat_id, "symbol": ticker.symbol, "price": ticker.price, - "base_asset": base_asset, - "quote_asset": quote_asset, }, ) - await message.answer(text) \ No newline at end of file + if edit_mode: + await target_message.edit_text(text, reply_markup=_market_keyboard()) + else: + await target_message.answer(text, reply_markup=_market_keyboard()) + + +@router.message(F.text == "📈 Рынок") +async def open_market(message: Message, state: FSMContext) -> None: + await state.clear() + + user_id = message.from_user.id if message.from_user else None + chat_id = message.chat.id if message.chat else None + + try: + await _render_market_screen( + message, + user_id=user_id, + chat_id=chat_id, + edit_mode=False, + ) + except ExchangeError as exc: + _safe_log_error( + JournalService(), + "market_open_error", + f"Не удалось открыть экран рынка: {exc}", + {"user_id": user_id, "chat_id": chat_id}, + ) + + await show_message_exchange_error( + message, + title="📈 Рынок", + exc=exc, + network_details="Рыночные данные недоступны.\nОбнови экран.", + auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.", + retry_callback_data="market:retry", + ) + + +@router.callback_query(F.data == "market:retry") +async def retry_market(callback: CallbackQuery, state: FSMContext) -> None: + await state.clear() + + if callback.message is None: + await callback.answer("Сообщение не найдено", show_alert=True) + return + + user_id = callback.from_user.id if callback.from_user else None + chat_id = callback.message.chat.id if callback.message.chat else None + + try: + await _render_market_screen( + callback.message, + user_id=user_id, + chat_id=chat_id, + edit_mode=True, + ) + await callback.answer() + except ExchangeError as exc: + _safe_log_error( + JournalService(), + "market_retry_error", + f"Не удалось повторно открыть рынок: {exc}", + {"user_id": user_id, "chat_id": chat_id}, + ) + + await show_callback_exchange_error( + callback, + title="📈 Рынок", + exc=exc, + network_details="Рыночные данные недоступны.\nОбнови экран.", + auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.", + retry_callback_data="market:retry", + ) \ No newline at end of file diff --git a/app/src/telegram/handlers/portfolio.py b/app/src/telegram/handlers/portfolio.py index 1748665..d3d458c 100644 --- a/app/src/telegram/handlers/portfolio.py +++ b/app/src/telegram/handlers/portfolio.py @@ -4,10 +4,24 @@ from __future__ import annotations from aiogram import F, Router from aiogram.fsm.context import FSMContext -from aiogram.types import Message +from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message +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.ui.common import mode_line, now_line +from src.telegram.ui.currency_ui import ( + balance_total, + estimate_balance_usd, + format_amount, + format_usd_amount, + is_zero_balance, +) +from src.telegram.ui.exchange_error import ( + show_callback_exchange_error, + show_message_exchange_error, +) from src.trading.accounts.service import AccountsService from src.trading.journal.service import JournalService @@ -15,23 +29,6 @@ from src.trading.journal.service import JournalService router = Router(name="portfolio") -FIAT_CURRENCIES = {"USD", "USDT", "EUR", "RUB", "BYN"} - -CURRENCY_ICONS = { - "USD": "💵", - "USDT": "💵", - "EUR": "💶", - "RUB": "₽", - "BYN": "Br", - "BTC": "₿", - "ETH": "Ξ", - "BNB": "🟡", - "SOL": "◎", - "ADA": "🔵", - "XRP": "✕", - "DOGE": "🐶", -} - PINNED_ORDER = { "USD": 1, "USDT": 2, @@ -40,55 +37,19 @@ PINNED_ORDER = { } -def format_amount(currency: str, value: float) -> str: - if currency.upper() in FIAT_CURRENCIES: - return f"{value:,.2f}".replace(",", " ") - return f"{value:,.8f}".replace(",", " ") +def _portfolio_keyboard() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button(text="🏠 К торговле", callback_data="trade:home") + builder.adjust(1) + return builder.as_markup() -def get_currency_label(currency: str) -> str: - icon = CURRENCY_ICONS.get(currency.upper(), "💰") - return f"{icon} {currency.upper()}" - - -def is_zero_balance(item: BalanceSummary) -> bool: - return abs(item.available) < 1e-12 and abs(item.locked) < 1e-12 - - -def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]: - def sort_key(item: BalanceSummary) -> tuple[int, str]: - currency = item.currency.upper() - priority = PINNED_ORDER.get(currency, 999) - return (priority, currency) - - return sorted(items, key=sort_key) - - -def split_balances( - items: list[BalanceSummary], -) -> tuple[list[BalanceSummary], list[BalanceSummary]]: - major: list[BalanceSummary] = [] - other: list[BalanceSummary] = [] - - for item in items: - if item.currency.upper() in PINNED_ORDER: - major.append(item) - else: - other.append(item) - - return major, other - - -def render_balance_block(item: BalanceSummary) -> list[str]: - total = item.available + item.locked - - return [ - f"{get_currency_label(item.currency)}", - f"• доступно: {format_amount(item.currency, item.available)}", - f"• заблокировано: {format_amount(item.currency, item.locked)}", - f"• всего: {format_amount(item.currency, total)}", - "", - ] +def _portfolio_warning_keyboard() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button(text="🔁 Обновить", callback_data="portfolio:retry") + builder.button(text="🏠 К торговле", callback_data="trade:home") + builder.adjust(1, 1) + return builder.as_markup() def _safe_log_info( @@ -127,17 +88,26 @@ def _safe_log_error( pass -@router.message(F.text == "💼 Портфель") -async def open_portfolio(message: Message, state: FSMContext) -> None: - # Глобальный экран: всегда выходим из текущего FSM-сценария. - await state.clear() +def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]: + def sort_key(item: BalanceSummary) -> tuple[int, str]: + currency = item.currency.upper() + priority = PINNED_ORDER.get(currency, 999) + return (priority, currency) + 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, +) -> None: service = AccountsService() + exchange_service = ExchangeService() journal = JournalService() - user_id = message.from_user.id if message.from_user else None - chat_id = message.chat.id if message.chat else None - _safe_log_info( journal, "user_open_portfolio", @@ -148,24 +118,7 @@ async def open_portfolio(message: Message, state: FSMContext) -> None: }, ) - try: - balances = service.get_live_balance_summary() - except ExchangeError as exc: - _safe_log_error( - journal, - "portfolio_open_error", - f"Не удалось открыть портфель: {exc}", - { - "user_id": user_id, - "chat_id": chat_id, - }, - ) - await message.answer( - "💼 Портфель\n\n" - "Не удалось получить баланс с private API.\n" - f"Ошибка: {exc}" - ) - return + balances = service.get_live_balance_summary() if not balances: _safe_log_warning( @@ -177,10 +130,17 @@ async def open_portfolio(message: Message, state: FSMContext) -> None: "chat_id": chat_id, }, ) - await message.answer( - "💼 Портфель\n\n" - "Баланс пуст." + + text = ( + "💼 Портфель\n" + f"{mode_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 visible_balances = [item for item in balances if not is_zero_balance(item)] @@ -197,34 +157,76 @@ async def open_portfolio(message: Message, state: FSMContext) -> None: "assets_count": len(balances), }, ) - await message.answer( - "💼 Портфель\n\n" - "Все балансы нулевые." + + text = ( + "💼 Портфель\n" + f"{mode_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 - major_balances, other_balances = split_balances(visible_balances) + price_cache: dict[str, float | None] = {} + total_estimated_usd = 0.0 + has_any_estimate = False + missing_estimate_assets: list[str] = [] - lines: list[str] = ["💼 Портфель", "", "Баланс аккаунта", ""] + lines: list[str] = [ + "💼 Портфель", + mode_line().rstrip(), + "", + f"БАЛАНС · АКТИВЫ · {len(visible_balances)}", + ] - if major_balances: - lines.append("Основные активы") - lines.append("") - for item in major_balances: - lines.extend(render_balance_block(item)) + asset_blocks: list[list[str]] = [] - if other_balances: - lines.append("Прочие активы") - lines.append("") - for item in other_balances: - lines.extend(render_balance_block(item)) + for item in visible_balances: + currency = item.currency.upper() + total = balance_total(item) + estimated_usd = estimate_balance_usd(item, exchange_service, price_cache) - lines.extend( - [ - "Итого", - f"• активов с ненулевым балансом: {len(visible_balances)}", + if estimated_usd is not None: + total_estimated_usd += estimated_usd + has_any_estimate = True + 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)}", ] - ) + + if estimated_usd is not None and currency not in {"USD", "USDT"}: + block.append(f"≈ {format_usd_amount(estimated_usd)} USD") + + asset_blocks.append(block) + + has_partial_data = len(missing_estimate_assets) > 0 + + if missing_estimate_assets: + lines.extend( + [ + "🟡 Данные загружены частично", + ] + ) + + if has_any_estimate: + lines.append(f"ОЦЕНКА · ~${format_usd_amount(total_estimated_usd)}") + + if missing_estimate_assets: + lines.append( + f"Нет оценки: {', '.join(missing_estimate_assets)}" + ) + + for block in asset_blocks: + lines.extend(block) _safe_log_info( journal, @@ -234,8 +236,115 @@ async def open_portfolio(message: Message, state: FSMContext) -> None: "user_id": user_id, "chat_id": chat_id, "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: + _safe_log_warning( + journal, + "portfolio_partial_estimate", + "Портфель показан частично: не для всех активов доступна USD-оценка.", + { + "user_id": user_id, + "chat_id": chat_id, + "missing_estimate_assets": missing_estimate_assets, + }, + ) + + if has_partial_data: + lines.extend( + [ + "", + now_line(), + ] + ) + text = "\n".join(lines).rstrip() - await message.answer(text) \ No newline at end of file + + 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) + else: + await target_message.answer(text, reply_markup=reply_markup) + + +@router.message(F.text == "💼 Портфель") +async def open_portfolio(message: Message, state: FSMContext) -> None: + await state.clear() + + user_id = message.from_user.id if message.from_user else None + chat_id = message.chat.id if message.chat else None + + try: + await _render_portfolio_screen( + message, + user_id=user_id, + chat_id=chat_id, + edit_mode=False, + ) + except ExchangeError as exc: + journal = JournalService() + _safe_log_error( + journal, + "portfolio_open_error", + f"Не удалось открыть портфель: {exc}", + { + "user_id": user_id, + "chat_id": chat_id, + }, + ) + await show_message_exchange_error( + message, + title="💼 Портфель", + exc=exc, + network_details="Не загружен баланс аккаунта.\nОбнови экран.", + auth_details="Не загружен баланс аккаунта.\nПроверь API ключи.", + retry_callback_data="portfolio:retry", + ) + + +@router.callback_query(F.data == "portfolio:retry") +async def retry_portfolio(callback: CallbackQuery, state: FSMContext) -> None: + await state.clear() + + if callback.message is None: + await callback.answer("Сообщение не найдено", show_alert=True) + return + + user_id = callback.from_user.id if callback.from_user else None + chat_id = callback.message.chat.id if callback.message.chat else None + + try: + await _render_portfolio_screen( + callback.message, + user_id=user_id, + chat_id=chat_id, + edit_mode=True, + ) + await callback.answer() + except ExchangeError as exc: + journal = JournalService() + _safe_log_error( + journal, + "portfolio_retry_error", + f"Не удалось повторно открыть портфель: {exc}", + { + "user_id": user_id, + "chat_id": chat_id, + }, + ) + await show_callback_exchange_error( + callback, + title="💼 Портфель", + exc=exc, + network_details="Не загружен баланс аккаунта.\nОбнови экран.", + auth_details="Не загружен баланс аккаунта.\nПроверь API ключи.", + retry_callback_data="portfolio:retry", + ) \ No newline at end of file diff --git a/app/src/telegram/handlers/system.py b/app/src/telegram/handlers/system.py index a201340..5439dff 100644 --- a/app/src/telegram/handlers/system.py +++ b/app/src/telegram/handlers/system.py @@ -4,16 +4,66 @@ from __future__ import annotations from aiogram import F, Router from aiogram.fsm.context import FSMContext -from aiogram.types import Message +from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message +from aiogram.utils.keyboard import InlineKeyboardBuilder -from src.core.system_status import build_system_text +from src.core.system_status import build_system_text, get_system_snapshot, has_system_alerts router = Router(name="system") +def _system_keyboard() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button(text="🏠 К торговле", callback_data="trade:home") + builder.adjust(1) + return builder.as_markup() + + +def _system_alert_keyboard() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button(text="🔁 Обновить", callback_data="system:retry") + builder.button(text="🏠 К торговле", callback_data="trade:home") + builder.adjust(1, 1) + return builder.as_markup() + + +async def _render_system_screen( + target_message: Message, + *, + edit_mode: bool, +) -> None: + snapshot = get_system_snapshot() + is_alert = has_system_alerts(snapshot) + + text = build_system_text(include_updated_at=is_alert) + reply_markup = _system_alert_keyboard() if is_alert else _system_keyboard() + + if edit_mode: + await target_message.edit_text(text, reply_markup=reply_markup) + else: + await target_message.answer(text, reply_markup=reply_markup) + + @router.message(F.text.in_({"⚙️ Система", "⚙ Система"})) async def open_system(message: Message, state: FSMContext) -> None: - # Глобальный экран: всегда выходим из текущего FSM-сценария. await state.clear() - await message.answer(build_system_text()) \ No newline at end of file + await _render_system_screen( + message, + edit_mode=False, + ) + + +@router.callback_query(F.data == "system:retry") +async def retry_system(callback: CallbackQuery, state: FSMContext) -> None: + await state.clear() + + if callback.message is None: + await callback.answer("Сообщение не найдено", show_alert=True) + return + + await _render_system_screen( + callback.message, + edit_mode=True, + ) + await callback.answer() \ No newline at end of file diff --git a/app/src/telegram/handlers/trade/new_order_flow.py b/app/src/telegram/handlers/trade/new_order_flow.py index 284a6fa..0a5b169 100644 --- a/app/src/telegram/handlers/trade/new_order_flow.py +++ b/app/src/telegram/handlers/trade/new_order_flow.py @@ -7,6 +7,12 @@ from aiogram.filters import Command from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, Message +from src.telegram.ui.exchange_error import ( + show_callback_exchange_error, + show_message_exchange_error, +) + +from src.integrations.exchange.exceptions import ExchangeError from src.telegram.handlers.trade.new_order_core import router from src.telegram.handlers.trade.new_order_ui import ( _confirm_keyboard, @@ -19,13 +25,12 @@ from src.telegram.handlers.trade.new_order_ui import ( _render_confirm, _render_draft_detail, _render_draft_summary, - # _render_inline_error, _render_manual_price_screen, _render_manual_quantity_screen, _render_order_path, + _render_price_inline_error, _render_price_input_help, _render_price_step_screen, - _render_price_inline_error, _render_quantity_inline_error, _render_quantity_input_help, _render_quantity_step_screen, @@ -34,8 +39,8 @@ from src.telegram.handlers.trade.new_order_ui import ( _side_keyboard, _trade_back_home_keyboard, _type_keyboard, - show_recent_drafts, mode_line, + show_recent_drafts, ) from src.trading.orders.service import OrderDraftsService from src.trading.orders.states import NewOrderDraftStates @@ -61,7 +66,6 @@ async def drafts_noop(callback: CallbackQuery) -> None: await callback.answer() -# Перелистывает список черновиков. @router.callback_query(F.data.startswith("drafts:")) async def paginate_drafts(callback: CallbackQuery) -> None: value = callback.data.split(":", 1)[1] @@ -75,7 +79,6 @@ async def paginate_drafts(callback: CallbackQuery) -> None: await show_recent_drafts(callback.message, edit_mode=True, page=page) -# Открывает карточку выбранного черновика. @router.callback_query(F.data.startswith("draft_open:")) async def open_draft(callback: CallbackQuery) -> None: service = OrderDraftsService() @@ -94,8 +97,6 @@ async def open_draft(callback: CallbackQuery) -> None: await callback.answer() - -# Переводит черновик в режим редактирования. @router.callback_query(F.data.startswith("draft_edit:")) async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None: service = OrderDraftsService() @@ -122,33 +123,44 @@ async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None: price=price, ) - title = _screen_title(is_edit_mode=True) - context = service.get_entry_context(side=side, order_type=order_type) + try: + title = _screen_title(is_edit_mode=True) + context = service.get_entry_context(side=side, order_type=order_type) - path = _render_order_path( - side=side, - order_type=order_type, - quantity=quantity, - base_currency=context.base_currency, - ) + path = _render_order_path( + side=side, + order_type=order_type, + quantity=quantity, + base_currency=context.base_currency, + ) - await state.set_state(NewOrderDraftStates.waiting_quantity) - await callback.message.edit_text( - _render_quantity_step_screen( - title=title, - symbol=context.symbol, - available_balance=context.available_balance, - balance_currency=context.balance_currency, - reference_price=context.reference_price, - quote_currency=context.quote_currency, - order_path=path, - ), - reply_markup=_quantity_keyboard( - context.quantity_presets, + await state.set_state(NewOrderDraftStates.waiting_quantity) + await callback.message.edit_text( + _render_quantity_step_screen( + title=title, + symbol=context.symbol, + available_balance=context.available_balance, + balance_currency=context.balance_currency, + reference_price=context.reference_price, + quote_currency=context.quote_currency, + order_path=path, + ), + reply_markup=_quantity_keyboard( + context.quantity_presets, + drafts_page=page, + ), + ) + await callback.answer() + except (ExchangeError, ValueError) as exc: + await show_callback_exchange_error( + callback, + title=_screen_title(is_edit_mode=True), + exc=exc, + retry_callback_data=callback.data, + back_callback_data=f"draft_open:{draft_id}:{page}", drafts_page=page, - ), - ) - await callback.answer() + ) + return @router.callback_query(F.data.startswith("draft_delete:")) @@ -156,7 +168,6 @@ async def delete_draft_stub(callback: CallbackQuery) -> None: await callback.answer("Удаление скоро появится") -# Отменяет сценарий создания черновика. @router.message(Command("cancel_order")) async def cancel_order_builder(message: Message, state: FSMContext) -> None: await state.clear() @@ -168,7 +179,6 @@ async def cancel_order_builder(message: Message, state: FSMContext) -> None: ) -# Точка входа в сценарий создания нового черновика. @router.message(Command("new_order")) async def start_new_order_draft( message: Message, @@ -179,22 +189,30 @@ async def start_new_order_draft( await state.set_state(NewOrderDraftStates.waiting_side) service = OrderDraftsService() - context = service.get_entry_context(side="BUY", order_type="MARKET") - text = ( - "📊 Торговля — Новый ордер\n" - f"{mode_line()}" - f"{context.symbol}\n\n" - "Шаг 1/4. Выбери сторону" - ) + try: + context = service.get_entry_context(side="BUY", order_type="MARKET") - if edit_mode: - await message.edit_text(text, reply_markup=_side_keyboard()) - else: - await message.answer(text, reply_markup=_side_keyboard()) + text = ( + "📊 Торговля — Новый ордер\n" + f"{mode_line()}" + f"{context.symbol}\n\n" + "Шаг 1/4. Выбери сторону" + ) + + if edit_mode: + await message.edit_text(text, reply_markup=_side_keyboard()) + else: + await message.answer(text, reply_markup=_side_keyboard()) + except ExchangeError as exc: + await show_message_exchange_error( + message, + title="📊 Торговля — Новый ордер", + exc=exc, + retry_callback_data="trade:new_order_retry", + ) -# Обрабатывает выбор стороны ордера. @router.callback_query( NewOrderDraftStates.waiting_side, F.data.startswith("order_side:"), @@ -209,18 +227,27 @@ async def process_order_side_callback( path = _render_order_path(side=side) service = OrderDraftsService() - context = service.get_entry_context(side=side, order_type="MARKET") - text = ( - "📊 Торговля — Новый ордер\n" - f"{mode_line()}" - f"{context.symbol}\n\n" - f"{path}\n\n" - "Шаг 2/4. Выбери тип ордера" - ) + try: + context = service.get_entry_context(side=side, order_type="MARKET") - await callback.message.edit_text(text, reply_markup=_type_keyboard()) - await callback.answer() + text = ( + "📊 Торговля — Новый ордер\n" + f"{mode_line()}" + f"{context.symbol}\n\n" + f"{path}\n\n" + "Шаг 2/4. Выбери тип ордера" + ) + + await callback.message.edit_text(text, reply_markup=_type_keyboard()) + await callback.answer() + except ExchangeError as exc: + await show_callback_exchange_error( + callback, + title="📊 Торговля — Новый ордер", + exc=exc, + retry_callback_data=callback.data, + ) @router.message( @@ -234,7 +261,6 @@ async def process_order_side_text(message: Message) -> None: ) -# Обрабатывает выбор типа ордера. @router.callback_query( NewOrderDraftStates.waiting_type, F.data.startswith("order_type:"), @@ -255,30 +281,39 @@ async def process_order_type_callback( await state.update_data(order_type=order_type) await state.set_state(NewOrderDraftStates.waiting_quantity) - context = service.get_entry_context(side=side, order_type=order_type) + try: + context = service.get_entry_context(side=side, order_type=order_type) - path = _render_order_path( - side=side, - order_type=order_type, - base_currency=context.base_currency, - ) + path = _render_order_path( + side=side, + order_type=order_type, + base_currency=context.base_currency, + ) - await callback.message.edit_text( - _render_quantity_step_screen( + await callback.message.edit_text( + _render_quantity_step_screen( + title=_screen_title(is_edit_mode), + symbol=context.symbol, + available_balance=context.available_balance, + balance_currency=context.balance_currency, + reference_price=context.reference_price, + quote_currency=context.quote_currency, + order_path=path, + ), + reply_markup=_quantity_keyboard( + context.quantity_presets, + drafts_page=drafts_page, + ), + ) + await callback.answer() + except ExchangeError as exc: + await show_callback_exchange_error( + callback, title=_screen_title(is_edit_mode), - symbol=context.symbol, - available_balance=context.available_balance, - balance_currency=context.balance_currency, - reference_price=context.reference_price, - quote_currency=context.quote_currency, - order_path=path, - ), - reply_markup=_quantity_keyboard( - context.quantity_presets, + exc=exc, + retry_callback_data=callback.data, drafts_page=drafts_page, - ), - ) - await callback.answer() + ) @router.message( @@ -292,7 +327,6 @@ async def process_order_type_text(message: Message) -> None: ) -# Обрабатывает выбор количества через кнопки. @router.callback_query( NewOrderDraftStates.waiting_quantity, F.data.startswith("order_qty:"), @@ -313,118 +347,126 @@ async def process_quantity_callback( side = data.get("side", "BUY") order_type = data.get("order_type", "MARKET") - context = service.get_entry_context(side=side, order_type=order_type) + try: + context = service.get_entry_context(side=side, order_type=order_type) - if value == "manual": - rules = service.get_entry_rules() - quantity_example = context.quantity_presets[0] if context.quantity_presets else "0.001" + if value == "manual": + rules = service.get_entry_rules() + quantity_example = context.quantity_presets[0] if context.quantity_presets else "0.001" - path = _render_order_path( + path = _render_order_path( + side=side, + order_type=order_type, + base_currency=context.base_currency, + ) + + await callback.message.edit_text( + _render_manual_quantity_screen( + title=title, + symbol=context.symbol, + reference_price=context.reference_price, + quote_currency=context.quote_currency, + min_qty=rules["min_qty"], + step_size=rules["step_size"], + min_notional=rules["min_notional"], + example=quantity_example, + order_path=path, + ), + reply_markup=_quantity_manual_keyboard(drafts_page=drafts_page), + ) + await callback.answer() + return + + quantity = service.normalize_preset_quantity( side=side, order_type=order_type, - base_currency=context.base_currency, + raw_quantity=value, ) + if quantity is None: + await callback.answer("Некорректное значение количества.", show_alert=True) + return - await callback.message.edit_text( - _render_manual_quantity_screen( - title=title, - symbol=context.symbol, - reference_price=context.reference_price, - quote_currency=context.quote_currency, - min_qty=rules["min_qty"], - step_size=rules["step_size"], - min_notional=rules["min_notional"], - example=quantity_example, - order_path=path, - ), - reply_markup=_quantity_manual_keyboard(drafts_page=drafts_page), - ) - await callback.answer() - return + await state.update_data(quantity=quantity) - quantity = service.normalize_preset_quantity( - side=side, - order_type=order_type, - raw_quantity=value, - ) - if quantity is None: - await callback.answer("Некорректное значение количества.", show_alert=True) - return + if order_type == "LIMIT": + path = _render_order_path( + side=side, + order_type=order_type, + quantity=quantity, + base_currency=context.base_currency, + ) - await state.update_data(quantity=quantity) + await state.set_state(NewOrderDraftStates.waiting_price) + await callback.message.edit_text( + _render_price_step_screen( + title=title, + symbol=context.symbol, + bid=context.bid_price, + ask=context.ask_price, + last=context.last_price, + quote_currency=context.quote_currency, + order_path=path, + ), + reply_markup=_price_keyboard( + bid=context.bid_price, + ask=context.ask_price, + last=context.last_price, + drafts_page=drafts_page, + ), + ) + await callback.answer() + return - if order_type == "LIMIT": - path = _render_order_path( + draft = service.build_draft( side=side, order_type=order_type, quantity=quantity, - base_currency=context.base_currency, ) - await state.set_state(NewOrderDraftStates.waiting_price) + notional = service.calculate_notional(quantity, f"{context.reference_price:.2f}") + + await state.update_data( + confirm_draft={ + "symbol": draft.symbol, + "side": draft.side, + "order_type": draft.order_type, + "quantity": draft.quantity, + "price": draft.price, + "reference_price": f"{context.reference_price:.2f}", + "base_currency": context.base_currency, + "quote_currency": context.quote_currency, + "notional": notional, + } + ) + + await state.set_state(NewOrderDraftStates.waiting_confirm) + await callback.message.edit_text( - _render_price_step_screen( - title=title, - symbol=context.symbol, - bid=context.bid_price, - ask=context.ask_price, - last=context.last_price, + _render_confirm( + symbol=draft.symbol, + side=draft.side, + order_type=draft.order_type, + quantity=draft.quantity, + price=draft.price, + notional=notional, + is_edit_mode=is_edit_mode, + base_currency=context.base_currency, quote_currency=context.quote_currency, - order_path=path, - ), - reply_markup=_price_keyboard( - bid=context.bid_price, - ask=context.ask_price, - last=context.last_price, - drafts_page=drafts_page, + reference_price=f"{context.reference_price:.2f}", ), + reply_markup=_confirm_keyboard(drafts_page=drafts_page), ) await callback.answer() - return - - draft = service.build_draft( - side=side, - order_type=order_type, - quantity=quantity, - ) - - notional = service.calculate_notional(quantity, f"{context.reference_price:.2f}") - - await state.update_data( - confirm_draft={ - "symbol": draft.symbol, - "side": draft.side, - "order_type": draft.order_type, - "quantity": draft.quantity, - "price": draft.price, - "reference_price": f"{context.reference_price:.2f}", - "base_currency": context.base_currency, - "quote_currency": context.quote_currency, - "notional": notional, - } - ) - - await state.set_state(NewOrderDraftStates.waiting_confirm) - - await callback.message.edit_text( - _render_confirm( - symbol=draft.symbol, - side=draft.side, - order_type=draft.order_type, - quantity=draft.quantity, - price=draft.price, - notional=notional, - is_edit_mode=is_edit_mode, - base_currency=context.base_currency, - quote_currency=context.quote_currency, - reference_price=f"{context.reference_price:.2f}", - ), - reply_markup=_confirm_keyboard(drafts_page=drafts_page), - ) - await callback.answer() + except ExchangeError as exc: + await show_callback_exchange_error( + callback, + title=title, + exc=exc, + retry_callback_data=callback.data, + drafts_page=drafts_page, + ) -# Обрабатывает ручной ввод количества. @router.message( NewOrderDraftStates.waiting_quantity, ~F.text.in_(MAIN_MENU_BUTTONS), @@ -441,139 +483,146 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None: draft_page = data.get("draft_edit_page") drafts_page = int(draft_page) if draft_page else None - quantity = service.normalize_entry_quantity( - side=side, - order_type=order_type, - raw_quantity=raw_quantity, - ) - - context = service.get_entry_context(side=side, order_type=order_type) - rules = service.get_entry_rules() - quantity_example = context.quantity_presets[0] if context.quantity_presets else "0.001" - - help_text = _render_quantity_input_help( - min_qty=rules["min_qty"], - step_size=rules["step_size"], - min_notional=rules["min_notional"], - price=context.reference_price, - quote_currency=context.quote_currency, - example=quantity_example, - ) - - if quantity is None: - path = _render_order_path( + try: + quantity = service.normalize_entry_quantity( side=side, order_type=order_type, - base_currency=context.base_currency, + raw_quantity=raw_quantity, ) - await message.answer( - _render_quantity_inline_error( - title=title, - symbol=context.symbol, - order_path=path, - errors=["Количество должно быть числом больше нуля."], - help_text=help_text, - ), - reply_markup=_quantity_manual_keyboard(drafts_page=drafts_page), - ) - return + context = service.get_entry_context(side=side, order_type=order_type) + rules = service.get_entry_rules() + quantity_example = context.quantity_presets[0] if context.quantity_presets else "0.001" - quantity_errors = service.validate_entry_quantity( - side=side, - order_type=order_type, - quantity=quantity, - price=None, - ) - if quantity_errors: - path = _render_order_path( - side=side, - order_type=order_type, - base_currency=context.base_currency, + help_text = _render_quantity_input_help( + min_qty=rules["min_qty"], + step_size=rules["step_size"], + min_notional=rules["min_notional"], + price=context.reference_price, + quote_currency=context.quote_currency, + example=quantity_example, ) - await message.answer( - _render_quantity_inline_error( - title=title, - symbol=context.symbol, - order_path=path, - errors=quantity_errors, - help_text=help_text, - ), - reply_markup=_quantity_manual_keyboard(drafts_page=drafts_page), - ) - return + if quantity is None: + path = _render_order_path( + side=side, + order_type=order_type, + base_currency=context.base_currency, + ) - await state.update_data(quantity=quantity) + await message.answer( + _render_quantity_inline_error( + title=title, + symbol=context.symbol, + order_path=path, + errors=["Количество должно быть числом больше нуля."], + help_text=help_text, + ), + reply_markup=_quantity_manual_keyboard(drafts_page=drafts_page), + ) + return - if order_type == "LIMIT": - path = _render_order_path( + quantity_errors = service.validate_entry_quantity( side=side, order_type=order_type, quantity=quantity, - base_currency=context.base_currency, + price=None, ) + if quantity_errors: + path = _render_order_path( + side=side, + order_type=order_type, + base_currency=context.base_currency, + ) + + await message.answer( + _render_quantity_inline_error( + title=title, + symbol=context.symbol, + order_path=path, + errors=quantity_errors, + help_text=help_text, + ), + reply_markup=_quantity_manual_keyboard(drafts_page=drafts_page), + ) + return + + await state.update_data(quantity=quantity) + + if order_type == "LIMIT": + path = _render_order_path( + side=side, + order_type=order_type, + quantity=quantity, + base_currency=context.base_currency, + ) + + await state.set_state(NewOrderDraftStates.waiting_price) + await message.answer( + _render_price_step_screen( + title=title, + symbol=context.symbol, + bid=context.bid_price, + ask=context.ask_price, + last=context.last_price, + quote_currency=context.quote_currency, + order_path=path, + ), + reply_markup=_price_keyboard( + bid=context.bid_price, + ask=context.ask_price, + last=context.last_price, + drafts_page=drafts_page, + ), + ) + return + + draft = service.build_draft( + side=side, + order_type=order_type, + quantity=quantity, + ) + notional = service.calculate_notional(quantity, f"{context.reference_price:.2f}") + + await state.update_data( + confirm_draft={ + "symbol": draft.symbol, + "side": draft.side, + "order_type": draft.order_type, + "quantity": draft.quantity, + "price": draft.price, + "reference_price": f"{context.reference_price:.2f}", + "base_currency": context.base_currency, + "quote_currency": context.quote_currency, + "notional": notional, + } + ) + await state.set_state(NewOrderDraftStates.waiting_confirm) - await state.set_state(NewOrderDraftStates.waiting_price) await message.answer( - _render_price_step_screen( - title=title, - symbol=context.symbol, - bid=context.bid_price, - ask=context.ask_price, - last=context.last_price, + _render_confirm( + symbol=draft.symbol, + side=draft.side, + order_type=draft.order_type, + quantity=draft.quantity, + price=draft.price, + notional=notional, + is_edit_mode=is_edit_mode, + base_currency=context.base_currency, quote_currency=context.quote_currency, - order_path=path, - ), - reply_markup=_price_keyboard( - bid=context.bid_price, - ask=context.ask_price, - last=context.last_price, - drafts_page=drafts_page, + reference_price=f"{context.reference_price:.2f}", ), + reply_markup=_confirm_keyboard(drafts_page=drafts_page), + ) + except ExchangeError as exc: + await show_message_exchange_error( + message, + title=title, + exc=exc, + drafts_page=drafts_page, ) - return - - draft = service.build_draft( - side=side, - order_type=order_type, - quantity=quantity, - ) - notional = service.calculate_notional(quantity, f"{context.reference_price:.2f}") - - await state.update_data( - confirm_draft={ - "symbol": draft.symbol, - "side": draft.side, - "order_type": draft.order_type, - "quantity": draft.quantity, - "price": draft.price, - "reference_price": f"{context.reference_price:.2f}", - "base_currency": context.base_currency, - "quote_currency": context.quote_currency, - "notional": notional, - } - ) - await state.set_state(NewOrderDraftStates.waiting_confirm) - - await message.answer( - _render_confirm( - symbol=draft.symbol, - side=draft.side, - order_type=draft.order_type, - quantity=draft.quantity, - price=draft.price, - notional=notional, - is_edit_mode=is_edit_mode, - base_currency=context.base_currency, - quote_currency=context.quote_currency, - reference_price=f"{context.reference_price:.2f}", - ), - reply_markup=_confirm_keyboard(drafts_page=drafts_page), - ) -# Обрабатывает выбор цены через кнопки. @router.callback_query( NewOrderDraftStates.waiting_price, F.data.startswith("order_price:"), @@ -591,83 +640,91 @@ async def process_price_callback( draft_page = data.get("draft_edit_page") drafts_page = int(draft_page) if draft_page else None - context = service.get_entry_context( - side=data.get("side", "BUY"), - order_type=data.get("order_type", "LIMIT"), - ) - - if value == "manual": - rules = service.get_entry_rules() - price_example = f"{context.last_price:.2f}" - - path = _render_order_path( - side=data.get("side"), - order_type=data.get("order_type"), - quantity=data.get("quantity"), - base_currency=context.base_currency, + try: + context = service.get_entry_context( + side=data.get("side", "BUY"), + order_type=data.get("order_type", "LIMIT"), ) + if value == "manual": + rules = service.get_entry_rules() + price_example = f"{context.last_price:.2f}" + + path = _render_order_path( + side=data.get("side"), + order_type=data.get("order_type"), + quantity=data.get("quantity"), + base_currency=context.base_currency, + ) + + await callback.message.edit_text( + _render_manual_price_screen( + title=title, + symbol=context.symbol, + tick_size=rules["tick_size"], + example=price_example, + quote_currency=context.quote_currency, + order_path=path, + ), + reply_markup=_price_manual_keyboard(drafts_page=drafts_page), + ) + await callback.answer() + return + + price = service.normalize_price(value) + if price is None: + await callback.answer("Некорректная цена.", show_alert=True) + return + + draft = service.build_draft( + side=data["side"], + order_type=data["order_type"], + quantity=data["quantity"], + price=price, + ) + + notional = service.calculate_notional(data["quantity"], price) + + await state.update_data( + confirm_draft={ + "symbol": draft.symbol, + "side": draft.side, + "order_type": draft.order_type, + "quantity": draft.quantity, + "price": draft.price, + "base_currency": context.base_currency, + "quote_currency": context.quote_currency, + "notional": notional, + } + ) + + await state.set_state(NewOrderDraftStates.waiting_confirm) + await callback.message.edit_text( - _render_manual_price_screen( - title=title, - symbol=context.symbol, - tick_size=rules["tick_size"], - example=price_example, + _render_confirm( + symbol=draft.symbol, + side=draft.side, + order_type=draft.order_type, + quantity=draft.quantity, + price=draft.price, + notional=notional, + is_edit_mode=is_edit_mode, + base_currency=context.base_currency, quote_currency=context.quote_currency, - order_path=path, ), - reply_markup=_price_manual_keyboard(drafts_page=drafts_page), + reply_markup=_confirm_keyboard(drafts_page=drafts_page), ) await callback.answer() - return - - price = service.normalize_price(value) - if price is None: - await callback.answer("Некорректная цена.", show_alert=True) - return - - draft = service.build_draft( - side=data["side"], - order_type=data["order_type"], - quantity=data["quantity"], - price=price, - ) - - notional = service.calculate_notional(data["quantity"], price) - - await state.update_data( - confirm_draft={ - "symbol": draft.symbol, - "side": draft.side, - "order_type": draft.order_type, - "quantity": draft.quantity, - "price": draft.price, - "base_currency": context.base_currency, - "quote_currency": context.quote_currency, - "notional": notional, - } - ) - - await state.set_state(NewOrderDraftStates.waiting_confirm) - - await callback.message.edit_text( - _render_confirm( - symbol=draft.symbol, - side=draft.side, - order_type=draft.order_type, - quantity=draft.quantity, - price=draft.price, - notional=notional, - is_edit_mode=is_edit_mode, - base_currency=context.base_currency, - quote_currency=context.quote_currency, - ), - reply_markup=_confirm_keyboard(drafts_page=drafts_page), - ) - await callback.answer() + except ExchangeError as exc: + await show_callback_exchange_error( + callback, + title=title, + exc=exc, + retry_callback_data=callback.data, + drafts_page=drafts_page, + ) -# Обрабатывает ручной ввод цены. @router.message( NewOrderDraftStates.waiting_price, ~F.text.in_(MAIN_MENU_BUTTONS), @@ -683,96 +740,104 @@ async def process_order_price(message: Message, state: FSMContext) -> None: draft_page = data.get("draft_edit_page") drafts_page = int(draft_page) if draft_page else None - rules = service.get_entry_rules() - context = service.get_entry_context( - side=data.get("side", "BUY"), - order_type=data.get("order_type", "LIMIT"), - ) - price_example = f"{context.last_price:.2f}" - help_text = _render_price_input_help( - tick_size=rules["tick_size"], - example=price_example, - quote_currency=context.quote_currency, - ) - - if price is None: - path = _render_order_path( - side=data.get("side"), - order_type=data.get("order_type"), - quantity=data.get("quantity"), - base_currency=context.base_currency, + try: + rules = service.get_entry_rules() + context = service.get_entry_context( + side=data.get("side", "BUY"), + order_type=data.get("order_type", "LIMIT"), ) - - await message.answer( - _render_price_inline_error( - title=title, - symbol=context.symbol, - order_path=path, - errors=["Цена должна быть числом больше нуля."], - help_text=help_text, - ), - reply_markup=_price_manual_keyboard(drafts_page=drafts_page), - ) - return - - draft = service.build_draft( - side=data["side"], - order_type=data["order_type"], - quantity=data["quantity"], - price=price, - ) - - validation = service.validate_draft(draft) - if not validation.is_valid: - path = _render_order_path( - side=draft.side, - order_type=draft.order_type, - quantity=draft.quantity, - base_currency=context.base_currency, - ) - - await message.answer( - _render_price_inline_error( - title=title, - symbol=context.symbol, - order_path=path, - errors=validation.errors, - help_text=help_text, - ), - reply_markup=_price_manual_keyboard(drafts_page=drafts_page), - ) - return - - notional = service.calculate_notional(data["quantity"], price) - - await state.update_data( - confirm_draft={ - "symbol": draft.symbol, - "side": draft.side, - "order_type": draft.order_type, - "quantity": draft.quantity, - "price": draft.price, - "base_currency": context.base_currency, - "quote_currency": context.quote_currency, - "notional": notional, - } - ) - await state.set_state(NewOrderDraftStates.waiting_confirm) - - await message.answer( - _render_confirm( - symbol=draft.symbol, - side=draft.side, - order_type=draft.order_type, - quantity=draft.quantity, - price=draft.price, - notional=notional, - is_edit_mode=is_edit_mode, - base_currency=context.base_currency, + price_example = f"{context.last_price:.2f}" + help_text = _render_price_input_help( + tick_size=rules["tick_size"], + example=price_example, quote_currency=context.quote_currency, - ), - reply_markup=_confirm_keyboard(drafts_page=drafts_page), - ) + ) + + if price is None: + path = _render_order_path( + side=data.get("side"), + order_type=data.get("order_type"), + quantity=data.get("quantity"), + base_currency=context.base_currency, + ) + + await message.answer( + _render_price_inline_error( + title=title, + symbol=context.symbol, + order_path=path, + errors=["Цена должна быть числом больше нуля."], + help_text=help_text, + ), + reply_markup=_price_manual_keyboard(drafts_page=drafts_page), + ) + return + + draft = service.build_draft( + side=data["side"], + order_type=data["order_type"], + quantity=data["quantity"], + price=price, + ) + + validation = service.validate_draft(draft) + if not validation.is_valid: + path = _render_order_path( + side=draft.side, + order_type=draft.order_type, + quantity=draft.quantity, + base_currency=context.base_currency, + ) + + await message.answer( + _render_price_inline_error( + title=title, + symbol=context.symbol, + order_path=path, + errors=validation.errors, + help_text=help_text, + ), + reply_markup=_price_manual_keyboard(drafts_page=drafts_page), + ) + return + + notional = service.calculate_notional(data["quantity"], price) + + await state.update_data( + confirm_draft={ + "symbol": draft.symbol, + "side": draft.side, + "order_type": draft.order_type, + "quantity": draft.quantity, + "price": draft.price, + "base_currency": context.base_currency, + "quote_currency": context.quote_currency, + "notional": notional, + } + ) + await state.set_state(NewOrderDraftStates.waiting_confirm) + + await message.answer( + _render_confirm( + symbol=draft.symbol, + side=draft.side, + order_type=draft.order_type, + quantity=draft.quantity, + price=draft.price, + notional=notional, + is_edit_mode=is_edit_mode, + base_currency=context.base_currency, + quote_currency=context.quote_currency, + ), + reply_markup=_confirm_keyboard(drafts_page=drafts_page), + ) + except ExchangeError as exc: + await show_message_exchange_error( + message, + title=title, + exc=exc, + drafts_page=drafts_page, + ) @router.message(Command("drafts")) @@ -780,7 +845,6 @@ async def drafts_command(message: Message) -> None: await show_recent_drafts(message, edit_mode=False, page=1) -# Финально сохраняет черновик и показывает результат. @router.callback_query(NewOrderDraftStates.waiting_confirm, F.data == "order_confirm") async def confirm_order(callback: CallbackQuery, state: FSMContext) -> None: service = OrderDraftsService() @@ -821,6 +885,16 @@ async def confirm_order(callback: CallbackQuery, state: FSMContext) -> None: ) await callback.answer() return + except ExchangeError as exc: + await state.clear() + await show_callback_exchange_error( + callback, + title="📊 Торговля — Подтверждение черновика", + exc=exc, + retry_callback_data=callback.data, + drafts_page=data.get("draft_edit_page"), + ) + return edit_page = data.get("draft_edit_page") await state.clear() diff --git a/app/src/telegram/handlers/trade/new_order_navigation.py b/app/src/telegram/handlers/trade/new_order_navigation.py index b03ab82..9f0bd14 100644 --- a/app/src/telegram/handlers/trade/new_order_navigation.py +++ b/app/src/telegram/handlers/trade/new_order_navigation.py @@ -6,13 +6,14 @@ from aiogram import F from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery +from src.integrations.exchange.exceptions import ExchangeError, format_exchange_error_for_user from src.telegram.handlers.trade.new_order_core import router from src.telegram.handlers.trade.new_order_ui import ( - mode_line, _draft_detail_keyboard, _price_keyboard, _quantity_keyboard, _render_draft_detail, + _render_exchange_error, _render_order_path, _render_price_step_screen, _render_quantity_step_screen, @@ -20,6 +21,7 @@ from src.telegram.handlers.trade.new_order_ui import ( _side_keyboard, _trade_back_home_keyboard, _type_keyboard, + mode_line, ) from src.trading.orders.service import OrderDraftsService from src.trading.orders.states import NewOrderDraftStates @@ -50,25 +52,65 @@ async def _return_to_draft_detail( await callback.answer() +async def _show_navigation_exchange_error( + callback: CallbackQuery, + *, + title: str, + exc: Exception, + draft_page: int | None = None, +) -> None: + reply_markup = ( + _draft_detail_keyboard("", draft_page) # won't use if branch below replaces + if False + else None + ) + + if draft_page: + keyboard = _draft_detail_keyboard("noop", draft_page) + # заменим клавиатуру сразу на корректную + # edit/detail тут не нужны, нужен простой возврат к черновикам + from src.telegram.handlers.trade.new_order_ui import _drafts_back_keyboard + + reply_markup = _drafts_back_keyboard(int(draft_page)) + else: + reply_markup = _trade_back_home_keyboard() + + await callback.message.edit_text( + _render_exchange_error( + title=title, + message=format_exchange_error_for_user(exc), + ), + reply_markup=reply_markup, + ) + await callback.answer() + + @router.callback_query(F.data == "order_back:side") async def go_back_to_side(callback: CallbackQuery, state: FSMContext) -> None: service = OrderDraftsService() - context = service.get_entry_context(side="BUY", order_type="MARKET") - await state.set_state(NewOrderDraftStates.waiting_side) - text = ( - "📊 Торговля — Новый ордер\n" - f"{mode_line()}" - f"{context.symbol}\n\n" - "Шаг 1/4. Выбери сторону" - ) - await callback.message.edit_text(text, reply_markup=_side_keyboard()) - await callback.answer() + try: + context = service.get_entry_context(side="BUY", order_type="MARKET") + + await state.set_state(NewOrderDraftStates.waiting_side) + text = ( + "📊 Торговля — Новый ордер\n" + f"{mode_line()}" + f"{context.symbol}\n\n" + "Шаг 1/4. Выбери сторону" + ) + await callback.message.edit_text(text, reply_markup=_side_keyboard()) + await callback.answer() + except ExchangeError as exc: + await _show_navigation_exchange_error( + callback, + title="📊 Торговля — Новый ордер", + exc=exc, + ) @router.callback_query(F.data == "order_back:type") async def go_back_to_type(callback: CallbackQuery, state: FSMContext) -> None: - """Возвращает пользователя на шаг выбора типа ордера или в карточку черновика при редактировании.""" data = await state.get_data() draft_id = data.get("draft_edit_id") @@ -84,27 +126,31 @@ async def go_back_to_type(callback: CallbackQuery, state: FSMContext) -> None: service = OrderDraftsService() side = data.get("side", "BUY") - context = service.get_entry_context(side=side, order_type="MARKET") - path = _render_order_path(side=side) - await state.set_state(NewOrderDraftStates.waiting_type) - text = ( - "📊 Торговля — Новый ордер\n" - f"{mode_line()}" - f"{context.symbol}\n\n" - f"{path}\n\n" - "Шаг 2/4. Выбери тип ордера" - ) - await callback.message.edit_text(text, reply_markup=_type_keyboard()) - await callback.answer() + try: + context = service.get_entry_context(side=side, order_type="MARKET") + path = _render_order_path(side=side) + + await state.set_state(NewOrderDraftStates.waiting_type) + text = ( + "📊 Торговля — Новый ордер\n" + f"{mode_line()}" + f"{context.symbol}\n\n" + f"{path}\n\n" + "Шаг 2/4. Выбери тип ордера" + ) + await callback.message.edit_text(text, reply_markup=_type_keyboard()) + await callback.answer() + except ExchangeError as exc: + await _show_navigation_exchange_error( + callback, + title="📊 Торговля — Новый ордер", + exc=exc, + ) @router.callback_query(F.data == "order_back:quantity") async def go_back_to_quantity(callback: CallbackQuery, state: FSMContext) -> None: - """ - Возвращает пользователя на шаг выбора количества. - Используется как возврат со шага цены. - """ service = OrderDraftsService() data = await state.get_data() @@ -115,39 +161,47 @@ async def go_back_to_quantity(callback: CallbackQuery, state: FSMContext) -> Non draft_page = data.get("draft_edit_page") drafts_page = int(draft_page) if draft_page else None - context = service.get_entry_context(side=side, order_type=order_type) + try: + context = service.get_entry_context(side=side, order_type=order_type) - path = _render_order_path( - side=side, - order_type=order_type, - quantity=quantity, - base_currency=context.base_currency, - ) - - if not quantity: path = _render_order_path( side=side, order_type=order_type, + quantity=quantity, base_currency=context.base_currency, ) - await state.set_state(NewOrderDraftStates.waiting_quantity) - await callback.message.edit_text( - _render_quantity_step_screen( + if not quantity: + path = _render_order_path( + side=side, + order_type=order_type, + base_currency=context.base_currency, + ) + + await state.set_state(NewOrderDraftStates.waiting_quantity) + await callback.message.edit_text( + _render_quantity_step_screen( + title=_screen_title(is_edit_mode), + symbol=context.symbol, + available_balance=context.available_balance, + balance_currency=context.balance_currency, + reference_price=context.reference_price, + quote_currency=context.quote_currency, + order_path=path, + ), + reply_markup=_quantity_keyboard( + context.quantity_presets, + drafts_page=drafts_page, + ), + ) + await callback.answer() + except ExchangeError as exc: + await _show_navigation_exchange_error( + callback, title=_screen_title(is_edit_mode), - symbol=context.symbol, - available_balance=context.available_balance, - balance_currency=context.balance_currency, - reference_price=context.reference_price, - quote_currency=context.quote_currency, - order_path=path, - ), - reply_markup=_quantity_keyboard( - context.quantity_presets, - drafts_page=drafts_page, - ), - ) - await callback.answer() + exc=exc, + draft_page=drafts_page, + ) @router.callback_query(F.data == "order_back:confirm") @@ -173,7 +227,144 @@ async def go_back_from_confirm(callback: CallbackQuery, state: FSMContext) -> No draft_page = data.get("draft_edit_page") drafts_page = int(draft_page) if draft_page else None - if order_type == "LIMIT": + try: + if order_type == "LIMIT": + context = service.get_entry_context(side=side, order_type=order_type) + path = _render_order_path( + side=side, + order_type=order_type, + quantity=quantity, + base_currency=context.base_currency, + ) + + await state.set_state(NewOrderDraftStates.waiting_price) + await callback.message.edit_text( + _render_price_step_screen( + title=_screen_title(is_edit_mode), + symbol=context.symbol, + bid=context.bid_price, + ask=context.ask_price, + last=context.last_price, + quote_currency=context.quote_currency, + order_path=path, + ), + reply_markup=_price_keyboard( + bid=context.bid_price, + ask=context.ask_price, + last=context.last_price, + drafts_page=drafts_page, + ), + ) + await callback.answer() + return + + context = service.get_entry_context(side=side, order_type=order_type) + path = _render_order_path( + side=side, + order_type=order_type, + quantity=quantity, + base_currency=context.base_currency, + ) + + await state.set_state(NewOrderDraftStates.waiting_quantity) + await callback.message.edit_text( + _render_quantity_step_screen( + title=_screen_title(is_edit_mode), + symbol=context.symbol, + available_balance=context.available_balance, + balance_currency=context.balance_currency, + reference_price=context.reference_price, + quote_currency=context.quote_currency, + order_path=path, + ), + reply_markup=_quantity_keyboard( + context.quantity_presets, + drafts_page=drafts_page, + ), + ) + await callback.answer() + except ExchangeError as exc: + await _show_navigation_exchange_error( + callback, + title=_screen_title(is_edit_mode), + exc=exc, + draft_page=drafts_page, + ) + + +@router.callback_query(F.data == "order_manual_back:quantity") +async def go_back_from_manual_quantity( + callback: CallbackQuery, + state: FSMContext, +) -> None: + service = OrderDraftsService() + data = await state.get_data() + + side = data.get("side", "BUY") + order_type = data.get("order_type", "MARKET") + quantity = data.get("quantity") + is_edit_mode = bool(data.get("draft_edit_id")) + draft_page = data.get("draft_edit_page") + drafts_page = int(draft_page) if draft_page else None + + try: + context = service.get_entry_context(side=side, order_type=order_type) + path = _render_order_path( + side=side, + order_type=order_type, + quantity=quantity, + base_currency=context.base_currency, + ) + + if not quantity: + path = _render_order_path( + side=side, + order_type=order_type, + base_currency=context.base_currency, + ) + + await state.set_state(NewOrderDraftStates.waiting_quantity) + await callback.message.edit_text( + _render_quantity_step_screen( + title=_screen_title(is_edit_mode), + symbol=context.symbol, + available_balance=context.available_balance, + balance_currency=context.balance_currency, + reference_price=context.reference_price, + quote_currency=context.quote_currency, + order_path=path, + ), + reply_markup=_quantity_keyboard( + context.quantity_presets, + drafts_page=drafts_page, + ), + ) + await callback.answer() + except ExchangeError as exc: + await _show_navigation_exchange_error( + callback, + title=_screen_title(is_edit_mode), + exc=exc, + draft_page=drafts_page, + ) + + +@router.callback_query(F.data == "order_manual_back:price") +async def go_back_from_manual_price( + callback: CallbackQuery, + state: FSMContext, +) -> None: + service = OrderDraftsService() + data = await state.get_data() + + side = data.get("side", "BUY") + order_type = data.get("order_type", "LIMIT") + quantity = data.get("quantity") + is_edit_mode = bool(data.get("draft_edit_id")) + draft_page = data.get("draft_edit_page") + drafts_page = int(draft_page) if draft_page else None + + try: context = service.get_entry_context(side=side, order_type=order_type) path = _render_order_path( side=side, @@ -201,123 +392,10 @@ async def go_back_from_confirm(callback: CallbackQuery, state: FSMContext) -> No ), ) await callback.answer() - return - - context = service.get_entry_context(side=side, order_type=order_type) - path = _render_order_path( - side=side, - order_type=order_type, - quantity=quantity, - base_currency=context.base_currency, - ) - - await state.set_state(NewOrderDraftStates.waiting_quantity) - await callback.message.edit_text( - _render_quantity_step_screen( + except ExchangeError as exc: + await _show_navigation_exchange_error( + callback, title=_screen_title(is_edit_mode), - symbol=context.symbol, - available_balance=context.available_balance, - balance_currency=context.balance_currency, - reference_price=context.reference_price, - quote_currency=context.quote_currency, - order_path=path, - ), - reply_markup=_quantity_keyboard( - context.quantity_presets, - drafts_page=drafts_page, - ), - ) - await callback.answer() - - -@router.callback_query(F.data == "order_manual_back:quantity") -async def go_back_from_manual_quantity( - callback: CallbackQuery, - state: FSMContext, -) -> None: - service = OrderDraftsService() - data = await state.get_data() - - side = data.get("side", "BUY") - order_type = data.get("order_type", "MARKET") - quantity = data.get("quantity") - is_edit_mode = bool(data.get("draft_edit_id")) - draft_page = data.get("draft_edit_page") - drafts_page = int(draft_page) if draft_page else None - - context = service.get_entry_context(side=side, order_type=order_type) - path = _render_order_path( - side=side, - order_type=order_type, - quantity=quantity, - base_currency=context.base_currency, - ) - - if not quantity: - path = _render_order_path( - side=side, - order_type=order_type, - base_currency=context.base_currency, - ) - - await state.set_state(NewOrderDraftStates.waiting_quantity) - await callback.message.edit_text( - _render_quantity_step_screen( - title=_screen_title(is_edit_mode), - symbol=context.symbol, - available_balance=context.available_balance, - balance_currency=context.balance_currency, - reference_price=context.reference_price, - quote_currency=context.quote_currency, - order_path=path, - ), - reply_markup=_quantity_keyboard( - context.quantity_presets, - drafts_page=drafts_page, - ), - ) - await callback.answer() - - -@router.callback_query(F.data == "order_manual_back:price") -async def go_back_from_manual_price( - callback: CallbackQuery, - state: FSMContext, -) -> None: - service = OrderDraftsService() - data = await state.get_data() - - side = data.get("side", "BUY") - order_type = data.get("order_type", "LIMIT") - quantity = data.get("quantity") - is_edit_mode = bool(data.get("draft_edit_id")) - draft_page = data.get("draft_edit_page") - drafts_page = int(draft_page) if draft_page else None - - context = service.get_entry_context(side=side, order_type=order_type) - path = _render_order_path( - side=side, - order_type=order_type, - quantity=quantity, - base_currency=context.base_currency, - ) - - await state.set_state(NewOrderDraftStates.waiting_price) - await callback.message.edit_text( - _render_price_step_screen( - title=_screen_title(is_edit_mode), - symbol=context.symbol, - bid=context.bid_price, - ask=context.ask_price, - last=context.last_price, - quote_currency=context.quote_currency, - order_path=path, - ), - reply_markup=_price_keyboard( - bid=context.bid_price, - ask=context.ask_price, - last=context.last_price, - drafts_page=drafts_page, - ), - ) - await callback.answer() \ No newline at end of file + exc=exc, + draft_page=drafts_page, + ) \ No newline at end of file diff --git a/app/src/telegram/handlers/trade/new_order_ui.py b/app/src/telegram/handlers/trade/new_order_ui.py index ae1ce00..0445cdc 100644 --- a/app/src/telegram/handlers/trade/new_order_ui.py +++ b/app/src/telegram/handlers/trade/new_order_ui.py @@ -12,6 +12,11 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder from src.telegram.handlers.trade.new_order_core import DRAFTS_PAGE_SIZE from src.telegram.ui.common import mode_line from src.trading.orders.service import OrderDraftsService +from src.integrations.exchange.exceptions import ( + ExchangeConnectionError, + ExchangeError, + ExchangeResponseError, +) def _clean_number(value: str | float | None, precision: int | None = None) -> str: @@ -32,26 +37,29 @@ def _clean_number(value: str | float | None, precision: int | None = None) -> st def _resolve_symbol_assets(symbol: str) -> tuple[str | None, str | None]: - service = OrderDraftsService() - validation = service.exchange.validate_symbol(symbol) + try: + service = OrderDraftsService() + validation = service.exchange.validate_symbol(symbol) + symbol_info = validation.symbol_info - symbol_info = validation.symbol_info - if symbol_info is None: + if symbol_info is None: + return None, None + + base_currency = ( + str(symbol_info.base_asset).upper() + if getattr(symbol_info, "base_asset", None) + else None + ) + quote_currency = ( + str(symbol_info.quote_asset).upper() + if getattr(symbol_info, "quote_asset", None) + else None + ) + + return base_currency, quote_currency + except Exception: return None, None - base_currency = ( - str(symbol_info.base_asset).upper() - if getattr(symbol_info, "base_asset", None) - else None - ) - quote_currency = ( - str(symbol_info.quote_asset).upper() - if getattr(symbol_info, "quote_asset", None) - else None - ) - - return base_currency, quote_currency - def _to_decimal(value: str | float | int | None) -> Decimal | None: if value is None: @@ -79,6 +87,52 @@ def _side_badge(side: str) -> str: return "🟢 BUY" if side.upper() == "BUY" else "🔴 SELL" +def _describe_exchange_error(exc: Exception) -> str: + text = str(exc).strip() + + if isinstance(exc, ExchangeResponseError) and ( + "-1021" in text or "doesn't match server time" in text + ): + return ( + "Не удалось получить данные биржи: время на устройстве " + "не синхронизировано со временем биржи. " + "Проверь системное время и повтори попытку." + ) + + if isinstance(exc, ExchangeConnectionError): + return ( + "Не удалось получить данные биржи: таймаут или ошибка сети. " + "Попробуй ещё раз через несколько секунд." + ) + + if isinstance(exc, ExchangeResponseError): + return ( + "Не удалось получить данные биржи: биржа вернула некорректный ответ. " + "Попробуй ещё раз через несколько секунд." + ) + + if isinstance(exc, ExchangeError): + return text or "Не удалось получить данные биржи." + + return text or "Не удалось получить данные биржи." + + +def _render_exchange_error( + *, + title: str, + exc: Exception, +) -> str: + lines = [ + title, + mode_line().rstrip(), + "", + "⚠️ Данные биржи временно недоступны", + "", + _describe_exchange_error(exc), + ] + return "\n".join(lines) + + # Оценивает минимально допустимое количество по правилу minNotional. def _estimate_min_quantity_by_notional( *, @@ -265,6 +319,27 @@ def _draft_detail_keyboard(draft_id: str, page: int) -> InlineKeyboardMarkup: return builder.as_markup() +def _exchange_error_keyboard( + *, + back_callback_data: str | None = None, + drafts_page: int | None = None, +) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + + # Кнопка "Назад" (если есть куда возвращаться) + if back_callback_data: + builder.button(text="⬅️ Назад", callback_data=back_callback_data) + + # Кнопка "К черновикам" (если мы в edit flow) + if drafts_page is not None: + builder.button(text="📚 К черновикам", callback_data=f"drafts:{drafts_page}") + else: + builder.button(text="🏠 К торговле", callback_data="trade:home") + + builder.adjust(2 if back_callback_data else 1) + return builder.as_markup() + + def _format_value_with_currency( value: str | float | None, currency: str | None, @@ -938,7 +1013,11 @@ def _render_order_card( quantity_text = _format_value_with_asset(quantity, base_currency) price_text = _format_value_with_currency(price, quote_currency) if price else None - notional_text = _format_value_with_currency(notional, quote_currency) if notional is not None else None + notional_text = ( + _format_value_with_currency(notional, quote_currency) + if notional is not None + else None + ) lines = [ f"{symbol}", diff --git a/app/src/telegram/ui/common.py b/app/src/telegram/ui/common.py index 9bb5d73..ebd7ca5 100644 --- a/app/src/telegram/ui/common.py +++ b/app/src/telegram/ui/common.py @@ -1,8 +1,26 @@ # app/src/telegram/ui/common.py +from __future__ import annotations + +from datetime import datetime +from zoneinfo import ZoneInfo + +from src.core.config import load_settings from src.core.system_status import get_runtime_mode_label def mode_line() -> str: label = get_runtime_mode_label() - return f"🔸 {label}\n\n" \ No newline at end of file + return f"🔸 {label}\n\n" + + +def now_line() -> str: + settings = load_settings() + tz_name = settings.tz or "UTC" + + try: + local_dt = datetime.now(ZoneInfo(tz_name)) + except Exception: + local_dt = datetime.utcnow() + + return f"Обновлено: {local_dt.strftime('%H:%M:%S')}" \ No newline at end of file diff --git a/app/src/telegram/ui/currency_ui.py b/app/src/telegram/ui/currency_ui.py new file mode 100644 index 0000000..76fef05 --- /dev/null +++ b/app/src/telegram/ui/currency_ui.py @@ -0,0 +1,189 @@ +# app/src/telegram/ui/currency_ui.py + +from __future__ import annotations + +from src.integrations.exchange.exceptions import ExchangeError +from src.integrations.exchange.models import BalanceSummary, ExchangeSymbol +from src.integrations.exchange.service import ExchangeService + + +FIAT_CURRENCIES = {"USD", "USDT", "EUR", "RUB", "BYN"} + +CURRENCY_ICONS = { + "USD": "$", + "USDT": "$", + "EUR": "€", + "RUB": "₽", + "BYN": "Br", + "BTC": "₿", + "ETH": "Ξ", + "LTC": "LTC", + "BNB": "BNB", + "SOL": "SOL", + "ADA": "ADA", + "XRP": "XRP", + "DOGE": "DOGE", +} + + +def is_fiat_currency(currency: str) -> bool: + return currency.upper() in FIAT_CURRENCIES + + +def get_currency_icon(currency: str) -> str: + return CURRENCY_ICONS.get(currency.upper(), currency.upper()) + + +def get_currency_label(currency: str) -> str: + return f"{get_currency_icon(currency)} {currency.upper()}" + + +def render_currency_title(currency: str) -> str: + return get_currency_label(currency) + + +def format_amount(currency: str, value: float) -> str: + if is_fiat_currency(currency): + return f"{value:,.2f}".replace(",", " ") + return f"{value:,.8f}".replace(",", " ") + + +def format_usd_amount(value: float) -> str: + return f"{value:,.2f}".replace(",", " ") + + +def render_currency_line( + *, + currency: str, + value: float, + show_code: bool = True, +) -> str: + icon = get_currency_icon(currency) + amount = format_amount(currency, value) + + if show_code: + return f"{icon} {currency.upper()} · {amount}" + + return f"{icon} {amount}" + + +def balance_total(item: BalanceSummary) -> float: + return item.available + item.locked + + +def is_zero_balance(item: BalanceSummary) -> bool: + return abs(item.available) < 1e-12 and abs(item.locked) < 1e-12 + + +def _quote_priority(quote_asset: str) -> int: + value = (quote_asset or "").upper() + if value == "USD": + return 3 + if value == "USDT": + return 2 + return 0 + + +def _status_priority(status: str) -> int: + value = (status or "").upper() + if value == "TRADING": + return 2 + if value in {"HALT", "BREAK"}: + return 0 + return 1 + + +def _market_type_priority(market_type: str) -> int: + value = (market_type or "").upper() + if value == "SPOT": + return 3 + if value == "LEVERAGE": + return 2 + return 1 + + +def _symbol_priority(symbol_info: ExchangeSymbol) -> tuple[int, int, int, str]: + return ( + _quote_priority(symbol_info.quote_asset), + _status_priority(symbol_info.status), + _market_type_priority(symbol_info.market_type), + symbol_info.symbol.upper(), + ) + + +def _resolve_asset_quote_symbol( + exchange_service: ExchangeService, + asset: str, +) -> ExchangeSymbol | None: + asset_upper = asset.upper() + + try: + symbols = exchange_service.get_exchange_symbols() + except ExchangeError: + return None + + candidates: list[ExchangeSymbol] = [] + + for symbol_info in symbols: + base_asset = (symbol_info.base_asset or "").upper() + quote_asset = (symbol_info.quote_asset or "").upper() + + if base_asset != asset_upper: + continue + + if quote_asset not in {"USD", "USDT"}: + continue + + candidates.append(symbol_info) + + if not candidates: + return None + + candidates.sort(key=_symbol_priority, reverse=True) + return candidates[0] + + +def get_asset_usd_rate( + exchange_service: ExchangeService, + currency: str, + price_cache: dict[str, float | None], +) -> float | None: + asset = currency.upper() + + if asset in {"USD", "USDT"}: + return 1.0 + + if asset in price_cache: + return price_cache[asset] + + symbol_info = _resolve_asset_quote_symbol(exchange_service, asset) + if symbol_info is None: + price_cache[asset] = None + return None + + try: + ticker = exchange_service.get_price(symbol_info.symbol) + rate = float(ticker.price) + + # Пока считаем USDT ~= USD + price_cache[asset] = rate + return rate + except ExchangeError: + price_cache[asset] = None + return None + + +def estimate_balance_usd( + item: BalanceSummary, + exchange_service: ExchangeService, + price_cache: dict[str, float | None], +) -> float | None: + total = balance_total(item) + if total <= 0: + return None + + rate = get_asset_usd_rate(exchange_service, item.currency, price_cache) + if rate is None: + return None + + return total * rate \ No newline at end of file diff --git a/app/src/telegram/ui/exchange_error.py b/app/src/telegram/ui/exchange_error.py new file mode 100644 index 0000000..47925e5 --- /dev/null +++ b/app/src/telegram/ui/exchange_error.py @@ -0,0 +1,247 @@ +# app/src/telegram/ui/exchange_error.py + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from zoneinfo import ZoneInfo + +from aiogram.exceptions import TelegramBadRequest +from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message + +from src.core.config import load_settings +from src.telegram.ui.common import mode_line, now_line + + +@dataclass(slots=True) +class ExchangeErrorView: + headline: str + details: str + + +def _classify_exchange_error(exc: Exception) -> str: + text = str(exc).lower() + + network_markers = [ + "nodename nor servname", + "name or service not known", + "network error", + "connection error", + "timed out", + "timeout", + ] + if any(marker in text for marker in network_markers): + return "network" + + time_markers = [ + "-1021", + "doesn't match server time", + ] + if any(marker in text for marker in time_markers): + return "time" + + auth_markers = [ + "invalid api key", + "api key", + "api-key", + "signature", + "expired", + "forbidden", + "unauthorized", + "private api error", + ] + if any(marker in text for marker in auth_markers): + return "auth" + + return "generic" + + +def _build_exchange_error_view( + *, + exc: Exception, + network_details: str, + auth_details: str, + time_details: str | None = None, + generic_details: str | None = None, +) -> ExchangeErrorView: + error_type = _classify_exchange_error(exc) + + if error_type == "network": + return ExchangeErrorView( + headline="🔴 Биржа недоступна", + details=network_details, + ) + + if error_type == "auth": + return ExchangeErrorView( + headline="🔴 Ошибка доступа к аккаунту", + details=auth_details, + ) + + if error_type == "time": + return ExchangeErrorView( + headline="🔴 Ошибка времени", + details=( + time_details + or "Не удалось выполнить запрос к бирже.\nОбнови экран." + ), + ) + + return ExchangeErrorView( + headline="🔴 Ошибка биржи", + details=( + generic_details + or "Не удалось получить данные с биржи.\nОбнови экран." + ), + ) + + +def render_exchange_error( + *, + title: str, + exc: Exception, + network_details: str, + auth_details: str, + time_details: str | None = None, + generic_details: str | None = None, +) -> str: + view = _build_exchange_error_view( + exc=exc, + network_details=network_details, + auth_details=auth_details, + time_details=time_details, + generic_details=generic_details, + ) + + return ( + f"{title}\n" + f"{mode_line()}" + f"{view.headline}\n\n" + f"{view.details}\n\n" + f"{now_line()}" + ) + + +def exchange_error_keyboard( + *, + retry_callback_data: str | None = None, + back_callback_data: str | None = None, + drafts_page: int | None = None, +) -> InlineKeyboardMarkup: + buttons: list[list[InlineKeyboardButton]] = [] + + first_row: list[InlineKeyboardButton] = [] + + if retry_callback_data: + first_row.append( + InlineKeyboardButton( + text="🔁 Обновить", + callback_data=retry_callback_data, + ) + ) + + if back_callback_data: + first_row.append( + InlineKeyboardButton( + text="⬅️ Назад", + callback_data=back_callback_data, + ) + ) + + if first_row: + buttons.append(first_row) + + if drafts_page is not None: + buttons.append( + [ + InlineKeyboardButton( + text="📚 К черновикам", + callback_data=f"drafts:{drafts_page}", + ) + ] + ) + else: + buttons.append( + [ + InlineKeyboardButton( + text="🏠 К торговле", + callback_data="trade:home", + ) + ] + ) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +async def show_callback_exchange_error( + callback: CallbackQuery, + *, + title: str, + exc: Exception, + network_details: str, + auth_details: str, + time_details: str | None = None, + generic_details: str | None = None, + retry_callback_data: str | None = None, + back_callback_data: str | None = None, + drafts_page: int | None = None, +) -> None: + if callback.message is None: + await callback.answer("Сообщение не найдено", show_alert=True) + return + + text = render_exchange_error( + title=title, + exc=exc, + network_details=network_details, + auth_details=auth_details, + time_details=time_details, + generic_details=generic_details, + ) + markup = exchange_error_keyboard( + retry_callback_data=retry_callback_data or callback.data, + back_callback_data=back_callback_data, + drafts_page=drafts_page, + ) + + try: + await callback.message.edit_text( + text, + reply_markup=markup, + ) + await callback.answer() + except TelegramBadRequest as tg_exc: + if "message is not modified" in str(tg_exc).lower(): + await callback.answer("Ошибка всё ещё актуальна") + return + raise + + +async def show_message_exchange_error( + message: Message, + *, + title: str, + exc: Exception, + network_details: str, + auth_details: str, + time_details: str | None = None, + generic_details: str | None = None, + retry_callback_data: str | None = None, + back_callback_data: str | None = None, + drafts_page: int | None = None, +) -> None: + await message.answer( + render_exchange_error( + title=title, + exc=exc, + network_details=network_details, + auth_details=auth_details, + time_details=time_details, + generic_details=generic_details, + ), + reply_markup=exchange_error_keyboard( + retry_callback_data=retry_callback_data, + back_callback_data=back_callback_data, + drafts_page=drafts_page, + ), + ) \ No newline at end of file diff --git a/docs/stages/stage-05_9-trading_ui_milestone_notes.md b/docs/stages/stage-05_9-trading_ui_milestone_notes.md new file mode 100644 index 0000000..59178b4 --- /dev/null +++ b/docs/stages/stage-05_9-trading_ui_milestone_notes.md @@ -0,0 +1,173 @@ +# Trading Bot UI/UX Stabilization Milestone + +## Что сделано + +### 1. Унификация экранов + +Приведены к единому UI-стандарту экраны: + +- 📈 Рынок +- 💼 Портфель +- ⚙️ Система + +Общий стиль: + +- заголовок экрана +- строка режима аккаунта (`🔸 ДЕМО аккаунт` / `🔸 РЕАЛЬНЫЙ аккаунт`) +- единый стиль кнопок +- единый стиль ошибок +- единый стиль времени обновления + +--- + +### 2. Экран Рынок + +Улучшения: + +- перевод строк на русский язык +- убрана техническая информация +- скрыта строка `Обновлено` в нормальном режиме +- добавлены понятные статусы + +Показывает: + +- Пара +- Цена +- Статус +- Тип инструмента +- Базовый актив +- Валюта котировки +- Шаг цены + +--- + +### 3. Экран Портфель + +Улучшения: + +- компактный UI +- сортировка активов +- скрытие нулевых балансов +- общая оценка портфеля в USD +- оценка каждого актива в USD +- partial degradation state (`🟡`) + +Состояния: + +- нормальное +- частично загружено +- ошибка + +--- + +### 4. Экран Система + +Полностью переработан: + +Показывает: + +- статус приложения +- статус БД +- статус Telegram +- статус биржи +- статус аккаунта +- статус журнала + +Состояния: + +- 🟢 OK +- 🟡 warning +- 🔴 error + +При авариях: + +- добавляется описание под компонентом +- появляется кнопка обновления +- появляется строка времени обновления + +--- + +### 5. Exchange Error UI + +Создан единый renderer ошибок: + +Типы ошибок: + +- network +- auth +- time +- generic + +Примеры: + +- 🔴 Нет связи с биржей +- 🔴 Ошибка доступа к аккаунту +- 🔴 Ошибка времени + +--- + +### 6. currency\_ui.py + +Создан единый модуль: + +Функции: + +- format\_amount() +- format\_usd\_amount() +- estimate\_balance\_usd() +- render\_currency\_line() +- get\_asset\_usd\_rate() + +Добавлено: + +- иконки валют +- форматирование +- оценка активов + +--- + +### 7. Автоподбор символа для оценки + +Реализован умный подбор инструмента: + +Приоритет: + +1. USD +2. USDT +3. TRADING +4. SPOT +5. LEVERAGE + +Пример: BTC/USD → BTC/USDT → BTC/USD\_LEVERAGE + +--- + +### 8. UX улучшения + +Добавлено: + +- динамическая кнопка обновления +- now\_line() +- mode\_line() + +--- + +## Commit + +Рекомендуемый commit: + +```bash +git add . +git commit -m "feat: unify market/portfolio/system UI, improve exchange errors and asset valuation" +``` + +--- + +## Следующие этапы + +1. Compact mode portfolio +2. Loading-state UI +3. О продукте +4. Auto refresh +5. Cache exchange symbols +