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