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
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from src.core.config import load_settings
|
from src.core.config import load_settings
|
||||||
from src.core.constants import APP_NAME, APP_VERSION
|
from src.core.constants import APP_NAME, APP_VERSION
|
||||||
@@ -40,7 +44,8 @@ def _extract_postgres_version(raw: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _build_exchange_status(
|
def _build_exchange_status(
|
||||||
exchange_service: ExchangeService, default_symbol: str
|
exchange_service: ExchangeService,
|
||||||
|
default_symbol: str,
|
||||||
) -> ComponentStatus:
|
) -> ComponentStatus:
|
||||||
try:
|
try:
|
||||||
symbol_validation = exchange_service.validate_symbol(default_symbol)
|
symbol_validation = exchange_service.validate_symbol(default_symbol)
|
||||||
@@ -48,7 +53,7 @@ def _build_exchange_status(
|
|||||||
return ComponentStatus(
|
return ComponentStatus(
|
||||||
name="Биржа",
|
name="Биржа",
|
||||||
state="🔴",
|
state="🔴",
|
||||||
details=f"Не удалось проверить инструмент: {exc}",
|
details=_humanize_error_message(str(exc)),
|
||||||
)
|
)
|
||||||
|
|
||||||
exchange_health = exchange_service.get_health()
|
exchange_health = exchange_service.get_health()
|
||||||
@@ -60,7 +65,7 @@ def _build_exchange_status(
|
|||||||
return ComponentStatus(
|
return ComponentStatus(
|
||||||
name="Биржа",
|
name="Биржа",
|
||||||
state="🔴",
|
state="🔴",
|
||||||
details=exchange_health.message or "Ошибка подключения к API биржи.",
|
details=_humanize_error_message(exchange_health.message or ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
return ComponentStatus(
|
return ComponentStatus(
|
||||||
@@ -78,7 +83,7 @@ def _build_account_status(exchange_service: ExchangeService) -> ComponentStatus:
|
|||||||
return ComponentStatus(
|
return ComponentStatus(
|
||||||
name="Аккаунт",
|
name="Аккаунт",
|
||||||
state="🔴",
|
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:
|
def _render_component(component: ComponentStatus) -> str:
|
||||||
if component.state == "🟢":
|
line = f"{component.state} {component.name}"
|
||||||
return f"{component.state} <b>{component.name}</b>"
|
|
||||||
|
|
||||||
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()
|
snapshot = get_system_snapshot()
|
||||||
components_block = "\n".join(
|
components_block = "\n".join(
|
||||||
_render_component(component) for component in snapshot.components
|
_render_component(component) for component in snapshot.components
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
text = (
|
||||||
"<b>⚙️ Система</b>\n\n"
|
"<b>⚙️ Система</b>\n"
|
||||||
f"{components_block}\n\n"
|
f"🔸 <b>{snapshot.mode_label}</b>\n"
|
||||||
"<b>🌐 Окружение</b>\n"
|
f"⏱️ {snapshot.timezone_name}\n\n"
|
||||||
f"• приложение: {snapshot.app_name} {snapshot.app_version}\n"
|
f"{components_block}"
|
||||||
f"• база данных: {snapshot.db_label}\n"
|
)
|
||||||
f"• часовой пояс: {snapshot.timezone_name}\n"
|
|
||||||
f"• режим: {snapshot.mode_label}\n"
|
if include_updated_at:
|
||||||
f"• инструмент: {snapshot.default_symbol}"
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
@@ -11,3 +13,41 @@ class ExchangeConnectionError(ExchangeError):
|
|||||||
|
|
||||||
class ExchangeResponseError(ExchangeError):
|
class ExchangeResponseError(ExchangeError):
|
||||||
"""Unexpected HTTP response or malformed JSON."""
|
"""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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from aiogram import F, Router
|
from aiogram import F, Router
|
||||||
from aiogram.fsm.context import FSMContext
|
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.exceptions import ExchangeError
|
||||||
from src.integrations.exchange.service import ExchangeService
|
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
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
|
|
||||||
@@ -50,16 +56,58 @@ def _safe_log_error(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.text == "📈 Рынок")
|
def _market_keyboard() -> InlineKeyboardMarkup:
|
||||||
async def open_market(message: Message, state: FSMContext) -> None:
|
builder = InlineKeyboardBuilder()
|
||||||
# Глобальный экран: всегда выходим из текущего FSM-сценария.
|
builder.button(text="🏠 К торговле", callback_data="trade:home")
|
||||||
await state.clear()
|
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()
|
service = ExchangeService()
|
||||||
journal = JournalService()
|
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
|
requested_symbol = service.settings.default_symbol
|
||||||
|
|
||||||
_safe_log_info(
|
_safe_log_info(
|
||||||
@@ -73,52 +121,38 @@ async def open_market(message: Message, state: FSMContext) -> None:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
validation = service.validate_symbol(requested_symbol)
|
||||||
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
|
|
||||||
|
|
||||||
ticker = service.get_price(validation.normalized_symbol)
|
if not validation.is_valid:
|
||||||
except ExchangeError as exc:
|
_safe_log_warning(
|
||||||
_safe_log_error(
|
|
||||||
journal,
|
journal,
|
||||||
"market_open_error",
|
"market_symbol_invalid",
|
||||||
f"Не удалось открыть экран рынка: {exc}",
|
f"Символ не прошел проверку: {validation.message}",
|
||||||
{
|
{
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"chat_id": chat_id,
|
"chat_id": chat_id,
|
||||||
"symbol": requested_symbol,
|
"symbol": requested_symbol,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await message.answer(
|
|
||||||
"<b>📈 Рынок</b>\n\n"
|
text = (
|
||||||
"Не удалось получить цену с биржи.\n"
|
"<b>📈 Рынок</b>\n"
|
||||||
f"Ошибка: {exc}"
|
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
|
return
|
||||||
|
|
||||||
|
ticker = service.get_price(validation.normalized_symbol)
|
||||||
|
|
||||||
symbol_info = validation.symbol_info
|
symbol_info = validation.symbol_info
|
||||||
symbol_status = symbol_info.status if symbol_info else "n/a"
|
symbol_status = symbol_info.status if symbol_info else "n/a"
|
||||||
market_type = symbol_info.market_type if symbol_info else "n/a"
|
market_type = symbol_info.market_type if symbol_info else "n/a"
|
||||||
market_modes = (
|
|
||||||
", ".join(symbol_info.market_modes)
|
|
||||||
if symbol_info and symbol_info.market_modes
|
|
||||||
else "n/a"
|
|
||||||
)
|
|
||||||
tick_size = (
|
tick_size = (
|
||||||
f"{symbol_info.tick_size}"
|
f"{symbol_info.tick_size}"
|
||||||
if symbol_info and symbol_info.tick_size is not None
|
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"
|
quote_asset = symbol_info.quote_asset if symbol_info and symbol_info.quote_asset else "n/a"
|
||||||
name = symbol_info.name if symbol_info and symbol_info.name else ticker.symbol
|
name = symbol_info.name if symbol_info and symbol_info.name else ticker.symbol
|
||||||
|
|
||||||
text = (
|
text = _build_market_text(
|
||||||
"<b>📈 Рынок</b>\n\n"
|
ticker_price=ticker.price,
|
||||||
f"Символ: <b>{ticker.symbol}</b>\n"
|
name=name,
|
||||||
f"Название: {name}\n"
|
symbol_status=symbol_status,
|
||||||
f"Цена: <b>{ticker.price:.2f}</b>\n"
|
market_type=market_type,
|
||||||
f"Статус: {symbol_status}\n"
|
base_asset=base_asset,
|
||||||
f"Тип рынка: {market_type}\n"
|
quote_asset=quote_asset,
|
||||||
f"Режимы: {market_modes}\n"
|
tick_size=tick_size,
|
||||||
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}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_safe_log_info(
|
_safe_log_info(
|
||||||
@@ -152,9 +181,79 @@ async def open_market(message: Message, state: FSMContext) -> None:
|
|||||||
"chat_id": chat_id,
|
"chat_id": chat_id,
|
||||||
"symbol": ticker.symbol,
|
"symbol": ticker.symbol,
|
||||||
"price": ticker.price,
|
"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 import F, Router
|
||||||
from aiogram.fsm.context import FSMContext
|
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.exceptions import ExchangeError
|
||||||
from src.integrations.exchange.models import BalanceSummary
|
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.accounts.service import AccountsService
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
@@ -15,23 +29,6 @@ from src.trading.journal.service import JournalService
|
|||||||
router = Router(name="portfolio")
|
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 = {
|
PINNED_ORDER = {
|
||||||
"USD": 1,
|
"USD": 1,
|
||||||
"USDT": 2,
|
"USDT": 2,
|
||||||
@@ -40,55 +37,19 @@ PINNED_ORDER = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def format_amount(currency: str, value: float) -> str:
|
def _portfolio_keyboard() -> InlineKeyboardMarkup:
|
||||||
if currency.upper() in FIAT_CURRENCIES:
|
builder = InlineKeyboardBuilder()
|
||||||
return f"{value:,.2f}".replace(",", " ")
|
builder.button(text="🏠 К торговле", callback_data="trade:home")
|
||||||
return f"{value:,.8f}".replace(",", " ")
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def get_currency_label(currency: str) -> str:
|
def _portfolio_warning_keyboard() -> InlineKeyboardMarkup:
|
||||||
icon = CURRENCY_ICONS.get(currency.upper(), "💰")
|
builder = InlineKeyboardBuilder()
|
||||||
return f"{icon} {currency.upper()}"
|
builder.button(text="🔁 Обновить", callback_data="portfolio:retry")
|
||||||
|
builder.button(text="🏠 К торговле", callback_data="trade:home")
|
||||||
|
builder.adjust(1, 1)
|
||||||
def is_zero_balance(item: BalanceSummary) -> bool:
|
return builder.as_markup()
|
||||||
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 _safe_log_info(
|
def _safe_log_info(
|
||||||
@@ -127,17 +88,26 @@ def _safe_log_error(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.text == "💼 Портфель")
|
def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
|
||||||
async def open_portfolio(message: Message, state: FSMContext) -> None:
|
def sort_key(item: BalanceSummary) -> tuple[int, str]:
|
||||||
# Глобальный экран: всегда выходим из текущего FSM-сценария.
|
currency = item.currency.upper()
|
||||||
await state.clear()
|
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()
|
service = AccountsService()
|
||||||
|
exchange_service = ExchangeService()
|
||||||
journal = JournalService()
|
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(
|
_safe_log_info(
|
||||||
journal,
|
journal,
|
||||||
"user_open_portfolio",
|
"user_open_portfolio",
|
||||||
@@ -148,24 +118,7 @@ async def open_portfolio(message: Message, state: FSMContext) -> None:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
balances = service.get_live_balance_summary()
|
||||||
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:
|
if not balances:
|
||||||
_safe_log_warning(
|
_safe_log_warning(
|
||||||
@@ -177,10 +130,17 @@ async def open_portfolio(message: Message, state: FSMContext) -> None:
|
|||||||
"chat_id": chat_id,
|
"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
|
return
|
||||||
|
|
||||||
visible_balances = [item for item in balances if not is_zero_balance(item)]
|
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),
|
"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
|
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:
|
asset_blocks: list[list[str]] = []
|
||||||
lines.append("<b>Основные активы</b>")
|
|
||||||
lines.append("")
|
|
||||||
for item in major_balances:
|
|
||||||
lines.extend(render_balance_block(item))
|
|
||||||
|
|
||||||
if other_balances:
|
for item in visible_balances:
|
||||||
lines.append("<b>Прочие активы</b>")
|
currency = item.currency.upper()
|
||||||
lines.append("")
|
total = balance_total(item)
|
||||||
for item in other_balances:
|
estimated_usd = estimate_balance_usd(item, exchange_service, price_cache)
|
||||||
lines.extend(render_balance_block(item))
|
|
||||||
|
|
||||||
lines.extend(
|
if estimated_usd is not None:
|
||||||
[
|
total_estimated_usd += estimated_usd
|
||||||
"<b>Итого</b>",
|
has_any_estimate = True
|
||||||
f"• активов с ненулевым балансом: {len(visible_balances)}",
|
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(
|
_safe_log_info(
|
||||||
journal,
|
journal,
|
||||||
@@ -234,8 +236,115 @@ async def open_portfolio(message: Message, state: FSMContext) -> None:
|
|||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"chat_id": chat_id,
|
"chat_id": chat_id,
|
||||||
"assets_count": len(visible_balances),
|
"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()
|
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 import F, Router
|
||||||
from aiogram.fsm.context import FSMContext
|
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")
|
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_({"⚙️ Система", "⚙ Система"}))
|
@router.message(F.text.in_({"⚙️ Система", "⚙ Система"}))
|
||||||
async def open_system(message: Message, state: FSMContext) -> None:
|
async def open_system(message: Message, state: FSMContext) -> None:
|
||||||
# Глобальный экран: всегда выходим из текущего FSM-сценария.
|
|
||||||
await state.clear()
|
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
@@ -6,13 +6,14 @@ from aiogram import F
|
|||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.types import CallbackQuery
|
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_core import router
|
||||||
from src.telegram.handlers.trade.new_order_ui import (
|
from src.telegram.handlers.trade.new_order_ui import (
|
||||||
mode_line,
|
|
||||||
_draft_detail_keyboard,
|
_draft_detail_keyboard,
|
||||||
_price_keyboard,
|
_price_keyboard,
|
||||||
_quantity_keyboard,
|
_quantity_keyboard,
|
||||||
_render_draft_detail,
|
_render_draft_detail,
|
||||||
|
_render_exchange_error,
|
||||||
_render_order_path,
|
_render_order_path,
|
||||||
_render_price_step_screen,
|
_render_price_step_screen,
|
||||||
_render_quantity_step_screen,
|
_render_quantity_step_screen,
|
||||||
@@ -20,6 +21,7 @@ from src.telegram.handlers.trade.new_order_ui import (
|
|||||||
_side_keyboard,
|
_side_keyboard,
|
||||||
_trade_back_home_keyboard,
|
_trade_back_home_keyboard,
|
||||||
_type_keyboard,
|
_type_keyboard,
|
||||||
|
mode_line,
|
||||||
)
|
)
|
||||||
from src.trading.orders.service import OrderDraftsService
|
from src.trading.orders.service import OrderDraftsService
|
||||||
from src.trading.orders.states import NewOrderDraftStates
|
from src.trading.orders.states import NewOrderDraftStates
|
||||||
@@ -50,25 +52,65 @@ async def _return_to_draft_detail(
|
|||||||
await callback.answer()
|
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")
|
@router.callback_query(F.data == "order_back:side")
|
||||||
async def go_back_to_side(callback: CallbackQuery, state: FSMContext) -> None:
|
async def go_back_to_side(callback: CallbackQuery, state: FSMContext) -> None:
|
||||||
service = OrderDraftsService()
|
service = OrderDraftsService()
|
||||||
context = service.get_entry_context(side="BUY", order_type="MARKET")
|
|
||||||
|
|
||||||
await state.set_state(NewOrderDraftStates.waiting_side)
|
try:
|
||||||
text = (
|
context = service.get_entry_context(side="BUY", order_type="MARKET")
|
||||||
"<b>📊 Торговля — Новый ордер</b>\n"
|
|
||||||
f"{mode_line()}"
|
await state.set_state(NewOrderDraftStates.waiting_side)
|
||||||
f"{context.symbol}\n\n"
|
text = (
|
||||||
"Шаг 1/4. Выбери сторону"
|
"<b>📊 Торговля — Новый ордер</b>\n"
|
||||||
)
|
f"{mode_line()}"
|
||||||
await callback.message.edit_text(text, reply_markup=_side_keyboard())
|
f"{context.symbol}\n\n"
|
||||||
await callback.answer()
|
"Шаг 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")
|
@router.callback_query(F.data == "order_back:type")
|
||||||
async def go_back_to_type(callback: CallbackQuery, state: FSMContext) -> None:
|
async def go_back_to_type(callback: CallbackQuery, state: FSMContext) -> None:
|
||||||
"""Возвращает пользователя на шаг выбора типа ордера или в карточку черновика при редактировании."""
|
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
|
|
||||||
draft_id = data.get("draft_edit_id")
|
draft_id = data.get("draft_edit_id")
|
||||||
@@ -84,27 +126,31 @@ async def go_back_to_type(callback: CallbackQuery, state: FSMContext) -> None:
|
|||||||
|
|
||||||
service = OrderDraftsService()
|
service = OrderDraftsService()
|
||||||
side = data.get("side", "BUY")
|
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)
|
try:
|
||||||
text = (
|
context = service.get_entry_context(side=side, order_type="MARKET")
|
||||||
"<b>📊 Торговля — Новый ордер</b>\n"
|
path = _render_order_path(side=side)
|
||||||
f"{mode_line()}"
|
|
||||||
f"{context.symbol}\n\n"
|
await state.set_state(NewOrderDraftStates.waiting_type)
|
||||||
f"{path}\n\n"
|
text = (
|
||||||
"Шаг 2/4. Выбери тип ордера"
|
"<b>📊 Торговля — Новый ордер</b>\n"
|
||||||
)
|
f"{mode_line()}"
|
||||||
await callback.message.edit_text(text, reply_markup=_type_keyboard())
|
f"{context.symbol}\n\n"
|
||||||
await callback.answer()
|
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")
|
@router.callback_query(F.data == "order_back:quantity")
|
||||||
async def go_back_to_quantity(callback: CallbackQuery, state: FSMContext) -> None:
|
async def go_back_to_quantity(callback: CallbackQuery, state: FSMContext) -> None:
|
||||||
"""
|
|
||||||
Возвращает пользователя на шаг выбора количества.
|
|
||||||
Используется как возврат со шага цены.
|
|
||||||
"""
|
|
||||||
service = OrderDraftsService()
|
service = OrderDraftsService()
|
||||||
data = await state.get_data()
|
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")
|
draft_page = data.get("draft_edit_page")
|
||||||
drafts_page = int(draft_page) if draft_page else None
|
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(
|
path = _render_order_path(
|
||||||
side=side,
|
side=side,
|
||||||
order_type=order_type,
|
order_type=order_type,
|
||||||
|
quantity=quantity,
|
||||||
base_currency=context.base_currency,
|
base_currency=context.base_currency,
|
||||||
)
|
)
|
||||||
|
|
||||||
await state.set_state(NewOrderDraftStates.waiting_quantity)
|
if not quantity:
|
||||||
await callback.message.edit_text(
|
path = _render_order_path(
|
||||||
_render_quantity_step_screen(
|
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),
|
title=_screen_title(is_edit_mode),
|
||||||
symbol=context.symbol,
|
exc=exc,
|
||||||
available_balance=context.available_balance,
|
draft_page=drafts_page,
|
||||||
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_back:confirm")
|
@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")
|
draft_page = data.get("draft_edit_page")
|
||||||
drafts_page = int(draft_page) if draft_page else None
|
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)
|
context = service.get_entry_context(side=side, order_type=order_type)
|
||||||
path = _render_order_path(
|
path = _render_order_path(
|
||||||
side=side,
|
side=side,
|
||||||
@@ -201,123 +392,10 @@ async def go_back_from_confirm(callback: CallbackQuery, state: FSMContext) -> No
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
return
|
except ExchangeError as exc:
|
||||||
|
await _show_navigation_exchange_error(
|
||||||
context = service.get_entry_context(side=side, order_type=order_type)
|
callback,
|
||||||
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),
|
title=_screen_title(is_edit_mode),
|
||||||
symbol=context.symbol,
|
exc=exc,
|
||||||
available_balance=context.available_balance,
|
draft_page=drafts_page,
|
||||||
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()
|
|
||||||
@@ -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.handlers.trade.new_order_core import DRAFTS_PAGE_SIZE
|
||||||
from src.telegram.ui.common import mode_line
|
from src.telegram.ui.common import mode_line
|
||||||
from src.trading.orders.service import OrderDraftsService
|
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:
|
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]:
|
def _resolve_symbol_assets(symbol: str) -> tuple[str | None, str | None]:
|
||||||
service = OrderDraftsService()
|
try:
|
||||||
validation = service.exchange.validate_symbol(symbol)
|
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
|
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:
|
def _to_decimal(value: str | float | int | None) -> Decimal | None:
|
||||||
if value is 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>"
|
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.
|
# Оценивает минимально допустимое количество по правилу minNotional.
|
||||||
def _estimate_min_quantity_by_notional(
|
def _estimate_min_quantity_by_notional(
|
||||||
*,
|
*,
|
||||||
@@ -265,6 +319,27 @@ def _draft_detail_keyboard(draft_id: str, page: int) -> InlineKeyboardMarkup:
|
|||||||
return builder.as_markup()
|
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(
|
def _format_value_with_currency(
|
||||||
value: str | float | None,
|
value: str | float | None,
|
||||||
currency: str | None,
|
currency: str | None,
|
||||||
@@ -938,7 +1013,11 @@ def _render_order_card(
|
|||||||
|
|
||||||
quantity_text = _format_value_with_asset(quantity, base_currency)
|
quantity_text = _format_value_with_asset(quantity, base_currency)
|
||||||
price_text = _format_value_with_currency(price, quote_currency) if price else None
|
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 = [
|
lines = [
|
||||||
f"<b>{symbol}</b>",
|
f"<b>{symbol}</b>",
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
# app/src/telegram/ui/common.py
|
# 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
|
from src.core.system_status import get_runtime_mode_label
|
||||||
|
|
||||||
|
|
||||||
def mode_line() -> str:
|
def mode_line() -> str:
|
||||||
label = get_runtime_mode_label()
|
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>"
|
||||||
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