Stage 06.1 - journal management UI, export and system menu redesign

This commit is contained in:
2026-04-27 15:02:56 +03:00
parent 1fb72ced58
commit f6fc300e84
19 changed files with 1935 additions and 421 deletions

View File

@@ -4,88 +4,51 @@ from __future__ import annotations
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.types import BufferedInputFile, CallbackQuery, Message
from src.telegram.handlers.journal_ui import (
PAGE_SIZE,
build_clear_confirm_keyboard,
build_keyboard,
render,
render_clear_confirm,
build_actions_keyboard,
render_actions,
)
from src.trading.journal.service import JournalService
router = Router(name="journal")
PAGE_SIZE = 3
LEVEL_ICONS = {
"INFO": "",
"WARNING": "⚠️",
"ERROR": "",
"CRITICAL": "🚨",
}
def _user_id_from_message(message: Message) -> int | None:
return message.from_user.id if message.from_user else None
def build_keyboard(page: int, total_pages: int):
kb = InlineKeyboardBuilder()
if page > 1:
kb.button(text="⏮️", callback_data="journal:1")
if page > 1:
kb.button(text="⬅️", callback_data=f"journal:{page - 1}")
kb.button(text=f"{page}/{total_pages}", callback_data="noop")
if page < total_pages:
kb.button(text="➡️", callback_data=f"journal:{page + 1}")
return kb.as_markup()
def _chat_id_from_message(message: Message) -> int | None:
return message.chat.id if message.chat else None
def render(events, page, total_pages):
lines = ["<b>📒 Журнал</b>", "", "<b>Последние события</b>", ""]
for e in events:
level = str(e.get("level", "INFO")).upper()
icon = LEVEL_ICONS.get(level, "")
lines.extend(
[
f"{icon} <b>{e['event_type']}</b>",
f"• уровень: {level}",
f"• время: {e['created_at']}",
f"• сообщение: {e['message']}",
"",
]
)
return "\n".join(lines).rstrip()
def _user_id_from_callback(callback: CallbackQuery) -> int | None:
return callback.from_user.id if callback.from_user else None
@router.message(F.text == "📒 Журнал")
async def open_journal(message: Message, state: FSMContext):
# Глобальный экран: всегда выходим из текущего FSM-сценария.
await state.clear()
def _chat_id_from_callback(callback: CallbackQuery) -> int | None:
if callback.message and callback.message.chat:
return callback.message.chat.id
return None
async def _show_journal_page(
target_message: Message,
*,
page: int,
edit_mode: bool,
) -> None:
service = JournalService()
total = service.get_total_count()
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
events = service.get_page(1, PAGE_SIZE)
text = render(events, 1, total_pages)
kb = build_keyboard(1, total_pages)
await message.answer(text, reply_markup=kb)
@router.callback_query(F.data.startswith("journal:"))
async def paginate(callback: CallbackQuery):
page = int(callback.data.split(":")[1])
service = JournalService()
total = service.get_total_count()
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
page = max(1, min(page, total_pages))
events = service.get_page(page, PAGE_SIZE)
@@ -93,5 +56,200 @@ async def paginate(callback: CallbackQuery):
text = render(events, page, total_pages)
kb = build_keyboard(page, total_pages)
await callback.message.edit_text(text, reply_markup=kb)
if edit_mode:
await target_message.edit_text(text, reply_markup=kb)
else:
await target_message.answer(text, reply_markup=kb)
@router.callback_query(F.data == "journal:actions")
async def journal_actions(callback: CallbackQuery) -> None:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
return
await callback.message.edit_text(
render_actions(),
reply_markup=build_actions_keyboard(),
)
await callback.answer()
@router.message(F.text == "📒 Журнал")
async def open_journal(message: Message, state: FSMContext) -> None:
await state.clear()
JournalService().log_ui_info(
event_type="journal_open_requested",
message="Запрошено открытие журнала.",
screen="journal",
action="open",
user_id=_user_id_from_message(message),
chat_id=_chat_id_from_message(message),
)
await _show_journal_page(
message,
page=1,
edit_mode=False,
)
@router.callback_query(F.data == "journal:noop")
async def journal_noop(callback: CallbackQuery) -> None:
await callback.answer()
@router.callback_query(F.data == "journal:export_csv")
async def export_journal_csv(callback: CallbackQuery) -> None:
service = JournalService()
try:
data = service.export_csv()
document = BufferedInputFile(
data,
filename=service.build_export_filename("csv"),
)
if callback.message is not None:
await callback.message.answer_document(document=document)
service.log_ui_info(
event_type="journal_export_csv_success",
message="Журнал экспортирован в CSV.",
screen="journal",
action="export_csv",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={"format": "csv"},
)
await callback.answer("CSV экспортирован")
except Exception as exc:
service.log_ui_error(
event_type="journal_export_csv_error",
message="Не удалось экспортировать журнал в CSV.",
screen="journal",
action="export_csv",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
raw_error=str(exc),
)
await callback.answer("Не удалось экспортировать CSV", show_alert=True)
@router.callback_query(F.data == "journal:export_xlsx")
async def export_journal_xlsx(callback: CallbackQuery) -> None:
service = JournalService()
try:
data = service.export_xlsx()
document = BufferedInputFile(
data,
filename=service.build_export_filename("xlsx"),
)
if callback.message is not None:
await callback.message.answer_document(document=document)
service.log_ui_info(
event_type="journal_export_xlsx_success",
message="Журнал экспортирован в Excel.",
screen="journal",
action="export_xlsx",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={"format": "xlsx"},
)
await callback.answer("Excel экспортирован")
except Exception as exc:
service.log_ui_error(
event_type="journal_export_xlsx_error",
message="Не удалось экспортировать журнал в Excel.",
screen="journal",
action="export_xlsx",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
raw_error=str(exc),
)
await callback.answer("Не удалось экспортировать Excel", show_alert=True)
@router.callback_query(F.data == "journal:clear_confirm")
async def clear_journal_confirm(callback: CallbackQuery) -> None:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
return
service = JournalService()
total_count = service.get_total_count()
await callback.message.edit_text(
render_clear_confirm(total_count=total_count),
reply_markup=build_clear_confirm_keyboard(),
)
await callback.answer()
@router.callback_query(F.data == "journal:clear")
async def clear_journal(callback: CallbackQuery) -> None:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
return
service = JournalService()
deleted_count = service.clear_all()
total_count = service.get_total_count()
await callback.message.edit_text(
render_clear_confirm(
total_count=total_count,
deleted_count=deleted_count,
),
reply_markup=build_clear_confirm_keyboard(),
)
await callback.answer(f"Удалено: {deleted_count}")
@router.callback_query(F.data == "journal:clear_older:90")
async def clear_journal_older_90(callback: CallbackQuery) -> None:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
return
service = JournalService()
deleted_count = service.clear_older_than_days(90)
total_count = service.get_total_count()
await callback.message.edit_text(
render_clear_confirm(
total_count=total_count,
deleted_count=deleted_count if deleted_count > 0 else None,
no_old_records_days=90 if deleted_count == 0 else None,
),
reply_markup=build_clear_confirm_keyboard(),
)
await callback.answer(f"Удалено: {deleted_count}")
@router.callback_query(F.data.startswith("journal:"))
async def paginate(callback: CallbackQuery) -> None:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
return
page_raw = callback.data.split(":", 1)[1]
try:
page = int(page_raw)
except ValueError:
await callback.answer("Неизвестное действие", show_alert=True)
return
await _show_journal_page(
callback.message,
page=page,
edit_mode=True,
)
await callback.answer()

View File

@@ -0,0 +1,217 @@
# app/src/telegram/handlers/journal_ui.py
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.core.config import load_settings
PAGE_SIZE = 5
LEVEL_ICONS = {
"INFO": "",
"WARNING": "⚠️",
"ERROR": "",
"CRITICAL": "🆘",
}
EVENT_TITLES = {
"app_start": "Запуск приложения",
"system_open_alert": "Система загружена с предупреждениями",
"system_open_requested": "Открытие системы",
"system_open_success": "Система загружена",
"system_retry": "Система обновлена",
"market_open_requested": "Открытие рынка",
"market_open_success": "Рынок загружен",
"market_open_error": "Ошибка открытия рынка",
"market_retry_error": "Ошибка обновления рынка",
"market_symbol_invalid": "Некорректный инструмент",
"market_price_error": "Ошибка загрузки цены",
"portfolio_open_requested": "Открытие портфеля",
"portfolio_open_success": "Портфель загружен",
"portfolio_open_error": "Ошибка открытия портфеля",
"portfolio_retry_error": "Ошибка обновления портфеля",
"portfolio_empty": "Портфель пуст",
"portfolio_zero_balances": "Нет активов с балансом",
"portfolio_partial_estimate": "Частичная оценка портфеля",
"balance_summary_loaded": "Баланс загружен",
"balance_summary_empty": "Баланс пуст",
"balance_summary_error": "Ошибка загрузки баланса",
"exchange_request_error": "Ошибка запроса к бирже",
"trade_drafts_open": "Открытие списка черновиков",
"trade_drafts_paginate": "Переключение страницы черновиков",
"trade_draft_open_success": "Черновик открыт",
"trade_draft_open_not_found": "Черновик не найден",
"trade_draft_edit_start": "Начато редактирование черновика",
"trade_draft_edit_error": "Ошибка редактирования черновика",
"trade_draft_edit_not_found": "Черновик не найден",
"trade_order_create_start": "Начато создание ордера",
"trade_order_create_start_error": "Ошибка создания ордера",
"trade_order_create_cancelled": "Создание ордера отменено",
"trade_order_side_selected": "Выбрана сторона ордера",
"trade_order_side_select_error": "Ошибка выбора стороны",
"trade_order_type_selected": "Выбран тип ордера",
"trade_order_type_select_error": "Ошибка выбора типа",
"trade_order_quantity_selected": "Выбрано количество",
"trade_order_quantity_select_error": "Ошибка выбора количества",
"trade_order_quantity_manual_open": "Ручной ввод количества",
"trade_order_quantity_manual_success": "Количество введено",
"trade_order_quantity_manual_error": "Ошибка ввода количества",
"trade_order_price_selected": "Выбрана цена",
"trade_order_price_select_error": "Ошибка выбора цены",
"trade_order_price_manual_open": "Ручной ввод цены",
"trade_order_price_manual_success": "Цена введена",
"trade_order_price_manual_error": "Ошибка ввода цены",
"trade_order_confirm_success": "Черновик сохранён",
"trade_order_confirm_error": "Ошибка сохранения",
"trade_order_confirm_validation_error": "Ошибка проверки",
"trade_order_confirm_state_error": "Ошибка состояния",
"journal_cleared": "Журнал очищен",
"journal_cleared_old": "Журнал очищен по сроку",
"journal_export_csv_success": "Экспорт CSV",
"journal_export_csv_error": "Ошибка экспорта CSV",
"journal_export_xlsx_success": "Экспорт Excel",
"journal_export_xlsx_error": "Ошибка экспорта Excel",
"journal_open_requested": "Открытие журнала",
}
TECH_TO_HUMAN_MESSAGES = {
"invalid api key": "Неверный API Key.",
"unauthorized": "Нет доступа к аккаунту.",
"forbidden": "Доступ запрещён.",
"network error": "Нет связи с биржей.",
"timeout": "Биржа не ответила вовремя.",
}
def build_keyboard(page: int, total_pages: int) -> InlineKeyboardMarkup:
kb = InlineKeyboardBuilder()
if page > 1:
kb.button(text="⏮️", callback_data="journal:1")
kb.button(text="⬅️", callback_data=f"journal:{page - 1}")
kb.button(text=f"{page}/{total_pages}", callback_data="journal:noop")
if page < total_pages:
kb.button(text="➡️", callback_data=f"journal:{page + 1}")
kb.button(text="📤 Экспорт", callback_data="journal:actions")
kb.button(text="🛠️ Настройки", callback_data="settings:journal")
kb.button(text="⬅️ Назад", callback_data="system:back")
nav_count = 1
if page > 1:
nav_count += 2
if page < total_pages:
nav_count += 1
kb.adjust(nav_count, 2, 1)
return kb.as_markup()
def build_actions_keyboard() -> InlineKeyboardMarkup:
kb = InlineKeyboardBuilder()
kb.button(text="📄 CSV", callback_data="journal:export_csv")
kb.button(text="📊 Excel", callback_data="journal:export_xlsx")
kb.button(text="⬅️ Назад", callback_data="journal:1")
kb.adjust(2, 1)
return kb.as_markup()
def render_actions() -> str:
return (
"<b>📤 Экспорт</b>\n\n"
"<b>СИСТЕМА · Журнал</b>\n\n"
"Выберите формат:"
)
def build_clear_confirm_keyboard() -> InlineKeyboardMarkup:
kb = InlineKeyboardBuilder()
kb.button(text="Очистить всё", callback_data="journal:clear")
kb.button(text="Старше 90 дней", callback_data="journal:clear_older:90")
kb.button(text="⬅️ Назад", callback_data="settings:journal")
kb.button(text="📒 Журнал", callback_data="journal:1")
kb.adjust(2, 2)
return kb.as_markup()
def render_clear_confirm(
*,
total_count: int,
deleted_count: int | None = None,
no_old_records_days: int | None = None,
) -> str:
lines = [
"<b>⚠️ Очистить журнал</b>",
"",
"<b>СИСТЕМА</b> · Настройки · Журнал",
"",
f"📄 Записей: {total_count}",
]
if deleted_count is not None:
lines.append(f"🧹 Удалено записей: {deleted_count}")
if no_old_records_days is not None:
lines.append(f"📭 Нет записей старше {no_old_records_days} дней")
return "\n".join(lines)
def _normalize_datetime(value: str) -> str:
try:
settings = load_settings()
dt = datetime.fromisoformat(value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
dt = dt.astimezone(ZoneInfo(settings.tz))
return dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return value
def _event_title(event_type: str) -> str:
return EVENT_TITLES.get(event_type, event_type)
def _humanize_message(message: str) -> str:
lower = message.lower()
for k, v in TECH_TO_HUMAN_MESSAGES.items():
if k in lower:
return v
return message
def render(events, page, total_pages):
lines = [
"<b>📒 Журнал</b>",
"",
"<b>СИСТЕМА</b>",
"",
"<b>Последние события:</b>",
"",
]
if not events:
lines.append("Событий пока нет.")
return "\n".join(lines)
for event in events:
level = str(event.get("level", "INFO")).upper()
icon = LEVEL_ICONS.get(level, "")
title = _event_title(str(event.get("event_type", "")))
created_at = _normalize_datetime(str(event.get("created_at", "")))
message = _humanize_message(str(event.get("message", "")))
lines.append(f"{icon} [ <b>{level}</b> ] <b>{title}</b>")
lines.append(f"{created_at}")
lines.append("")
return "\n".join(lines).rstrip()

View File

@@ -11,6 +11,7 @@ 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 (
classify_exchange_error,
show_callback_exchange_error,
show_message_exchange_error,
)
@@ -20,42 +21,6 @@ from src.trading.journal.service import JournalService
router = Router(name="market")
def _safe_log_info(
journal: JournalService,
event_type: str,
message: str,
payload: dict | None = None,
) -> None:
try:
journal.log_info(event_type, message, payload)
except Exception:
pass
def _safe_log_warning(
journal: JournalService,
event_type: str,
message: str,
payload: dict | None = None,
) -> None:
try:
journal.log_warning(event_type, message, payload)
except Exception:
pass
def _safe_log_error(
journal: JournalService,
event_type: str,
message: str,
payload: dict | None = None,
) -> None:
try:
journal.log_error(event_type, message, payload)
except Exception:
pass
def _market_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🏠 К торговле", callback_data="trade:home")
@@ -105,33 +70,35 @@ async def _render_market_screen(
user_id: int | None,
chat_id: int | None,
edit_mode: bool,
action: str,
) -> None:
service = ExchangeService()
journal = JournalService()
requested_symbol = service.settings.default_symbol
_safe_log_info(
journal,
"user_open_market",
"Пользователь открыл экран рынка.",
{
"user_id": user_id,
"chat_id": chat_id,
"symbol": requested_symbol,
},
journal.log_ui_info(
event_type="market_open_requested",
message="Запрошено открытие экрана рынка.",
screen="market",
action=action,
user_id=user_id,
chat_id=chat_id,
payload={"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,
journal.log_ui_warning(
event_type="market_symbol_invalid",
message="Инструмент недоступен.",
screen="market",
action=action,
user_id=user_id,
chat_id=chat_id,
payload={
"symbol": requested_symbol,
"validation_message": validation.message,
},
)
@@ -172,13 +139,14 @@ async def _render_market_screen(
tick_size=tick_size,
)
_safe_log_info(
journal,
"market_open_success",
"Экран рынка успешно показан пользователю.",
{
"user_id": user_id,
"chat_id": chat_id,
journal.log_ui_info(
event_type="market_open_success",
message="Экран рынка загружен.",
screen="market",
action=action,
user_id=user_id,
chat_id=chat_id,
payload={
"symbol": ticker.symbol,
"price": ticker.price,
},
@@ -203,13 +171,18 @@ async def open_market(message: Message, state: FSMContext) -> None:
user_id=user_id,
chat_id=chat_id,
edit_mode=False,
action="open",
)
except ExchangeError as exc:
_safe_log_error(
JournalService(),
"market_open_error",
f"Не удалось открыть экран рынка: {exc}",
{"user_id": user_id, "chat_id": chat_id},
JournalService().log_ui_error(
event_type="market_open_error",
message="Не удалось загрузить экран рынка.",
screen="market",
action="open",
user_id=user_id,
chat_id=chat_id,
error_type=classify_exchange_error(exc),
raw_error=str(exc),
)
await show_message_exchange_error(
@@ -239,14 +212,19 @@ async def retry_market(callback: CallbackQuery, state: FSMContext) -> None:
user_id=user_id,
chat_id=chat_id,
edit_mode=True,
action="retry",
)
await callback.answer()
await callback.answer()
except ExchangeError as exc:
_safe_log_error(
JournalService(),
"market_retry_error",
f"Не удалось повторно открыть рынок: {exc}",
{"user_id": user_id, "chat_id": chat_id},
JournalService().log_ui_error(
event_type="market_retry_error",
message="Не удалось обновить экран рынка.",
screen="market",
action="retry",
user_id=user_id,
chat_id=chat_id,
error_type=classify_exchange_error(exc),
raw_error=str(exc),
)
await show_callback_exchange_error(

View File

@@ -19,6 +19,7 @@ from src.telegram.ui.currency_ui import (
is_zero_balance,
)
from src.telegram.ui.exchange_error import (
classify_exchange_error,
show_callback_exchange_error,
show_message_exchange_error,
)
@@ -52,42 +53,6 @@ def _portfolio_warning_keyboard() -> InlineKeyboardMarkup:
return builder.as_markup()
def _safe_log_info(
journal: JournalService,
event_type: str,
message: str,
payload: dict | None = None,
) -> None:
try:
journal.log_info(event_type, message, payload)
except Exception:
pass
def _safe_log_warning(
journal: JournalService,
event_type: str,
message: str,
payload: dict | None = None,
) -> None:
try:
journal.log_warning(event_type, message, payload)
except Exception:
pass
def _safe_log_error(
journal: JournalService,
event_type: str,
message: str,
payload: dict | None = None,
) -> None:
try:
journal.log_error(event_type, message, payload)
except Exception:
pass
def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
def sort_key(item: BalanceSummary) -> tuple[int, str]:
currency = item.currency.upper()
@@ -103,32 +68,31 @@ async def _render_portfolio_screen(
user_id: int | None,
chat_id: int | None,
edit_mode: bool,
action: str,
) -> None:
service = AccountsService()
exchange_service = ExchangeService()
journal = JournalService()
_safe_log_info(
journal,
"user_open_portfolio",
"Пользователь открыл экран портфеля.",
{
"user_id": user_id,
"chat_id": chat_id,
},
journal.log_ui_info(
event_type="portfolio_open_requested",
message="Запрошено открытие экрана портфеля.",
screen="portfolio",
action=action,
user_id=user_id,
chat_id=chat_id,
)
balances = service.get_live_balance_summary()
if not balances:
_safe_log_warning(
journal,
"portfolio_empty",
"Портфель открыт, но баланс пуст.",
{
"user_id": user_id,
"chat_id": chat_id,
},
journal.log_ui_warning(
event_type="portfolio_empty",
message="Нет данных по балансу.",
screen="portfolio",
action=action,
user_id=user_id,
chat_id=chat_id,
)
text = (
@@ -147,15 +111,14 @@ async def _render_portfolio_screen(
visible_balances = sort_balances(visible_balances)
if not visible_balances:
_safe_log_warning(
journal,
"portfolio_zero_balances",
"Портфель открыт, но все балансы нулевые.",
{
"user_id": user_id,
"chat_id": chat_id,
"assets_count": len(balances),
},
journal.log_ui_warning(
event_type="portfolio_zero_balances",
message="Нет активов с балансом.",
screen="portfolio",
action=action,
user_id=user_id,
chat_id=chat_id,
payload={"assets_count": len(balances)},
)
text = (
@@ -211,30 +174,25 @@ async def _render_portfolio_screen(
has_partial_data = len(missing_estimate_assets) > 0
if missing_estimate_assets:
lines.extend(
[
"🟡 <b>Данные загружены частично</b>",
]
)
lines.append("🟡 <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)}"
)
lines.append(f"Нет оценки: {', '.join(missing_estimate_assets)}")
for block in asset_blocks:
lines.extend(block)
_safe_log_info(
journal,
"portfolio_open_success",
"Портфель успешно показан пользователю.",
{
"user_id": user_id,
"chat_id": chat_id,
journal.log_ui_info(
event_type="portfolio_open_success",
message="Портфель загружен.",
screen="portfolio",
action=action,
user_id=user_id,
chat_id=chat_id,
payload={
"assets_count": len(visible_balances),
"estimated_usd": round(total_estimated_usd, 2) if has_any_estimate else None,
"missing_estimate_assets": missing_estimate_assets,
@@ -242,25 +200,23 @@ async def _render_portfolio_screen(
)
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,
journal.log_ui_warning(
event_type="portfolio_partial_estimate",
message="Портфель загружен частично.",
screen="portfolio",
action=action,
user_id=user_id,
chat_id=chat_id,
payload={
"assets_count": len(visible_balances),
"estimated_assets": len(visible_balances) - len(missing_estimate_assets),
"failed_assets": missing_estimate_assets,
},
)
if has_partial_data:
lines.extend(
[
"",
now_line(),
]
)
lines.extend(["", now_line()])
text = "\n".join(lines).rstrip()
reply_markup = (
@@ -288,18 +244,20 @@ async def open_portfolio(message: Message, state: FSMContext) -> None:
user_id=user_id,
chat_id=chat_id,
edit_mode=False,
action="open",
)
except ExchangeError as exc:
journal = JournalService()
_safe_log_error(
journal,
"portfolio_open_error",
f"Не удалось открыть портфель: {exc}",
{
"user_id": user_id,
"chat_id": chat_id,
},
JournalService().log_ui_error(
event_type="portfolio_open_error",
message="Не удалось загрузить портфель.",
screen="portfolio",
action="open",
user_id=user_id,
chat_id=chat_id,
error_type=classify_exchange_error(exc),
raw_error=str(exc),
)
await show_message_exchange_error(
message,
title="<b>💼 Портфель</b>",
@@ -327,19 +285,21 @@ async def retry_portfolio(callback: CallbackQuery, state: FSMContext) -> None:
user_id=user_id,
chat_id=chat_id,
edit_mode=True,
action="retry",
)
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,
},
JournalService().log_ui_error(
event_type="portfolio_retry_error",
message="Не удалось обновить портфель.",
screen="portfolio",
action="retry",
user_id=user_id,
chat_id=chat_id,
error_type=classify_exchange_error(exc),
raw_error=str(exc),
)
await show_callback_exchange_error(
callback,
title="<b>💼 Портфель</b>",

View File

@@ -8,6 +8,7 @@ from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.core.system_status import build_system_text, get_system_snapshot, has_system_alerts
from src.trading.journal.service import JournalService
router = Router(name="system")
@@ -15,16 +16,20 @@ router = Router(name="system")
def _system_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🏠 К торговле", callback_data="trade:home")
builder.adjust(1)
builder.button(text="📒 Журнал", callback_data="journal:1")
builder.button(text="🛠️ Настройки", callback_data="system:management")
builder.button(text=" Информация", callback_data="system:about")
builder.adjust(2, 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)
builder.button(text="📒 Журнал", callback_data="journal:1")
builder.button(text="🛠️ Настройки", callback_data="system:management")
builder.button(text=" Информация", callback_data="system:about")
builder.adjust(1, 2, 1)
return builder.as_markup()
@@ -32,10 +37,56 @@ async def _render_system_screen(
target_message: Message,
*,
edit_mode: bool,
user_id: int | None,
chat_id: int | None,
action: str,
) -> None:
journal = JournalService()
journal.log_ui_info(
event_type="system_open_requested",
message="Запрошено открытие экрана системы.",
screen="system",
action=action,
user_id=user_id,
chat_id=chat_id,
)
snapshot = get_system_snapshot()
is_alert = has_system_alerts(snapshot)
if is_alert:
journal.log_ui_warning(
event_type="system_open_alert",
message="Система загружена с предупреждениями.",
screen="system",
action=action,
user_id=user_id,
chat_id=chat_id,
payload={
"has_alerts": True,
"components": [
{
"name": component.name,
"state": component.state,
"details": component.details,
}
for component in snapshot.components
if component.state != "🟢"
],
},
)
else:
journal.log_ui_info(
event_type="system_open_success",
message="Экран системы загружен.",
screen="system",
action=action,
user_id=user_id,
chat_id=chat_id,
payload={"has_alerts": False},
)
text = build_system_text(include_updated_at=is_alert)
reply_markup = _system_alert_keyboard() if is_alert else _system_keyboard()
@@ -45,12 +96,19 @@ async def _render_system_screen(
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:
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
await _render_system_screen(
message,
edit_mode=False,
user_id=user_id,
chat_id=chat_id,
action="open",
)
@@ -62,8 +120,100 @@ async def retry_system(callback: CallbackQuery, state: FSMContext) -> 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
await _render_system_screen(
callback.message,
edit_mode=True,
user_id=user_id,
chat_id=chat_id,
action="retry",
)
await callback.answer()
await callback.answer()
@router.callback_query(F.data == "system:management")
async def open_system_management(callback: CallbackQuery) -> None:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
return
text = (
"<b>🛠️ Настройки</b>\n\n"
"<b>СИСТЕМА</b>\n\n"
"Выберите раздел:"
)
builder = InlineKeyboardBuilder()
builder.button(text="🤖 Автоторговля", callback_data="settings:auto")
builder.button(text="📊 Торговля", callback_data="settings:trade")
builder.button(text="🌍 Общие", callback_data="settings:general")
builder.button(text="📒 Журнал", callback_data="settings:journal")
builder.button(text="⬅️ Назад", callback_data="system:back")
builder.adjust(2, 2, 1)
await callback.message.edit_text(
text,
reply_markup=builder.as_markup(),
)
await callback.answer()
@router.callback_query(F.data == "settings:journal")
async def open_journal_settings(callback: CallbackQuery) -> None:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
return
service = JournalService()
total = service.get_total_count()
text = (
"<b>📒 Журнал</b>\n\n"
"<b>СИСТЕМА</b> · Настройки\n\n"
f"📄 Записей: {total}\n"
"📦 Лимит: —\n"
"⏳ Хранение: —\n"
"🗄 Архив: —\n\n"
)
builder = InlineKeyboardBuilder()
builder.button(text="🗑 Очистка", callback_data="journal:clear_confirm")
builder.button(text="🗄 Архив", callback_data="settings:journal_archive")
builder.button(text="📦 Лимит", callback_data="settings:journal_limit")
builder.button(text="⏳ Хранение", callback_data="settings:journal_retention")
builder.button(text="⬅️ Назад", callback_data="system:control")
builder.button(text="📒 Журнал", callback_data="journal:1")
builder.adjust(2, 2, 2)
await callback.message.edit_text(
text,
reply_markup=builder.as_markup(),
)
await callback.answer()
@router.callback_query(F.data == "system:back")
async def back_to_system(callback: CallbackQuery) -> None:
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
await _render_system_screen(
callback.message,
edit_mode=True,
user_id=user_id,
chat_id=chat_id,
action="back",
)
await callback.answer()
@router.callback_query(F.data == "system:about")
async def open_system_about(callback: CallbackQuery) -> None:
await callback.answer("О продукте скоро появится")

View File

@@ -7,11 +7,6 @@ from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from src.telegram.ui.exchange_error import (
show_callback_exchange_error,
show_message_exchange_error,
)
from src.integrations.exchange.exceptions import ExchangeError
from src.telegram.handlers.trade.new_order_core import router
from src.telegram.handlers.trade.new_order_ui import (
@@ -42,6 +37,12 @@ from src.telegram.handlers.trade.new_order_ui import (
mode_line,
show_recent_drafts,
)
from src.telegram.ui.exchange_error import (
classify_exchange_error,
show_callback_exchange_error,
show_message_exchange_error,
)
from src.trading.journal.service import JournalService
from src.trading.orders.service import OrderDraftsService
from src.trading.orders.states import NewOrderDraftStates
@@ -61,6 +62,24 @@ MAIN_MENU_BUTTONS = {
}
def _user_id_from_message(message: Message) -> int | None:
return message.from_user.id if message.from_user else None
def _chat_id_from_message(message: Message) -> int | None:
return message.chat.id if message.chat else None
def _user_id_from_callback(callback: CallbackQuery) -> int | None:
return callback.from_user.id if callback.from_user else None
def _chat_id_from_callback(callback: CallbackQuery) -> int | None:
if callback.message and callback.message.chat:
return callback.message.chat.id
return None
@router.callback_query(F.data == "drafts:noop")
async def drafts_noop(callback: CallbackQuery) -> None:
await callback.answer()
@@ -75,7 +94,17 @@ async def paginate_drafts(callback: CallbackQuery) -> None:
page = int(value)
await callback.answer()
if callback.message is not None:
JournalService().log_ui_info(
event_type="trade_drafts_paginate",
message="Открыта страница черновиков.",
screen="trade",
action="drafts_paginate",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={"page": page},
)
await show_recent_drafts(callback.message, edit_mode=True, page=page)
@@ -87,9 +116,28 @@ async def open_draft(callback: CallbackQuery) -> None:
draft = service.get_draft_by_id(draft_id)
if not draft:
JournalService().log_ui_warning(
event_type="trade_draft_open_not_found",
message="Черновик не найден.",
screen="trade",
action="draft_open",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={"draft_id": draft_id, "page": page},
)
await callback.answer("Черновик не найден", show_alert=True)
return
JournalService().log_ui_info(
event_type="trade_draft_open_success",
message="Черновик открыт.",
screen="trade",
action="draft_open",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={"draft_id": draft_id, "page": page},
)
await callback.message.edit_text(
_render_draft_detail(draft),
reply_markup=_draft_detail_keyboard(draft_id, page),
@@ -100,11 +148,22 @@ async def open_draft(callback: CallbackQuery) -> None:
@router.callback_query(F.data.startswith("draft_edit:"))
async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None:
service = OrderDraftsService()
journal = JournalService()
_, draft_id, page_raw = callback.data.split(":", 2)
page = int(page_raw)
draft = service.get_draft_by_id(draft_id)
if not draft:
journal.log_ui_warning(
event_type="trade_draft_edit_not_found",
message="Черновик не найден.",
screen="trade",
action="draft_edit",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={"draft_id": draft_id, "page": page},
)
await callback.answer("Черновик не найден", show_alert=True)
return
@@ -150,8 +209,34 @@ async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None:
drafts_page=page,
),
)
journal.log_ui_info(
event_type="trade_draft_edit_requested",
message="Запрошено редактирование черновика.",
screen="trade",
action="draft_edit",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={
"draft_id": draft_id,
"page": page,
"side": side,
"order_type": order_type,
},
)
await callback.answer()
except (ExchangeError, ValueError) as exc:
journal.log_ui_error(
event_type="trade_draft_edit_error",
message="Не удалось открыть редактирование черновика.",
screen="trade",
action="draft_edit",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
error_type=classify_exchange_error(exc) if isinstance(exc, ExchangeError) else "generic",
raw_error=str(exc),
payload={"draft_id": draft_id, "page": page},
)
await show_callback_exchange_error(
callback,
title=_screen_title(is_edit_mode=True),
@@ -160,17 +245,35 @@ async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None:
back_callback_data=f"draft_open:{draft_id}:{page}",
drafts_page=page,
)
return
@router.callback_query(F.data.startswith("draft_delete:"))
async def delete_draft_stub(callback: CallbackQuery) -> None:
JournalService().log_ui_info(
event_type="trade_draft_delete_requested",
message="Запрошено удаление черновика.",
screen="trade",
action="draft_delete",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={"callback_data": callback.data},
)
await callback.answer("Удаление скоро появится")
@router.message(Command("cancel_order"))
async def cancel_order_builder(message: Message, state: FSMContext) -> None:
await state.clear()
JournalService().log_ui_info(
event_type="trade_order_create_cancelled",
message="Создание черновика ордера отменено.",
screen="trade",
action="order_cancel",
user_id=_user_id_from_message(message),
chat_id=_chat_id_from_message(message),
)
await message.answer(
"<b>📊 Торговля — Новый ордер</b>\n"
f"{mode_line()}"
@@ -189,6 +292,7 @@ async def start_new_order_draft(
await state.set_state(NewOrderDraftStates.waiting_side)
service = OrderDraftsService()
journal = JournalService()
try:
context = service.get_entry_context(side="BUY", order_type="MARKET")
@@ -200,11 +304,31 @@ async def start_new_order_draft(
"Шаг 1/4. Выбери сторону"
)
journal.log_ui_info(
event_type="trade_order_create_requested",
message="Запрошено создание черновика ордера.",
screen="trade",
action="order_create",
user_id=_user_id_from_message(message),
chat_id=_chat_id_from_message(message),
payload={"symbol": context.symbol},
)
if edit_mode:
await message.edit_text(text, reply_markup=_side_keyboard())
else:
await message.answer(text, reply_markup=_side_keyboard())
except ExchangeError as exc:
journal.log_ui_error(
event_type="trade_order_create_error",
message="Не удалось открыть создание черновика ордера.",
screen="trade",
action="order_create",
user_id=_user_id_from_message(message),
chat_id=_chat_id_from_message(message),
error_type=classify_exchange_error(exc),
raw_error=str(exc),
)
await show_message_exchange_error(
message,
title="<b>📊 Торговля — Новый ордер</b>",
@@ -227,6 +351,7 @@ async def process_order_side_callback(
path = _render_order_path(side=side)
service = OrderDraftsService()
journal = JournalService()
try:
context = service.get_entry_context(side=side, order_type="MARKET")
@@ -239,9 +364,30 @@ async def process_order_side_callback(
"Шаг 2/4. Выбери тип ордера"
)
journal.log_ui_info(
event_type="trade_order_side_selected",
message="Выбрана сторона ордера.",
screen="trade",
action="order_side",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={"side": side, "symbol": context.symbol},
)
await callback.message.edit_text(text, reply_markup=_type_keyboard())
await callback.answer()
except ExchangeError as exc:
journal.log_ui_error(
event_type="trade_order_side_error",
message="Не удалось обработать выбор стороны ордера.",
screen="trade",
action="order_side",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
error_type=classify_exchange_error(exc),
raw_error=str(exc),
payload={"side": side},
)
await show_callback_exchange_error(
callback,
title="<b>📊 Торговля — Новый ордер</b>",
@@ -270,6 +416,7 @@ async def process_order_type_callback(
state: FSMContext,
) -> None:
service = OrderDraftsService()
journal = JournalService()
order_type = callback.data.split(":", 1)[1]
data = await state.get_data()
@@ -290,6 +437,21 @@ async def process_order_type_callback(
base_currency=context.base_currency,
)
journal.log_ui_info(
event_type="trade_order_type_selected",
message="Пользователь выбрал тип ордера.",
screen="trade",
action="order_select_type",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={
"side": side,
"order_type": order_type,
"symbol": context.symbol,
"is_edit_mode": is_edit_mode,
},
)
await callback.message.edit_text(
_render_quantity_step_screen(
title=_screen_title(is_edit_mode),
@@ -307,6 +469,21 @@ async def process_order_type_callback(
)
await callback.answer()
except ExchangeError as exc:
journal.log_ui_error(
event_type="trade_order_type_select_error",
message="Не удалось обработать выбор типа ордера.",
screen="trade",
action="order_select_type",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
error_type=classify_exchange_error(exc),
raw_error=str(exc),
payload={
"side": side,
"order_type": order_type,
"is_edit_mode": is_edit_mode,
},
)
await show_callback_exchange_error(
callback,
title=_screen_title(is_edit_mode),
@@ -327,6 +504,10 @@ async def process_order_type_text(message: Message) -> None:
)
@router.callback_query(
NewOrderDraftStates.waiting_quantity,
F.data.startswith("order_qty:"),
)
@router.callback_query(
NewOrderDraftStates.waiting_quantity,
F.data.startswith("order_qty:"),
@@ -336,6 +517,7 @@ async def process_quantity_callback(
state: FSMContext,
) -> None:
service = OrderDraftsService()
journal = JournalService()
value = callback.data.split(":", 1)[1]
data = await state.get_data()
@@ -360,6 +542,20 @@ async def process_quantity_callback(
base_currency=context.base_currency,
)
journal.log_ui_info(
event_type="trade_order_quantity_manual_open",
message="Открыт ручной ввод количества.",
screen="trade",
action="order_quantity_manual",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={
"side": side,
"order_type": order_type,
"is_edit_mode": is_edit_mode,
},
)
await callback.message.edit_text(
_render_manual_quantity_screen(
title=title,
@@ -388,6 +584,21 @@ async def process_quantity_callback(
await state.update_data(quantity=quantity)
journal.log_ui_info(
event_type="trade_order_quantity_selected",
message="Выбрано количество ордера.",
screen="trade",
action="order_quantity",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={
"side": side,
"order_type": order_type,
"quantity": quantity,
"is_edit_mode": is_edit_mode,
},
)
if order_type == "LIMIT":
path = _render_order_path(
side=side,
@@ -458,6 +669,22 @@ async def process_quantity_callback(
)
await callback.answer()
except ExchangeError as exc:
journal.log_ui_error(
event_type="trade_order_quantity_error",
message="Не удалось обработать выбор количества ордера.",
screen="trade",
action="order_quantity",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
error_type=classify_exchange_error(exc),
raw_error=str(exc),
payload={
"side": side,
"order_type": order_type,
"value": value,
"is_edit_mode": is_edit_mode,
},
)
await show_callback_exchange_error(
callback,
title=title,
@@ -473,6 +700,7 @@ async def process_quantity_callback(
)
async def process_order_quantity(message: Message, state: FSMContext) -> None:
service = OrderDraftsService()
journal = JournalService()
raw_quantity = message.text or ""
data = await state.get_data()
@@ -504,17 +732,15 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None:
)
if quantity is None:
path = _render_order_path(
side=side,
order_type=order_type,
base_currency=context.base_currency,
)
await message.answer(
_render_quantity_inline_error(
title=title,
symbol=context.symbol,
order_path=path,
order_path=_render_order_path(
side=side,
order_type=order_type,
base_currency=context.base_currency,
),
errors=["Количество должно быть числом больше нуля."],
help_text=help_text,
),
@@ -529,17 +755,15 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None:
price=None,
)
if quantity_errors:
path = _render_order_path(
side=side,
order_type=order_type,
base_currency=context.base_currency,
)
await message.answer(
_render_quantity_inline_error(
title=title,
symbol=context.symbol,
order_path=path,
order_path=_render_order_path(
side=side,
order_type=order_type,
base_currency=context.base_currency,
),
errors=quantity_errors,
help_text=help_text,
),
@@ -549,6 +773,21 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None:
await state.update_data(quantity=quantity)
journal.log_ui_info(
event_type="trade_order_quantity_manual_success",
message="Количество ордера введено вручную.",
screen="trade",
action="order_quantity_manual",
user_id=_user_id_from_message(message),
chat_id=_chat_id_from_message(message),
payload={
"side": side,
"order_type": order_type,
"quantity": quantity,
"is_edit_mode": is_edit_mode,
},
)
if order_type == "LIMIT":
path = _render_order_path(
side=side,
@@ -615,6 +854,22 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None:
reply_markup=_confirm_keyboard(drafts_page=drafts_page),
)
except ExchangeError as exc:
journal.log_ui_error(
event_type="trade_order_quantity_manual_error",
message="Не удалось обработать ручной ввод количества.",
screen="trade",
action="order_quantity_manual",
user_id=_user_id_from_message(message),
chat_id=_chat_id_from_message(message),
error_type=classify_exchange_error(exc),
raw_error=str(exc),
payload={
"side": side,
"order_type": order_type,
"raw_quantity": raw_quantity,
"is_edit_mode": is_edit_mode,
},
)
await show_message_exchange_error(
message,
title=title,
@@ -632,6 +887,7 @@ async def process_price_callback(
state: FSMContext,
) -> None:
service = OrderDraftsService()
journal = JournalService()
value = callback.data.split(":", 1)[1]
data = await state.get_data()
@@ -657,6 +913,16 @@ async def process_price_callback(
base_currency=context.base_currency,
)
journal.log_ui_info(
event_type="trade_order_price_manual_open",
message="Открыт ручной ввод цены.",
screen="trade",
action="order_price_manual",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={"is_edit_mode": is_edit_mode},
)
await callback.message.edit_text(
_render_manual_price_screen(
title=title,
@@ -700,6 +966,19 @@ async def process_price_callback(
await state.set_state(NewOrderDraftStates.waiting_confirm)
journal.log_ui_info(
event_type="trade_order_price_selected",
message="Выбрана цена ордера.",
screen="trade",
action="order_price",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={
"price": price,
"is_edit_mode": is_edit_mode,
},
)
await callback.message.edit_text(
_render_confirm(
symbol=draft.symbol,
@@ -716,6 +995,20 @@ async def process_price_callback(
)
await callback.answer()
except ExchangeError as exc:
journal.log_ui_error(
event_type="trade_order_price_error",
message="Не удалось обработать выбор цены ордера.",
screen="trade",
action="order_price",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
error_type=classify_exchange_error(exc),
raw_error=str(exc),
payload={
"value": value,
"is_edit_mode": is_edit_mode,
},
)
await show_callback_exchange_error(
callback,
title=title,
@@ -731,6 +1024,7 @@ async def process_price_callback(
)
async def process_order_price(message: Message, state: FSMContext) -> None:
service = OrderDraftsService()
journal = JournalService()
raw_price = message.text or ""
price = service.normalize_price(raw_price)
@@ -754,18 +1048,16 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
)
if price is None:
path = _render_order_path(
side=data.get("side"),
order_type=data.get("order_type"),
quantity=data.get("quantity"),
base_currency=context.base_currency,
)
await message.answer(
_render_price_inline_error(
title=title,
symbol=context.symbol,
order_path=path,
order_path=_render_order_path(
side=data.get("side"),
order_type=data.get("order_type"),
quantity=data.get("quantity"),
base_currency=context.base_currency,
),
errors=["Цена должна быть числом больше нуля."],
help_text=help_text,
),
@@ -782,18 +1074,16 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
validation = service.validate_draft(draft)
if not validation.is_valid:
path = _render_order_path(
side=draft.side,
order_type=draft.order_type,
quantity=draft.quantity,
base_currency=context.base_currency,
)
await message.answer(
_render_price_inline_error(
title=title,
symbol=context.symbol,
order_path=path,
order_path=_render_order_path(
side=draft.side,
order_type=draft.order_type,
quantity=draft.quantity,
base_currency=context.base_currency,
),
errors=validation.errors,
help_text=help_text,
),
@@ -817,6 +1107,19 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
)
await state.set_state(NewOrderDraftStates.waiting_confirm)
journal.log_ui_info(
event_type="trade_order_price_manual_success",
message="Цена ордера введена вручную.",
screen="trade",
action="order_price_manual",
user_id=_user_id_from_message(message),
chat_id=_chat_id_from_message(message),
payload={
"price": price,
"is_edit_mode": is_edit_mode,
},
)
await message.answer(
_render_confirm(
symbol=draft.symbol,
@@ -832,6 +1135,20 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
reply_markup=_confirm_keyboard(drafts_page=drafts_page),
)
except ExchangeError as exc:
journal.log_ui_error(
event_type="trade_order_price_manual_error",
message="Не удалось обработать ручной ввод цены.",
screen="trade",
action="order_price_manual",
user_id=_user_id_from_message(message),
chat_id=_chat_id_from_message(message),
error_type=classify_exchange_error(exc),
raw_error=str(exc),
payload={
"raw_price": raw_price,
"is_edit_mode": is_edit_mode,
},
)
await show_message_exchange_error(
message,
title=title,
@@ -842,17 +1159,34 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
@router.message(Command("drafts"))
async def drafts_command(message: Message) -> None:
JournalService().log_ui_info(
event_type="trade_drafts_open_requested",
message="Запрошено открытие списка черновиков.",
screen="trade",
action="drafts_open",
user_id=_user_id_from_message(message),
chat_id=_chat_id_from_message(message),
)
await show_recent_drafts(message, edit_mode=False, page=1)
@router.callback_query(NewOrderDraftStates.waiting_confirm, F.data == "order_confirm")
async def confirm_order(callback: CallbackQuery, state: FSMContext) -> None:
service = OrderDraftsService()
journal = JournalService()
data = await state.get_data()
raw = data.get("confirm_draft")
if not raw:
await state.clear()
journal.log_ui_warning(
event_type="trade_order_confirm_state_error",
message="Состояние подтверждения черновика не найдено.",
screen="trade",
action="order_confirm",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
)
await callback.answer("Ошибка состояния", show_alert=True)
return
@@ -874,6 +1208,21 @@ async def confirm_order(callback: CallbackQuery, state: FSMContext) -> None:
edit_page = data.get("draft_edit_page")
await state.clear()
errors = [item.strip() for item in str(exc).split(";") if item.strip()]
journal.log_ui_warning(
event_type="trade_order_confirm_validation_error",
message="Черновик не прошёл проверку при сохранении.",
screen="trade",
action="order_confirm",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
raw_error=str(exc),
payload={
"errors": errors,
"edit_page": edit_page,
},
)
reply_markup = (
_drafts_back_keyboard(int(edit_page))
if edit_page
@@ -887,6 +1236,19 @@ async def confirm_order(callback: CallbackQuery, state: FSMContext) -> None:
return
except ExchangeError as exc:
await state.clear()
journal.log_ui_error(
event_type="trade_order_confirm_error",
message="Не удалось сохранить черновик ордера.",
screen="trade",
action="order_confirm",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
error_type=classify_exchange_error(exc),
raw_error=str(exc),
payload={"draft_edit_page": data.get("draft_edit_page")},
)
await show_callback_exchange_error(
callback,
title="<b>📊 Торговля — Подтверждение черновика</b>",
@@ -899,6 +1261,23 @@ async def confirm_order(callback: CallbackQuery, state: FSMContext) -> None:
edit_page = data.get("draft_edit_page")
await state.clear()
journal.log_ui_info(
event_type="trade_order_confirm_success",
message="Черновик ордера сохранён.",
screen="trade",
action="order_confirm",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={
"symbol": draft.symbol,
"side": draft.side,
"order_type": draft.order_type,
"quantity": draft.quantity,
"price": draft.price,
"is_edit_mode": bool(edit_page),
},
)
reply_markup = (
_drafts_back_keyboard(int(edit_page))
if edit_page