Stage 05.3 - order validation, error handling and journal logging
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 == "📒 Журнал")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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Раздел пока в разработке."
|
||||||
@@ -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:
|
||||||
@@ -63,11 +83,3 @@ class JournalService:
|
|||||||
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()
|
|
||||||
@@ -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)
|
||||||
@@ -25,3 +25,9 @@ class OrderEntryContext:
|
|||||||
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)
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -161,3 +223,19 @@ class OrderDraftsService:
|
|||||||
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()
|
||||||
28
docs/stages/stage-05-3-order-validation.md
Normal file
28
docs/stages/stage-05-3-order-validation.md
Normal 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
|
||||||
Reference in New Issue
Block a user