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.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),

View File

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

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

View File

@@ -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 = ["<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 == "📒 Журнал")
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()

View File

@@ -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(
"<b>📈 Рынок</b>\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(
"<b>📈 Рынок</b>\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)

View File

@@ -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(
"<b>💼 Портфель</b>\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(
"<b>💼 Портфель</b>\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(
"<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()
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
(структурированный доступ к данным и подготовка к работе с ордерами)