feat: unify market/portfolio/system UI, improve exchange errors and asset valuation
This commit is contained in:
@@ -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 "Не удалось получить данные с биржи"
|
||||
@@ -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 "Временная ошибка получения данных биржи. Попробуй ещё раз через несколько секунд."
|
||||
@@ -1,3 +1,5 @@
|
||||
# app/src/integrations/exchange/symbol_utils.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
|
||||
@@ -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,8 +121,8 @@ 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,
|
||||
@@ -86,39 +134,25 @@ async def open_market(message: Message, state: FSMContext) -> None:
|
||||
"symbol": requested_symbol,
|
||||
},
|
||||
)
|
||||
await message.answer(
|
||||
"<b>📈 Рынок</b>\n\n"
|
||||
f"Ошибка инструмента: {validation.message}"
|
||||
|
||||
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)
|
||||
except ExchangeError as exc:
|
||||
_safe_log_error(
|
||||
journal,
|
||||
"market_open_error",
|
||||
f"Не удалось открыть экран рынка: {exc}",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"chat_id": chat_id,
|
||||
"symbol": requested_symbol,
|
||||
},
|
||||
)
|
||||
await message.answer(
|
||||
"<b>📈 Рынок</b>\n\n"
|
||||
"Не удалось получить цену с биржи.\n"
|
||||
f"Ошибка: {exc}"
|
||||
)
|
||||
return
|
||||
|
||||
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",
|
||||
)
|
||||
@@ -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
|
||||
|
||||
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,35 +157,77 @@ 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)
|
||||
|
||||
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>",
|
||||
f"• активов с ненулевым балансом: {len(visible_balances)}",
|
||||
"🟡 <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,
|
||||
"portfolio_open_success",
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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,6 +123,7 @@ async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
price=price,
|
||||
)
|
||||
|
||||
try:
|
||||
title = _screen_title(is_edit_mode=True)
|
||||
context = service.get_entry_context(side=side, order_type=order_type)
|
||||
|
||||
@@ -149,6 +151,16 @@ async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
),
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,6 +189,8 @@ async def start_new_order_draft(
|
||||
await state.set_state(NewOrderDraftStates.waiting_side)
|
||||
|
||||
service = OrderDraftsService()
|
||||
|
||||
try:
|
||||
context = service.get_entry_context(side="BUY", order_type="MARKET")
|
||||
|
||||
text = (
|
||||
@@ -192,9 +204,15 @@ async def start_new_order_draft(
|
||||
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="<b>📊 Торговля — Новый ордер</b>",
|
||||
exc=exc,
|
||||
retry_callback_data="trade:new_order_retry",
|
||||
)
|
||||
|
||||
|
||||
# Обрабатывает выбор стороны ордера.
|
||||
@router.callback_query(
|
||||
NewOrderDraftStates.waiting_side,
|
||||
F.data.startswith("order_side:"),
|
||||
@@ -209,6 +227,8 @@ async def process_order_side_callback(
|
||||
|
||||
path = _render_order_path(side=side)
|
||||
service = OrderDraftsService()
|
||||
|
||||
try:
|
||||
context = service.get_entry_context(side=side, order_type="MARKET")
|
||||
|
||||
text = (
|
||||
@@ -221,6 +241,13 @@ async def process_order_side_callback(
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=_type_keyboard())
|
||||
await callback.answer()
|
||||
except ExchangeError as exc:
|
||||
await show_callback_exchange_error(
|
||||
callback,
|
||||
title="<b>📊 Торговля — Новый ордер</b>",
|
||||
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,6 +281,7 @@ async def process_order_type_callback(
|
||||
await state.update_data(order_type=order_type)
|
||||
await state.set_state(NewOrderDraftStates.waiting_quantity)
|
||||
|
||||
try:
|
||||
context = service.get_entry_context(side=side, order_type=order_type)
|
||||
|
||||
path = _render_order_path(
|
||||
@@ -279,6 +306,14 @@ async def process_order_type_callback(
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
except ExchangeError as exc:
|
||||
await show_callback_exchange_error(
|
||||
callback,
|
||||
title=_screen_title(is_edit_mode),
|
||||
exc=exc,
|
||||
retry_callback_data=callback.data,
|
||||
drafts_page=drafts_page,
|
||||
)
|
||||
|
||||
|
||||
@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,6 +347,7 @@ async def process_quantity_callback(
|
||||
side = data.get("side", "BUY")
|
||||
order_type = data.get("order_type", "MARKET")
|
||||
|
||||
try:
|
||||
context = service.get_entry_context(side=side, order_type=order_type)
|
||||
|
||||
if value == "manual":
|
||||
@@ -422,9 +457,16 @@ async def process_quantity_callback(
|
||||
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,6 +483,7 @@ 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
|
||||
|
||||
try:
|
||||
quantity = service.normalize_entry_quantity(
|
||||
side=side,
|
||||
order_type=order_type,
|
||||
@@ -571,9 +614,15 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None:
|
||||
),
|
||||
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.callback_query(
|
||||
NewOrderDraftStates.waiting_price,
|
||||
F.data.startswith("order_price:"),
|
||||
@@ -591,6 +640,7 @@ async def process_price_callback(
|
||||
draft_page = data.get("draft_edit_page")
|
||||
drafts_page = int(draft_page) if draft_page else None
|
||||
|
||||
try:
|
||||
context = service.get_entry_context(
|
||||
side=data.get("side", "BUY"),
|
||||
order_type=data.get("order_type", "LIMIT"),
|
||||
@@ -665,9 +715,16 @@ async def process_price_callback(
|
||||
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,6 +740,7 @@ 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
|
||||
|
||||
try:
|
||||
rules = service.get_entry_rules()
|
||||
context = service.get_entry_context(
|
||||
side=data.get("side", "BUY"),
|
||||
@@ -773,6 +831,13 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
|
||||
),
|
||||
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="<b>📊 Торговля — Подтверждение черновика</b>",
|
||||
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()
|
||||
|
||||
@@ -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,9 +52,44 @@ 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()
|
||||
|
||||
try:
|
||||
context = service.get_entry_context(side="BUY", order_type="MARKET")
|
||||
|
||||
await state.set_state(NewOrderDraftStates.waiting_side)
|
||||
@@ -64,11 +101,16 @@ async def go_back_to_side(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
)
|
||||
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,6 +126,8 @@ async def go_back_to_type(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
|
||||
service = OrderDraftsService()
|
||||
side = data.get("side", "BUY")
|
||||
|
||||
try:
|
||||
context = service.get_entry_context(side=side, order_type="MARKET")
|
||||
path = _render_order_path(side=side)
|
||||
|
||||
@@ -97,14 +141,16 @@ async def go_back_to_type(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
)
|
||||
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,6 +161,7 @@ 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
|
||||
|
||||
try:
|
||||
context = service.get_entry_context(side=side, order_type=order_type)
|
||||
|
||||
path = _render_order_path(
|
||||
@@ -148,6 +195,13 @@ async def go_back_to_quantity(callback: CallbackQuery, state: FSMContext) -> Non
|
||||
),
|
||||
)
|
||||
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_back:confirm")
|
||||
@@ -173,6 +227,7 @@ 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
|
||||
|
||||
try:
|
||||
if order_type == "LIMIT":
|
||||
context = service.get_entry_context(side=side, order_type=order_type)
|
||||
path = _render_order_path(
|
||||
@@ -228,6 +283,13 @@ async def go_back_from_confirm(callback: CallbackQuery, state: FSMContext) -> No
|
||||
),
|
||||
)
|
||||
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")
|
||||
@@ -245,6 +307,7 @@ async def go_back_from_manual_quantity(
|
||||
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,
|
||||
@@ -277,6 +340,13 @@ async def go_back_from_manual_quantity(
|
||||
),
|
||||
)
|
||||
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")
|
||||
@@ -294,6 +364,7 @@ async def go_back_from_manual_price(
|
||||
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,
|
||||
@@ -321,3 +392,10 @@ async def go_back_from_manual_price(
|
||||
),
|
||||
)
|
||||
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,
|
||||
)
|
||||
@@ -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,10 +37,11 @@ def _clean_number(value: str | float | None, precision: int | None = None) -> st
|
||||
|
||||
|
||||
def _resolve_symbol_assets(symbol: str) -> tuple[str | None, str | None]:
|
||||
try:
|
||||
service = OrderDraftsService()
|
||||
validation = service.exchange.validate_symbol(symbol)
|
||||
|
||||
symbol_info = validation.symbol_info
|
||||
|
||||
if symbol_info is None:
|
||||
return None, None
|
||||
|
||||
@@ -51,6 +57,8 @@ def _resolve_symbol_assets(symbol: str) -> tuple[str | None, str | None]:
|
||||
)
|
||||
|
||||
return base_currency, quote_currency
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
def _to_decimal(value: str | float | int | None) -> Decimal | 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>",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
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>"
|
||||
189
app/src/telegram/ui/currency_ui.py
Normal file
189
app/src/telegram/ui/currency_ui.py
Normal 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
|
||||
247
app/src/telegram/ui/exchange_error.py
Normal file
247
app/src/telegram/ui/exchange_error.py
Normal 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,
|
||||
),
|
||||
)
|
||||
173
docs/stages/stage-05_9-trading_ui_milestone_notes.md
Normal file
173
docs/stages/stage-05_9-trading_ui_milestone_notes.md
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user