From 39b35d742a6a968b1f937952fccb98dba4d9d1e3 Mon Sep 17 00:00:00 2001 From: Sergey Date: Sat, 18 Apr 2026 20:45:33 +0300 Subject: [PATCH] Stage 05.6 - order draft logic improvements --- app/src/integrations/exchange/models.py | 7 +- app/src/integrations/exchange/service.py | 88 +++++- app/src/storage/repositories/order_drafts.py | 61 +++- app/src/trading/orders/models.py | 4 +- app/src/trading/orders/service.py | 288 +++++++++++++++++-- app/src/trading/orders/states.py | 3 + 6 files changed, 413 insertions(+), 38 deletions(-) diff --git a/app/src/integrations/exchange/models.py b/app/src/integrations/exchange/models.py index b9c9923..d4a1767 100644 --- a/app/src/integrations/exchange/models.py +++ b/app/src/integrations/exchange/models.py @@ -1,3 +1,5 @@ +# app/src/integrations/exchange/models.py + from __future__ import annotations from dataclasses import dataclass @@ -36,6 +38,9 @@ class ExchangeSymbol: market_modes: list[str] market_type: str tick_size: float | None + step_size: float | None + min_qty: float | None + min_notional: float | None @dataclass(slots=True) @@ -50,4 +55,4 @@ class SymbolValidationResult: @dataclass(slots=True) class PrivateAuthHealth: ok: bool - message: str + message: str \ No newline at end of file diff --git a/app/src/integrations/exchange/service.py b/app/src/integrations/exchange/service.py index d14e513..4eeeadc 100644 --- a/app/src/integrations/exchange/service.py +++ b/app/src/integrations/exchange/service.py @@ -1,3 +1,5 @@ +# app/src/integrations/exchange/service.py + from __future__ import annotations from datetime import datetime @@ -227,28 +229,85 @@ class ExchangeService: if isinstance(inner, dict) and isinstance(inner.get("symbols"), list): symbols_raw = inner["symbols"] else: - raise ExchangeError( - "Field 'symbols' is missing in exchangeInfo response." - ) + raise ExchangeError("Field 'symbols' is missing in exchangeInfo response.") def _safe_str(value: object, default: str = "") -> str: if value is None: return default return str(value).strip() + def _safe_float(value: object) -> float | None: + if value in (None, ""): + return None + try: + return float(str(value).strip()) + except (TypeError, ValueError): + return None + + def _extract_filter_value( + filters: object, + filter_names: list[str], + keys: list[str], + ) -> float | None: + if not isinstance(filters, list): + return None + + normalized_filter_names = {name.upper() for name in filter_names} + + for entry in filters: + if not isinstance(entry, dict): + continue + + filter_type = str(entry.get("filterType", "")).strip().upper() + if filter_type not in normalized_filter_names: + continue + + for key in keys: + value = _safe_float(entry.get(key)) + if value is not None: + return value + + return None + items: list[ExchangeSymbol] = [] for item in symbols_raw: if not isinstance(item, dict): continue - tick_size_raw = item.get("tickSize") - tick_size = None - if tick_size_raw not in (None, ""): - try: - tick_size = float(str(tick_size_raw)) - except (TypeError, ValueError): - tick_size = None + filters = item.get("filters") + + tick_size = _safe_float(item.get("tickSize")) + if tick_size is None: + tick_size = _extract_filter_value( + filters, + filter_names=["PRICE_FILTER"], + keys=["tickSize"], + ) + + step_size = _safe_float(item.get("stepSize")) + if step_size is None: + step_size = _extract_filter_value( + filters, + filter_names=["LOT_SIZE", "MARKET_LOT_SIZE"], + keys=["stepSize"], + ) + + min_qty = _safe_float(item.get("minQty")) + if min_qty is None: + min_qty = _extract_filter_value( + filters, + filter_names=["LOT_SIZE", "MARKET_LOT_SIZE"], + keys=["minQty"], + ) + + min_notional = _safe_float(item.get("minNotional")) + if min_notional is None: + min_notional = _extract_filter_value( + filters, + filter_names=["MIN_NOTIONAL", "NOTIONAL"], + keys=["minNotional", "notional"], + ) market_modes_raw = item.get("marketModes") if isinstance(market_modes_raw, list): @@ -259,7 +318,11 @@ class ExchangeService: market_modes = [] market_type_raw = item.get("marketType") - market_type = str(market_type_raw).strip() if market_type_raw is not None else "unknown" + market_type = ( + str(market_type_raw).strip() + if market_type_raw is not None + else "unknown" + ) items.append( ExchangeSymbol( @@ -271,6 +334,9 @@ class ExchangeService: market_modes=market_modes, market_type=market_type, tick_size=tick_size, + step_size=step_size, + min_qty=min_qty, + min_notional=min_notional, ) ) diff --git a/app/src/storage/repositories/order_drafts.py b/app/src/storage/repositories/order_drafts.py index 91dd040..4bd7ec9 100644 --- a/app/src/storage/repositories/order_drafts.py +++ b/app/src/storage/repositories/order_drafts.py @@ -1,3 +1,5 @@ +# app/src/storage/repositories/order_drafts.py + from __future__ import annotations import json @@ -34,7 +36,7 @@ class OrderDraftRepository: with connection.cursor() as cursor: cursor.execute( ''' - SELECT created_at, symbol, side, order_type, quantity::text, status + SELECT id, created_at, symbol, side, order_type, quantity::text, status FROM order_drafts ORDER BY created_at DESC, id DESC LIMIT %s @@ -47,20 +49,63 @@ class OrderDraftRepository: for row in rows: items.append( { - "created_at": str(row[0]), - "symbol": str(row[1]), - "side": str(row[2]), - "order_type": str(row[3]), - "quantity": str(row[4]), - "status": str(row[5]), + "id": str(row[0]), + "created_at": str(row[1]), + "symbol": str(row[2]), + "side": str(row[3]), + "order_type": str(row[4]), + "quantity": str(row[5]), + "status": str(row[6]), } ) return items + def get_draft_by_id(self, draft_id: str) -> dict[str, str] | None: + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + ''' + SELECT id, created_at, symbol, side, order_type, quantity::text, status, payload_json + FROM order_drafts + WHERE id = %s + LIMIT 1 + ''', + (draft_id,), + ) + row = cursor.fetchone() + + if not row: + return None + + payload_raw = row[7] + payload: dict[str, Any] = {} + + if isinstance(payload_raw, dict): + payload = payload_raw + elif payload_raw: + try: + payload = json.loads(str(payload_raw)) + except Exception: + payload = {} + + price = payload.get("price") + price_text = str(price) if price not in (None, "") else "" + + return { + "id": str(row[0]), + "created_at": str(row[1]), + "symbol": str(row[2]), + "side": str(row[3]), + "order_type": str(row[4]), + "quantity": str(row[5]), + "status": str(row[6]), + "price": price_text, + } + def count_drafts(self) -> int: with get_connection() as connection: with connection.cursor() as cursor: cursor.execute("SELECT COUNT(*) FROM order_drafts") row = cursor.fetchone() - return int(row[0]) if row else 0 + return int(row[0]) if row else 0 \ No newline at end of file diff --git a/app/src/trading/orders/models.py b/app/src/trading/orders/models.py index 546dd10..78cb0fb 100644 --- a/app/src/trading/orders/models.py +++ b/app/src/trading/orders/models.py @@ -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) diff --git a/app/src/trading/orders/service.py b/app/src/trading/orders/service.py index 3015f3b..276279f 100644 --- a/app/src/trading/orders/service.py +++ b/app/src/trading/orders/service.py @@ -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() \ No newline at end of file + 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 \ No newline at end of file diff --git a/app/src/trading/orders/states.py b/app/src/trading/orders/states.py index 2a0c553..4d647ea 100644 --- a/app/src/trading/orders/states.py +++ b/app/src/trading/orders/states.py @@ -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() \ No newline at end of file