Stage 05.8 - quantity normalization by exchange rules

This commit is contained in:
2026-04-20 20:18:03 +03:00
parent c36e43f5e8
commit 2a9ef16524
20 changed files with 1025 additions and 140 deletions

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from decimal import Decimal, InvalidOperation, ROUND_DOWN
from decimal import Decimal, InvalidOperation, ROUND_DOWN, ROUND_UP
from src.core.config import load_settings
from src.integrations.exchange.models import ExchangeSymbol
@@ -181,8 +181,6 @@ class OrderDraftsService:
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:
raise ValueError(validation.message)
@@ -307,6 +305,103 @@ class OrderDraftsService:
return errors
def normalize_preset_quantity(
self,
*,
side: str,
order_type: str,
raw_quantity: str,
price: str | None = None,
) -> str | None:
return self._normalize_entry_quantity_with_rules(
side=side,
order_type=order_type,
raw_quantity=raw_quantity,
price=price,
raise_to_minimum=True,
)
def normalize_entry_quantity(
self,
*,
side: str,
order_type: str,
raw_quantity: str,
price: str | None = None,
) -> str | None:
return self._normalize_entry_quantity_with_rules(
side=side,
order_type=order_type,
raw_quantity=raw_quantity,
price=price,
raise_to_minimum=True,
)
def _normalize_entry_quantity_with_rules(
self,
*,
side: str,
order_type: str,
raw_quantity: str,
price: str | None = None,
raise_to_minimum: bool,
) -> str | None:
validation = self.exchange.validate_symbol(self.settings.default_symbol)
if not validation.is_valid or validation.symbol_info is None:
return self.normalize_quantity(raw_quantity)
original_quantity = self._to_decimal((raw_quantity or "").strip().replace(",", "."))
if original_quantity is None or original_quantity <= 0:
return None
symbol_info = validation.symbol_info
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))
minimum_allowed = min_qty if min_qty is not None and min_qty > 0 else None
reference_price = self._resolve_reference_price_for_entry(
side=side,
order_type=order_type,
price=price,
)
if (
reference_price is not None
and reference_price > 0
and min_notional is not None
and min_notional > 0
):
min_by_notional = min_notional / reference_price
if step_size is not None and step_size > 0:
min_by_notional = self._ceil_to_step(min_by_notional, step_size)
if minimum_allowed is None or min_by_notional > minimum_allowed:
minimum_allowed = min_by_notional
quantity = original_quantity
if step_size is not None and step_size > 0:
quantity = self._floor_to_step(quantity, step_size)
if quantity <= 0:
if raise_to_minimum and minimum_allowed is not None and minimum_allowed > 0:
quantity = minimum_allowed
if step_size is not None and step_size > 0 and not self._fits_step(quantity, step_size):
quantity = self._ceil_to_step(quantity, step_size)
else:
return None
if raise_to_minimum and minimum_allowed is not None and quantity < minimum_allowed:
quantity = minimum_allowed
if step_size is not None and step_size > 0 and not self._fits_step(quantity, step_size):
quantity = self._ceil_to_step(quantity, step_size)
if quantity <= 0:
return None
return self._format_decimal(quantity)
def _build_quantity_presets(
self,
*,
@@ -452,6 +547,13 @@ class OrderDraftsService:
ratio = (value / step).to_integral_value(rounding=ROUND_DOWN)
return ratio * step
@staticmethod
def _ceil_to_step(value: Decimal, step: Decimal) -> Decimal:
if step <= 0:
return value
ratio = (value / step).to_integral_value(rounding=ROUND_UP)
return ratio * step
def _normalize_quantity_to_exchange_rules(
self,
*,
@@ -498,7 +600,9 @@ class OrderDraftsService:
price: str | None = None,
) -> Decimal | None:
if order_type.upper() == "LIMIT":
return self._to_decimal(price)
explicit_price = self._to_decimal(price)
if explicit_price is not None and explicit_price > 0:
return explicit_price
try:
market = self.exchange.get_market_snapshot(self.settings.default_symbol)