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
+