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

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

File diff suppressed because it is too large Load Diff

View File

@@ -6,13 +6,14 @@ from aiogram import F
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from src.integrations.exchange.exceptions import ExchangeError, format_exchange_error_for_user
from src.telegram.handlers.trade.new_order_core import router
from src.telegram.handlers.trade.new_order_ui import (
mode_line,
_draft_detail_keyboard,
_price_keyboard,
_quantity_keyboard,
_render_draft_detail,
_render_exchange_error,
_render_order_path,
_render_price_step_screen,
_render_quantity_step_screen,
@@ -20,6 +21,7 @@ from src.telegram.handlers.trade.new_order_ui import (
_side_keyboard,
_trade_back_home_keyboard,
_type_keyboard,
mode_line,
)
from src.trading.orders.service import OrderDraftsService
from src.trading.orders.states import NewOrderDraftStates
@@ -50,25 +52,65 @@ async def _return_to_draft_detail(
await callback.answer()
async def _show_navigation_exchange_error(
callback: CallbackQuery,
*,
title: str,
exc: Exception,
draft_page: int | None = None,
) -> None:
reply_markup = (
_draft_detail_keyboard("", draft_page) # won't use if branch below replaces
if False
else None
)
if draft_page:
keyboard = _draft_detail_keyboard("noop", draft_page)
# заменим клавиатуру сразу на корректную
# edit/detail тут не нужны, нужен простой возврат к черновикам
from src.telegram.handlers.trade.new_order_ui import _drafts_back_keyboard
reply_markup = _drafts_back_keyboard(int(draft_page))
else:
reply_markup = _trade_back_home_keyboard()
await callback.message.edit_text(
_render_exchange_error(
title=title,
message=format_exchange_error_for_user(exc),
),
reply_markup=reply_markup,
)
await callback.answer()
@router.callback_query(F.data == "order_back:side")
async def go_back_to_side(callback: CallbackQuery, state: FSMContext) -> None:
service = OrderDraftsService()
context = service.get_entry_context(side="BUY", order_type="MARKET")
await state.set_state(NewOrderDraftStates.waiting_side)
text = (
"<b>📊 Торговля — Новый ордер</b>\n"
f"{mode_line()}"
f"{context.symbol}\n\n"
"Шаг 1/4. Выбери сторону"
)
await callback.message.edit_text(text, reply_markup=_side_keyboard())
await callback.answer()
try:
context = service.get_entry_context(side="BUY", order_type="MARKET")
await state.set_state(NewOrderDraftStates.waiting_side)
text = (
"<b>📊 Торговля — Новый ордер</b>\n"
f"{mode_line()}"
f"{context.symbol}\n\n"
"Шаг 1/4. Выбери сторону"
)
await callback.message.edit_text(text, reply_markup=_side_keyboard())
await callback.answer()
except ExchangeError as exc:
await _show_navigation_exchange_error(
callback,
title="<b>📊 Торговля — Новый ордер</b>",
exc=exc,
)
@router.callback_query(F.data == "order_back:type")
async def go_back_to_type(callback: CallbackQuery, state: FSMContext) -> None:
"""Возвращает пользователя на шаг выбора типа ордера или в карточку черновика при редактировании."""
data = await state.get_data()
draft_id = data.get("draft_edit_id")
@@ -84,27 +126,31 @@ async def go_back_to_type(callback: CallbackQuery, state: FSMContext) -> None:
service = OrderDraftsService()
side = data.get("side", "BUY")
context = service.get_entry_context(side=side, order_type="MARKET")
path = _render_order_path(side=side)
await state.set_state(NewOrderDraftStates.waiting_type)
text = (
"<b>📊 Торговля — Новый ордер</b>\n"
f"{mode_line()}"
f"{context.symbol}\n\n"
f"{path}\n\n"
"Шаг 2/4. Выбери тип ордера"
)
await callback.message.edit_text(text, reply_markup=_type_keyboard())
await callback.answer()
try:
context = service.get_entry_context(side=side, order_type="MARKET")
path = _render_order_path(side=side)
await state.set_state(NewOrderDraftStates.waiting_type)
text = (
"<b>📊 Торговля — Новый ордер</b>\n"
f"{mode_line()}"
f"{context.symbol}\n\n"
f"{path}\n\n"
"Шаг 2/4. Выбери тип ордера"
)
await callback.message.edit_text(text, reply_markup=_type_keyboard())
await callback.answer()
except ExchangeError as exc:
await _show_navigation_exchange_error(
callback,
title="<b>📊 Торговля — Новый ордер</b>",
exc=exc,
)
@router.callback_query(F.data == "order_back:quantity")
async def go_back_to_quantity(callback: CallbackQuery, state: FSMContext) -> None:
"""
Возвращает пользователя на шаг выбора количества.
Используется как возврат со шага цены.
"""
service = OrderDraftsService()
data = await state.get_data()
@@ -115,39 +161,47 @@ async def go_back_to_quantity(callback: CallbackQuery, state: FSMContext) -> Non
draft_page = data.get("draft_edit_page")
drafts_page = int(draft_page) if draft_page else None
context = service.get_entry_context(side=side, order_type=order_type)
try:
context = service.get_entry_context(side=side, order_type=order_type)
path = _render_order_path(
side=side,
order_type=order_type,
quantity=quantity,
base_currency=context.base_currency,
)
if not quantity:
path = _render_order_path(
side=side,
order_type=order_type,
quantity=quantity,
base_currency=context.base_currency,
)
await state.set_state(NewOrderDraftStates.waiting_quantity)
await callback.message.edit_text(
_render_quantity_step_screen(
if not quantity:
path = _render_order_path(
side=side,
order_type=order_type,
base_currency=context.base_currency,
)
await state.set_state(NewOrderDraftStates.waiting_quantity)
await callback.message.edit_text(
_render_quantity_step_screen(
title=_screen_title(is_edit_mode),
symbol=context.symbol,
available_balance=context.available_balance,
balance_currency=context.balance_currency,
reference_price=context.reference_price,
quote_currency=context.quote_currency,
order_path=path,
),
reply_markup=_quantity_keyboard(
context.quantity_presets,
drafts_page=drafts_page,
),
)
await callback.answer()
except ExchangeError as exc:
await _show_navigation_exchange_error(
callback,
title=_screen_title(is_edit_mode),
symbol=context.symbol,
available_balance=context.available_balance,
balance_currency=context.balance_currency,
reference_price=context.reference_price,
quote_currency=context.quote_currency,
order_path=path,
),
reply_markup=_quantity_keyboard(
context.quantity_presets,
drafts_page=drafts_page,
),
)
await callback.answer()
exc=exc,
draft_page=drafts_page,
)
@router.callback_query(F.data == "order_back:confirm")
@@ -173,7 +227,144 @@ async def go_back_from_confirm(callback: CallbackQuery, state: FSMContext) -> No
draft_page = data.get("draft_edit_page")
drafts_page = int(draft_page) if draft_page else None
if order_type == "LIMIT":
try:
if order_type == "LIMIT":
context = service.get_entry_context(side=side, order_type=order_type)
path = _render_order_path(
side=side,
order_type=order_type,
quantity=quantity,
base_currency=context.base_currency,
)
await state.set_state(NewOrderDraftStates.waiting_price)
await callback.message.edit_text(
_render_price_step_screen(
title=_screen_title(is_edit_mode),
symbol=context.symbol,
bid=context.bid_price,
ask=context.ask_price,
last=context.last_price,
quote_currency=context.quote_currency,
order_path=path,
),
reply_markup=_price_keyboard(
bid=context.bid_price,
ask=context.ask_price,
last=context.last_price,
drafts_page=drafts_page,
),
)
await callback.answer()
return
context = service.get_entry_context(side=side, order_type=order_type)
path = _render_order_path(
side=side,
order_type=order_type,
quantity=quantity,
base_currency=context.base_currency,
)
await state.set_state(NewOrderDraftStates.waiting_quantity)
await callback.message.edit_text(
_render_quantity_step_screen(
title=_screen_title(is_edit_mode),
symbol=context.symbol,
available_balance=context.available_balance,
balance_currency=context.balance_currency,
reference_price=context.reference_price,
quote_currency=context.quote_currency,
order_path=path,
),
reply_markup=_quantity_keyboard(
context.quantity_presets,
drafts_page=drafts_page,
),
)
await callback.answer()
except ExchangeError as exc:
await _show_navigation_exchange_error(
callback,
title=_screen_title(is_edit_mode),
exc=exc,
draft_page=drafts_page,
)
@router.callback_query(F.data == "order_manual_back:quantity")
async def go_back_from_manual_quantity(
callback: CallbackQuery,
state: FSMContext,
) -> None:
service = OrderDraftsService()
data = await state.get_data()
side = data.get("side", "BUY")
order_type = data.get("order_type", "MARKET")
quantity = data.get("quantity")
is_edit_mode = bool(data.get("draft_edit_id"))
draft_page = data.get("draft_edit_page")
drafts_page = int(draft_page) if draft_page else None
try:
context = service.get_entry_context(side=side, order_type=order_type)
path = _render_order_path(
side=side,
order_type=order_type,
quantity=quantity,
base_currency=context.base_currency,
)
if not quantity:
path = _render_order_path(
side=side,
order_type=order_type,
base_currency=context.base_currency,
)
await state.set_state(NewOrderDraftStates.waiting_quantity)
await callback.message.edit_text(
_render_quantity_step_screen(
title=_screen_title(is_edit_mode),
symbol=context.symbol,
available_balance=context.available_balance,
balance_currency=context.balance_currency,
reference_price=context.reference_price,
quote_currency=context.quote_currency,
order_path=path,
),
reply_markup=_quantity_keyboard(
context.quantity_presets,
drafts_page=drafts_page,
),
)
await callback.answer()
except ExchangeError as exc:
await _show_navigation_exchange_error(
callback,
title=_screen_title(is_edit_mode),
exc=exc,
draft_page=drafts_page,
)
@router.callback_query(F.data == "order_manual_back:price")
async def go_back_from_manual_price(
callback: CallbackQuery,
state: FSMContext,
) -> None:
service = OrderDraftsService()
data = await state.get_data()
side = data.get("side", "BUY")
order_type = data.get("order_type", "LIMIT")
quantity = data.get("quantity")
is_edit_mode = bool(data.get("draft_edit_id"))
draft_page = data.get("draft_edit_page")
drafts_page = int(draft_page) if draft_page else None
try:
context = service.get_entry_context(side=side, order_type=order_type)
path = _render_order_path(
side=side,
@@ -201,123 +392,10 @@ async def go_back_from_confirm(callback: CallbackQuery, state: FSMContext) -> No
),
)
await callback.answer()
return
context = service.get_entry_context(side=side, order_type=order_type)
path = _render_order_path(
side=side,
order_type=order_type,
quantity=quantity,
base_currency=context.base_currency,
)
await state.set_state(NewOrderDraftStates.waiting_quantity)
await callback.message.edit_text(
_render_quantity_step_screen(
except ExchangeError as exc:
await _show_navigation_exchange_error(
callback,
title=_screen_title(is_edit_mode),
symbol=context.symbol,
available_balance=context.available_balance,
balance_currency=context.balance_currency,
reference_price=context.reference_price,
quote_currency=context.quote_currency,
order_path=path,
),
reply_markup=_quantity_keyboard(
context.quantity_presets,
drafts_page=drafts_page,
),
)
await callback.answer()
@router.callback_query(F.data == "order_manual_back:quantity")
async def go_back_from_manual_quantity(
callback: CallbackQuery,
state: FSMContext,
) -> None:
service = OrderDraftsService()
data = await state.get_data()
side = data.get("side", "BUY")
order_type = data.get("order_type", "MARKET")
quantity = data.get("quantity")
is_edit_mode = bool(data.get("draft_edit_id"))
draft_page = data.get("draft_edit_page")
drafts_page = int(draft_page) if draft_page else None
context = service.get_entry_context(side=side, order_type=order_type)
path = _render_order_path(
side=side,
order_type=order_type,
quantity=quantity,
base_currency=context.base_currency,
)
if not quantity:
path = _render_order_path(
side=side,
order_type=order_type,
base_currency=context.base_currency,
)
await state.set_state(NewOrderDraftStates.waiting_quantity)
await callback.message.edit_text(
_render_quantity_step_screen(
title=_screen_title(is_edit_mode),
symbol=context.symbol,
available_balance=context.available_balance,
balance_currency=context.balance_currency,
reference_price=context.reference_price,
quote_currency=context.quote_currency,
order_path=path,
),
reply_markup=_quantity_keyboard(
context.quantity_presets,
drafts_page=drafts_page,
),
)
await callback.answer()
@router.callback_query(F.data == "order_manual_back:price")
async def go_back_from_manual_price(
callback: CallbackQuery,
state: FSMContext,
) -> None:
service = OrderDraftsService()
data = await state.get_data()
side = data.get("side", "BUY")
order_type = data.get("order_type", "LIMIT")
quantity = data.get("quantity")
is_edit_mode = bool(data.get("draft_edit_id"))
draft_page = data.get("draft_edit_page")
drafts_page = int(draft_page) if draft_page else None
context = service.get_entry_context(side=side, order_type=order_type)
path = _render_order_path(
side=side,
order_type=order_type,
quantity=quantity,
base_currency=context.base_currency,
)
await state.set_state(NewOrderDraftStates.waiting_price)
await callback.message.edit_text(
_render_price_step_screen(
title=_screen_title(is_edit_mode),
symbol=context.symbol,
bid=context.bid_price,
ask=context.ask_price,
last=context.last_price,
quote_currency=context.quote_currency,
order_path=path,
),
reply_markup=_price_keyboard(
bid=context.bid_price,
ask=context.ask_price,
last=context.last_price,
drafts_page=drafts_page,
),
)
await callback.answer()
exc=exc,
draft_page=drafts_page,
)

View File

@@ -12,6 +12,11 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.telegram.handlers.trade.new_order_core import DRAFTS_PAGE_SIZE
from src.telegram.ui.common import mode_line
from src.trading.orders.service import OrderDraftsService
from src.integrations.exchange.exceptions import (
ExchangeConnectionError,
ExchangeError,
ExchangeResponseError,
)
def _clean_number(value: str | float | None, precision: int | None = None) -> str:
@@ -32,26 +37,29 @@ def _clean_number(value: str | float | None, precision: int | None = None) -> st
def _resolve_symbol_assets(symbol: str) -> tuple[str | None, str | None]:
service = OrderDraftsService()
validation = service.exchange.validate_symbol(symbol)
try:
service = OrderDraftsService()
validation = service.exchange.validate_symbol(symbol)
symbol_info = validation.symbol_info
symbol_info = validation.symbol_info
if symbol_info is None:
if symbol_info is None:
return None, None
base_currency = (
str(symbol_info.base_asset).upper()
if getattr(symbol_info, "base_asset", None)
else None
)
quote_currency = (
str(symbol_info.quote_asset).upper()
if getattr(symbol_info, "quote_asset", None)
else None
)
return base_currency, quote_currency
except Exception:
return None, None
base_currency = (
str(symbol_info.base_asset).upper()
if getattr(symbol_info, "base_asset", None)
else None
)
quote_currency = (
str(symbol_info.quote_asset).upper()
if getattr(symbol_info, "quote_asset", None)
else None
)
return base_currency, quote_currency
def _to_decimal(value: str | float | int | None) -> Decimal | None:
if value is None:
@@ -79,6 +87,52 @@ def _side_badge(side: str) -> str:
return "🟢 <b>BUY</b>" if side.upper() == "BUY" else "🔴 <b>SELL</b>"
def _describe_exchange_error(exc: Exception) -> str:
text = str(exc).strip()
if isinstance(exc, ExchangeResponseError) and (
"-1021" in text or "doesn't match server time" in text
):
return (
"Не удалось получить данные биржи: время на устройстве "
"не синхронизировано со временем биржи. "
"Проверь системное время и повтори попытку."
)
if isinstance(exc, ExchangeConnectionError):
return (
"Не удалось получить данные биржи: таймаут или ошибка сети. "
"Попробуй ещё раз через несколько секунд."
)
if isinstance(exc, ExchangeResponseError):
return (
"Не удалось получить данные биржи: биржа вернула некорректный ответ. "
"Попробуй ещё раз через несколько секунд."
)
if isinstance(exc, ExchangeError):
return text or "Не удалось получить данные биржи."
return text or "Не удалось получить данные биржи."
def _render_exchange_error(
*,
title: str,
exc: Exception,
) -> str:
lines = [
title,
mode_line().rstrip(),
"",
"<b>⚠️ Данные биржи временно недоступны</b>",
"",
_describe_exchange_error(exc),
]
return "\n".join(lines)
# Оценивает минимально допустимое количество по правилу minNotional.
def _estimate_min_quantity_by_notional(
*,
@@ -265,6 +319,27 @@ def _draft_detail_keyboard(draft_id: str, page: int) -> InlineKeyboardMarkup:
return builder.as_markup()
def _exchange_error_keyboard(
*,
back_callback_data: str | None = None,
drafts_page: int | None = None,
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
# Кнопка "Назад" (если есть куда возвращаться)
if back_callback_data:
builder.button(text="⬅️ Назад", callback_data=back_callback_data)
# Кнопка "К черновикам" (если мы в edit flow)
if drafts_page is not None:
builder.button(text="📚 К черновикам", callback_data=f"drafts:{drafts_page}")
else:
builder.button(text="🏠 К торговле", callback_data="trade:home")
builder.adjust(2 if back_callback_data else 1)
return builder.as_markup()
def _format_value_with_currency(
value: str | float | None,
currency: str | None,
@@ -938,7 +1013,11 @@ def _render_order_card(
quantity_text = _format_value_with_asset(quantity, base_currency)
price_text = _format_value_with_currency(price, quote_currency) if price else None
notional_text = _format_value_with_currency(notional, quote_currency) if notional is not None else None
notional_text = (
_format_value_with_currency(notional, quote_currency)
if notional is not None
else None
)
lines = [
f"<b>{symbol}</b>",