feat: unify market/portfolio/system UI, improve exchange errors and asset valuation

This commit is contained in:
2026-04-22 18:21:34 +03:00
parent 2a9ef16524
commit 1fb72ced58
13 changed files with 2034 additions and 822 deletions

View File

@@ -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} <b>{component.name}</b>"
line = f"{component.state} {component.name}"
return f"{component.state} <b>{component.name}</b>\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 (
"<b>⚙️ Система</b>\n\n"
f"{components_block}\n\n"
"<b>🌐 Окружение</b>\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}"
)
text = (
"<b>⚙️ Система</b>\n"
f"🔸 <b>{snapshot.mode_label}</b>\n"
f"⏱️ {snapshot.timezone_name}\n\n"
f"{components_block}"
)
if include_updated_at:
text += f"\n\n<i>Обновлено: {_now_hhmmss()}</i>"
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 "Не удалось получить данные с биржи"

View File

@@ -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 "Временная ошибка получения данных биржи. Попробуй ещё раз через несколько секунд."

View File

@@ -1,3 +1,5 @@
# app/src/integrations/exchange/symbol_utils.py
from __future__ import annotations

View File

@@ -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 (
"<b>📈 Рынок</b>\n"
f"{mode_line()}"
f"Пара: <b>{name}</b>\n"
f"Цена: <b>{ticker_price:.2f} {quote_asset}</b>\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(
"<b>📈 Рынок</b>\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(
"<b>📈 Рынок</b>\n\n"
"Не удалось получить цену с биржи.\n"
f"Ошибка: {exc}"
text = (
"<b>📈 Рынок</b>\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 = (
"<b>📈 Рынок</b>\n\n"
f"Символ: <b>{ticker.symbol}</b>\n"
f"Название: {name}\n"
f"Цена: <b>{ticker.price:.2f}</b>\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)
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="<b>📈 Рынок</b>",
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="<b>📈 Рынок</b>",
exc=exc,
network_details="Рыночные данные недоступны.\nОбнови экран.",
auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.",
retry_callback_data="market:retry",
)

View File

@@ -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"<b>{get_currency_label(item.currency)}</b>",
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(
"<b>💼 Портфель</b>\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(
"<b>💼 Портфель</b>\n\n"
"Баланс пуст."
text = (
"<b>💼 Портфель</b>\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(
"<b>💼 Портфель</b>\n\n"
"Все балансы нулевые."
text = (
"<b>💼 Портфель</b>\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] = ["<b>💼 Портфель</b>", "", "<b>Баланс аккаунта</b>", ""]
lines: list[str] = [
"<b>💼 Портфель</b>",
mode_line().rstrip(),
"",
f"<b>БАЛАНС · АКТИВЫ · {len(visible_balances)}</b>",
]
if major_balances:
lines.append("<b>Основные активы</b>")
lines.append("")
for item in major_balances:
lines.extend(render_balance_block(item))
asset_blocks: list[list[str]] = []
if other_balances:
lines.append("<b>Прочие активы</b>")
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(
[
"<b>Итого</b>",
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"<b>{currency}</b>",
f"• доступно: {format_amount(currency, item.available)}",
f"• заблокировано: {format_amount(currency, item.locked)}",
f"• всего: {format_amount(currency, total)}",
]
)
if estimated_usd is not None and currency not in {"USD", "USDT"}:
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(
[
"🟡 <b>Данные загружены частично</b>",
]
)
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)
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="<b>💼 Портфель</b>",
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="<b>💼 Портфель</b>",
exc=exc,
network_details="Не загружен баланс аккаунта.\nОбнови экран.",
auth_details="Не загружен баланс аккаунта.\nПроверь API ключи.",
retry_callback_data="portfolio:retry",
)

View File

@@ -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())
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()

File diff suppressed because it is too large Load Diff

View File

@@ -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 = (
"<b>📊 Торговля — Новый ордер</b>\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 = (
"<b>📊 Торговля — Новый ордер</b>\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="<b>📊 Торговля — Новый ордер</b>",
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 = (
"<b>📊 Торговля — Новый ордер</b>\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 = (
"<b>📊 Торговля — Новый ордер</b>\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="<b>📊 Торговля — Новый ордер</b>",
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()
exc=exc,
draft_page=drafts_page,
)

View File

@@ -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 "🟢 <b>BUY</b>" if side.upper() == "BUY" else "🔴 <b>SELL</b>"
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(),
"",
"<b>⚠️ Данные биржи временно недоступны</b>",
"",
_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"<b>{symbol}</b>",

View File

@@ -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"🔸 <b>{label}</b>\n\n"
return f"🔸 <b>{label}</b>\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"<i>Обновлено: {local_dt.strftime('%H:%M:%S')}</i>"

View File

@@ -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

View File

@@ -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,
),
)

View File

@@ -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