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

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

View File

@@ -1,3 +1,6 @@
# app/requirements.txt
aiogram==3.13.1 aiogram==3.13.1
python-dotenv==1.0.1 python-dotenv==1.0.1
psycopg[binary]==3.2.9 psycopg[binary]==3.2.9
openpyxl==3.1.5

View File

@@ -114,11 +114,11 @@ def _build_journal_status() -> ComponentStatus:
def get_runtime_mode_key() -> str: def get_runtime_mode_key() -> str:
settings = load_settings() 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: 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: def get_system_snapshot() -> SystemSnapshot:
@@ -182,7 +182,7 @@ def build_system_text(*, include_updated_at: bool = False) -> str:
) )
text = ( text = (
"<b> Система</b>\n" "<b>🖥 Система</b>\n"
f"🔸 <b>{snapshot.mode_label}</b>\n" f"🔸 <b>{snapshot.mode_label}</b>\n"
f"⏱️ {snapshot.timezone_name}\n\n" f"⏱️ {snapshot.timezone_name}\n\n"
f"{components_block}" f"{components_block}"

View File

@@ -50,6 +50,89 @@ class ExchangeService:
except Exception: except Exception:
pass 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: def get_health(self) -> ExchangeHealth:
if not self.settings.exchange_enabled: if not self.settings.exchange_enabled:
return mock_exchange_health() return mock_exchange_health()
@@ -94,6 +177,10 @@ class ExchangeService:
payload = ExchangePrivateClient().get_account_info(show_zero_balance=False) payload = ExchangePrivateClient().get_account_info(show_zero_balance=False)
balances = parse_account_balances(payload) balances = parse_account_balances(payload)
except Exception as exc: except Exception as exc:
self._log_exchange_error(
endpoint="private/account_info",
exc=exc,
)
return PrivateAuthHealth( return PrivateAuthHealth(
ok=False, ok=False,
message=f"Private API error: {exc}", message=f"Private API error: {exc}",
@@ -134,33 +221,40 @@ class ExchangeService:
raise ExchangeError(validation.message) raise ExchangeError(validation.message)
client = ExchangeRestClient() client = ExchangeRestClient()
payload = client.get_json(
"/api/v2/ticker/24hr", try:
params={"symbol": validation.normalized_symbol}, 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") last_raw = payload.get("lastPrice")
if last_raw is None: 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 bid_raw = payload.get("bidPrice") or last_raw
ask_raw = payload.get("askPrice") or last_raw ask_raw = payload.get("askPrice") or last_raw
close_time = payload.get("closeTime") or payload.get("eventTime") or "" 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 { return {
"symbol": validation.normalized_symbol, "symbol": validation.normalized_symbol,
"last_price": float(last_raw), "last_price": float(last_raw),
"bid_price": float(bid_raw), "bid_price": float(bid_raw),
"ask_price": float(ask_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]: def get_balance_summary(self) -> list[BalanceSummary]:
@@ -169,25 +263,24 @@ class ExchangeService:
auth_health = self.get_private_auth_health() auth_health = self.get_private_auth_health()
if not auth_health.ok: if not auth_health.ok:
self._log_error( auth_exc = ExchangeError(auth_health.message)
"balance_summary_error", self._log_exchange_error(
auth_health.message, endpoint="private/account_info",
{ exc=auth_exc,
"exchange_name": self.settings.exchange_name, extra_payload={
"default_symbol": self.settings.default_symbol, "default_symbol": self.settings.default_symbol,
}, },
) )
raise ExchangeError(auth_health.message) raise auth_exc
try: try:
payload = ExchangePrivateClient().get_account_info(show_zero_balance=False) payload = ExchangePrivateClient().get_account_info(show_zero_balance=False)
balances = parse_account_balances(payload) balances = parse_account_balances(payload)
except Exception as exc: except Exception as exc:
self._log_error( self._log_exchange_error(
"balance_summary_error", endpoint="private/account_info",
f"Не удалось получить баланс: {exc}", exc=exc,
{ extra_payload={
"exchange_name": self.settings.exchange_name,
"default_symbol": self.settings.default_symbol, "default_symbol": self.settings.default_symbol,
}, },
) )
@@ -220,7 +313,15 @@ class ExchangeService:
return [] return []
client = ExchangeRestClient() 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): if isinstance(payload.get("symbols"), list):
symbols_raw = payload["symbols"] symbols_raw = payload["symbols"]
@@ -229,7 +330,12 @@ class ExchangeService:
if isinstance(inner, dict) and isinstance(inner.get("symbols"), list): if isinstance(inner, dict) and isinstance(inner.get("symbols"), list):
symbols_raw = inner["symbols"] symbols_raw = inner["symbols"]
else: 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: def _safe_str(value: object, default: str = "") -> str:
if value is None: if value is None:
@@ -394,46 +500,28 @@ class ExchangeService:
params={"symbol": symbol}, params={"symbol": symbol},
) )
except Exception as exc: except Exception as exc:
self._log_error( self._log_exchange_error(
"market_price_error", endpoint="ticker/24hr",
f"Не удалось получить цену инструмента {symbol}: {exc}", exc=exc,
{ symbol=symbol,
"symbol": symbol,
"exchange_name": self.settings.exchange_name,
},
) )
raise raise ExchangeError(str(exc)) from exc
price_raw = payload.get("lastPrice") price_raw = payload.get("lastPrice")
if price_raw is None: if price_raw is None:
self._log_error( exc = ExchangeError("Field 'lastPrice' is missing in ticker response.")
"market_price_error", self._log_exchange_error(
"Field 'lastPrice' is missing in ticker response.", endpoint="ticker/24hr",
{ exc=exc,
"symbol": symbol, symbol=symbol,
"exchange_name": self.settings.exchange_name,
},
) )
raise ExchangeError("Field 'lastPrice' is missing in ticker response.") raise exc
close_time = payload.get("closeTime") or payload.get("eventTime") or "" 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( return TickerPrice(
symbol=symbol, symbol=symbol,
price=float(price_raw), price=float(price_raw),
source=source, source=self._source_name(),
updated_at=updated_at, updated_at=self._format_exchange_time(close_time),
) )

View File

@@ -1,3 +1,5 @@
# app/src/storage/repositories/journal.py
from __future__ import annotations from __future__ import annotations
import json import json
@@ -20,10 +22,10 @@ class JournalRepository:
with get_connection() as connection: with get_connection() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute( cursor.execute(
''' """
INSERT INTO journal_events (level, event_type, message, payload_json) INSERT INTO journal_events (level, event_type, message, payload_json)
VALUES (%s, %s, %s, %s::jsonb) VALUES (%s, %s, %s, %s::jsonb)
''', """,
( (
level.upper().strip(), level.upper().strip(),
event_type.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 get_connection() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute( cursor.execute(
''' """
SELECT id, created_at, level, event_type, message SELECT id, created_at, level, event_type, message, payload_json
FROM journal_events FROM journal_events
ORDER BY created_at DESC, id DESC ORDER BY created_at DESC, id DESC
LIMIT %s LIMIT %s
''', """,
(limit,), (limit,),
) )
rows = cursor.fetchall() rows = cursor.fetchall()
items: list[dict[str, str]] = [] return [self._row_to_dict(row) for row in rows]
for row in rows:
items.append( def list_recent_with_offset(self, limit: int, offset: int) -> list[dict[str, Any]]:
{ with get_connection() as connection:
"id": str(row[0]), with connection.cursor() as cursor:
"created_at": str(row[1]), cursor.execute(
"level": str(row[2]), """
"event_type": str(row[3]), SELECT id, created_at, level, event_type, message, payload_json
"message": str(row[4]), FROM journal_events
} ORDER BY created_at DESC, id DESC
) LIMIT %s OFFSET %s
return items """,
(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: def count_events(self) -> int:
with get_connection() as connection: with get_connection() as connection:
@@ -67,26 +106,35 @@ class JournalRepository:
return int(row[0]) if row else 0 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 get_connection() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute( cursor.execute(
""" """
SELECT created_at, level, event_type, message DELETE FROM journal_events
FROM journal_events WHERE created_at < NOW() - (%s * INTERVAL '1 day')
ORDER BY created_at DESC
LIMIT %s OFFSET %s
""", """,
(limit, offset), (days,),
) )
rows = cursor.fetchall() deleted_count = cursor.rowcount
return [ return deleted_count
{
"created_at": str(r[0]),
"level": r[1],
"event_type": r[2],
"message": r[3],
}
for r in rows
]

View File

@@ -4,88 +4,51 @@ from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message from aiogram.types import BufferedInputFile, CallbackQuery, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
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 from src.trading.journal.service import JournalService
router = Router(name="journal") router = Router(name="journal")
PAGE_SIZE = 3
LEVEL_ICONS = { def _user_id_from_message(message: Message) -> int | None:
"INFO": "", return message.from_user.id if message.from_user else None
"WARNING": "⚠️",
"ERROR": "",
"CRITICAL": "🚨",
}
def build_keyboard(page: int, total_pages: int): def _chat_id_from_message(message: Message) -> int | None:
kb = InlineKeyboardBuilder() return message.chat.id if message.chat else None
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 render(events, page, total_pages): def _user_id_from_callback(callback: CallbackQuery) -> int | None:
lines = ["<b>📒 Журнал</b>", "", "<b>Последние события</b>", ""] return callback.from_user.id if callback.from_user else None
for e in events:
level = str(e.get("level", "INFO")).upper()
icon = LEVEL_ICONS.get(level, "")
lines.extend(
[
f"{icon} <b>{e['event_type']}</b>",
f"• уровень: {level}",
f"• время: {e['created_at']}",
f"• сообщение: {e['message']}",
"",
]
)
return "\n".join(lines).rstrip()
@router.message(F.text == "📒 Журнал") def _chat_id_from_callback(callback: CallbackQuery) -> int | None:
async def open_journal(message: Message, state: FSMContext): if callback.message and callback.message.chat:
# Глобальный экран: всегда выходим из текущего FSM-сценария. return callback.message.chat.id
await state.clear() return None
async def _show_journal_page(
target_message: Message,
*,
page: int,
edit_mode: bool,
) -> None:
service = JournalService() service = JournalService()
total = service.get_total_count() total = service.get_total_count()
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE) 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)) page = max(1, min(page, total_pages))
events = service.get_page(page, PAGE_SIZE) events = service.get_page(page, PAGE_SIZE)
@@ -93,5 +56,200 @@ async def paginate(callback: CallbackQuery):
text = render(events, page, total_pages) text = render(events, page, total_pages)
kb = build_keyboard(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() await callback.answer()

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,11 +7,6 @@ from aiogram.filters import Command
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message 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.integrations.exchange.exceptions import ExchangeError
from src.telegram.handlers.trade.new_order_core import router from src.telegram.handlers.trade.new_order_core import router
from src.telegram.handlers.trade.new_order_ui import ( from src.telegram.handlers.trade.new_order_ui import (
@@ -42,6 +37,12 @@ from src.telegram.handlers.trade.new_order_ui import (
mode_line, mode_line,
show_recent_drafts, 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.service import OrderDraftsService
from src.trading.orders.states import NewOrderDraftStates 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") @router.callback_query(F.data == "drafts:noop")
async def drafts_noop(callback: CallbackQuery) -> None: async def drafts_noop(callback: CallbackQuery) -> None:
await callback.answer() await callback.answer()
@@ -75,7 +94,17 @@ async def paginate_drafts(callback: CallbackQuery) -> None:
page = int(value) page = int(value)
await callback.answer() await callback.answer()
if callback.message is not None: 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) 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) draft = service.get_draft_by_id(draft_id)
if not draft: 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) await callback.answer("Черновик не найден", show_alert=True)
return 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( await callback.message.edit_text(
_render_draft_detail(draft), _render_draft_detail(draft),
reply_markup=_draft_detail_keyboard(draft_id, page), 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:")) @router.callback_query(F.data.startswith("draft_edit:"))
async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None: async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None:
service = OrderDraftsService() service = OrderDraftsService()
journal = JournalService()
_, draft_id, page_raw = callback.data.split(":", 2) _, draft_id, page_raw = callback.data.split(":", 2)
page = int(page_raw) page = int(page_raw)
draft = service.get_draft_by_id(draft_id) draft = service.get_draft_by_id(draft_id)
if not draft: 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) await callback.answer("Черновик не найден", show_alert=True)
return return
@@ -150,8 +209,34 @@ async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None:
drafts_page=page, 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() await callback.answer()
except (ExchangeError, ValueError) as exc: 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( await show_callback_exchange_error(
callback, callback,
title=_screen_title(is_edit_mode=True), 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}", back_callback_data=f"draft_open:{draft_id}:{page}",
drafts_page=page, drafts_page=page,
) )
return
@router.callback_query(F.data.startswith("draft_delete:")) @router.callback_query(F.data.startswith("draft_delete:"))
async def delete_draft_stub(callback: CallbackQuery) -> None: 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("Удаление скоро появится") await callback.answer("Удаление скоро появится")
@router.message(Command("cancel_order")) @router.message(Command("cancel_order"))
async def cancel_order_builder(message: Message, state: FSMContext) -> None: async def cancel_order_builder(message: Message, state: FSMContext) -> None:
await state.clear() 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( await message.answer(
"<b>📊 Торговля — Новый ордер</b>\n" "<b>📊 Торговля — Новый ордер</b>\n"
f"{mode_line()}" f"{mode_line()}"
@@ -189,6 +292,7 @@ async def start_new_order_draft(
await state.set_state(NewOrderDraftStates.waiting_side) await state.set_state(NewOrderDraftStates.waiting_side)
service = OrderDraftsService() service = OrderDraftsService()
journal = JournalService()
try: try:
context = service.get_entry_context(side="BUY", order_type="MARKET") context = service.get_entry_context(side="BUY", order_type="MARKET")
@@ -200,11 +304,31 @@ async def start_new_order_draft(
"Шаг 1/4. Выбери сторону" "Шаг 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: if edit_mode:
await message.edit_text(text, reply_markup=_side_keyboard()) await message.edit_text(text, reply_markup=_side_keyboard())
else: else:
await message.answer(text, reply_markup=_side_keyboard()) await message.answer(text, reply_markup=_side_keyboard())
except ExchangeError as exc: 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( await show_message_exchange_error(
message, message,
title="<b>📊 Торговля — Новый ордер</b>", title="<b>📊 Торговля — Новый ордер</b>",
@@ -227,6 +351,7 @@ async def process_order_side_callback(
path = _render_order_path(side=side) path = _render_order_path(side=side)
service = OrderDraftsService() service = OrderDraftsService()
journal = JournalService()
try: try:
context = service.get_entry_context(side=side, order_type="MARKET") context = service.get_entry_context(side=side, order_type="MARKET")
@@ -239,9 +364,30 @@ async def process_order_side_callback(
"Шаг 2/4. Выбери тип ордера" "Шаг 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.message.edit_text(text, reply_markup=_type_keyboard())
await callback.answer() await callback.answer()
except ExchangeError as exc: 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( await show_callback_exchange_error(
callback, callback,
title="<b>📊 Торговля — Новый ордер</b>", title="<b>📊 Торговля — Новый ордер</b>",
@@ -270,6 +416,7 @@ async def process_order_type_callback(
state: FSMContext, state: FSMContext,
) -> None: ) -> None:
service = OrderDraftsService() service = OrderDraftsService()
journal = JournalService()
order_type = callback.data.split(":", 1)[1] order_type = callback.data.split(":", 1)[1]
data = await state.get_data() data = await state.get_data()
@@ -290,6 +437,21 @@ async def process_order_type_callback(
base_currency=context.base_currency, 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( await callback.message.edit_text(
_render_quantity_step_screen( _render_quantity_step_screen(
title=_screen_title(is_edit_mode), title=_screen_title(is_edit_mode),
@@ -307,6 +469,21 @@ async def process_order_type_callback(
) )
await callback.answer() await callback.answer()
except ExchangeError as exc: 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( await show_callback_exchange_error(
callback, callback,
title=_screen_title(is_edit_mode), 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( @router.callback_query(
NewOrderDraftStates.waiting_quantity, NewOrderDraftStates.waiting_quantity,
F.data.startswith("order_qty:"), F.data.startswith("order_qty:"),
@@ -336,6 +517,7 @@ async def process_quantity_callback(
state: FSMContext, state: FSMContext,
) -> None: ) -> None:
service = OrderDraftsService() service = OrderDraftsService()
journal = JournalService()
value = callback.data.split(":", 1)[1] value = callback.data.split(":", 1)[1]
data = await state.get_data() data = await state.get_data()
@@ -360,6 +542,20 @@ async def process_quantity_callback(
base_currency=context.base_currency, 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( await callback.message.edit_text(
_render_manual_quantity_screen( _render_manual_quantity_screen(
title=title, title=title,
@@ -388,6 +584,21 @@ async def process_quantity_callback(
await state.update_data(quantity=quantity) 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": if order_type == "LIMIT":
path = _render_order_path( path = _render_order_path(
side=side, side=side,
@@ -458,6 +669,22 @@ async def process_quantity_callback(
) )
await callback.answer() await callback.answer()
except ExchangeError as exc: 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( await show_callback_exchange_error(
callback, callback,
title=title, title=title,
@@ -473,6 +700,7 @@ async def process_quantity_callback(
) )
async def process_order_quantity(message: Message, state: FSMContext) -> None: async def process_order_quantity(message: Message, state: FSMContext) -> None:
service = OrderDraftsService() service = OrderDraftsService()
journal = JournalService()
raw_quantity = message.text or "" raw_quantity = message.text or ""
data = await state.get_data() data = await state.get_data()
@@ -504,17 +732,15 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None:
) )
if quantity is None: if quantity is None:
path = _render_order_path(
side=side,
order_type=order_type,
base_currency=context.base_currency,
)
await message.answer( await message.answer(
_render_quantity_inline_error( _render_quantity_inline_error(
title=title, title=title,
symbol=context.symbol, symbol=context.symbol,
order_path=path, order_path=_render_order_path(
side=side,
order_type=order_type,
base_currency=context.base_currency,
),
errors=["Количество должно быть числом больше нуля."], errors=["Количество должно быть числом больше нуля."],
help_text=help_text, help_text=help_text,
), ),
@@ -529,17 +755,15 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None:
price=None, price=None,
) )
if quantity_errors: if quantity_errors:
path = _render_order_path(
side=side,
order_type=order_type,
base_currency=context.base_currency,
)
await message.answer( await message.answer(
_render_quantity_inline_error( _render_quantity_inline_error(
title=title, title=title,
symbol=context.symbol, 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, errors=quantity_errors,
help_text=help_text, help_text=help_text,
), ),
@@ -549,6 +773,21 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None:
await state.update_data(quantity=quantity) 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": if order_type == "LIMIT":
path = _render_order_path( path = _render_order_path(
side=side, side=side,
@@ -615,6 +854,22 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None:
reply_markup=_confirm_keyboard(drafts_page=drafts_page), reply_markup=_confirm_keyboard(drafts_page=drafts_page),
) )
except ExchangeError as exc: 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( await show_message_exchange_error(
message, message,
title=title, title=title,
@@ -632,6 +887,7 @@ async def process_price_callback(
state: FSMContext, state: FSMContext,
) -> None: ) -> None:
service = OrderDraftsService() service = OrderDraftsService()
journal = JournalService()
value = callback.data.split(":", 1)[1] value = callback.data.split(":", 1)[1]
data = await state.get_data() data = await state.get_data()
@@ -657,6 +913,16 @@ async def process_price_callback(
base_currency=context.base_currency, 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( await callback.message.edit_text(
_render_manual_price_screen( _render_manual_price_screen(
title=title, title=title,
@@ -700,6 +966,19 @@ async def process_price_callback(
await state.set_state(NewOrderDraftStates.waiting_confirm) 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( await callback.message.edit_text(
_render_confirm( _render_confirm(
symbol=draft.symbol, symbol=draft.symbol,
@@ -716,6 +995,20 @@ async def process_price_callback(
) )
await callback.answer() await callback.answer()
except ExchangeError as exc: 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( await show_callback_exchange_error(
callback, callback,
title=title, title=title,
@@ -731,6 +1024,7 @@ async def process_price_callback(
) )
async def process_order_price(message: Message, state: FSMContext) -> None: async def process_order_price(message: Message, state: FSMContext) -> None:
service = OrderDraftsService() service = OrderDraftsService()
journal = JournalService()
raw_price = message.text or "" raw_price = message.text or ""
price = service.normalize_price(raw_price) price = service.normalize_price(raw_price)
@@ -754,18 +1048,16 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
) )
if price is 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( await message.answer(
_render_price_inline_error( _render_price_inline_error(
title=title, title=title,
symbol=context.symbol, 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=["Цена должна быть числом больше нуля."], errors=["Цена должна быть числом больше нуля."],
help_text=help_text, help_text=help_text,
), ),
@@ -782,18 +1074,16 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
validation = service.validate_draft(draft) validation = service.validate_draft(draft)
if not validation.is_valid: 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( await message.answer(
_render_price_inline_error( _render_price_inline_error(
title=title, title=title,
symbol=context.symbol, 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, errors=validation.errors,
help_text=help_text, 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) 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( await message.answer(
_render_confirm( _render_confirm(
symbol=draft.symbol, 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), reply_markup=_confirm_keyboard(drafts_page=drafts_page),
) )
except ExchangeError as exc: 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( await show_message_exchange_error(
message, message,
title=title, title=title,
@@ -842,17 +1159,34 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
@router.message(Command("drafts")) @router.message(Command("drafts"))
async def drafts_command(message: Message) -> None: 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) await show_recent_drafts(message, edit_mode=False, page=1)
@router.callback_query(NewOrderDraftStates.waiting_confirm, F.data == "order_confirm") @router.callback_query(NewOrderDraftStates.waiting_confirm, F.data == "order_confirm")
async def confirm_order(callback: CallbackQuery, state: FSMContext) -> None: async def confirm_order(callback: CallbackQuery, state: FSMContext) -> None:
service = OrderDraftsService() service = OrderDraftsService()
journal = JournalService()
data = await state.get_data() data = await state.get_data()
raw = data.get("confirm_draft") raw = data.get("confirm_draft")
if not raw: if not raw:
await state.clear() 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) await callback.answer("Ошибка состояния", show_alert=True)
return return
@@ -874,6 +1208,21 @@ async def confirm_order(callback: CallbackQuery, state: FSMContext) -> None:
edit_page = data.get("draft_edit_page") edit_page = data.get("draft_edit_page")
await state.clear() await state.clear()
errors = [item.strip() for item in str(exc).split(";") if item.strip()] 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 = ( reply_markup = (
_drafts_back_keyboard(int(edit_page)) _drafts_back_keyboard(int(edit_page))
if edit_page if edit_page
@@ -887,6 +1236,19 @@ async def confirm_order(callback: CallbackQuery, state: FSMContext) -> None:
return return
except ExchangeError as exc: except ExchangeError as exc:
await state.clear() 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( await show_callback_exchange_error(
callback, callback,
title="<b>📊 Торговля — Подтверждение черновика</b>", title="<b>📊 Торговля — Подтверждение черновика</b>",
@@ -899,6 +1261,23 @@ async def confirm_order(callback: CallbackQuery, state: FSMContext) -> None:
edit_page = data.get("draft_edit_page") edit_page = data.get("draft_edit_page")
await state.clear() 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 = ( reply_markup = (
_drafts_back_keyboard(int(edit_page)) _drafts_back_keyboard(int(edit_page))
if edit_page if edit_page

View File

@@ -7,19 +7,17 @@ def build_main_menu_keyboard() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup( return ReplyKeyboardMarkup(
keyboard=[ keyboard=[
[ [
KeyboardButton(text="🏠 Главная"), 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, resize_keyboard=True,
input_field_placeholder="Выбери раздел...", input_field_placeholder="Выбери раздел...",
) )

View File

@@ -14,6 +14,36 @@ def mode_line() -> str:
return f"🔸 <b>{label}</b>\n\n" return f"🔸 <b>{label}</b>\n\n"
def breadcrumb_line(*items: str) -> str:
if not items:
return ""
first = items[0].upper()
rest = " · ".join(items[1:])
if rest:
return f"<b>{first}</b> · {rest}\n\n"
return f"<b>{first}</b>\n\n"
def screen_header(
*,
title: str,
path: list[str] | None = None,
show_mode: bool = False,
) -> str:
parts: list[str] = [f"<b>{title}</b>"]
if show_mode:
parts.append(f"🔸 <b>{get_runtime_mode_label()}</b>")
if path:
parts.append(f"<b>{''.join(path).upper()}</b>")
return "\n".join(parts) + "\n\n"
def now_line() -> str: def now_line() -> str:
settings = load_settings() settings = load_settings()
tz_name = settings.tz or "UTC" tz_name = settings.tz or "UTC"

View File

@@ -3,13 +3,10 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from zoneinfo import ZoneInfo
from aiogram.exceptions import TelegramBadRequest from aiogram.exceptions import TelegramBadRequest
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message 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 from src.telegram.ui.common import mode_line, now_line
@@ -19,7 +16,16 @@ class ExchangeErrorView:
details: str 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() text = str(exc).lower()
network_markers = [ network_markers = [
@@ -64,7 +70,7 @@ def _build_exchange_error_view(
time_details: str | None = None, time_details: str | None = None,
generic_details: str | None = None, generic_details: str | None = None,
) -> ExchangeErrorView: ) -> ExchangeErrorView:
error_type = _classify_exchange_error(exc) error_type = classify_exchange_error(exc)
if error_type == "network": if error_type == "network":
return ExchangeErrorView( return ExchangeErrorView(
@@ -81,18 +87,12 @@ def _build_exchange_error_view(
if error_type == "time": if error_type == "time":
return ExchangeErrorView( return ExchangeErrorView(
headline="🔴 Ошибка времени", headline="🔴 Ошибка времени",
details=( details=time_details or DEFAULT_TIME_DETAILS,
time_details
or "Не удалось выполнить запрос к бирже.\nОбнови экран."
),
) )
return ExchangeErrorView( return ExchangeErrorView(
headline="🔴 Ошибка биржи", headline="🔴 Ошибка биржи",
details=( details=generic_details or DEFAULT_GENERIC_DETAILS,
generic_details
or "Не удалось получить данные с биржи.\nОбнови экран."
),
) )
@@ -100,8 +100,8 @@ def render_exchange_error(
*, *,
title: str, title: str,
exc: Exception, exc: Exception,
network_details: str, network_details: str = DEFAULT_NETWORK_DETAILS,
auth_details: str, auth_details: str = DEFAULT_AUTH_DETAILS,
time_details: str | None = None, time_details: str | None = None,
generic_details: str | None = None, generic_details: str | None = None,
) -> str: ) -> str:
@@ -178,8 +178,8 @@ async def show_callback_exchange_error(
*, *,
title: str, title: str,
exc: Exception, exc: Exception,
network_details: str, network_details: str = DEFAULT_NETWORK_DETAILS,
auth_details: str, auth_details: str = DEFAULT_AUTH_DETAILS,
time_details: str | None = None, time_details: str | None = None,
generic_details: str | None = None, generic_details: str | None = None,
retry_callback_data: str | None = None, retry_callback_data: str | None = None,
@@ -205,10 +205,7 @@ async def show_callback_exchange_error(
) )
try: try:
await callback.message.edit_text( await callback.message.edit_text(text, reply_markup=markup)
text,
reply_markup=markup,
)
await callback.answer() await callback.answer()
except TelegramBadRequest as tg_exc: except TelegramBadRequest as tg_exc:
if "message is not modified" in str(tg_exc).lower(): if "message is not modified" in str(tg_exc).lower():
@@ -222,8 +219,8 @@ async def show_message_exchange_error(
*, *,
title: str, title: str,
exc: Exception, exc: Exception,
network_details: str, network_details: str = DEFAULT_NETWORK_DETAILS,
auth_details: str, auth_details: str = DEFAULT_AUTH_DETAILS,
time_details: str | None = None, time_details: str | None = None,
generic_details: str | None = None, generic_details: str | None = None,
retry_callback_data: str | None = None, retry_callback_data: str | None = None,

View File

@@ -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()

View File

@@ -1,15 +1,67 @@
# app/src/trading/journal/service.py
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import Any 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.repositories.journal import JournalRepository
from src.storage.session import check_database_health from src.storage.session import check_database_health
from src.trading.journal.exporter import build_csv, build_xlsx
EXPORT_LIMIT = 5000
class JournalService: class JournalService:
def __init__(self) -> None: def __init__(self) -> None:
self.repository = JournalRepository() 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( def log_info(
self, self,
event_type: str, event_type: str,
@@ -62,16 +114,155 @@ class JournalService:
payload=payload, 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) 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 offset = (page - 1) * page_size
return self.repository.list_recent_with_offset(limit=page_size, offset=offset) return self.repository.list_recent_with_offset(limit=page_size, offset=offset)
def get_total_count(self) -> int: def get_total_count(self) -> int:
return self.repository.count_events() 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]: def get_journal_health(self) -> tuple[bool, str]:
db_ok, db_message = check_database_health() db_ok, db_message = check_database_health()
if not db_ok: if not db_ok:
@@ -82,4 +273,20 @@ class JournalService:
except Exception as exc: except Exception as exc:
return False, f"Ошибка чтения журнала: {exc}" return False, f"Ошибка чтения журнала: {exc}"
return True, f"Журнал работает. Событий: {total}" 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

BIN
docs/Archive.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1,14 @@
# 0014 — System Navigation and Journal Settings
## Решение
Вынести журнал и системные настройки в отдельный раздел “Система” с многоуровневой навигацией.
## Причины
- разгрузить главное меню
- разделить operational UI и settings UI
- упростить масштабирование новых системных экранов
## Последствия
- появляется единый navigation pattern
- журнал становится отдельным subsystem
- проще расширять настройки

View File

@@ -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
--- ---

View File

@@ -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"