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

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

View File

@@ -4,10 +4,16 @@ from __future__ import annotations
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import Message
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.integrations.exchange.exceptions import ExchangeError
from src.integrations.exchange.service import ExchangeService
from src.telegram.ui.common import mode_line
from src.telegram.ui.exchange_error import (
show_callback_exchange_error,
show_message_exchange_error,
)
from src.trading.journal.service import JournalService
@@ -50,16 +56,58 @@ def _safe_log_error(
pass
@router.message(F.text == "📈 Рынок")
async def open_market(message: Message, state: FSMContext) -> None:
# Глобальный экран: всегда выходим из текущего FSM-сценария.
await state.clear()
def _market_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🏠 К торговле", callback_data="trade:home")
builder.adjust(1)
return builder.as_markup()
def _build_market_text(
*,
ticker_price: float,
name: str,
symbol_status: str,
market_type: str,
base_asset: str,
quote_asset: str,
tick_size: str,
) -> str:
status_map = {
"TRADING": "доступен для торговли",
"HALT": "торги остановлены",
"BREAK": "перерыв",
}
status_ru = status_map.get(symbol_status.upper(), symbol_status.lower())
type_map = {
"LEVERAGE": "leverage",
"SPOT": "spot",
}
market_type_ru = type_map.get(market_type.upper(), market_type.lower())
return (
"<b>📈 Рынок</b>\n"
f"{mode_line()}"
f"Пара: <b>{name}</b>\n"
f"Цена: <b>{ticker_price:.2f} {quote_asset}</b>\n"
f"Статус: {status_ru}\n"
f"Тип инструмента: {market_type_ru}\n"
f"Базовый актив: {base_asset}\n"
f"Валюта котировки: {quote_asset}\n"
f"Шаг цены: {tick_size} {quote_asset}"
)
async def _render_market_screen(
target_message: Message,
*,
user_id: int | None,
chat_id: int | None,
edit_mode: bool,
) -> None:
service = ExchangeService()
journal = JournalService()
user_id = message.from_user.id if message.from_user else None
chat_id = message.chat.id if message.chat else None
requested_symbol = service.settings.default_symbol
_safe_log_info(
@@ -73,52 +121,38 @@ async def open_market(message: Message, state: FSMContext) -> None:
},
)
try:
validation = service.validate_symbol(requested_symbol)
if not validation.is_valid:
_safe_log_warning(
journal,
"market_symbol_invalid",
f"Символ не прошел проверку: {validation.message}",
{
"user_id": user_id,
"chat_id": chat_id,
"symbol": requested_symbol,
},
)
await message.answer(
"<b>📈 Рынок</b>\n\n"
f"Ошибка инструмента: {validation.message}"
)
return
validation = service.validate_symbol(requested_symbol)
ticker = service.get_price(validation.normalized_symbol)
except ExchangeError as exc:
_safe_log_error(
if not validation.is_valid:
_safe_log_warning(
journal,
"market_open_error",
f"Не удалось открыть экран рынка: {exc}",
"market_symbol_invalid",
f"Символ не прошел проверку: {validation.message}",
{
"user_id": user_id,
"chat_id": chat_id,
"symbol": requested_symbol,
},
)
await message.answer(
"<b>📈 Рынок</b>\n\n"
"Не удалось получить цену с биржи.\n"
f"Ошибка: {exc}"
text = (
"<b>📈 Рынок</b>\n"
f"{mode_line()}"
"⚠️ Ошибка инструмента\n\n"
"Инструмент недоступен."
)
if edit_mode:
await target_message.edit_text(text, reply_markup=_market_keyboard())
else:
await target_message.answer(text, reply_markup=_market_keyboard())
return
ticker = service.get_price(validation.normalized_symbol)
symbol_info = validation.symbol_info
symbol_status = symbol_info.status if symbol_info else "n/a"
market_type = symbol_info.market_type if symbol_info else "n/a"
market_modes = (
", ".join(symbol_info.market_modes)
if symbol_info and symbol_info.market_modes
else "n/a"
)
tick_size = (
f"{symbol_info.tick_size}"
if symbol_info and symbol_info.tick_size is not None
@@ -128,19 +162,14 @@ async def open_market(message: Message, state: FSMContext) -> None:
quote_asset = symbol_info.quote_asset if symbol_info and symbol_info.quote_asset else "n/a"
name = symbol_info.name if symbol_info and symbol_info.name else ticker.symbol
text = (
"<b>📈 Рынок</b>\n\n"
f"Символ: <b>{ticker.symbol}</b>\n"
f"Название: {name}\n"
f"Цена: <b>{ticker.price:.2f}</b>\n"
f"Статус: {symbol_status}\n"
f"Тип рынка: {market_type}\n"
f"Режимы: {market_modes}\n"
f"Base asset: {base_asset}\n"
f"Quote asset: {quote_asset}\n"
f"Tick size: {tick_size}\n"
f"Источник: {ticker.source}\n"
f"Обновлено: {ticker.updated_at}"
text = _build_market_text(
ticker_price=ticker.price,
name=name,
symbol_status=symbol_status,
market_type=market_type,
base_asset=base_asset,
quote_asset=quote_asset,
tick_size=tick_size,
)
_safe_log_info(
@@ -152,9 +181,79 @@ async def open_market(message: Message, state: FSMContext) -> None:
"chat_id": chat_id,
"symbol": ticker.symbol,
"price": ticker.price,
"base_asset": base_asset,
"quote_asset": quote_asset,
},
)
await message.answer(text)
if edit_mode:
await target_message.edit_text(text, reply_markup=_market_keyboard())
else:
await target_message.answer(text, reply_markup=_market_keyboard())
@router.message(F.text == "📈 Рынок")
async def open_market(message: Message, state: FSMContext) -> None:
await state.clear()
user_id = message.from_user.id if message.from_user else None
chat_id = message.chat.id if message.chat else None
try:
await _render_market_screen(
message,
user_id=user_id,
chat_id=chat_id,
edit_mode=False,
)
except ExchangeError as exc:
_safe_log_error(
JournalService(),
"market_open_error",
f"Не удалось открыть экран рынка: {exc}",
{"user_id": user_id, "chat_id": chat_id},
)
await show_message_exchange_error(
message,
title="<b>📈 Рынок</b>",
exc=exc,
network_details="Рыночные данные недоступны.\nОбнови экран.",
auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.",
retry_callback_data="market:retry",
)
@router.callback_query(F.data == "market:retry")
async def retry_market(callback: CallbackQuery, state: FSMContext) -> None:
await state.clear()
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
return
user_id = callback.from_user.id if callback.from_user else None
chat_id = callback.message.chat.id if callback.message.chat else None
try:
await _render_market_screen(
callback.message,
user_id=user_id,
chat_id=chat_id,
edit_mode=True,
)
await callback.answer()
except ExchangeError as exc:
_safe_log_error(
JournalService(),
"market_retry_error",
f"Не удалось повторно открыть рынок: {exc}",
{"user_id": user_id, "chat_id": chat_id},
)
await show_callback_exchange_error(
callback,
title="<b>📈 Рынок</b>",
exc=exc,
network_details="Рыночные данные недоступны.\nОбнови экран.",
auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.",
retry_callback_data="market:retry",
)

View File

@@ -4,10 +4,24 @@ from __future__ import annotations
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import Message
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.integrations.exchange.exceptions import ExchangeError
from src.integrations.exchange.models import BalanceSummary
from src.integrations.exchange.service import ExchangeService
from src.telegram.ui.common import mode_line, now_line
from src.telegram.ui.currency_ui import (
balance_total,
estimate_balance_usd,
format_amount,
format_usd_amount,
is_zero_balance,
)
from src.telegram.ui.exchange_error import (
show_callback_exchange_error,
show_message_exchange_error,
)
from src.trading.accounts.service import AccountsService
from src.trading.journal.service import JournalService
@@ -15,23 +29,6 @@ from src.trading.journal.service import JournalService
router = Router(name="portfolio")
FIAT_CURRENCIES = {"USD", "USDT", "EUR", "RUB", "BYN"}
CURRENCY_ICONS = {
"USD": "💵",
"USDT": "💵",
"EUR": "💶",
"RUB": "",
"BYN": "Br",
"BTC": "",
"ETH": "Ξ",
"BNB": "🟡",
"SOL": "",
"ADA": "🔵",
"XRP": "",
"DOGE": "🐶",
}
PINNED_ORDER = {
"USD": 1,
"USDT": 2,
@@ -40,55 +37,19 @@ PINNED_ORDER = {
}
def format_amount(currency: str, value: float) -> str:
if currency.upper() in FIAT_CURRENCIES:
return f"{value:,.2f}".replace(",", " ")
return f"{value:,.8f}".replace(",", " ")
def _portfolio_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🏠 К торговле", callback_data="trade:home")
builder.adjust(1)
return builder.as_markup()
def get_currency_label(currency: str) -> str:
icon = CURRENCY_ICONS.get(currency.upper(), "💰")
return f"{icon} {currency.upper()}"
def is_zero_balance(item: BalanceSummary) -> bool:
return abs(item.available) < 1e-12 and abs(item.locked) < 1e-12
def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
def sort_key(item: BalanceSummary) -> tuple[int, str]:
currency = item.currency.upper()
priority = PINNED_ORDER.get(currency, 999)
return (priority, currency)
return sorted(items, key=sort_key)
def split_balances(
items: list[BalanceSummary],
) -> tuple[list[BalanceSummary], list[BalanceSummary]]:
major: list[BalanceSummary] = []
other: list[BalanceSummary] = []
for item in items:
if item.currency.upper() in PINNED_ORDER:
major.append(item)
else:
other.append(item)
return major, other
def render_balance_block(item: BalanceSummary) -> list[str]:
total = item.available + item.locked
return [
f"<b>{get_currency_label(item.currency)}</b>",
f"• доступно: {format_amount(item.currency, item.available)}",
f"• заблокировано: {format_amount(item.currency, item.locked)}",
f"• всего: {format_amount(item.currency, total)}",
"",
]
def _portfolio_warning_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🔁 Обновить", callback_data="portfolio:retry")
builder.button(text="🏠 К торговле", callback_data="trade:home")
builder.adjust(1, 1)
return builder.as_markup()
def _safe_log_info(
@@ -127,17 +88,26 @@ def _safe_log_error(
pass
@router.message(F.text == "💼 Портфель")
async def open_portfolio(message: Message, state: FSMContext) -> None:
# Глобальный экран: всегда выходим из текущего FSM-сценария.
await state.clear()
def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
def sort_key(item: BalanceSummary) -> tuple[int, str]:
currency = item.currency.upper()
priority = PINNED_ORDER.get(currency, 999)
return (priority, currency)
return sorted(items, key=sort_key)
async def _render_portfolio_screen(
target_message: Message,
*,
user_id: int | None,
chat_id: int | None,
edit_mode: bool,
) -> None:
service = AccountsService()
exchange_service = ExchangeService()
journal = JournalService()
user_id = message.from_user.id if message.from_user else None
chat_id = message.chat.id if message.chat else None
_safe_log_info(
journal,
"user_open_portfolio",
@@ -148,24 +118,7 @@ async def open_portfolio(message: Message, state: FSMContext) -> None:
},
)
try:
balances = service.get_live_balance_summary()
except ExchangeError as exc:
_safe_log_error(
journal,
"portfolio_open_error",
f"Не удалось открыть портфель: {exc}",
{
"user_id": user_id,
"chat_id": chat_id,
},
)
await message.answer(
"<b>💼 Портфель</b>\n\n"
"Не удалось получить баланс с private API.\n"
f"Ошибка: {exc}"
)
return
balances = service.get_live_balance_summary()
if not balances:
_safe_log_warning(
@@ -177,10 +130,17 @@ async def open_portfolio(message: Message, state: FSMContext) -> None:
"chat_id": chat_id,
},
)
await message.answer(
"<b>💼 Портфель</b>\n\n"
"Баланс пуст."
text = (
"<b>💼 Портфель</b>\n"
f"{mode_line()}"
"Нет данных по балансу."
)
if edit_mode:
await target_message.edit_text(text, reply_markup=_portfolio_keyboard())
else:
await target_message.answer(text, reply_markup=_portfolio_keyboard())
return
visible_balances = [item for item in balances if not is_zero_balance(item)]
@@ -197,34 +157,76 @@ async def open_portfolio(message: Message, state: FSMContext) -> None:
"assets_count": len(balances),
},
)
await message.answer(
"<b>💼 Портфель</b>\n\n"
"Все балансы нулевые."
text = (
"<b>💼 Портфель</b>\n"
f"{mode_line()}"
"Нет активов с балансом."
)
if edit_mode:
await target_message.edit_text(text, reply_markup=_portfolio_keyboard())
else:
await target_message.answer(text, reply_markup=_portfolio_keyboard())
return
major_balances, other_balances = split_balances(visible_balances)
price_cache: dict[str, float | None] = {}
total_estimated_usd = 0.0
has_any_estimate = False
missing_estimate_assets: list[str] = []
lines: list[str] = ["<b>💼 Портфель</b>", "", "<b>Баланс аккаунта</b>", ""]
lines: list[str] = [
"<b>💼 Портфель</b>",
mode_line().rstrip(),
"",
f"<b>БАЛАНС · АКТИВЫ · {len(visible_balances)}</b>",
]
if major_balances:
lines.append("<b>Основные активы</b>")
lines.append("")
for item in major_balances:
lines.extend(render_balance_block(item))
asset_blocks: list[list[str]] = []
if other_balances:
lines.append("<b>Прочие активы</b>")
lines.append("")
for item in other_balances:
lines.extend(render_balance_block(item))
for item in visible_balances:
currency = item.currency.upper()
total = balance_total(item)
estimated_usd = estimate_balance_usd(item, exchange_service, price_cache)
lines.extend(
[
"<b>Итого</b>",
f"• активов с ненулевым балансом: {len(visible_balances)}",
if estimated_usd is not None:
total_estimated_usd += estimated_usd
has_any_estimate = True
elif total > 0:
missing_estimate_assets.append(currency)
block = [
"",
f"<b>{currency}</b>",
f"• доступно: {format_amount(currency, item.available)}",
f"• заблокировано: {format_amount(currency, item.locked)}",
f"• всего: {format_amount(currency, total)}",
]
)
if estimated_usd is not None and currency not in {"USD", "USDT"}:
block.append(f"{format_usd_amount(estimated_usd)} USD")
asset_blocks.append(block)
has_partial_data = len(missing_estimate_assets) > 0
if missing_estimate_assets:
lines.extend(
[
"🟡 <b>Данные загружены частично</b>",
]
)
if has_any_estimate:
lines.append(f"ОЦЕНКА · ~${format_usd_amount(total_estimated_usd)}")
if missing_estimate_assets:
lines.append(
f"Нет оценки: {', '.join(missing_estimate_assets)}"
)
for block in asset_blocks:
lines.extend(block)
_safe_log_info(
journal,
@@ -234,8 +236,115 @@ async def open_portfolio(message: Message, state: FSMContext) -> None:
"user_id": user_id,
"chat_id": chat_id,
"assets_count": len(visible_balances),
"estimated_usd": round(total_estimated_usd, 2) if has_any_estimate else None,
"missing_estimate_assets": missing_estimate_assets,
},
)
if missing_estimate_assets:
_safe_log_warning(
journal,
"portfolio_partial_estimate",
"Портфель показан частично: не для всех активов доступна USD-оценка.",
{
"user_id": user_id,
"chat_id": chat_id,
"missing_estimate_assets": missing_estimate_assets,
},
)
if has_partial_data:
lines.extend(
[
"",
now_line(),
]
)
text = "\n".join(lines).rstrip()
await message.answer(text)
reply_markup = (
_portfolio_warning_keyboard()
if has_partial_data
else _portfolio_keyboard()
)
if edit_mode:
await target_message.edit_text(text, reply_markup=reply_markup)
else:
await target_message.answer(text, reply_markup=reply_markup)
@router.message(F.text == "💼 Портфель")
async def open_portfolio(message: Message, state: FSMContext) -> None:
await state.clear()
user_id = message.from_user.id if message.from_user else None
chat_id = message.chat.id if message.chat else None
try:
await _render_portfolio_screen(
message,
user_id=user_id,
chat_id=chat_id,
edit_mode=False,
)
except ExchangeError as exc:
journal = JournalService()
_safe_log_error(
journal,
"portfolio_open_error",
f"Не удалось открыть портфель: {exc}",
{
"user_id": user_id,
"chat_id": chat_id,
},
)
await show_message_exchange_error(
message,
title="<b>💼 Портфель</b>",
exc=exc,
network_details="Не загружен баланс аккаунта.\nОбнови экран.",
auth_details="Не загружен баланс аккаунта.\nПроверь API ключи.",
retry_callback_data="portfolio:retry",
)
@router.callback_query(F.data == "portfolio:retry")
async def retry_portfolio(callback: CallbackQuery, state: FSMContext) -> None:
await state.clear()
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
return
user_id = callback.from_user.id if callback.from_user else None
chat_id = callback.message.chat.id if callback.message.chat else None
try:
await _render_portfolio_screen(
callback.message,
user_id=user_id,
chat_id=chat_id,
edit_mode=True,
)
await callback.answer()
except ExchangeError as exc:
journal = JournalService()
_safe_log_error(
journal,
"portfolio_retry_error",
f"Не удалось повторно открыть портфель: {exc}",
{
"user_id": user_id,
"chat_id": chat_id,
},
)
await show_callback_exchange_error(
callback,
title="<b>💼 Портфель</b>",
exc=exc,
network_details="Не загружен баланс аккаунта.\nОбнови экран.",
auth_details="Не загружен баланс аккаунта.\nПроверь API ключи.",
retry_callback_data="portfolio:retry",
)

View File

@@ -4,16 +4,66 @@ from __future__ import annotations
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import Message
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.core.system_status import build_system_text
from src.core.system_status import build_system_text, get_system_snapshot, has_system_alerts
router = Router(name="system")
def _system_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🏠 К торговле", callback_data="trade:home")
builder.adjust(1)
return builder.as_markup()
def _system_alert_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🔁 Обновить", callback_data="system:retry")
builder.button(text="🏠 К торговле", callback_data="trade:home")
builder.adjust(1, 1)
return builder.as_markup()
async def _render_system_screen(
target_message: Message,
*,
edit_mode: bool,
) -> None:
snapshot = get_system_snapshot()
is_alert = has_system_alerts(snapshot)
text = build_system_text(include_updated_at=is_alert)
reply_markup = _system_alert_keyboard() if is_alert else _system_keyboard()
if edit_mode:
await target_message.edit_text(text, reply_markup=reply_markup)
else:
await target_message.answer(text, reply_markup=reply_markup)
@router.message(F.text.in_({"⚙️ Система", "⚙ Система"}))
async def open_system(message: Message, state: FSMContext) -> None:
# Глобальный экран: всегда выходим из текущего FSM-сценария.
await state.clear()
await message.answer(build_system_text())
await _render_system_screen(
message,
edit_mode=False,
)
@router.callback_query(F.data == "system:retry")
async def retry_system(callback: CallbackQuery, state: FSMContext) -> None:
await state.clear()
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
return
await _render_system_screen(
callback.message,
edit_mode=True,
)
await callback.answer()

File diff suppressed because it is too large Load Diff

View File

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

View File

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