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"