Stage 05.3 - order validation, error handling and journal logging
This commit is contained in:
@@ -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()
|
||||
return True, f"Журнал работает. Событий: {total}"
|
||||
@@ -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]
|
||||
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 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"
|
||||
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()
|
||||
Reference in New Issue
Block a user