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