Stage 04.2 - journal and event log
This commit is contained in:
@@ -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),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
try:
|
||||||
payload = ExchangePrivateClient().get_account_info(show_zero_balance=False)
|
payload = ExchangePrivateClient().get_account_info(show_zero_balance=False)
|
||||||
balances = parse_account_balances(payload)
|
balances = parse_account_balances(payload)
|
||||||
|
except Exception as exc:
|
||||||
|
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()
|
||||||
|
|
||||||
|
try:
|
||||||
payload = client.get_json(
|
payload = client.get_json(
|
||||||
"/api/v2/ticker/24hr",
|
"/api/v2/ticker/24hr",
|
||||||
params={"symbol": symbol},
|
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 ""
|
||||||
|
|||||||
92
app/src/storage/repositories/journal.py
Normal file
92
app/src/storage/repositories/journal.py
Normal 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
|
||||||
|
]
|
||||||
@@ -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,8 +40,6 @@ 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:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_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)
|
await message.answer(text)
|
||||||
@@ -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)
|
||||||
73
app/src/trading/journal/service.py
Normal file
73
app/src/trading/journal/service.py
Normal 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()
|
||||||
15
docs/decisions/0010-journal-first.md
Normal file
15
docs/decisions/0010-journal-first.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# 0010 — Journal First
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
После storage foundation первым прикладным use case сделать журнал событий, а не order drafts.
|
||||||
|
|
||||||
|
## Причины
|
||||||
|
- журнал полезен сразу
|
||||||
|
- помогает отлаживать проект
|
||||||
|
- не связан с торговым риском
|
||||||
|
- даёт первую реальную пользу от PostgreSQL
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
- появляется история действий бота
|
||||||
|
- ошибки и события можно анализировать
|
||||||
|
- дальнейшие этапы проще отлаживать
|
||||||
205
docs/stages/stage-04-2-journal.md
Normal file
205
docs/stages/stage-04-2-journal.md
Normal 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
|
||||||
|
(структурированный доступ к данным и подготовка к работе с ордерами)
|
||||||
Reference in New Issue
Block a user