From 2c49bb70c0c7f5939738d7faa9cba86a7c938f07 Mon Sep 17 00:00:00 2001 From: Sergey Date: Thu, 16 Apr 2026 13:13:03 +0300 Subject: [PATCH] Stage 04.2 - journal and event log --- app/src/bootstrap/app_factory.py | 16 ++ app/src/core/system_status.py | 23 ++- app/src/integrations/exchange/service.py | 87 +++++++++- app/src/storage/repositories/journal.py | 92 ++++++++++ app/src/storage/schema.py | 15 +- app/src/telegram/handlers/journal.py | 83 ++++++++- app/src/telegram/handlers/market.py | 103 +++++++++++- app/src/telegram/handlers/portfolio.py | 96 ++++++++++- app/src/trading/journal/service.py | 73 ++++++++ docs/decisions/0010-journal-first.md | 15 ++ docs/stages/stage-04-2-journal.md | 205 +++++++++++++++++++++++ 11 files changed, 780 insertions(+), 28 deletions(-) create mode 100644 app/src/storage/repositories/journal.py create mode 100644 app/src/trading/journal/service.py create mode 100644 docs/decisions/0010-journal-first.md create mode 100644 docs/stages/stage-04-2-journal.md diff --git a/app/src/bootstrap/app_factory.py b/app/src/bootstrap/app_factory.py index 26b6669..d06df21 100644 --- a/app/src/bootstrap/app_factory.py +++ b/app/src/bootstrap/app_factory.py @@ -7,6 +7,7 @@ from src.bootstrap.logging import setup_logging from src.core.config import load_settings from src.storage.schema import init_schema from src.telegram.routers import setup_routers +from src.trading.journal.service import JournalService def create_app() -> tuple[Bot, Dispatcher]: @@ -14,6 +15,21 @@ def create_app() -> tuple[Bot, Dispatcher]: setup_logging(settings.log_level) init_schema() + journal = JournalService() + try: + journal.log_info( + "app_start", + "Приложение запущено.", + { + "env": settings.app_env, + "exchange_name": settings.exchange_name, + "default_symbol": settings.default_symbol, + }, + ) + except Exception: + # журнал не должен ломать запуск приложения + pass + bot = Bot( token=settings.bot_token, default=DefaultBotProperties(parse_mode=settings.bot_parse_mode), diff --git a/app/src/core/system_status.py b/app/src/core/system_status.py index b4e0ac8..7f05f95 100644 --- a/app/src/core/system_status.py +++ b/app/src/core/system_status.py @@ -1,6 +1,5 @@ from __future__ import annotations -import platform import re from dataclasses import dataclass @@ -8,6 +7,7 @@ from src.core.config import load_settings from src.core.constants import APP_NAME, APP_VERSION from src.integrations.exchange.service import ExchangeService from src.storage.session import check_database_health +from src.trading.journal.service import JournalService @dataclass(slots=True) @@ -39,7 +39,9 @@ def _extract_postgres_version(raw: str) -> str: return "PostgreSQL" -def _build_exchange_status(exchange_service: ExchangeService, default_symbol: str) -> ComponentStatus: +def _build_exchange_status( + exchange_service: ExchangeService, default_symbol: str +) -> ComponentStatus: try: symbol_validation = exchange_service.validate_symbol(default_symbol) except Exception as exc: @@ -50,6 +52,7 @@ def _build_exchange_status(exchange_service: ExchangeService, default_symbol: st ) exchange_health = exchange_service.get_health() + if exchange_health.ok and symbol_validation.is_valid: return ComponentStatus(name="Биржа", state="🟢") @@ -63,7 +66,7 @@ def _build_exchange_status(exchange_service: ExchangeService, default_symbol: st return ComponentStatus( name="Биржа", state="🔴", - details=symbol_validation.message or "Инструмент не прошел проверку.", + details=symbol_validation.message or "Инструмент не прошёл проверку.", ) @@ -96,6 +99,14 @@ def _build_database_status() -> tuple[ComponentStatus, str]: ) +def _build_journal_status() -> ComponentStatus: + ok, message = JournalService().get_journal_health() + if ok: + return ComponentStatus(name="Журнал", state="🟢") + + return ComponentStatus(name="Журнал", state="🔴", details=message) + + def _resolve_mode_label(exchange_testnet: bool) -> str: return "ДЕМО аккаунт" if exchange_testnet else "РЕАЛЬНЫЙ аккаунт" @@ -107,6 +118,7 @@ def get_system_snapshot() -> SystemSnapshot: database_status, db_label = _build_database_status() exchange_status = _build_exchange_status(exchange_service, settings.default_symbol) account_status = _build_account_status(exchange_service) + journal_status = _build_journal_status() components = [ ComponentStatus(name="Приложение", state="🟢"), @@ -114,6 +126,7 @@ def get_system_snapshot() -> SystemSnapshot: ComponentStatus(name="Telegram", state="🟢"), exchange_status, account_status, + journal_status, ] return SystemSnapshot( @@ -136,7 +149,9 @@ def _render_component(component: ComponentStatus) -> str: def build_system_text() -> str: snapshot = get_system_snapshot() - components_block = "\n".join(_render_component(component) for component in snapshot.components) + components_block = "\n".join( + _render_component(component) for component in snapshot.components + ) return ( "⚙️ Система\n\n" diff --git a/app/src/integrations/exchange/service.py b/app/src/integrations/exchange/service.py index c96c88e..8725064 100644 --- a/app/src/integrations/exchange/service.py +++ b/app/src/integrations/exchange/service.py @@ -22,11 +22,31 @@ from src.integrations.exchange.models import ( from src.integrations.exchange.private_client import ExchangePrivateClient from src.integrations.exchange.rest_client import ExchangeRestClient from src.integrations.exchange.symbol_utils import normalize_symbol, symbol_candidates +from src.trading.journal.service import JournalService class ExchangeService: def __init__(self) -> None: self.settings = load_settings() + self.journal = JournalService() + + def _log_info(self, event_type: str, message: str, payload: dict | None = None) -> None: + try: + self.journal.log_info(event_type, message, payload) + except Exception: + pass + + def _log_warning(self, event_type: str, message: str, payload: dict | None = None) -> None: + try: + self.journal.log_warning(event_type, message, payload) + except Exception: + pass + + def _log_error(self, event_type: str, message: str, payload: dict | None = None) -> None: + try: + self.journal.log_error(event_type, message, payload) + except Exception: + pass def get_health(self) -> ExchangeHealth: if not self.settings.exchange_enabled: @@ -100,14 +120,50 @@ class ExchangeService: auth_health = self.get_private_auth_health() if not auth_health.ok: + self._log_error( + "balance_summary_error", + auth_health.message, + { + "exchange_name": self.settings.exchange_name, + "default_symbol": self.settings.default_symbol, + }, + ) raise ExchangeError(auth_health.message) - payload = ExchangePrivateClient().get_account_info(show_zero_balance=False) - balances = parse_account_balances(payload) + try: + payload = ExchangePrivateClient().get_account_info(show_zero_balance=False) + balances = parse_account_balances(payload) + except Exception as exc: + self._log_error( + "balance_summary_error", + f"Не удалось получить баланс: {exc}", + { + "exchange_name": self.settings.exchange_name, + "default_symbol": self.settings.default_symbol, + }, + ) + raise ExchangeError(f"Не удалось получить баланс: {exc}") from exc if not balances: + self._log_warning( + "balance_summary_empty", + "Баланс получен, но список активов пуст или не распознан.", + { + "exchange_name": self.settings.exchange_name, + "default_symbol": self.settings.default_symbol, + }, + ) raise ExchangeError("Баланс получен, но список активов пуст или не распознан.") + self._log_info( + "balance_summary_loaded", + f"Баланс успешно получен. Активов: {len(balances)}", + { + "exchange_name": self.settings.exchange_name, + "assets_count": len(balances), + }, + ) + return balances def get_exchange_symbols(self) -> list[ExchangeSymbol]: @@ -219,13 +275,32 @@ class ExchangeService: def _get_real_price(self, symbol: str) -> TickerPrice: client = ExchangeRestClient() - payload = client.get_json( - "/api/v2/ticker/24hr", - params={"symbol": symbol}, - ) + try: + payload = client.get_json( + "/api/v2/ticker/24hr", + params={"symbol": symbol}, + ) + except Exception as exc: + self._log_error( + "market_price_error", + f"Не удалось получить цену инструмента {symbol}: {exc}", + { + "symbol": symbol, + "exchange_name": self.settings.exchange_name, + }, + ) + raise price_raw = payload.get("lastPrice") if price_raw is None: + self._log_error( + "market_price_error", + "Field 'lastPrice' is missing in ticker response.", + { + "symbol": symbol, + "exchange_name": self.settings.exchange_name, + }, + ) raise ExchangeError("Field 'lastPrice' is missing in ticker response.") close_time = payload.get("closeTime") or payload.get("eventTime") or "" diff --git a/app/src/storage/repositories/journal.py b/app/src/storage/repositories/journal.py new file mode 100644 index 0000000..713564f --- /dev/null +++ b/app/src/storage/repositories/journal.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import json +from typing import Any + +from src.storage.session import get_connection + + +class JournalRepository: + def add_event( + self, + *, + level: str, + event_type: str, + message: str, + payload: dict[str, Any] | None = None, + ) -> None: + payload_json = json.dumps(payload, ensure_ascii=False) if payload is not None else None + + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + ''' + INSERT INTO journal_events (level, event_type, message, payload_json) + VALUES (%s, %s, %s, %s::jsonb) + ''', + ( + level.upper().strip(), + event_type.strip(), + message.strip(), + payload_json, + ), + ) + + def list_recent_events(self, limit: int = 10) -> list[dict[str, str]]: + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + ''' + SELECT id, created_at, level, event_type, message + FROM journal_events + ORDER BY created_at DESC, id DESC + LIMIT %s + ''', + (limit,), + ) + rows = cursor.fetchall() + + items: list[dict[str, str]] = [] + for row in rows: + items.append( + { + "id": str(row[0]), + "created_at": str(row[1]), + "level": str(row[2]), + "event_type": str(row[3]), + "message": str(row[4]), + } + ) + return items + + def count_events(self) -> int: + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute("SELECT COUNT(*) FROM journal_events") + row = cursor.fetchone() + + return int(row[0]) if row else 0 + + def list_recent_with_offset(self, limit: int, offset: int): + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT created_at, level, event_type, message + FROM journal_events + ORDER BY created_at DESC + LIMIT %s OFFSET %s + """, + (limit, offset), + ) + rows = cursor.fetchall() + + return [ + { + "created_at": str(r[0]), + "level": r[1], + "event_type": r[2], + "message": r[3], + } + for r in rows + ] \ No newline at end of file diff --git a/app/src/storage/schema.py b/app/src/storage/schema.py index 6f91268..7a3c66d 100644 --- a/app/src/storage/schema.py +++ b/app/src/storage/schema.py @@ -1,8 +1,5 @@ from __future__ import annotations - from src.storage.session import get_connection - - DDL = [ ''' CREATE TABLE IF NOT EXISTS balance_snapshots ( @@ -23,6 +20,14 @@ DDL = [ ) ''', ''' + CREATE INDEX IF NOT EXISTS idx_journal_events_created_at + ON journal_events (created_at DESC) + ''', + ''' + CREATE INDEX IF NOT EXISTS idx_journal_events_event_type + ON journal_events (event_type) + ''', + ''' CREATE TABLE IF NOT EXISTS order_drafts ( id BIGSERIAL PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -35,10 +40,8 @@ DDL = [ ) ''' ] - - def init_schema() -> None: with get_connection() as connection: with connection.cursor() as cursor: for statement in DDL: - cursor.execute(statement) + cursor.execute(statement) \ No newline at end of file diff --git a/app/src/telegram/handlers/journal.py b/app/src/telegram/handlers/journal.py index 3a86c6f..31099ac 100644 --- a/app/src/telegram/handlers/journal.py +++ b/app/src/telegram/handlers/journal.py @@ -1,12 +1,85 @@ -from aiogram import F, Router -from aiogram.types import Message +from __future__ import annotations -from src.telegram.menus import JOURNAL_TEXT +from aiogram import F, Router +from aiogram.types import Message, CallbackQuery +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from src.trading.journal.service import JournalService router = Router(name="journal") +PAGE_SIZE = 3 + + +def build_keyboard(page: int, total_pages: int): + kb = InlineKeyboardBuilder() + + # кнопка "в начало" + if page > 1: + kb.button(text="⏮️", callback_data="journal:1") + + # назад + if page > 1: + kb.button(text="⬅️", callback_data=f"journal:{page-1}") + + # текущая страница + kb.button(text=f"{page}/{total_pages}", callback_data="noop") + + # вперед + if page < total_pages: + kb.button(text="➡️", callback_data=f"journal:{page+1}") + + return kb.as_markup() + + +def render(events, page, total_pages): + lines = ["📒 Журнал", "", "Последние события", ""] + + for e in events: + lines.extend( + [ + f"ℹ️ {e['event_type']}", + f"• уровень: {e['level']}", + f"• время: {e['created_at']}", + f"• сообщение: {e['message']}", + "", + ] + ) + + return "\n".join(lines) + @router.message(F.text == "📒 Журнал") -async def open_journal(message: Message) -> None: - await message.answer(JOURNAL_TEXT) +async def open_journal(message: Message): + service = JournalService() + + total = service.get_total_count() + total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE) + + events = service.get_page(1, PAGE_SIZE) + + text = render(events, 1, total_pages) + kb = build_keyboard(1, total_pages) + + await message.answer(text, reply_markup=kb) + + +@router.callback_query(F.data.startswith("journal:")) +async def paginate(callback: CallbackQuery): + page = int(callback.data.split(":")[1]) + + service = JournalService() + + total = service.get_total_count() + total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE) + + page = max(1, min(page, total_pages)) + + events = service.get_page(page, PAGE_SIZE) + + text = render(events, page, total_pages) + kb = build_keyboard(page, total_pages) + + await callback.message.edit_text(text, reply_markup=kb) + await callback.answer() \ No newline at end of file diff --git a/app/src/telegram/handlers/market.py b/app/src/telegram/handlers/market.py index 00f8f96..9fda2df 100644 --- a/app/src/telegram/handlers/market.py +++ b/app/src/telegram/handlers/market.py @@ -5,26 +5,99 @@ from aiogram.types import Message from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.service import ExchangeService +from src.trading.journal.service import JournalService router = Router(name="market") +def _safe_log_info( + journal: JournalService, + event_type: str, + message: str, + payload: dict | None = None, +) -> None: + try: + journal.log_info(event_type, message, payload) + except Exception: + pass + + +def _safe_log_warning( + journal: JournalService, + event_type: str, + message: str, + payload: dict | None = None, +) -> None: + try: + journal.log_warning(event_type, message, payload) + except Exception: + pass + + +def _safe_log_error( + journal: JournalService, + event_type: str, + message: str, + payload: dict | None = None, +) -> None: + try: + journal.log_error(event_type, message, payload) + except Exception: + pass + + @router.message(F.text == "📈 Рынок") async def open_market(message: Message) -> None: service = ExchangeService() + journal = JournalService() + + user_id = message.from_user.id if message.from_user else None + chat_id = message.chat.id if message.chat else None + requested_symbol = service.settings.default_symbol + + _safe_log_info( + journal, + "user_open_market", + "Пользователь открыл экран рынка.", + { + "user_id": user_id, + "chat_id": chat_id, + "symbol": requested_symbol, + }, + ) try: - validation = service.validate_symbol(service.settings.default_symbol) + validation = service.validate_symbol(requested_symbol) if not validation.is_valid: + _safe_log_warning( + journal, + "market_symbol_invalid", + f"Символ не прошел проверку: {validation.message}", + { + "user_id": user_id, + "chat_id": chat_id, + "symbol": requested_symbol, + }, + ) await message.answer( "📈 Рынок\n\n" - f"Ошибка символа: {validation.message}" + f"Ошибка инструмента: {validation.message}" ) return ticker = service.get_price(validation.normalized_symbol) except ExchangeError as exc: + _safe_log_error( + journal, + "market_open_error", + f"Не удалось открыть экран рынка: {exc}", + { + "user_id": user_id, + "chat_id": chat_id, + "symbol": requested_symbol, + }, + ) await message.answer( "📈 Рынок\n\n" "Не удалось получить цену с биржи.\n" @@ -35,8 +108,16 @@ async def open_market(message: Message) -> None: symbol_info = validation.symbol_info symbol_status = symbol_info.status if symbol_info else "n/a" market_type = symbol_info.market_type if symbol_info else "n/a" - market_modes = ", ".join(symbol_info.market_modes) if symbol_info and symbol_info.market_modes else "n/a" - tick_size = f"{symbol_info.tick_size}" if symbol_info and symbol_info.tick_size is not None else "n/a" + market_modes = ( + ", ".join(symbol_info.market_modes) + if symbol_info and symbol_info.market_modes + else "n/a" + ) + tick_size = ( + f"{symbol_info.tick_size}" + if symbol_info and symbol_info.tick_size is not None + else "n/a" + ) name = symbol_info.name if symbol_info and symbol_info.name else ticker.symbol text = ( @@ -52,4 +133,16 @@ async def open_market(message: Message) -> None: f"Обновлено: {ticker.updated_at}" ) - await message.answer(text) + _safe_log_info( + journal, + "market_open_success", + "Экран рынка успешно показан пользователю.", + { + "user_id": user_id, + "chat_id": chat_id, + "symbol": ticker.symbol, + "price": ticker.price, + }, + ) + + await message.answer(text) \ No newline at end of file diff --git a/app/src/telegram/handlers/portfolio.py b/app/src/telegram/handlers/portfolio.py index a0cb54e..36c33e2 100644 --- a/app/src/telegram/handlers/portfolio.py +++ b/app/src/telegram/handlers/portfolio.py @@ -6,6 +6,7 @@ from aiogram.types import Message from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.models import BalanceSummary from src.integrations.exchange.service import ExchangeService +from src.trading.journal.service import JournalService router = Router(name="portfolio") @@ -60,7 +61,9 @@ def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]: return sorted(items, key=sort_key) -def split_balances(items: list[BalanceSummary]) -> tuple[list[BalanceSummary], list[BalanceSummary]]: +def split_balances( + items: list[BalanceSummary], +) -> tuple[list[BalanceSummary], list[BalanceSummary]]: major: list[BalanceSummary] = [] other: list[BalanceSummary] = [] @@ -85,13 +88,72 @@ def render_balance_block(item: BalanceSummary) -> list[str]: ] +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 + + @router.message(F.text == "💼 Портфель") async def open_portfolio(message: Message) -> None: service = ExchangeService() + journal = JournalService() + + user_id = message.from_user.id if message.from_user else None + chat_id = message.chat.id if message.chat else None + + _safe_log_info( + journal, + "user_open_portfolio", + "Пользователь открыл экран портфеля.", + { + "user_id": user_id, + "chat_id": chat_id, + }, + ) try: balances = service.get_balance_summary() except ExchangeError as exc: + _safe_log_error( + journal, + "portfolio_open_error", + f"Не удалось открыть портфель: {exc}", + { + "user_id": user_id, + "chat_id": chat_id, + }, + ) await message.answer( "💼 Портфель\n\n" "Не удалось получить баланс с private API.\n" @@ -100,6 +162,15 @@ async def open_portfolio(message: Message) -> None: return if not balances: + _safe_log_warning( + journal, + "portfolio_empty", + "Портфель открыт, но баланс пуст.", + { + "user_id": user_id, + "chat_id": chat_id, + }, + ) await message.answer( "💼 Портфель\n\n" "Баланс пуст." @@ -110,6 +181,16 @@ async def open_portfolio(message: Message) -> None: visible_balances = sort_balances(visible_balances) if not visible_balances: + _safe_log_warning( + journal, + "portfolio_zero_balances", + "Портфель открыт, но все балансы нулевые.", + { + "user_id": user_id, + "chat_id": chat_id, + "assets_count": len(balances), + }, + ) await message.answer( "💼 Портфель\n\n" "Все балансы нулевые." @@ -139,5 +220,16 @@ async def open_portfolio(message: Message) -> None: ] ) + _safe_log_info( + journal, + "portfolio_open_success", + "Портфель успешно показан пользователю.", + { + "user_id": user_id, + "chat_id": chat_id, + "assets_count": len(visible_balances), + }, + ) + text = "\n".join(lines).rstrip() - await message.answer(text) + await message.answer(text) \ No newline at end of file diff --git a/app/src/trading/journal/service.py b/app/src/trading/journal/service.py new file mode 100644 index 0000000..1300833 --- /dev/null +++ b/app/src/trading/journal/service.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import Any + +from src.storage.repositories.journal import JournalRepository +from src.storage.session import check_database_health + + +class JournalService: + def __init__(self) -> None: + self.repository = JournalRepository() + + def log_info( + self, + event_type: str, + message: str, + payload: dict[str, Any] | None = None, + ) -> None: + self.repository.add_event( + level="INFO", + event_type=event_type, + message=message, + payload=payload, + ) + + def log_warning( + self, + event_type: str, + message: str, + payload: dict[str, Any] | None = None, + ) -> None: + self.repository.add_event( + level="WARNING", + event_type=event_type, + message=message, + payload=payload, + ) + + def log_error( + self, + event_type: str, + message: str, + payload: dict[str, Any] | None = None, + ) -> None: + self.repository.add_event( + level="ERROR", + event_type=event_type, + message=message, + payload=payload, + ) + + def get_recent(self, limit: int = 10) -> list[dict[str, str]]: + return self.repository.list_recent_events(limit=limit) + + def get_journal_health(self) -> tuple[bool, str]: + db_ok, db_message = check_database_health() + if not db_ok: + return False, f"Журнал недоступен: {db_message}" + + try: + total = self.repository.count_events() + except Exception as exc: + return False, f"Ошибка чтения журнала: {exc}" + + return True, f"Журнал работает. Событий: {total}" + + def get_page(self, page: int = 1, page_size: int = 3): + offset = (page - 1) * page_size + return self.repository.list_recent_with_offset(limit=page_size, offset=offset) + + + def get_total_count(self) -> int: + return self.repository.count_events() \ No newline at end of file diff --git a/docs/decisions/0010-journal-first.md b/docs/decisions/0010-journal-first.md new file mode 100644 index 0000000..a3ccca3 --- /dev/null +++ b/docs/decisions/0010-journal-first.md @@ -0,0 +1,15 @@ +# 0010 — Journal First + +## Решение +После storage foundation первым прикладным use case сделать журнал событий, а не order drafts. + +## Причины +- журнал полезен сразу +- помогает отлаживать проект +- не связан с торговым риском +- даёт первую реальную пользу от PostgreSQL + +## Последствия +- появляется история действий бота +- ошибки и события можно анализировать +- дальнейшие этапы проще отлаживать diff --git a/docs/stages/stage-04-2-journal.md b/docs/stages/stage-04-2-journal.md new file mode 100644 index 0000000..d5046e3 --- /dev/null +++ b/docs/stages/stage-04-2-journal.md @@ -0,0 +1,205 @@ +# Stage 04.2 — Journal (Event Log) + +## Цель +Добавить в систему слой журналирования (event log), который сохраняет события в PostgreSQL и отображает их пользователю через Telegram. + +--- + +## Что реализовано + +### Journal (журнал событий) + +Добавлена таблица: + +- `journal_events` + +Структура: +- `id` +- `created_at` +- `level` (INFO / WARNING / ERROR) +- `event_type` +- `message` +- `payload_json` + +--- + +### Repository слой + +Добавлен: + +- `JournalRepository` + +Функции: +- запись события (`add_event`) +- получение списка событий +- получение количества событий +- получение событий с offset (для пагинации) + +--- + +### Service слой + +Добавлен: + +- `JournalService` + +Функции: +- `log_info` +- `log_warning` +- `log_error` +- `get_recent` +- `get_page` (пагинация) +- `get_total_count` +- `get_journal_health` + +Особенность: +- журнал не должен ломать приложение (все вызовы обёрнуты в try/except) + +--- + +### Интеграция в систему + +#### При старте приложения +Записывается событие: + +- `app_start` + +--- + +#### ExchangeService + +Добавлено логирование: + +- `balance_summary_loaded` +- `balance_summary_error` +- `balance_summary_empty` +- `market_price_error` + +--- + +#### Portfolio handler (`💼 Портфель`) + +Добавлены события: + +- `user_open_portfolio` +- `portfolio_open_success` +- `portfolio_open_error` +- `portfolio_empty` +- `portfolio_zero_balances` + +--- + +#### Market handler (`📈 Рынок`) + +Добавлены события: + +- `user_open_market` +- `market_open_success` +- `market_open_error` +- `market_symbol_invalid` + +--- + +### UI — экран `📒 Журнал` + +Функциональность: + +- вывод последних событий +- форматирование: + - уровень + - время + - сообщение +- отображение иконок: + - ℹ️ INFO + - 🟡 WARNING + - 🔴 ERROR + +--- + +### Пагинация + +Реализована постраничная навигация: + +- размер страницы: 3 события +- кнопки: + - ⏮️ — в начало + - ⬅️ — предыдущая страница + - ➡️ — следующая страница +- отображение текущей страницы: `3/9` + +Особенности: +- отсутствует дублирующий текст "Страница X из Y" +- кнопка ⏮️ отображается только начиная со 2-й страницы + +--- + +### Интеграция в `⚙️ Система` + +Добавлен компонент: + +- `🟢 Журнал` + +Проверка: +- доступность БД +- возможность чтения журнала + +--- + +## Архитектурный результат + +После Stage 04.2 система получила: + +- persistent event log +- трассировку пользовательских действий +- диагностику ошибок через UI +- основу для аналитики и трейдинга + +--- + +## Важные принципы + +### 1. Journal = append-only +События не удаляются и не изменяются. + +--- + +### 2. Логируем только важное +Не логируются: +- каждый вызов system screen +- внутренние технические операции + +Логируются: +- действия пользователя +- ошибки +- ключевые результаты операций + +--- + +### 3. Journal не влияет на стабильность +Ошибки журнала не должны ломать приложение. + +--- + +## Ограничения текущей реализации + +- журнал растёт без ограничения +- нет фильтрации (по уровню / типу) +- нет очистки или архивации + +--- + +## Что дальше + +Возможные улучшения: + +- фильтр по уровню (ERROR / INFO) +- локализация времени (timezone вместо UTC) +- ограничение размера журнала +- очистка старых событий + +--- + +## Следующий этап + +- Stage 04.3 — Repositories +(структурированный доступ к данным и подготовка к работе с ордерами) \ No newline at end of file