Stage 04.2 - journal and event log

This commit is contained in:
2026-04-16 13:13:03 +03:00
parent c35deeaefa
commit 2c49bb70c0
11 changed files with 780 additions and 28 deletions

View File

@@ -7,6 +7,7 @@ from src.bootstrap.logging import setup_logging
from src.core.config import load_settings from src.core.config import load_settings
from src.storage.schema import init_schema from src.storage.schema import init_schema
from src.telegram.routers import setup_routers from src.telegram.routers import setup_routers
from src.trading.journal.service import JournalService
def create_app() -> tuple[Bot, Dispatcher]: def create_app() -> tuple[Bot, Dispatcher]:
@@ -14,6 +15,21 @@ def create_app() -> tuple[Bot, Dispatcher]:
setup_logging(settings.log_level) setup_logging(settings.log_level)
init_schema() 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( bot = Bot(
token=settings.bot_token, token=settings.bot_token,
default=DefaultBotProperties(parse_mode=settings.bot_parse_mode), default=DefaultBotProperties(parse_mode=settings.bot_parse_mode),

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import platform
import re import re
from dataclasses import dataclass 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.core.constants import APP_NAME, APP_VERSION
from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.service import ExchangeService
from src.storage.session import check_database_health from src.storage.session import check_database_health
from src.trading.journal.service import JournalService
@dataclass(slots=True) @dataclass(slots=True)
@@ -39,7 +39,9 @@ def _extract_postgres_version(raw: str) -> str:
return "PostgreSQL" 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: try:
symbol_validation = exchange_service.validate_symbol(default_symbol) symbol_validation = exchange_service.validate_symbol(default_symbol)
except Exception as exc: except Exception as exc:
@@ -50,6 +52,7 @@ def _build_exchange_status(exchange_service: ExchangeService, default_symbol: st
) )
exchange_health = exchange_service.get_health() exchange_health = exchange_service.get_health()
if exchange_health.ok and symbol_validation.is_valid: if exchange_health.ok and symbol_validation.is_valid:
return ComponentStatus(name="Биржа", state="🟢") return ComponentStatus(name="Биржа", state="🟢")
@@ -63,7 +66,7 @@ def _build_exchange_status(exchange_service: ExchangeService, default_symbol: st
return ComponentStatus( return ComponentStatus(
name="Биржа", name="Биржа",
state="🔴", 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: def _resolve_mode_label(exchange_testnet: bool) -> str:
return "ДЕМО аккаунт" if exchange_testnet else "РЕАЛЬНЫЙ аккаунт" return "ДЕМО аккаунт" if exchange_testnet else "РЕАЛЬНЫЙ аккаунт"
@@ -107,6 +118,7 @@ def get_system_snapshot() -> SystemSnapshot:
database_status, db_label = _build_database_status() database_status, db_label = _build_database_status()
exchange_status = _build_exchange_status(exchange_service, settings.default_symbol) exchange_status = _build_exchange_status(exchange_service, settings.default_symbol)
account_status = _build_account_status(exchange_service) account_status = _build_account_status(exchange_service)
journal_status = _build_journal_status()
components = [ components = [
ComponentStatus(name="Приложение", state="🟢"), ComponentStatus(name="Приложение", state="🟢"),
@@ -114,6 +126,7 @@ def get_system_snapshot() -> SystemSnapshot:
ComponentStatus(name="Telegram", state="🟢"), ComponentStatus(name="Telegram", state="🟢"),
exchange_status, exchange_status,
account_status, account_status,
journal_status,
] ]
return SystemSnapshot( return SystemSnapshot(
@@ -136,7 +149,9 @@ def _render_component(component: ComponentStatus) -> str:
def build_system_text() -> str: def build_system_text() -> str:
snapshot = get_system_snapshot() 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 ( return (
"<b>⚙️ Система</b>\n\n" "<b>⚙️ Система</b>\n\n"

View File

@@ -22,11 +22,31 @@ from src.integrations.exchange.models import (
from src.integrations.exchange.private_client import ExchangePrivateClient from src.integrations.exchange.private_client import ExchangePrivateClient
from src.integrations.exchange.rest_client import ExchangeRestClient from src.integrations.exchange.rest_client import ExchangeRestClient
from src.integrations.exchange.symbol_utils import normalize_symbol, symbol_candidates from src.integrations.exchange.symbol_utils import normalize_symbol, symbol_candidates
from src.trading.journal.service import JournalService
class ExchangeService: class ExchangeService:
def __init__(self) -> None: def __init__(self) -> None:
self.settings = load_settings() 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: def get_health(self) -> ExchangeHealth:
if not self.settings.exchange_enabled: if not self.settings.exchange_enabled:
@@ -100,14 +120,50 @@ 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(
"balance_summary_error",
auth_health.message,
{
"exchange_name": self.settings.exchange_name,
"default_symbol": self.settings.default_symbol,
},
)
raise ExchangeError(auth_health.message) raise ExchangeError(auth_health.message)
payload = ExchangePrivateClient().get_account_info(show_zero_balance=False) try:
balances = parse_account_balances(payload) 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: if not balances:
self._log_warning(
"balance_summary_empty",
"Баланс получен, но список активов пуст или не распознан.",
{
"exchange_name": self.settings.exchange_name,
"default_symbol": self.settings.default_symbol,
},
)
raise ExchangeError("Баланс получен, но список активов пуст или не распознан.") raise ExchangeError("Баланс получен, но список активов пуст или не распознан.")
self._log_info(
"balance_summary_loaded",
f"Баланс успешно получен. Активов: {len(balances)}",
{
"exchange_name": self.settings.exchange_name,
"assets_count": len(balances),
},
)
return balances return balances
def get_exchange_symbols(self) -> list[ExchangeSymbol]: def get_exchange_symbols(self) -> list[ExchangeSymbol]:
@@ -219,13 +275,32 @@ class ExchangeService:
def _get_real_price(self, symbol: str) -> TickerPrice: def _get_real_price(self, symbol: str) -> TickerPrice:
client = ExchangeRestClient() client = ExchangeRestClient()
payload = client.get_json( try:
"/api/v2/ticker/24hr", payload = client.get_json(
params={"symbol": symbol}, "/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") price_raw = payload.get("lastPrice")
if price_raw is None: 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.") raise ExchangeError("Field 'lastPrice' is missing in ticker response.")
close_time = payload.get("closeTime") or payload.get("eventTime") or "" close_time = payload.get("closeTime") or payload.get("eventTime") or ""

View File

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

View File

@@ -1,8 +1,5 @@
from __future__ import annotations from __future__ import annotations
from src.storage.session import get_connection from src.storage.session import get_connection
DDL = [ DDL = [
''' '''
CREATE TABLE IF NOT EXISTS balance_snapshots ( 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 ( CREATE TABLE IF NOT EXISTS order_drafts (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -35,10 +40,8 @@ DDL = [
) )
''' '''
] ]
def init_schema() -> None: def init_schema() -> None:
with get_connection() as connection: with get_connection() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
for statement in DDL: for statement in DDL:
cursor.execute(statement) cursor.execute(statement)

View File

@@ -1,12 +1,85 @@
from aiogram import F, Router from __future__ import annotations
from aiogram.types import Message
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") 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 = ["<b>📒 Журнал</b>", "", "<b>Последние события</b>", ""]
for e in events:
lines.extend(
[
f" <b>{e['event_type']}</b>",
f"• уровень: {e['level']}",
f"• время: {e['created_at']}",
f"• сообщение: {e['message']}",
"",
]
)
return "\n".join(lines)
@router.message(F.text == "📒 Журнал") @router.message(F.text == "📒 Журнал")
async def open_journal(message: Message) -> None: async def open_journal(message: Message):
await message.answer(JOURNAL_TEXT) 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()

View File

@@ -5,26 +5,99 @@ from aiogram.types import Message
from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.exceptions import ExchangeError
from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.service import ExchangeService
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
@router.message(F.text == "📈 Рынок") @router.message(F.text == "📈 Рынок")
async def open_market(message: Message) -> None: async def open_market(message: Message) -> None:
service = ExchangeService() 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: try:
validation = service.validate_symbol(service.settings.default_symbol) validation = service.validate_symbol(requested_symbol)
if not validation.is_valid: 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( await message.answer(
"<b>📈 Рынок</b>\n\n" "<b>📈 Рынок</b>\n\n"
f"Ошибка символа: {validation.message}" f"Ошибка инструмента: {validation.message}"
) )
return return
ticker = service.get_price(validation.normalized_symbol) ticker = service.get_price(validation.normalized_symbol)
except ExchangeError as exc: 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( await message.answer(
"<b>📈 Рынок</b>\n\n" "<b>📈 Рынок</b>\n\n"
"Не удалось получить цену с биржи.\n" "Не удалось получить цену с биржи.\n"
@@ -35,8 +108,16 @@ async def open_market(message: Message) -> None:
symbol_info = validation.symbol_info symbol_info = validation.symbol_info
symbol_status = symbol_info.status if symbol_info else "n/a" symbol_status = symbol_info.status if symbol_info else "n/a"
market_type = symbol_info.market_type 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" market_modes = (
tick_size = f"{symbol_info.tick_size}" if symbol_info and symbol_info.tick_size is not None else "n/a" ", ".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 name = symbol_info.name if symbol_info and symbol_info.name else ticker.symbol
text = ( text = (
@@ -52,4 +133,16 @@ async def open_market(message: Message) -> None:
f"Обновлено: {ticker.updated_at}" 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)

View File

@@ -6,6 +6,7 @@ from aiogram.types import Message
from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.exceptions import ExchangeError
from src.integrations.exchange.models import BalanceSummary from src.integrations.exchange.models import BalanceSummary
from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.service import ExchangeService
from src.trading.journal.service import JournalService
router = Router(name="portfolio") router = Router(name="portfolio")
@@ -60,7 +61,9 @@ def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
return sorted(items, key=sort_key) 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] = [] major: list[BalanceSummary] = []
other: 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 == "💼 Портфель") @router.message(F.text == "💼 Портфель")
async def open_portfolio(message: Message) -> None: async def open_portfolio(message: Message) -> None:
service = ExchangeService() 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: try:
balances = service.get_balance_summary() balances = service.get_balance_summary()
except ExchangeError as exc: except ExchangeError as exc:
_safe_log_error(
journal,
"portfolio_open_error",
f"Не удалось открыть портфель: {exc}",
{
"user_id": user_id,
"chat_id": chat_id,
},
)
await message.answer( await message.answer(
"<b>💼 Портфель</b>\n\n" "<b>💼 Портфель</b>\n\n"
"Не удалось получить баланс с private API.\n" "Не удалось получить баланс с private API.\n"
@@ -100,6 +162,15 @@ async def open_portfolio(message: Message) -> None:
return return
if not balances: if not balances:
_safe_log_warning(
journal,
"portfolio_empty",
"Портфель открыт, но баланс пуст.",
{
"user_id": user_id,
"chat_id": chat_id,
},
)
await message.answer( await message.answer(
"<b>💼 Портфель</b>\n\n" "<b>💼 Портфель</b>\n\n"
"Баланс пуст." "Баланс пуст."
@@ -110,6 +181,16 @@ async def open_portfolio(message: Message) -> None:
visible_balances = sort_balances(visible_balances) visible_balances = sort_balances(visible_balances)
if not 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( await message.answer(
"<b>💼 Портфель</b>\n\n" "<b>💼 Портфель</b>\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() text = "\n".join(lines).rstrip()
await message.answer(text) await message.answer(text)

View File

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

View File

@@ -0,0 +1,15 @@
# 0010 — Journal First
## Решение
После storage foundation первым прикладным use case сделать журнал событий, а не order drafts.
## Причины
- журнал полезен сразу
- помогает отлаживать проект
- не связан с торговым риском
- даёт первую реальную пользу от PostgreSQL
## Последствия
- появляется история действий бота
- ошибки и события можно анализировать
- дальнейшие этапы проще отлаживать

View File

@@ -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
(структурированный доступ к данным и подготовка к работе с ордерами)