diff --git a/app/requirements.txt b/app/requirements.txt index d15d1d8..7c70f68 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,3 +1,6 @@ +# app/requirements.txt + aiogram==3.13.1 python-dotenv==1.0.1 -psycopg[binary]==3.2.9 \ No newline at end of file +psycopg[binary]==3.2.9 +openpyxl==3.1.5 \ No newline at end of file diff --git a/app/src/core/system_status.py b/app/src/core/system_status.py index 3d36b1d..82ac7ef 100644 --- a/app/src/core/system_status.py +++ b/app/src/core/system_status.py @@ -114,11 +114,11 @@ def _build_journal_status() -> ComponentStatus: def get_runtime_mode_key() -> str: settings = load_settings() - return "demo" if "demo" in settings.exchange_base_url.lower() else "real" + return "demo" if "demo" in settings.exchange_base_url.lower() else "live" def get_runtime_mode_label() -> str: - return "ДЕМО аккаунт" if get_runtime_mode_key() == "demo" else "РЕАЛЬНЫЙ аккаунт" + return "DEMO аккаунт" if get_runtime_mode_key() == "demo" else "LIVE аккаунт" def get_system_snapshot() -> SystemSnapshot: @@ -182,7 +182,7 @@ def build_system_text(*, include_updated_at: bool = False) -> str: ) text = ( - "⚙️ Система\n" + "🖥️ Система\n" f"🔸 {snapshot.mode_label}\n" f"⏱️ {snapshot.timezone_name}\n\n" f"{components_block}" diff --git a/app/src/integrations/exchange/service.py b/app/src/integrations/exchange/service.py index 4eeeadc..6c68d54 100644 --- a/app/src/integrations/exchange/service.py +++ b/app/src/integrations/exchange/service.py @@ -50,6 +50,89 @@ class ExchangeService: except Exception: pass + def _classify_error(self, exc: Exception) -> str: + text = str(exc).lower() + + if any( + marker in text + for marker in [ + "invalid api key", + "api key", + "api-key", + "signature", + "unauthorized", + "forbidden", + "private api error", + "expired", + ] + ): + return "auth" + + if any( + marker in text + for marker in [ + "timeout", + "timed out", + "connection error", + "network error", + "name or service not known", + "nodename nor servname", + ] + ): + return "network" + + if any( + marker in text + for marker in [ + "-1021", + "server time", + "doesn't match server time", + ] + ): + return "time" + + return "generic" + + def _log_exchange_error( + self, + *, + endpoint: str, + exc: Exception, + symbol: str | None = None, + extra_payload: dict | None = None, + ) -> None: + payload = { + "endpoint": endpoint, + "symbol": symbol, + "exchange_name": self.settings.exchange_name, + "error_type": self._classify_error(exc), + "raw_error": str(exc), + } + + if extra_payload: + payload.update(extra_payload) + + self._log_error( + "exchange_request_error", + str(exc), + payload, + ) + + def _format_exchange_time(self, raw_timestamp: object) -> str: + if not raw_timestamp: + return "n/a" + + dt_utc = datetime.fromtimestamp(int(raw_timestamp) / 1000, tz=ZoneInfo("UTC")) + dt_local = dt_utc.astimezone(ZoneInfo(self.settings.tz)) + return dt_local.strftime("%d.%m.%Y %H:%M:%S") + + def _source_name(self) -> str: + return ( + "dzengi-demo-api" + if "demo" in self.settings.exchange_base_url.lower() + else "dzengi-api" + ) + def get_health(self) -> ExchangeHealth: if not self.settings.exchange_enabled: return mock_exchange_health() @@ -94,6 +177,10 @@ class ExchangeService: payload = ExchangePrivateClient().get_account_info(show_zero_balance=False) balances = parse_account_balances(payload) except Exception as exc: + self._log_exchange_error( + endpoint="private/account_info", + exc=exc, + ) return PrivateAuthHealth( ok=False, message=f"Private API error: {exc}", @@ -134,33 +221,40 @@ class ExchangeService: raise ExchangeError(validation.message) client = ExchangeRestClient() - payload = client.get_json( - "/api/v2/ticker/24hr", - params={"symbol": validation.normalized_symbol}, - ) + + try: + payload = client.get_json( + "/api/v2/ticker/24hr", + params={"symbol": validation.normalized_symbol}, + ) + except Exception as exc: + self._log_exchange_error( + endpoint="ticker/24hr", + exc=exc, + symbol=validation.normalized_symbol, + ) + raise ExchangeError(str(exc)) from exc last_raw = payload.get("lastPrice") if last_raw is None: - raise ExchangeError("Field 'lastPrice' is missing in ticker response.") + exc = ExchangeError("Field 'lastPrice' is missing in ticker response.") + self._log_exchange_error( + endpoint="ticker/24hr", + exc=exc, + symbol=validation.normalized_symbol, + ) + raise exc bid_raw = payload.get("bidPrice") or last_raw ask_raw = payload.get("askPrice") or last_raw - close_time = payload.get("closeTime") or payload.get("eventTime") or "" - if close_time: - dt_utc = datetime.fromtimestamp(int(close_time) / 1000, tz=ZoneInfo("UTC")) - dt_local = dt_utc.astimezone(ZoneInfo(self.settings.tz)) - updated_at = dt_local.strftime("%d.%m.%Y %H:%M:%S") - else: - updated_at = "n/a" - return { "symbol": validation.normalized_symbol, "last_price": float(last_raw), "bid_price": float(bid_raw), "ask_price": float(ask_raw), - "updated_at": updated_at, + "updated_at": self._format_exchange_time(close_time), } def get_balance_summary(self) -> list[BalanceSummary]: @@ -169,25 +263,24 @@ class ExchangeService: auth_health = self.get_private_auth_health() if not auth_health.ok: - self._log_error( - "balance_summary_error", - auth_health.message, - { - "exchange_name": self.settings.exchange_name, + auth_exc = ExchangeError(auth_health.message) + self._log_exchange_error( + endpoint="private/account_info", + exc=auth_exc, + extra_payload={ "default_symbol": self.settings.default_symbol, }, ) - raise ExchangeError(auth_health.message) + raise auth_exc try: payload = ExchangePrivateClient().get_account_info(show_zero_balance=False) balances = parse_account_balances(payload) except Exception as exc: - self._log_error( - "balance_summary_error", - f"Не удалось получить баланс: {exc}", - { - "exchange_name": self.settings.exchange_name, + self._log_exchange_error( + endpoint="private/account_info", + exc=exc, + extra_payload={ "default_symbol": self.settings.default_symbol, }, ) @@ -220,7 +313,15 @@ class ExchangeService: return [] client = ExchangeRestClient() - payload = client.get_json("/api/v2/exchangeInfo") + + try: + payload = client.get_json("/api/v2/exchangeInfo") + except Exception as exc: + self._log_exchange_error( + endpoint="exchangeInfo", + exc=exc, + ) + raise ExchangeError(str(exc)) from exc if isinstance(payload.get("symbols"), list): symbols_raw = payload["symbols"] @@ -229,7 +330,12 @@ class ExchangeService: if isinstance(inner, dict) and isinstance(inner.get("symbols"), list): symbols_raw = inner["symbols"] else: - raise ExchangeError("Field 'symbols' is missing in exchangeInfo response.") + exc = ExchangeError("Field 'symbols' is missing in exchangeInfo response.") + self._log_exchange_error( + endpoint="exchangeInfo", + exc=exc, + ) + raise exc def _safe_str(value: object, default: str = "") -> str: if value is None: @@ -394,46 +500,28 @@ class ExchangeService: params={"symbol": symbol}, ) except Exception as exc: - self._log_error( - "market_price_error", - f"Не удалось получить цену инструмента {symbol}: {exc}", - { - "symbol": symbol, - "exchange_name": self.settings.exchange_name, - }, + self._log_exchange_error( + endpoint="ticker/24hr", + exc=exc, + symbol=symbol, ) - raise + raise ExchangeError(str(exc)) from exc price_raw = payload.get("lastPrice") if price_raw is None: - self._log_error( - "market_price_error", - "Field 'lastPrice' is missing in ticker response.", - { - "symbol": symbol, - "exchange_name": self.settings.exchange_name, - }, + exc = ExchangeError("Field 'lastPrice' is missing in ticker response.") + self._log_exchange_error( + endpoint="ticker/24hr", + exc=exc, + symbol=symbol, ) - raise ExchangeError("Field 'lastPrice' is missing in ticker response.") + raise exc close_time = payload.get("closeTime") or payload.get("eventTime") or "" - if close_time: - dt_utc = datetime.fromtimestamp(int(close_time) / 1000, tz=ZoneInfo("UTC")) - dt_local = dt_utc.astimezone(ZoneInfo(self.settings.tz)) - updated_at = dt_local.strftime("%d.%m.%Y %H:%M:%S") - else: - updated_at = "n/a" - - source = ( - "dzengi-demo-api" - if "demo" in self.settings.exchange_base_url.lower() - else "dzengi-api" - ) - return TickerPrice( symbol=symbol, price=float(price_raw), - source=source, - updated_at=updated_at, + source=self._source_name(), + updated_at=self._format_exchange_time(close_time), ) \ No newline at end of file diff --git a/app/src/storage/repositories/journal.py b/app/src/storage/repositories/journal.py index 713564f..09731aa 100644 --- a/app/src/storage/repositories/journal.py +++ b/app/src/storage/repositories/journal.py @@ -1,3 +1,5 @@ +# app/src/storage/repositories/journal.py + from __future__ import annotations import json @@ -20,10 +22,10 @@ class JournalRepository: with get_connection() as connection: with connection.cursor() as cursor: cursor.execute( - ''' + """ INSERT INTO journal_events (level, event_type, message, payload_json) VALUES (%s, %s, %s, %s::jsonb) - ''', + """, ( level.upper().strip(), event_type.strip(), @@ -32,32 +34,69 @@ class JournalRepository: ), ) - def list_recent_events(self, limit: int = 10) -> list[dict[str, str]]: + def _parse_payload(self, raw_payload: Any) -> dict[str, Any] | None: + if raw_payload is None: + return None + + if isinstance(raw_payload, dict): + return raw_payload + + if isinstance(raw_payload, str): + try: + parsed = json.loads(raw_payload) + return parsed if isinstance(parsed, dict) else None + except json.JSONDecodeError: + return None + + return None + + def list_recent_events(self, limit: int = 10) -> list[dict[str, Any]]: with get_connection() as connection: with connection.cursor() as cursor: cursor.execute( - ''' - SELECT id, created_at, level, event_type, message + """ + SELECT id, created_at, level, event_type, message, payload_json FROM journal_events ORDER BY created_at DESC, id DESC LIMIT %s - ''', + """, (limit,), ) rows = cursor.fetchall() - items: list[dict[str, str]] = [] - for row in rows: - items.append( - { - "id": str(row[0]), - "created_at": str(row[1]), - "level": str(row[2]), - "event_type": str(row[3]), - "message": str(row[4]), - } - ) - return items + return [self._row_to_dict(row) for row in rows] + + def list_recent_with_offset(self, limit: int, offset: int) -> list[dict[str, Any]]: + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT id, created_at, level, event_type, message, payload_json + FROM journal_events + ORDER BY created_at DESC, id DESC + LIMIT %s OFFSET %s + """, + (limit, offset), + ) + rows = cursor.fetchall() + + return [self._row_to_dict(row) for row in rows] + + def list_export_rows(self, limit: int = 5000) -> list[dict[str, Any]]: + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT id, created_at, level, event_type, message, payload_json + FROM journal_events + ORDER BY created_at DESC, id DESC + LIMIT %s + """, + (limit,), + ) + rows = cursor.fetchall() + + return [self._row_to_dict(row) for row in rows] def count_events(self) -> int: with get_connection() as connection: @@ -67,26 +106,35 @@ class JournalRepository: return int(row[0]) if row else 0 - def list_recent_with_offset(self, limit: int, offset: int): + def delete_all(self) -> int: + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute("DELETE FROM journal_events") + deleted_count = cursor.rowcount + + return int(deleted_count or 0) + + def _row_to_dict(self, row: tuple[Any, ...]) -> dict[str, Any]: + return { + "id": str(row[0]), + "created_at": str(row[1]), + "level": str(row[2]), + "event_type": str(row[3]), + "message": str(row[4]), + "payload": self._parse_payload(row[5]), + } + + + def delete_older_than_days(self, days: int) -> int: with get_connection() as connection: with connection.cursor() as cursor: cursor.execute( """ - SELECT created_at, level, event_type, message - FROM journal_events - ORDER BY created_at DESC - LIMIT %s OFFSET %s + DELETE FROM journal_events + WHERE created_at < NOW() - (%s * INTERVAL '1 day') """, - (limit, offset), + (days,), ) - rows = cursor.fetchall() + deleted_count = cursor.rowcount - return [ - { - "created_at": str(r[0]), - "level": r[1], - "event_type": r[2], - "message": r[3], - } - for r in rows - ] \ No newline at end of file + return deleted_count \ No newline at end of file diff --git a/app/src/telegram/handlers/journal.py b/app/src/telegram/handlers/journal.py index d010a4c..3c506aa 100644 --- a/app/src/telegram/handlers/journal.py +++ b/app/src/telegram/handlers/journal.py @@ -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 = ["📒 Журнал", "", "Последние события", ""] - - for e in events: - level = str(e.get("level", "INFO")).upper() - icon = LEVEL_ICONS.get(level, "•") - - lines.extend( - [ - f"{icon} {e['event_type']}", - 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() \ No newline at end of file diff --git a/app/src/telegram/handlers/journal_ui.py b/app/src/telegram/handlers/journal_ui.py new file mode 100644 index 0000000..bba7f5b --- /dev/null +++ b/app/src/telegram/handlers/journal_ui.py @@ -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 ( + "📤 Экспорт\n\n" + "СИСТЕМА · Журнал\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 = [ + "⚠️ Очистить журнал", + "", + "СИСТЕМА · Настройки · Журнал", + "", + 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 = [ + "📒 Журнал", + "", + "СИСТЕМА", + "", + "Последние события:", + "", + ] + + 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} [ {level} ] {title}") + lines.append(f"• {created_at}") + lines.append("") + + return "\n".join(lines).rstrip() \ No newline at end of file diff --git a/app/src/telegram/handlers/market.py b/app/src/telegram/handlers/market.py index ba3ea85..41f0149 100644 --- a/app/src/telegram/handlers/market.py +++ b/app/src/telegram/handlers/market.py @@ -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( diff --git a/app/src/telegram/handlers/portfolio.py b/app/src/telegram/handlers/portfolio.py index d3d458c..f23d5aa 100644 --- a/app/src/telegram/handlers/portfolio.py +++ b/app/src/telegram/handlers/portfolio.py @@ -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( - [ - "🟡 Данные загружены частично", - ] - ) + lines.append("🟡 Данные загружены частично") 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="💼 Портфель", @@ -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="💼 Портфель", diff --git a/app/src/telegram/handlers/system.py b/app/src/telegram/handlers/system.py index 5439dff..45b3d93 100644 --- a/app/src/telegram/handlers/system.py +++ b/app/src/telegram/handlers/system.py @@ -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() \ No newline at end of file + 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 = ( + "🛠️ Настройки\n\n" + "СИСТЕМА\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 = ( + "📒 Журнал\n\n" + "СИСТЕМА · Настройки\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("О продукте скоро появится") \ No newline at end of file diff --git a/app/src/telegram/handlers/trade/new_order_flow.py b/app/src/telegram/handlers/trade/new_order_flow.py index 0a5b169..f11e3da 100644 --- a/app/src/telegram/handlers/trade/new_order_flow.py +++ b/app/src/telegram/handlers/trade/new_order_flow.py @@ -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( "📊 Торговля — Новый ордер\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="📊 Торговля — Новый ордер", @@ -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="📊 Торговля — Новый ордер", @@ -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="📊 Торговля — Подтверждение черновика", @@ -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 diff --git a/app/src/telegram/keyboards/reply.py b/app/src/telegram/keyboards/reply.py index 063382c..22ba154 100644 --- a/app/src/telegram/keyboards/reply.py +++ b/app/src/telegram/keyboards/reply.py @@ -7,19 +7,17 @@ def build_main_menu_keyboard() -> ReplyKeyboardMarkup: return ReplyKeyboardMarkup( keyboard=[ [ - KeyboardButton(text="🏠 Главная"), - KeyboardButton(text="📈 Рынок"), - KeyboardButton(text="💼 Портфель"), - ], - [ + KeyboardButton(text="🤖 Автоторговля"), KeyboardButton(text="📊 Торговля"), - KeyboardButton(text="🤖 Авто"), - KeyboardButton(text="📒 Журнал"), ], [ - KeyboardButton(text="⚙️ Система"), + KeyboardButton(text="💼 Портфель"), + KeyboardButton(text="📈 Рынок"), + ], + [ + KeyboardButton(text="🖥️ Система"), ], ], resize_keyboard=True, input_field_placeholder="Выбери раздел...", - ) + ) \ No newline at end of file diff --git a/app/src/telegram/ui/common.py b/app/src/telegram/ui/common.py index ebd7ca5..33e95a8 100644 --- a/app/src/telegram/ui/common.py +++ b/app/src/telegram/ui/common.py @@ -14,6 +14,36 @@ def mode_line() -> str: return f"🔸 {label}\n\n" +def breadcrumb_line(*items: str) -> str: + if not items: + return "" + + first = items[0].upper() + rest = " · ".join(items[1:]) + + if rest: + return f"{first} · {rest}\n\n" + + return f"{first}\n\n" + + +def screen_header( + *, + title: str, + path: list[str] | None = None, + show_mode: bool = False, +) -> str: + parts: list[str] = [f"{title}"] + + if show_mode: + parts.append(f"🔸 {get_runtime_mode_label()}") + + if path: + parts.append(f"{' → '.join(path).upper()}") + + return "\n".join(parts) + "\n\n" + + def now_line() -> str: settings = load_settings() tz_name = settings.tz or "UTC" diff --git a/app/src/telegram/ui/exchange_error.py b/app/src/telegram/ui/exchange_error.py index 47925e5..b7ceb14 100644 --- a/app/src/telegram/ui/exchange_error.py +++ b/app/src/telegram/ui/exchange_error.py @@ -3,13 +3,10 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime -from zoneinfo import ZoneInfo from aiogram.exceptions import TelegramBadRequest from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message -from src.core.config import load_settings from src.telegram.ui.common import mode_line, now_line @@ -19,7 +16,16 @@ class ExchangeErrorView: details: str -def _classify_exchange_error(exc: Exception) -> str: +DEFAULT_NETWORK_DETAILS = "Не удалось получить данные с биржи.\nОбнови экран." +DEFAULT_AUTH_DETAILS = "Не удалось выполнить запрос к аккаунту.\nПроверь API ключи." +DEFAULT_TIME_DETAILS = ( + "Не удалось выполнить запрос к бирже.\n" + "Проверь синхронизацию времени и обнови экран." +) +DEFAULT_GENERIC_DETAILS = "Не удалось получить данные с биржи.\nОбнови экран." + + +def classify_exchange_error(exc: Exception) -> str: text = str(exc).lower() network_markers = [ @@ -64,7 +70,7 @@ def _build_exchange_error_view( time_details: str | None = None, generic_details: str | None = None, ) -> ExchangeErrorView: - error_type = _classify_exchange_error(exc) + error_type = classify_exchange_error(exc) if error_type == "network": return ExchangeErrorView( @@ -81,18 +87,12 @@ def _build_exchange_error_view( if error_type == "time": return ExchangeErrorView( headline="🔴 Ошибка времени", - details=( - time_details - or "Не удалось выполнить запрос к бирже.\nОбнови экран." - ), + details=time_details or DEFAULT_TIME_DETAILS, ) return ExchangeErrorView( headline="🔴 Ошибка биржи", - details=( - generic_details - or "Не удалось получить данные с биржи.\nОбнови экран." - ), + details=generic_details or DEFAULT_GENERIC_DETAILS, ) @@ -100,8 +100,8 @@ def render_exchange_error( *, title: str, exc: Exception, - network_details: str, - auth_details: str, + network_details: str = DEFAULT_NETWORK_DETAILS, + auth_details: str = DEFAULT_AUTH_DETAILS, time_details: str | None = None, generic_details: str | None = None, ) -> str: @@ -178,8 +178,8 @@ async def show_callback_exchange_error( *, title: str, exc: Exception, - network_details: str, - auth_details: str, + network_details: str = DEFAULT_NETWORK_DETAILS, + auth_details: str = DEFAULT_AUTH_DETAILS, time_details: str | None = None, generic_details: str | None = None, retry_callback_data: str | None = None, @@ -205,10 +205,7 @@ async def show_callback_exchange_error( ) try: - await callback.message.edit_text( - text, - reply_markup=markup, - ) + await callback.message.edit_text(text, reply_markup=markup) await callback.answer() except TelegramBadRequest as tg_exc: if "message is not modified" in str(tg_exc).lower(): @@ -222,8 +219,8 @@ async def show_message_exchange_error( *, title: str, exc: Exception, - network_details: str, - auth_details: str, + network_details: str = DEFAULT_NETWORK_DETAILS, + auth_details: str = DEFAULT_AUTH_DETAILS, time_details: str | None = None, generic_details: str | None = None, retry_callback_data: str | None = None, diff --git a/app/src/trading/journal/exporter.py b/app/src/trading/journal/exporter.py new file mode 100644 index 0000000..c2c12de --- /dev/null +++ b/app/src/trading/journal/exporter.py @@ -0,0 +1,231 @@ +# app/src/trading/journal/exporter.py + +from __future__ import annotations + +import csv +import json +from datetime import datetime +from io import BytesIO, StringIO +from zoneinfo import ZoneInfo + +from openpyxl import Workbook +from openpyxl.styles import Font + +from src.core.config import load_settings + + +EVENT_TITLES = { + "app_start": "Запуск приложения", + "journal_open_requested": "Открытие журнала", + "journal_export_csv_success": "Экспорт CSV", + "journal_export_csv_error": "Ошибка экспорта CSV", + "journal_export_xlsx_success": "Экспорт Excel", + "journal_export_xlsx_error": "Ошибка экспорта Excel", + "journal_cleared": "Журнал очищен", + "journal_cleared_old": "Очистка старых записей", + "system_open_alert": "Система загружена с предупреждениями", + "system_open_success": "Система загружена", + "market_open_requested": "Открытие рынка", + "market_open_success": "Рынок загружен", + "market_open_error": "Ошибка открытия рынка", + "portfolio_open_requested": "Открытие портфеля", + "portfolio_open_success": "Портфель загружен", + "portfolio_open_error": "Ошибка открытия портфеля", + "portfolio_partial_estimate": "Частичная оценка портфеля", + "exchange_request_error": "Ошибка запроса к бирже", + "balance_summary_loaded": "Баланс загружен", + "balance_summary_error": "Ошибка загрузки баланса", +} + + +def _now_local() -> datetime: + settings = load_settings() + try: + return datetime.now(ZoneInfo(settings.tz)) + except Exception: + return datetime.utcnow() + + +def _format_datetime(value: object) -> str: + text = str(value or "").strip() + if not text: + return "" + + try: + settings = load_settings() + dt = datetime.fromisoformat(text) + + if dt.tzinfo is None: + dt = dt.replace(tzinfo=ZoneInfo("UTC")) + + return dt.astimezone(ZoneInfo(settings.tz)).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return text + + +def _event_title(event_type: object) -> str: + value = str(event_type or "").strip() + return EVENT_TITLES.get(value, value.replace("_", " ").strip().capitalize()) + + +def _payload(row: dict) -> dict: + payload = row.get("payload") + return payload if isinstance(payload, dict) else {} + + +def _payload_json(payload: dict) -> str: + if not payload: + return "" + return json.dumps(payload, ensure_ascii=False, sort_keys=True) + + +def _export_row(row: dict) -> list[str]: + payload = _payload(row) + + return [ + _format_datetime(row.get("created_at")), + str(row.get("level") or ""), + str(row.get("event_type") or ""), + _event_title(row.get("event_type")), + str(row.get("message") or ""), + str(payload.get("account_mode") or "").upper(), + str(payload.get("screen") or ""), + str(payload.get("action") or ""), + str(payload.get("error_type") or ""), + str(payload.get("raw_error") or ""), + _payload_json(payload), + ] + + +def _headers() -> list[str]: + return [ + "Дата", + "Уровень", + "Событие", + "Заголовок", + "Сообщение", + "Аккаунт", + "Экран", + "Действие", + "Тип ошибки", + "Техническая ошибка", + "Payload", + ] + + +def _levels_summary(rows: list[dict]) -> str: + levels = sorted( + {str(row.get("level") or "").upper() for row in rows if row.get("level")} + ) + return ", ".join(levels) if levels else "—" + + +def _period_summary(rows: list[dict]) -> str: + dates = [_format_datetime(row.get("created_at")) for row in rows if row.get("created_at")] + dates = [value for value in dates if value] + + if not dates: + return "—" + + return f"{min(dates)} — {max(dates)}" + + +def _metadata_rows( + *, + rows: list[dict], + total_count: int, + export_limit: int, + account_mode: str, + journal_level: str, +) -> list[list[str]]: + exported_count = len(rows) + is_limited = total_count > exported_count + + return [ + ["Экспорт журнала"], + ["Дата экспорта", _now_local().strftime("%Y-%m-%d %H:%M:%S")], + ["Аккаунт", account_mode.upper()], + ["Уровень журнала", journal_level], + ["Всего записей в журнале", str(total_count)], + ["Записей в файле", str(exported_count)], + ["Лимит экспорта", str(export_limit)], + ["Экспорт ограничен", "да" if is_limited else "нет"], + ["Уровни в файле", _levels_summary(rows)], + ["Период", _period_summary(rows)], + [], + ] + + +def build_csv( + rows: list[dict], + *, + total_count: int, + export_limit: int, + account_mode: str, + journal_level: str, +) -> bytes: + output = StringIO() + writer = csv.writer( + output, + delimiter=";", + quoting=csv.QUOTE_ALL, + lineterminator="\n", + ) + + for metadata_row in _metadata_rows( + rows=rows, + total_count=total_count, + export_limit=export_limit, + account_mode=account_mode, + journal_level=journal_level, + ): + writer.writerow(metadata_row) + + writer.writerow(_headers()) + + for row in rows: + writer.writerow(_export_row(row)) + + return output.getvalue().encode("utf-8-sig") + + +def build_xlsx( + rows: list[dict], + *, + total_count: int, + export_limit: int, + account_mode: str, + journal_level: str, +) -> bytes: + wb = Workbook() + ws = wb.active + ws.title = "Journal" + + for metadata_row in _metadata_rows( + rows=rows, + total_count=total_count, + export_limit=export_limit, + account_mode=account_mode, + journal_level=journal_level, + ): + ws.append(metadata_row) + + header_row_index = ws.max_row + 1 + ws.append(_headers()) + + for cell in ws[1]: + cell.font = Font(bold=True) + + for cell in ws[header_row_index]: + cell.font = Font(bold=True) + + for row in rows: + ws.append(_export_row(row)) + + for column_cells in ws.columns: + max_length = max(len(str(cell.value or "")) for cell in column_cells) + ws.column_dimensions[column_cells[0].column_letter].width = min(max_length + 2, 60) + + stream = BytesIO() + wb.save(stream) + return stream.getvalue() \ No newline at end of file diff --git a/app/src/trading/journal/service.py b/app/src/trading/journal/service.py index 128a9bb..7ca4ed5 100644 --- a/app/src/trading/journal/service.py +++ b/app/src/trading/journal/service.py @@ -1,15 +1,67 @@ +# app/src/trading/journal/service.py + from __future__ import annotations +from datetime import datetime from typing import Any +from zoneinfo import ZoneInfo +from src.core.config import load_settings from src.storage.repositories.journal import JournalRepository from src.storage.session import check_database_health +from src.trading.journal.exporter import build_csv, build_xlsx + +EXPORT_LIMIT = 5000 class JournalService: def __init__(self) -> None: self.repository = JournalRepository() + def _account_mode(self) -> str: + settings = load_settings() + return "demo" if "demo" in settings.exchange_base_url.lower() else "live" + + def _account_prefix(self) -> str: + return f"[{self._account_mode().upper()}]" + + def _build_message(self, message: str) -> str: + return f"{self._account_prefix()} {message}".strip() + + def _build_payload( + self, + *, + user_id: int | None = None, + chat_id: int | None = None, + screen: str | None = None, + action: str | None = None, + error_type: str | None = None, + raw_error: str | None = None, + payload: dict[str, Any] | None = None, + ) -> dict[str, Any]: + result: dict[str, Any] = dict(payload or {}) + result.setdefault("account_mode", self._account_mode()) + + if user_id is not None: + result.setdefault("user_id", user_id) + + if chat_id is not None: + result.setdefault("chat_id", chat_id) + + if screen is not None: + result.setdefault("screen", screen) + + if action is not None: + result.setdefault("action", action) + + if error_type is not None: + result.setdefault("error_type", error_type) + + if raw_error is not None: + result.setdefault("raw_error", raw_error) + + return result + def log_info( self, event_type: str, @@ -62,16 +114,155 @@ class JournalService: payload=payload, ) - def get_recent(self, limit: int = 10) -> list[dict[str, str]]: + def log_ui_info( + self, + *, + event_type: str, + message: str, + screen: str, + action: str, + user_id: int | None = None, + chat_id: int | None = None, + payload: dict[str, Any] | None = None, + ) -> None: + self.log_info( + event_type=event_type, + message=self._build_message(message), + payload=self._build_payload( + user_id=user_id, + chat_id=chat_id, + screen=screen, + action=action, + payload=payload, + ), + ) + + def log_ui_warning( + self, + *, + event_type: str, + message: str, + screen: str, + action: str, + user_id: int | None = None, + chat_id: int | None = None, + payload: dict[str, Any] | None = None, + error_type: str | None = None, + raw_error: str | None = None, + ) -> None: + self.log_warning( + event_type=event_type, + message=self._build_message(message), + payload=self._build_payload( + user_id=user_id, + chat_id=chat_id, + screen=screen, + action=action, + error_type=error_type, + raw_error=raw_error, + payload=payload, + ), + ) + + def log_ui_error( + self, + *, + event_type: str, + message: str, + screen: str, + action: str, + user_id: int | None = None, + chat_id: int | None = None, + payload: dict[str, Any] | None = None, + error_type: str | None = None, + raw_error: str | None = None, + ) -> None: + self.log_error( + event_type=event_type, + message=self._build_message(message), + payload=self._build_payload( + user_id=user_id, + chat_id=chat_id, + screen=screen, + action=action, + error_type=error_type, + raw_error=raw_error, + payload=payload, + ), + ) + + def get_recent(self, limit: int = 10) -> list[dict[str, Any]]: return self.repository.list_recent_events(limit=limit) - def get_page(self, page: int = 1, page_size: int = 3) -> list[dict[str, str]]: + def get_page(self, page: int = 1, page_size: int = 5) -> list[dict[str, Any]]: offset = (page - 1) * page_size return self.repository.list_recent_with_offset(limit=page_size, offset=offset) def get_total_count(self) -> int: return self.repository.count_events() + def get_export_rows(self, limit: int = EXPORT_LIMIT) -> list[dict[str, Any]]: + return self.repository.list_export_rows(limit=limit) + + def _journal_level(self) -> str: + return "INFO+" + + def _export_timestamp(self) -> str: + settings = load_settings() + try: + now = datetime.now(ZoneInfo(settings.tz)) + except Exception: + now = datetime.utcnow() + + return now.strftime("%Y-%m-%d_%H-%M-%S") + + def build_export_filename(self, extension: str) -> str: + safe_extension = extension.lower().strip().lstrip(".") + safe_level = self._journal_level().lower().replace("+", "_plus") + + return ( + f"journal_" + f"{self._account_mode()}_" + f"{safe_level}_" + f"{self._export_timestamp()}." + f"{safe_extension}" + ) + + def export_csv(self, limit: int = EXPORT_LIMIT) -> bytes: + rows = self.get_export_rows(limit=limit) + + return build_csv( + rows, + total_count=self.get_total_count(), + export_limit=limit, + account_mode=self._account_mode(), + journal_level=self._journal_level(), + ) + + def export_xlsx(self, limit: int = EXPORT_LIMIT) -> bytes: + rows = self.get_export_rows(limit=limit) + + return build_xlsx( + rows, + total_count=self.get_total_count(), + export_limit=limit, + account_mode=self._account_mode(), + journal_level=self._journal_level(), + ) + + def clear_all(self) -> int: + deleted_count = self.repository.delete_all() + + self.log_ui_warning( + event_type="journal_cleared", + message="Журнал очищен.", + screen="journal", + action="clear", + payload={"deleted_count": deleted_count}, + ) + + return deleted_count + def get_journal_health(self) -> tuple[bool, str]: db_ok, db_message = check_database_health() if not db_ok: @@ -82,4 +273,20 @@ class JournalService: except Exception as exc: return False, f"Ошибка чтения журнала: {exc}" - return True, f"Журнал работает. Событий: {total}" \ No newline at end of file + return True, f"Журнал работает. Событий: {total}" + + def clear_older_than_days(self, days: int) -> int: + deleted_count = self.repository.delete_older_than_days(days) + + self.log_ui_warning( + event_type="journal_cleared_old", + message=f"Журнал очищен старше {days} дней.", + screen="journal", + action="clear_old", + payload={ + "days": days, + "deleted_count": deleted_count, + }, + ) + + return deleted_count \ No newline at end of file diff --git a/docs/Archive.zip b/docs/Archive.zip new file mode 100644 index 0000000..0259f60 Binary files /dev/null and b/docs/Archive.zip differ diff --git a/docs/decisions/0014-system-navigation-and-journal-settings.md b/docs/decisions/0014-system-navigation-and-journal-settings.md new file mode 100644 index 0000000..8041ad9 --- /dev/null +++ b/docs/decisions/0014-system-navigation-and-journal-settings.md @@ -0,0 +1,14 @@ +# 0014 — System Navigation and Journal Settings + +## Решение +Вынести журнал и системные настройки в отдельный раздел “Система” с многоуровневой навигацией. + +## Причины +- разгрузить главное меню +- разделить operational UI и settings UI +- упростить масштабирование новых системных экранов + +## Последствия +- появляется единый navigation pattern +- журнал становится отдельным subsystem +- проще расширять настройки \ No newline at end of file diff --git a/docs/roadmap/master-roadmap.md b/docs/roadmap/master-roadmap.md index b4d536d..46d12c4 100644 --- a/docs/roadmap/master-roadmap.md +++ b/docs/roadmap/master-roadmap.md @@ -68,10 +68,18 @@ --- -## Stage 06 — Automation -⏳ авто-режим -⏳ планировщик -⏳ фоновые задачи +## Stage 06 — UI / Journal / Navigation + +### 06.1 +✔ journal management UI +✔ export CSV/XLSX +✔ system navigation redesign + +### 06.2 +⏳ archive / backup + +### 06.3 +⏳ retention / limits --- diff --git a/docs/stages/stage-06_1-journal_management_ui_and_system_navigation.md b/docs/stages/stage-06_1-journal_management_ui_and_system_navigation.md new file mode 100644 index 0000000..fc6796a --- /dev/null +++ b/docs/stages/stage-06_1-journal_management_ui_and_system_navigation.md @@ -0,0 +1,48 @@ +# Journal Management UI and System Navigation + +## Что сделано + +### 1. Полная переработка раздела Система +- 🤖 Автоторговля +- 📊 Торговля +- 💼 Портфель +- 📈 Рынок +- 🖥️ Система + +### 2. Новый navigation pattern +СИСТЕМА · Настройки · Журнал + +### 3. Экран 📒 Журнал +- последние события +- экспорт +- настройки + +### 4. Экран экспорта журнала +CSV / Excel + +### 5. Экспорт CSV/XLSX +journal_{mode}_{level}_{timestamp}.{ext} + +### 6. Metadata block в export +- дата экспорта +- аккаунт +- уровень журнала +- период + +### 7. Экран настроек журнала +- 📄 Записей +- 📦 Лимит +- ⏳ Хранение +- 🗄 Архив + +### 8. Очистка журнала +- Очистить всё +- Старше 90 дней + +### 9. Journal UI refactor +telegram/handlers/journal_ui.py + +### 10. Logging integration + +## Commit +git commit -m "Stage 06.1 - journal management UI, export and system menu redesign"