diff --git a/app/src/bootstrap/app_factory.py b/app/src/bootstrap/app_factory.py index d06df21..24bc11d 100644 --- a/app/src/bootstrap/app_factory.py +++ b/app/src/bootstrap/app_factory.py @@ -13,9 +13,26 @@ from src.trading.journal.service import JournalService def create_app() -> tuple[Bot, Dispatcher]: settings = load_settings() setup_logging(settings.log_level) - init_schema() 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: journal.log_info( "app_start", @@ -37,4 +54,4 @@ def create_app() -> tuple[Bot, Dispatcher]: dispatcher = Dispatcher() setup_routers(dispatcher) - return bot, dispatcher + return bot, dispatcher \ No newline at end of file diff --git a/app/src/telegram/handlers/auto.py b/app/src/telegram/handlers/auto.py index 081e148..1d771f5 100644 --- a/app/src/telegram/handlers/auto.py +++ b/app/src/telegram/handlers/auto.py @@ -1,3 +1,5 @@ +# app/src/telegram/handlers/auto.py + from aiogram import F, Router from aiogram.types import Message diff --git a/app/src/telegram/handlers/home.py b/app/src/telegram/handlers/home.py index a57e333..3d5bc0a 100644 --- a/app/src/telegram/handlers/home.py +++ b/app/src/telegram/handlers/home.py @@ -1,3 +1,5 @@ +# app/src/telegram/handlers/home.py + from aiogram import F, Router from aiogram.types import Message diff --git a/app/src/telegram/handlers/journal.py b/app/src/telegram/handlers/journal.py index 31099ac..85717e2 100644 --- a/app/src/telegram/handlers/journal.py +++ b/app/src/telegram/handlers/journal.py @@ -1,7 +1,9 @@ +# app/src/telegram/handlers/journal.py + from __future__ import annotations from aiogram import F, Router -from aiogram.types import Message, CallbackQuery +from aiogram.types import CallbackQuery, Message from aiogram.utils.keyboard import InlineKeyboardBuilder from src.trading.journal.service import JournalService @@ -11,22 +13,25 @@ router = Router(name="journal") PAGE_SIZE = 3 +LEVEL_ICONS = { + "INFO": "ℹ️", + "WARNING": "⚠️", + "ERROR": "❌", + "CRITICAL": "🚨", +} + 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}") @@ -37,17 +42,20 @@ def render(events, page, total_pages): lines = ["📒 Журнал", "", "Последние события", ""] for e in events: + level = str(e.get("level", "INFO")).upper() + icon = LEVEL_ICONS.get(level, "•") + lines.extend( [ - f"ℹ️ {e['event_type']}", - f"• уровень: {e['level']}", + f"{icon} {e['event_type']}", + f"• уровень: {level}", f"• время: {e['created_at']}", f"• сообщение: {e['message']}", "", ] ) - return "\n".join(lines) + return "\n".join(lines).rstrip() @router.message(F.text == "📒 Журнал") diff --git a/app/src/telegram/handlers/market.py b/app/src/telegram/handlers/market.py index 9fda2df..84b1f15 100644 --- a/app/src/telegram/handlers/market.py +++ b/app/src/telegram/handlers/market.py @@ -1,3 +1,5 @@ +# app/src/telegram/handlers/market.py + from __future__ import annotations from aiogram import F, Router diff --git a/app/src/telegram/handlers/portfolio.py b/app/src/telegram/handlers/portfolio.py index 8e2ea6a..20f3deb 100644 --- a/app/src/telegram/handlers/portfolio.py +++ b/app/src/telegram/handlers/portfolio.py @@ -1,3 +1,5 @@ +# app/src/telegram/handlers/portfolio.py + from __future__ import annotations from aiogram import F, Router diff --git a/app/src/telegram/handlers/start.py b/app/src/telegram/handlers/start.py index 0b9dfab..217683f 100644 --- a/app/src/telegram/handlers/start.py +++ b/app/src/telegram/handlers/start.py @@ -1,3 +1,5 @@ +# app/src/telegram/handlers/start.py + from __future__ import annotations from aiogram import F, Router diff --git a/app/src/telegram/handlers/system.py b/app/src/telegram/handlers/system.py index 6548cca..5de4c23 100644 --- a/app/src/telegram/handlers/system.py +++ b/app/src/telegram/handlers/system.py @@ -1,3 +1,5 @@ +# app/src/telegram/handlers/system.py + from __future__ import annotations from aiogram import F, Router diff --git a/app/src/telegram/menus.py b/app/src/telegram/menus.py index 6723cb9..d9afce9 100644 --- a/app/src/telegram/menus.py +++ b/app/src/telegram/menus.py @@ -1,3 +1,5 @@ +# app/src/telegram/menus.py + MAIN_MENU_TEXT = ( "Dzentra Bot\n\n" "Новый каркас проекта успешно создан.\n\n" @@ -25,6 +27,6 @@ SYSTEM_TEXT = ( MARKET_TEXT = "📈 Рынок\n\nРаздел пока в разработке." PORTFOLIO_TEXT = "💼 Портфель\n\nРаздел пока в разработке." -TRADE_TEXT = "⚡ Торговля\n\nРаздел пока в разработке." +TRADE_TEXT = "⚡ Торговля\n\nВыберите действие:\nDRAFT режим" AUTO_TEXT = "🤖 Авто\n\nРаздел пока в разработке." -JOURNAL_TEXT = "📒 Журнал\n\nРаздел пока в разработке." +JOURNAL_TEXT = "📒 Журнал\n\nРаздел пока в разработке." \ No newline at end of file diff --git a/app/src/trading/journal/service.py b/app/src/trading/journal/service.py index 1300833..128a9bb 100644 --- a/app/src/trading/journal/service.py +++ b/app/src/trading/journal/service.py @@ -49,9 +49,29 @@ class JournalService: 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]]: 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]: db_ok, db_message = check_database_health() if not db_ok: @@ -62,12 +82,4 @@ class JournalService: except Exception as exc: return False, f"Ошибка чтения журнала: {exc}" - return True, f"Журнал работает. Событий: {total}" - - def get_page(self, page: int = 1, page_size: int = 3): - offset = (page - 1) * page_size - return self.repository.list_recent_with_offset(limit=page_size, offset=offset) - - - def get_total_count(self) -> int: - return self.repository.count_events() \ No newline at end of file + return True, f"Журнал работает. Событий: {total}" \ No newline at end of file diff --git a/app/src/trading/orders/models.py b/app/src/trading/orders/models.py index 529d85f..546dd10 100644 --- a/app/src/trading/orders/models.py +++ b/app/src/trading/orders/models.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field @dataclass(slots=True) @@ -24,4 +24,10 @@ class OrderEntryContext: last_price: float bid_price: float ask_price: float - quantity_presets: list[str] \ No newline at end of file + quantity_presets: list[str] + + +@dataclass(slots=True) +class OrderValidationResult: + is_valid: bool + errors: list[str] = field(default_factory=list) \ No newline at end of file diff --git a/app/src/trading/orders/service.py b/app/src/trading/orders/service.py index dae5057..3015f3b 100644 --- a/app/src/trading/orders/service.py +++ b/app/src/trading/orders/service.py @@ -1,10 +1,12 @@ from __future__ import annotations +from decimal import Decimal, InvalidOperation + from src.core.config import load_settings from src.integrations.exchange.service import ExchangeService from src.storage.repositories.order_drafts import OrderDraftRepository 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: @@ -32,6 +34,25 @@ class OrderDraftsService: ) 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 = { "source": "trade_screen", "mode": "draft_only", @@ -63,6 +84,47 @@ class OrderDraftsService: except Exception: 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]]: return self.repository.list_recent_drafts(limit=limit) @@ -160,4 +222,20 @@ class OrderDraftsService: def _format_number(value: float) -> str: text = f"{value:.8f}" text = text.rstrip("0").rstrip(".") - return text or "0" \ No newline at end of file + 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() \ No newline at end of file diff --git a/docs/stages/stage-05-3-order-validation.md b/docs/stages/stage-05-3-order-validation.md new file mode 100644 index 0000000..5f47ac1 --- /dev/null +++ b/docs/stages/stage-05-3-order-validation.md @@ -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