Stage 05.6 - order draft logic improvements

This commit is contained in:
2026-04-18 20:45:33 +03:00
parent 2be2ac1d30
commit 39b35d742a
6 changed files with 413 additions and 38 deletions

View File

@@ -1,3 +1,5 @@
# app/src/trading/orders/models.py
from __future__ import annotations
from dataclasses import dataclass, field
@@ -24,7 +26,7 @@ class OrderEntryContext:
last_price: float
bid_price: float
ask_price: float
quantity_presets: list[str]
quantity_presets: list[str] = field(default_factory=list)
@dataclass(slots=True)

View File

@@ -1,8 +1,11 @@
# /app/src/trading/orders/service.py
from __future__ import annotations
from decimal import Decimal, InvalidOperation
from decimal import Decimal, InvalidOperation, ROUND_DOWN
from src.core.config import load_settings
from src.integrations.exchange.models import ExchangeSymbol
from src.integrations.exchange.service import ExchangeService
from src.storage.repositories.order_drafts import OrderDraftRepository
from src.trading.journal.service import JournalService
@@ -33,6 +36,30 @@ class OrderDraftsService:
status="draft",
)
def get_entry_rules(self) -> dict[str, str | None]:
validation = self.exchange.validate_symbol(self.settings.default_symbol)
symbol_info = validation.symbol_info
if symbol_info is None:
return {
"min_qty": None,
"step_size": None,
"min_notional": None,
"tick_size": None,
}
min_qty = getattr(symbol_info, "min_qty", None)
step_size = getattr(symbol_info, "step_size", None)
min_notional = getattr(symbol_info, "min_notional", None)
tick_size = getattr(symbol_info, "tick_size", None)
return {
"min_qty": str(min_qty) if min_qty not in (None, "") else None,
"step_size": str(step_size) if step_size not in (None, "") else None,
"min_notional": str(min_notional) if min_notional not in (None, "") else None,
"tick_size": str(tick_size) if tick_size not in (None, "") else None,
}
def save_draft(self, draft: OrderDraft) -> None:
validation = self.validate_draft(draft)
if not validation.is_valid:
@@ -101,6 +128,21 @@ class OrderDraftsService:
if quantity is None or quantity <= 0:
errors.append("Количество должно быть числом больше нуля.")
symbol_info = symbol_validation.symbol_info
if quantity is not None and quantity > 0 and symbol_info is not None:
min_qty = self._to_decimal(getattr(symbol_info, "min_qty", None))
if min_qty is not None and min_qty > 0 and quantity < min_qty:
errors.append(
f"Количество должно быть не меньше minQty = {getattr(symbol_info, 'min_qty', None)}."
)
step_size = self._to_decimal(getattr(symbol_info, "step_size", None))
if step_size is not None and step_size > 0 and not self._fits_step(quantity, step_size):
errors.append(
f"Количество должно соответствовать шагу stepSize = {getattr(symbol_info, 'step_size', None)}."
)
if draft.order_type == "LIMIT":
if not draft.price:
errors.append("Для LIMIT ордера требуется цена.")
@@ -109,25 +151,35 @@ class OrderDraftsService:
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
tick_size = self._to_decimal(getattr(symbol_info, "tick_size", None))
if tick_size is not None and tick_size > 0:
tick = Decimal(str(tick_size))
if not self._fits_step(price, tick):
if not self._fits_step(price, tick_size):
errors.append(
f"Цена должна соответствовать шагу tickSize = {tick_size}."
f"Цена должна соответствовать шагу tickSize = {getattr(symbol_info, 'tick_size', None)}."
)
if quantity is not None and quantity > 0 and symbol_info is not None:
reference_price = self._resolve_reference_price_for_validation(draft, symbol_info)
if reference_price is not None:
min_notional = self._to_decimal(getattr(symbol_info, "min_notional", None))
if min_notional is not None and min_notional > 0:
notional = quantity * reference_price
if notional < min_notional:
errors.append(
f"Сумма ордера должна быть не меньше minNotional = {getattr(symbol_info, 'min_notional', None)}."
)
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 | int]]:
return self.repository.list_recent_drafts(limit=limit)
def get_draft_by_id(self, draft_id: str) -> dict[str, str] | None:
return self.repository.get_draft_by_id(draft_id)
def get_entry_context(self, *, side: str, order_type: str) -> OrderEntryContext:
validation = self.exchange.validate_symbol(self.settings.default_symbol)
if not validation.is_valid or validation.symbol_info is None:
@@ -158,12 +210,11 @@ class OrderDraftsService:
reference_price = float(market["bid_price"])
max_qty = available_balance
quantity_presets = [
self._format_number(max_qty * 0.25),
self._format_number(max_qty * 0.50),
self._format_number(max_qty * 0.75),
self._format_number(max_qty),
]
quantity_presets = self._build_quantity_presets(
max_qty=max_qty,
reference_price=reference_price,
symbol_info=validation.symbol_info,
)
return OrderEntryContext(
symbol=self.settings.default_symbol,
@@ -178,6 +229,127 @@ class OrderDraftsService:
quantity_presets=quantity_presets,
)
def validate_entry_quantity(
self,
*,
side: str,
order_type: str,
quantity: str,
price: str | None = None,
) -> list[str]:
errors: list[str] = []
validation = self.exchange.validate_symbol(self.settings.default_symbol)
if not validation.is_valid or validation.symbol_info is None:
errors.append(validation.message)
return errors
symbol_info = validation.symbol_info
quantity_dec = self._to_decimal(quantity)
if quantity_dec is None or quantity_dec <= 0:
errors.append("Количество должно быть числом больше нуля.")
return errors
min_qty = self._to_decimal(getattr(symbol_info, "min_qty", None))
if min_qty is not None and min_qty > 0 and quantity_dec < min_qty:
errors.append(
f"Количество должно быть не меньше minQty = {getattr(symbol_info, 'min_qty', None)}."
)
step_size = self._to_decimal(getattr(symbol_info, "step_size", None))
if step_size is not None and step_size > 0 and not self._fits_step(quantity_dec, step_size):
errors.append(
f"Количество должно соответствовать шагу stepSize = {getattr(symbol_info, 'step_size', None)}."
)
reference_price = self._resolve_reference_price_for_entry(
side=side,
order_type=order_type,
price=price,
)
if reference_price is not None:
min_notional = self._to_decimal(getattr(symbol_info, "min_notional", None))
if min_notional is not None and min_notional > 0:
notional = quantity_dec * reference_price
if notional < min_notional:
errors.append(
f"Сумма ордера должна быть не меньше minNotional = {getattr(symbol_info, 'min_notional', None)}."
)
return errors
def _build_quantity_presets(
self,
*,
max_qty: float,
reference_price: float,
symbol_info: ExchangeSymbol,
) -> list[str]:
percents = [0.01, 0.05, 0.10, 0.25, 0.50, 1.00]
max_qty_dec = self._to_decimal(max_qty)
reference_price_dec = self._to_decimal(reference_price)
if max_qty_dec is None or max_qty_dec <= 0:
return []
step_size = self._to_decimal(getattr(symbol_info, "step_size", None))
min_qty = self._to_decimal(getattr(symbol_info, "min_qty", None))
min_notional = self._to_decimal(getattr(symbol_info, "min_notional", None))
result: list[str] = []
seen: set[str] = set()
for percent in percents:
qty = max_qty_dec * Decimal(str(percent))
qty = self._normalize_quantity_to_exchange_rules(
quantity=qty,
step_size=step_size,
)
if qty is None or qty <= 0:
continue
if min_qty is not None and min_qty > 0 and qty < min_qty:
continue
if reference_price_dec is not None and reference_price_dec > 0:
if min_notional is not None and min_notional > 0:
if qty * reference_price_dec < min_notional:
continue
if qty > max_qty_dec:
continue
text = self._format_decimal(qty)
if text == "0" or text in seen:
continue
seen.add(text)
result.append(text)
if result:
return result
fallback = self._normalize_quantity_to_exchange_rules(
quantity=max_qty_dec,
step_size=step_size,
)
if fallback is None or fallback <= 0:
return []
if min_qty is not None and min_qty > 0 and fallback < min_qty:
return []
if reference_price_dec is not None and reference_price_dec > 0:
if min_notional is not None and min_notional > 0:
if fallback * reference_price_dec < min_notional:
return []
return [self._format_decimal(fallback)]
@staticmethod
def normalize_side(raw: str) -> str | None:
value = (raw or "").strip().upper()
@@ -225,7 +397,13 @@ class OrderDraftsService:
return text or "0"
@staticmethod
def _to_decimal(value: str | None) -> Decimal | None:
def _format_decimal(value: Decimal) -> str:
text = f"{value:.8f}"
text = text.rstrip("0").rstrip(".")
return text or "0"
@staticmethod
def _to_decimal(value: str | float | Decimal | None) -> Decimal | None:
if value is None:
return None
try:
@@ -238,4 +416,80 @@ class OrderDraftsService:
if step <= 0:
return True
ratio = value / step
return ratio == ratio.to_integral_value()
return ratio == ratio.to_integral_value()
@staticmethod
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
if step <= 0:
return value
ratio = (value / step).to_integral_value(rounding=ROUND_DOWN)
return ratio * step
def _normalize_quantity_to_exchange_rules(
self,
*,
quantity: Decimal,
step_size: Decimal | None,
) -> Decimal | None:
if quantity <= 0:
return None
if step_size is not None and step_size > 0:
quantity = self._floor_to_step(quantity, step_size)
if quantity <= 0:
return None
return quantity
def _resolve_reference_price_for_validation(
self,
draft: OrderDraft,
symbol_info: ExchangeSymbol | None,
) -> Decimal | None:
price = self._to_decimal(draft.price)
if price is not None and price > 0:
return price
if symbol_info is None:
return None
try:
market = self.exchange.get_market_snapshot(draft.symbol)
except Exception:
return None
if draft.side.upper() == "BUY":
return self._to_decimal(market.get("ask_price"))
return self._to_decimal(market.get("bid_price"))
def _resolve_reference_price_for_entry(
self,
*,
side: str,
order_type: str,
price: str | None = None,
) -> Decimal | None:
if order_type.upper() == "LIMIT":
return self._to_decimal(price)
try:
market = self.exchange.get_market_snapshot(self.settings.default_symbol)
except Exception:
return None
if side.upper() == "BUY":
return self._to_decimal(market.get("ask_price"))
return self._to_decimal(market.get("bid_price"))
def calculate_notional(self, quantity: str, price: str | None) -> float | None:
q = self._to_decimal(quantity)
p = self._to_decimal(price) if price else None
if q is None or p is None:
return None
try:
return float(q * p)
except Exception:
return None

View File

@@ -1,3 +1,5 @@
# /app/src/trading/orders/states.py
from aiogram.fsm.state import State, StatesGroup
@@ -6,3 +8,4 @@ class NewOrderDraftStates(StatesGroup):
waiting_type = State()
waiting_quantity = State()
waiting_price = State()
waiting_confirm = State()