Stage 05.3 - order validation, error handling and journal logging

This commit is contained in:
2026-04-17 13:05:35 +03:00
parent b1b9beef78
commit 604a8c0069
13 changed files with 188 additions and 25 deletions

View File

@@ -13,9 +13,26 @@ from src.trading.journal.service import JournalService
def create_app() -> tuple[Bot, Dispatcher]: def create_app() -> tuple[Bot, Dispatcher]:
settings = load_settings() settings = load_settings()
setup_logging(settings.log_level) setup_logging(settings.log_level)
init_schema()
journal = JournalService() journal = JournalService()
try:
init_schema()
except Exception as exc:
try:
journal.log_critical(
"app_bootstrap_failed",
f"Не удалось инициализировать схему БД: {exc}",
{
"env": settings.app_env,
"exchange_name": settings.exchange_name,
"default_symbol": settings.default_symbol,
},
)
except Exception:
pass
raise
try: try:
journal.log_info( journal.log_info(
"app_start", "app_start",
@@ -37,4 +54,4 @@ def create_app() -> tuple[Bot, Dispatcher]:
dispatcher = Dispatcher() dispatcher = Dispatcher()
setup_routers(dispatcher) setup_routers(dispatcher)
return bot, dispatcher return bot, dispatcher

View File

@@ -1,3 +1,5 @@
# app/src/telegram/handlers/auto.py
from aiogram import F, Router from aiogram import F, Router
from aiogram.types import Message from aiogram.types import Message

View File

@@ -1,3 +1,5 @@
# app/src/telegram/handlers/home.py
from aiogram import F, Router from aiogram import F, Router
from aiogram.types import Message from aiogram.types import Message

View File

@@ -1,7 +1,9 @@
# app/src/telegram/handlers/journal.py
from __future__ import annotations from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.types import Message, CallbackQuery from aiogram.types import CallbackQuery, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.trading.journal.service import JournalService from src.trading.journal.service import JournalService
@@ -11,22 +13,25 @@ router = Router(name="journal")
PAGE_SIZE = 3 PAGE_SIZE = 3
LEVEL_ICONS = {
"INFO": "",
"WARNING": "⚠️",
"ERROR": "",
"CRITICAL": "🚨",
}
def build_keyboard(page: int, total_pages: int): def build_keyboard(page: int, total_pages: int):
kb = InlineKeyboardBuilder() kb = InlineKeyboardBuilder()
# кнопка "в начало"
if page > 1: if page > 1:
kb.button(text="⏮️", callback_data="journal:1") kb.button(text="⏮️", callback_data="journal:1")
# назад
if page > 1: if page > 1:
kb.button(text="⬅️", callback_data=f"journal:{page-1}") kb.button(text="⬅️", callback_data=f"journal:{page-1}")
# текущая страница
kb.button(text=f"{page}/{total_pages}", callback_data="noop") kb.button(text=f"{page}/{total_pages}", callback_data="noop")
# вперед
if page < total_pages: if page < total_pages:
kb.button(text="➡️", callback_data=f"journal:{page+1}") kb.button(text="➡️", callback_data=f"journal:{page+1}")
@@ -37,17 +42,20 @@ def render(events, page, total_pages):
lines = ["<b>📒 Журнал</b>", "", "<b>Последние события</b>", ""] lines = ["<b>📒 Журнал</b>", "", "<b>Последние события</b>", ""]
for e in events: for e in events:
level = str(e.get("level", "INFO")).upper()
icon = LEVEL_ICONS.get(level, "")
lines.extend( lines.extend(
[ [
f" <b>{e['event_type']}</b>", f"{icon} <b>{e['event_type']}</b>",
f"• уровень: {e['level']}", f"• уровень: {level}",
f"• время: {e['created_at']}", f"• время: {e['created_at']}",
f"• сообщение: {e['message']}", f"• сообщение: {e['message']}",
"", "",
] ]
) )
return "\n".join(lines) return "\n".join(lines).rstrip()
@router.message(F.text == "📒 Журнал") @router.message(F.text == "📒 Журнал")

View File

@@ -1,3 +1,5 @@
# app/src/telegram/handlers/market.py
from __future__ import annotations from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router

View File

@@ -1,3 +1,5 @@
# app/src/telegram/handlers/portfolio.py
from __future__ import annotations from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router

View File

@@ -1,3 +1,5 @@
# app/src/telegram/handlers/start.py
from __future__ import annotations from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router

View File

@@ -1,3 +1,5 @@
# app/src/telegram/handlers/system.py
from __future__ import annotations from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router

View File

@@ -1,3 +1,5 @@
# app/src/telegram/menus.py
MAIN_MENU_TEXT = ( MAIN_MENU_TEXT = (
"<b>Dzentra Bot</b>\n\n" "<b>Dzentra Bot</b>\n\n"
"Новый каркас проекта успешно создан.\n\n" "Новый каркас проекта успешно создан.\n\n"
@@ -25,6 +27,6 @@ SYSTEM_TEXT = (
MARKET_TEXT = "<b>📈 Рынок</b>\n\nРаздел пока в разработке." MARKET_TEXT = "<b>📈 Рынок</b>\n\nРаздел пока в разработке."
PORTFOLIO_TEXT = "<b>💼 Портфель</b>\n\nРаздел пока в разработке." PORTFOLIO_TEXT = "<b>💼 Портфель</b>\n\nРаздел пока в разработке."
TRADE_TEXT = "<b>⚡ Торговля</b>\n\nРаздел пока в разработке." TRADE_TEXT = "<b>⚡ Торговля</b>\n\nВыберите действие:\n<i>DRAFT режим</i>"
AUTO_TEXT = "<b>🤖 Авто</b>\n\nРаздел пока в разработке." AUTO_TEXT = "<b>🤖 Авто</b>\n\nРаздел пока в разработке."
JOURNAL_TEXT = "<b>📒 Журнал</b>\n\nРаздел пока в разработке." JOURNAL_TEXT = "<b>📒 Журнал</b>\n\nРаздел пока в разработке."

View File

@@ -49,9 +49,29 @@ class JournalService:
payload=payload, payload=payload,
) )
def log_critical(
self,
event_type: str,
message: str,
payload: dict[str, Any] | None = None,
) -> None:
self.repository.add_event(
level="CRITICAL",
event_type=event_type,
message=message,
payload=payload,
)
def get_recent(self, limit: int = 10) -> list[dict[str, str]]: def get_recent(self, limit: int = 10) -> list[dict[str, str]]:
return self.repository.list_recent_events(limit=limit) return self.repository.list_recent_events(limit=limit)
def get_page(self, page: int = 1, page_size: int = 3) -> list[dict[str, str]]:
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()
def get_journal_health(self) -> tuple[bool, str]: def get_journal_health(self) -> tuple[bool, str]:
db_ok, db_message = check_database_health() db_ok, db_message = check_database_health()
if not db_ok: if not db_ok:
@@ -62,12 +82,4 @@ class JournalService:
except Exception as exc: except Exception as exc:
return False, f"Ошибка чтения журнала: {exc}" return False, f"Ошибка чтения журнала: {exc}"
return True, f"Журнал работает. Событий: {total}" 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

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass, field
@dataclass(slots=True) @dataclass(slots=True)
@@ -24,4 +24,10 @@ class OrderEntryContext:
last_price: float last_price: float
bid_price: float bid_price: float
ask_price: float ask_price: float
quantity_presets: list[str] quantity_presets: list[str]
@dataclass(slots=True)
class OrderValidationResult:
is_valid: bool
errors: list[str] = field(default_factory=list)

View File

@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
from decimal import Decimal, InvalidOperation
from src.core.config import load_settings from src.core.config import load_settings
from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.service import ExchangeService
from src.storage.repositories.order_drafts import OrderDraftRepository from src.storage.repositories.order_drafts import OrderDraftRepository
from src.trading.journal.service import JournalService from src.trading.journal.service import JournalService
from src.trading.orders.models import OrderDraft, OrderEntryContext from src.trading.orders.models import OrderDraft, OrderEntryContext, OrderValidationResult
class OrderDraftsService: class OrderDraftsService:
@@ -32,6 +34,25 @@ class OrderDraftsService:
) )
def save_draft(self, draft: OrderDraft) -> None: def save_draft(self, draft: OrderDraft) -> None:
validation = self.validate_draft(draft)
if not validation.is_valid:
try:
self.journal.log_warning(
"order_draft_validation_failed",
"Черновик ордера не прошёл валидацию.",
{
"symbol": draft.symbol,
"side": draft.side,
"order_type": draft.order_type,
"quantity": draft.quantity,
"price": draft.price,
"errors": validation.errors,
},
)
except Exception:
pass
raise ValueError("; ".join(validation.errors))
payload = { payload = {
"source": "trade_screen", "source": "trade_screen",
"mode": "draft_only", "mode": "draft_only",
@@ -63,6 +84,47 @@ class OrderDraftsService:
except Exception: except Exception:
pass pass
def validate_draft(self, draft: OrderDraft) -> OrderValidationResult:
errors: list[str] = []
if draft.side not in {"BUY", "SELL"}:
errors.append("Сторона ордера должна быть BUY или SELL.")
if draft.order_type not in {"MARKET", "LIMIT"}:
errors.append("Тип ордера должен быть MARKET или LIMIT.")
symbol_validation = self.exchange.validate_symbol(draft.symbol)
if not symbol_validation.is_valid:
errors.append(symbol_validation.message)
quantity = self._to_decimal(draft.quantity)
if quantity is None or quantity <= 0:
errors.append("Количество должно быть числом больше нуля.")
if draft.order_type == "LIMIT":
if not draft.price:
errors.append("Для LIMIT ордера требуется цена.")
else:
price = self._to_decimal(draft.price)
if price is None or price <= 0:
errors.append("Цена должна быть числом больше нуля.")
else:
tick_size = None
if symbol_validation.symbol_info is not None:
tick_size = symbol_validation.symbol_info.tick_size
if tick_size is not None and tick_size > 0:
tick = Decimal(str(tick_size))
if not self._fits_step(price, tick):
errors.append(
f"Цена должна соответствовать шагу tickSize = {tick_size}."
)
return OrderValidationResult(
is_valid=len(errors) == 0,
errors=errors,
)
def list_recent_drafts(self, limit: int = 5) -> list[dict[str, str]]: def list_recent_drafts(self, limit: int = 5) -> list[dict[str, str]]:
return self.repository.list_recent_drafts(limit=limit) return self.repository.list_recent_drafts(limit=limit)
@@ -160,4 +222,20 @@ class OrderDraftsService:
def _format_number(value: float) -> str: def _format_number(value: float) -> str:
text = f"{value:.8f}" text = f"{value:.8f}"
text = text.rstrip("0").rstrip(".") text = text.rstrip("0").rstrip(".")
return text or "0" return text or "0"
@staticmethod
def _to_decimal(value: str | None) -> Decimal | None:
if value is None:
return None
try:
return Decimal(str(value).strip())
except (InvalidOperation, ValueError):
return None
@staticmethod
def _fits_step(value: Decimal, step: Decimal) -> bool:
if step <= 0:
return True
ratio = value / step
return ratio == ratio.to_integral_value()

View File

@@ -0,0 +1,28 @@
# Stage 05.3 — Order Validation
## Цель
Добавить слой валидации черновика ордера перед сохранением в БД.
## Что реализовано
- `OrderValidationResult`
- `validate_draft()` в `OrderDraftsService`
- проверки:
- сторона BUY / SELL
- тип MARKET / LIMIT
- валидность символа
- количество > 0
- цена для LIMIT
- соответствие цены шагу `tickSize`, если он доступен
## UX
- невалидный draft не сохраняется
- пользователь видит понятный список причин
- в журнале пишется `order_draft_validation_failed`
## Ограничения
- пока нет `minQty`
- пока нет `minNotional`
- пока нет confirm screen
## Следующий этап
- Stage 05.4 — confirmation screen