diff --git a/app/src/telegram/handlers/trade/new_order_flow.py b/app/src/telegram/handlers/trade/new_order_flow.py index f0450cc..284a6fa 100644 --- a/app/src/telegram/handlers/trade/new_order_flow.py +++ b/app/src/telegram/handlers/trade/new_order_flow.py @@ -19,11 +19,14 @@ from src.telegram.handlers.trade.new_order_ui import ( _render_confirm, _render_draft_detail, _render_draft_summary, - _render_inline_error, + # _render_inline_error, _render_manual_price_screen, _render_manual_quantity_screen, + _render_order_path, _render_price_input_help, _render_price_step_screen, + _render_price_inline_error, + _render_quantity_inline_error, _render_quantity_input_help, _render_quantity_step_screen, _render_validation_error, @@ -84,22 +87,14 @@ async def open_draft(callback: CallbackQuery) -> None: await callback.answer("Черновик не найден", show_alert=True) return - context = service.get_entry_context( - side=str(draft["side"]).upper(), - order_type=str(draft["order_type"]).upper(), - ) - await callback.message.edit_text( - _render_draft_detail( - draft, - base_currency=context.base_currency, - quote_currency=context.quote_currency, - ), + _render_draft_detail(draft), reply_markup=_draft_detail_keyboard(draft_id, page), ) await callback.answer() + # Переводит черновик в режим редактирования. @router.callback_query(F.data.startswith("draft_edit:")) async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None: @@ -115,6 +110,7 @@ async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None: side = str(draft["side"]).upper() order_type = str(draft["order_type"]).upper() quantity = str(draft["quantity"]) + price = str(draft.get("price") or "") or None await state.clear() await state.update_data( @@ -123,29 +119,18 @@ async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None: side=side, order_type=order_type, quantity=quantity, + price=price, ) title = _screen_title(is_edit_mode=True) context = service.get_entry_context(side=side, order_type=order_type) - if order_type == "LIMIT": - await state.set_state(NewOrderDraftStates.waiting_price) - await callback.message.edit_text( - _render_price_step_screen( - title=title, - bid=context.bid_price, - ask=context.ask_price, - last=context.last_price, - quote_currency=context.quote_currency, - ), - reply_markup=_price_keyboard( - bid=context.bid_price, - ask=context.ask_price, - last=context.last_price, - ), - ) - await callback.answer() - return + path = _render_order_path( + side=side, + order_type=order_type, + quantity=quantity, + base_currency=context.base_currency, + ) await state.set_state(NewOrderDraftStates.waiting_quantity) await callback.message.edit_text( @@ -156,8 +141,12 @@ async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None: balance_currency=context.balance_currency, reference_price=context.reference_price, quote_currency=context.quote_currency, + order_path=path, + ), + reply_markup=_quantity_keyboard( + context.quantity_presets, + drafts_page=page, ), - reply_markup=_quantity_keyboard(context.quantity_presets), ) await callback.answer() @@ -189,9 +178,13 @@ async def start_new_order_draft( await state.clear() await state.set_state(NewOrderDraftStates.waiting_side) + service = OrderDraftsService() + context = service.get_entry_context(side="BUY", order_type="MARKET") + text = ( "📊 Торговля — Новый ордер\n" f"{mode_line()}" + f"{context.symbol}\n\n" "Шаг 1/4. Выбери сторону" ) @@ -211,13 +204,18 @@ async def process_order_side_callback( state: FSMContext, ) -> None: side = callback.data.split(":", 1)[1] - await state.update_data(side=side) await state.set_state(NewOrderDraftStates.waiting_type) + path = _render_order_path(side=side) + service = OrderDraftsService() + context = service.get_entry_context(side=side, order_type="MARKET") + text = ( "📊 Торговля — Новый ордер\n" f"{mode_line()}" + f"{context.symbol}\n\n" + f"{path}\n\n" "Шаг 2/4. Выбери тип ордера" ) @@ -251,12 +249,20 @@ async def process_order_type_callback( data = await state.get_data() side = data.get("side", "BUY") is_edit_mode = bool(data.get("draft_edit_id")) + draft_page = data.get("draft_edit_page") + drafts_page = int(draft_page) if draft_page else None await state.update_data(order_type=order_type) await state.set_state(NewOrderDraftStates.waiting_quantity) context = service.get_entry_context(side=side, order_type=order_type) + path = _render_order_path( + side=side, + order_type=order_type, + base_currency=context.base_currency, + ) + await callback.message.edit_text( _render_quantity_step_screen( title=_screen_title(is_edit_mode), @@ -265,8 +271,12 @@ async def process_order_type_callback( balance_currency=context.balance_currency, reference_price=context.reference_price, quote_currency=context.quote_currency, + order_path=path, + ), + reply_markup=_quantity_keyboard( + context.quantity_presets, + drafts_page=drafts_page, ), - reply_markup=_quantity_keyboard(context.quantity_presets), ) await callback.answer() @@ -297,64 +307,87 @@ async def process_quantity_callback( data = await state.get_data() is_edit_mode = bool(data.get("draft_edit_id")) title = _screen_title(is_edit_mode) + draft_page = data.get("draft_edit_page") + drafts_page = int(draft_page) if draft_page else None + + side = data.get("side", "BUY") + order_type = data.get("order_type", "MARKET") + + context = service.get_entry_context(side=side, order_type=order_type) if value == "manual": rules = service.get_entry_rules() - context = service.get_entry_context( - side=data.get("side", "BUY"), - order_type=data.get("order_type", "MARKET"), - ) quantity_example = context.quantity_presets[0] if context.quantity_presets else "0.001" + path = _render_order_path( + side=side, + order_type=order_type, + base_currency=context.base_currency, + ) + await callback.message.edit_text( _render_manual_quantity_screen( title=title, + symbol=context.symbol, reference_price=context.reference_price, quote_currency=context.quote_currency, min_qty=rules["min_qty"], step_size=rules["step_size"], min_notional=rules["min_notional"], example=quantity_example, + order_path=path, ), - reply_markup=_quantity_manual_keyboard(), + reply_markup=_quantity_manual_keyboard(drafts_page=drafts_page), ) await callback.answer() return - quantity = service.normalize_quantity(value) + quantity = service.normalize_preset_quantity( + side=side, + order_type=order_type, + raw_quantity=value, + ) if quantity is None: await callback.answer("Некорректное значение количества.", show_alert=True) return - order_type = data.get("order_type", "MARKET") await state.update_data(quantity=quantity) - context = service.get_entry_context(side=data["side"], order_type=order_type) - if order_type == "LIMIT": + path = _render_order_path( + side=side, + order_type=order_type, + quantity=quantity, + base_currency=context.base_currency, + ) + await state.set_state(NewOrderDraftStates.waiting_price) await callback.message.edit_text( _render_price_step_screen( title=title, + symbol=context.symbol, bid=context.bid_price, ask=context.ask_price, last=context.last_price, quote_currency=context.quote_currency, + order_path=path, ), reply_markup=_price_keyboard( bid=context.bid_price, ask=context.ask_price, last=context.last_price, + drafts_page=drafts_page, ), ) await callback.answer() return draft = service.build_draft( - side=data["side"], + side=side, order_type=order_type, quantity=quantity, ) + notional = service.calculate_notional(quantity, f"{context.reference_price:.2f}") await state.update_data( @@ -370,6 +403,7 @@ async def process_quantity_callback( "notional": notional, } ) + await state.set_state(NewOrderDraftStates.waiting_confirm) await callback.message.edit_text( @@ -385,7 +419,7 @@ async def process_quantity_callback( quote_currency=context.quote_currency, reference_price=f"{context.reference_price:.2f}", ), - reply_markup=_confirm_keyboard(), + reply_markup=_confirm_keyboard(drafts_page=drafts_page), ) await callback.answer() @@ -398,17 +432,25 @@ async def process_quantity_callback( async def process_order_quantity(message: Message, state: FSMContext) -> None: service = OrderDraftsService() raw_quantity = message.text or "" - quantity = service.normalize_quantity(raw_quantity) data = await state.get_data() side = data.get("side", "BUY") order_type = data.get("order_type", "MARKET") is_edit_mode = bool(data.get("draft_edit_id")) title = _screen_title(is_edit_mode) + draft_page = data.get("draft_edit_page") + drafts_page = int(draft_page) if draft_page else None + + quantity = service.normalize_entry_quantity( + side=side, + order_type=order_type, + raw_quantity=raw_quantity, + ) context = service.get_entry_context(side=side, order_type=order_type) rules = service.get_entry_rules() quantity_example = context.quantity_presets[0] if context.quantity_presets else "0.001" + help_text = _render_quantity_input_help( min_qty=rules["min_qty"], step_size=rules["step_size"], @@ -419,14 +461,21 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None: ) if quantity is None: + path = _render_order_path( + side=side, + order_type=order_type, + base_currency=context.base_currency, + ) + await message.answer( - _render_inline_error( + _render_quantity_inline_error( title=title, - step_text="Шаг 3/4. Проверь введённое значение", + symbol=context.symbol, + order_path=path, errors=["Количество должно быть числом больше нуля."], help_text=help_text, ), - reply_markup=_quantity_manual_keyboard(), + reply_markup=_quantity_manual_keyboard(drafts_page=drafts_page), ) return @@ -437,33 +486,50 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None: price=None, ) if quantity_errors: + path = _render_order_path( + side=side, + order_type=order_type, + base_currency=context.base_currency, + ) + await message.answer( - _render_inline_error( + _render_quantity_inline_error( title=title, - step_text="Шаг 3/4. Проверь введённое значение", + symbol=context.symbol, + order_path=path, errors=quantity_errors, help_text=help_text, ), - reply_markup=_quantity_manual_keyboard(), + reply_markup=_quantity_manual_keyboard(drafts_page=drafts_page), ) return await state.update_data(quantity=quantity) if order_type == "LIMIT": + path = _render_order_path( + side=side, + order_type=order_type, + quantity=quantity, + base_currency=context.base_currency, + ) + await state.set_state(NewOrderDraftStates.waiting_price) await message.answer( _render_price_step_screen( title=title, + symbol=context.symbol, bid=context.bid_price, ask=context.ask_price, last=context.last_price, quote_currency=context.quote_currency, + order_path=path, ), reply_markup=_price_keyboard( bid=context.bid_price, ask=context.ask_price, last=context.last_price, + drafts_page=drafts_page, ), ) return @@ -503,7 +569,7 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None: quote_currency=context.quote_currency, reference_price=f"{context.reference_price:.2f}", ), - reply_markup=_confirm_keyboard(), + reply_markup=_confirm_keyboard(drafts_page=drafts_page), ) @@ -522,6 +588,8 @@ async def process_price_callback( data = await state.get_data() is_edit_mode = bool(data.get("draft_edit_id")) title = _screen_title(is_edit_mode) + draft_page = data.get("draft_edit_page") + drafts_page = int(draft_page) if draft_page else None context = service.get_entry_context( side=data.get("side", "BUY"), @@ -532,14 +600,23 @@ async def process_price_callback( rules = service.get_entry_rules() price_example = f"{context.last_price:.2f}" + path = _render_order_path( + side=data.get("side"), + order_type=data.get("order_type"), + quantity=data.get("quantity"), + base_currency=context.base_currency, + ) + await callback.message.edit_text( _render_manual_price_screen( title=title, + symbol=context.symbol, tick_size=rules["tick_size"], example=price_example, quote_currency=context.quote_currency, + order_path=path, ), - reply_markup=_price_manual_keyboard(), + reply_markup=_price_manual_keyboard(drafts_page=drafts_page), ) await callback.answer() return @@ -555,6 +632,7 @@ async def process_price_callback( quantity=data["quantity"], price=price, ) + notional = service.calculate_notional(data["quantity"], price) await state.update_data( @@ -569,6 +647,7 @@ async def process_price_callback( "notional": notional, } ) + await state.set_state(NewOrderDraftStates.waiting_confirm) await callback.message.edit_text( @@ -583,7 +662,7 @@ async def process_price_callback( base_currency=context.base_currency, quote_currency=context.quote_currency, ), - reply_markup=_confirm_keyboard(), + reply_markup=_confirm_keyboard(drafts_page=drafts_page), ) await callback.answer() @@ -601,6 +680,8 @@ async def process_order_price(message: Message, state: FSMContext) -> None: data = await state.get_data() is_edit_mode = bool(data.get("draft_edit_id")) title = _screen_title(is_edit_mode) + draft_page = data.get("draft_edit_page") + drafts_page = int(draft_page) if draft_page else None rules = service.get_entry_rules() context = service.get_entry_context( @@ -615,14 +696,22 @@ async def process_order_price(message: Message, state: FSMContext) -> None: ) if price is None: + path = _render_order_path( + side=data.get("side"), + order_type=data.get("order_type"), + quantity=data.get("quantity"), + base_currency=context.base_currency, + ) + await message.answer( - _render_inline_error( + _render_price_inline_error( title=title, - step_text="Шаг 4/4. Проверь введённое значение", + symbol=context.symbol, + order_path=path, errors=["Цена должна быть числом больше нуля."], help_text=help_text, ), - reply_markup=_price_manual_keyboard(), + reply_markup=_price_manual_keyboard(drafts_page=drafts_page), ) return @@ -635,14 +724,22 @@ async def process_order_price(message: Message, state: FSMContext) -> None: validation = service.validate_draft(draft) if not validation.is_valid: + path = _render_order_path( + side=draft.side, + order_type=draft.order_type, + quantity=draft.quantity, + base_currency=context.base_currency, + ) + await message.answer( - _render_inline_error( + _render_price_inline_error( title=title, - step_text="Шаг 4/4. Проверь введённое значение", + symbol=context.symbol, + order_path=path, errors=validation.errors, help_text=help_text, ), - reply_markup=_price_manual_keyboard(), + reply_markup=_price_manual_keyboard(drafts_page=drafts_page), ) return @@ -674,7 +771,7 @@ async def process_order_price(message: Message, state: FSMContext) -> None: base_currency=context.base_currency, quote_currency=context.quote_currency, ), - reply_markup=_confirm_keyboard(), + reply_markup=_confirm_keyboard(drafts_page=drafts_page), ) @@ -745,6 +842,7 @@ async def confirm_order(callback: CallbackQuery, state: FSMContext) -> None: quote_currency=quote_currency, reference_price=reference_price, notional=notional, + is_edit_mode=bool(edit_page), ), reply_markup=reply_markup, ) diff --git a/app/src/telegram/handlers/trade/new_order_navigation.py b/app/src/telegram/handlers/trade/new_order_navigation.py index f127dd1..b03ab82 100644 --- a/app/src/telegram/handlers/trade/new_order_navigation.py +++ b/app/src/telegram/handlers/trade/new_order_navigation.py @@ -9,26 +9,57 @@ from aiogram.types import CallbackQuery from src.telegram.handlers.trade.new_order_core import router from src.telegram.handlers.trade.new_order_ui import ( mode_line, + _draft_detail_keyboard, _price_keyboard, _quantity_keyboard, + _render_draft_detail, + _render_order_path, _render_price_step_screen, _render_quantity_step_screen, _screen_title, + _side_keyboard, _trade_back_home_keyboard, _type_keyboard, - _side_keyboard, ) from src.trading.orders.service import OrderDraftsService from src.trading.orders.states import NewOrderDraftStates +async def _return_to_draft_detail( + callback: CallbackQuery, + *, + draft_id: str, + page: int, +) -> None: + service = OrderDraftsService() + draft = service.get_draft_by_id(draft_id) + + if not draft: + await callback.message.edit_text( + "📊 Торговля\n\n" + "Черновик не найден.", + reply_markup=_trade_back_home_keyboard(), + ) + await callback.answer() + return + + await callback.message.edit_text( + _render_draft_detail(draft), + reply_markup=_draft_detail_keyboard(draft_id, page), + ) + await callback.answer() + + @router.callback_query(F.data == "order_back:side") async def go_back_to_side(callback: CallbackQuery, state: FSMContext) -> None: - """Возвращает пользователя на первый шаг выбора стороны.""" + service = OrderDraftsService() + context = service.get_entry_context(side="BUY", order_type="MARKET") + await state.set_state(NewOrderDraftStates.waiting_side) text = ( "📊 Торговля — Новый ордер\n" f"{mode_line()}" + f"{context.symbol}\n\n" "Шаг 1/4. Выбери сторону" ) await callback.message.edit_text(text, reply_markup=_side_keyboard()) @@ -37,11 +68,31 @@ async def go_back_to_side(callback: CallbackQuery, state: FSMContext) -> None: @router.callback_query(F.data == "order_back:type") async def go_back_to_type(callback: CallbackQuery, state: FSMContext) -> None: - """Возвращает пользователя на шаг выбора типа ордера.""" + """Возвращает пользователя на шаг выбора типа ордера или в карточку черновика при редактировании.""" + data = await state.get_data() + + draft_id = data.get("draft_edit_id") + draft_page = data.get("draft_edit_page") + + if draft_id and draft_page: + await _return_to_draft_detail( + callback, + draft_id=str(draft_id), + page=int(draft_page), + ) + return + + service = OrderDraftsService() + side = data.get("side", "BUY") + context = service.get_entry_context(side=side, order_type="MARKET") + path = _render_order_path(side=side) + await state.set_state(NewOrderDraftStates.waiting_type) text = ( "📊 Торговля — Новый ордер\n" f"{mode_line()}" + f"{context.symbol}\n\n" + f"{path}\n\n" "Шаг 2/4. Выбери тип ордера" ) await callback.message.edit_text(text, reply_markup=_type_keyboard()) @@ -50,14 +101,36 @@ async def go_back_to_type(callback: CallbackQuery, state: FSMContext) -> None: @router.callback_query(F.data == "order_back:quantity") async def go_back_to_quantity(callback: CallbackQuery, state: FSMContext) -> None: + """ + Возвращает пользователя на шаг выбора количества. + Используется как возврат со шага цены. + """ service = OrderDraftsService() data = await state.get_data() + side = data.get("side", "BUY") order_type = data.get("order_type", "MARKET") + quantity = data.get("quantity") is_edit_mode = bool(data.get("draft_edit_id")) + draft_page = data.get("draft_edit_page") + drafts_page = int(draft_page) if draft_page else None context = service.get_entry_context(side=side, order_type=order_type) + path = _render_order_path( + side=side, + order_type=order_type, + quantity=quantity, + base_currency=context.base_currency, + ) + + if not quantity: + path = _render_order_path( + side=side, + order_type=order_type, + base_currency=context.base_currency, + ) + await state.set_state(NewOrderDraftStates.waiting_quantity) await callback.message.edit_text( _render_quantity_step_screen( @@ -66,8 +139,13 @@ async def go_back_to_quantity(callback: CallbackQuery, state: FSMContext) -> Non available_balance=context.available_balance, balance_currency=context.balance_currency, reference_price=context.reference_price, + quote_currency=context.quote_currency, + order_path=path, + ), + reply_markup=_quantity_keyboard( + context.quantity_presets, + drafts_page=drafts_page, ), - reply_markup=_quantity_keyboard(context.quantity_presets), ) await callback.answer() @@ -90,29 +168,48 @@ async def go_back_from_confirm(callback: CallbackQuery, state: FSMContext) -> No side = confirm_draft["side"] order_type = confirm_draft["order_type"] + quantity = confirm_draft.get("quantity") is_edit_mode = bool(data.get("draft_edit_id")) + draft_page = data.get("draft_edit_page") + drafts_page = int(draft_page) if draft_page else None if order_type == "LIMIT": context = service.get_entry_context(side=side, order_type=order_type) + path = _render_order_path( + side=side, + order_type=order_type, + quantity=quantity, + base_currency=context.base_currency, + ) await state.set_state(NewOrderDraftStates.waiting_price) await callback.message.edit_text( _render_price_step_screen( title=_screen_title(is_edit_mode), + symbol=context.symbol, bid=context.bid_price, ask=context.ask_price, last=context.last_price, + quote_currency=context.quote_currency, + order_path=path, ), reply_markup=_price_keyboard( bid=context.bid_price, ask=context.ask_price, last=context.last_price, + drafts_page=drafts_page, ), ) await callback.answer() return context = service.get_entry_context(side=side, order_type=order_type) + path = _render_order_path( + side=side, + order_type=order_type, + quantity=quantity, + base_currency=context.base_currency, + ) await state.set_state(NewOrderDraftStates.waiting_quantity) await callback.message.edit_text( @@ -122,8 +219,13 @@ async def go_back_from_confirm(callback: CallbackQuery, state: FSMContext) -> No available_balance=context.available_balance, balance_currency=context.balance_currency, reference_price=context.reference_price, + quote_currency=context.quote_currency, + order_path=path, + ), + reply_markup=_quantity_keyboard( + context.quantity_presets, + drafts_page=drafts_page, ), - reply_markup=_quantity_keyboard(context.quantity_presets), ) await callback.answer() @@ -138,9 +240,25 @@ async def go_back_from_manual_quantity( side = data.get("side", "BUY") order_type = data.get("order_type", "MARKET") + quantity = data.get("quantity") is_edit_mode = bool(data.get("draft_edit_id")) + draft_page = data.get("draft_edit_page") + drafts_page = int(draft_page) if draft_page else None context = service.get_entry_context(side=side, order_type=order_type) + path = _render_order_path( + side=side, + order_type=order_type, + quantity=quantity, + base_currency=context.base_currency, + ) + + if not quantity: + path = _render_order_path( + side=side, + order_type=order_type, + base_currency=context.base_currency, + ) await state.set_state(NewOrderDraftStates.waiting_quantity) await callback.message.edit_text( @@ -150,8 +268,13 @@ async def go_back_from_manual_quantity( available_balance=context.available_balance, balance_currency=context.balance_currency, reference_price=context.reference_price, + quote_currency=context.quote_currency, + order_path=path, + ), + reply_markup=_quantity_keyboard( + context.quantity_presets, + drafts_page=drafts_page, ), - reply_markup=_quantity_keyboard(context.quantity_presets), ) await callback.answer() @@ -166,22 +289,35 @@ async def go_back_from_manual_price( side = data.get("side", "BUY") order_type = data.get("order_type", "LIMIT") + quantity = data.get("quantity") is_edit_mode = bool(data.get("draft_edit_id")) + draft_page = data.get("draft_edit_page") + drafts_page = int(draft_page) if draft_page else None context = service.get_entry_context(side=side, order_type=order_type) + path = _render_order_path( + side=side, + order_type=order_type, + quantity=quantity, + base_currency=context.base_currency, + ) await state.set_state(NewOrderDraftStates.waiting_price) await callback.message.edit_text( _render_price_step_screen( title=_screen_title(is_edit_mode), + symbol=context.symbol, bid=context.bid_price, ask=context.ask_price, last=context.last_price, + quote_currency=context.quote_currency, + order_path=path, ), reply_markup=_price_keyboard( bid=context.bid_price, ask=context.ask_price, last=context.last_price, + drafts_page=drafts_page, ), ) await callback.answer() \ No newline at end of file diff --git a/app/src/telegram/handlers/trade/new_order_ui.py b/app/src/telegram/handlers/trade/new_order_ui.py index 511f11f..ae1ce00 100644 --- a/app/src/telegram/handlers/trade/new_order_ui.py +++ b/app/src/telegram/handlers/trade/new_order_ui.py @@ -14,6 +14,45 @@ from src.telegram.ui.common import mode_line from src.trading.orders.service import OrderDraftsService +def _clean_number(value: str | float | None, precision: int | None = None) -> str: + if value is None: + return "" + + try: + num = float(value) + except (ValueError, TypeError): + return str(value) + + if precision is not None: + text = f"{num:.{precision}f}" + return text.rstrip("0").rstrip(".") + + text = f"{num:.18f}" + return text.rstrip("0").rstrip(".") + + +def _resolve_symbol_assets(symbol: str) -> tuple[str | None, str | None]: + service = OrderDraftsService() + validation = service.exchange.validate_symbol(symbol) + + symbol_info = validation.symbol_info + if symbol_info is None: + return None, None + + base_currency = ( + str(symbol_info.base_asset).upper() + if getattr(symbol_info, "base_asset", None) + else None + ) + quote_currency = ( + str(symbol_info.quote_asset).upper() + if getattr(symbol_info, "quote_asset", None) + else None + ) + + return base_currency, quote_currency + + def _to_decimal(value: str | float | int | None) -> Decimal | None: if value is None: return None @@ -36,6 +75,10 @@ def _format_decimal_text(value: Decimal) -> str: return text or "0" +def _side_badge(side: str) -> str: + return "🟢 BUY" if side.upper() == "BUY" else "🔴 SELL" + + # Оценивает минимально допустимое количество по правилу minNotional. def _estimate_min_quantity_by_notional( *, @@ -77,7 +120,10 @@ def _type_keyboard() -> InlineKeyboardMarkup: return builder.as_markup() -def _quantity_keyboard(presets: list[str]) -> InlineKeyboardMarkup: +def _quantity_keyboard( + presets: list[str], + drafts_page: int | None = None, +) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() all_labels = ["1%", "5%", "10%", "25%", "50%", "100%"] @@ -88,7 +134,11 @@ def _quantity_keyboard(presets: list[str]) -> InlineKeyboardMarkup: builder.button(text="✍️ Ввести вручную", callback_data="order_qty:manual") builder.button(text="⬅️ Назад", callback_data="order_back:type") - builder.button(text="🏠 К торговле", callback_data="trade:home") + + if drafts_page is not None: + builder.button(text="📚 К черновикам", callback_data=f"drafts:{drafts_page}") + else: + builder.button(text="🏠 К торговле", callback_data="trade:home") if len(presets) == 0: builder.adjust(1, 2) @@ -102,39 +152,70 @@ def _quantity_keyboard(presets: list[str]) -> InlineKeyboardMarkup: return builder.as_markup() -def _quantity_manual_keyboard() -> InlineKeyboardMarkup: +def _quantity_manual_keyboard( + drafts_page: int | None = None, +) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="⬅️ Назад", callback_data="order_manual_back:quantity") - builder.button(text="🏠 К торговле", callback_data="trade:home") + + if drafts_page is not None: + builder.button(text="📚 К черновикам", callback_data=f"drafts:{drafts_page}") + else: + builder.button(text="🏠 К торговле", callback_data="trade:home") + builder.adjust(2) return builder.as_markup() -def _price_keyboard(bid: float, ask: float, last: float) -> InlineKeyboardMarkup: +def _price_keyboard( + bid: float, + ask: float, + last: float, + drafts_page: int | None = None, +) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text=f"Bid {bid:.2f}", callback_data=f"order_price:{bid}") builder.button(text=f"Ask {ask:.2f}", callback_data=f"order_price:{ask}") builder.button(text=f"Last {last:.2f}", callback_data=f"order_price:{last}") builder.button(text="✍️ Ввести вручную", callback_data="order_price:manual") builder.button(text="⬅️ Назад", callback_data="order_back:quantity") - builder.button(text="🏠 К торговле", callback_data="trade:home") + + if drafts_page is not None: + builder.button(text="📚 К черновикам", callback_data=f"drafts:{drafts_page}") + else: + builder.button(text="🏠 К торговле", callback_data="trade:home") + builder.adjust(2, 2, 2) return builder.as_markup() -def _price_manual_keyboard() -> InlineKeyboardMarkup: +def _price_manual_keyboard( + drafts_page: int | None = None, +) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="⬅️ Назад", callback_data="order_manual_back:price") - builder.button(text="🏠 К торговле", callback_data="trade:home") + + if drafts_page is not None: + builder.button(text="📚 К черновикам", callback_data=f"drafts:{drafts_page}") + else: + builder.button(text="🏠 К торговле", callback_data="trade:home") + builder.adjust(2) return builder.as_markup() -def _confirm_keyboard() -> InlineKeyboardMarkup: +def _confirm_keyboard( + drafts_page: int | None = None, +) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="✅ Подтвердить", callback_data="order_confirm") builder.button(text="⬅️ Назад", callback_data="order_back:confirm") - builder.button(text="🏠 К торговле", callback_data="trade:home") + + if drafts_page is not None: + builder.button(text="📚 К черновикам", callback_data=f"drafts:{drafts_page}") + else: + builder.button(text="🏠 К торговле", callback_data="trade:home") + builder.adjust(1, 2) return builder.as_markup() @@ -147,7 +228,7 @@ def _trade_back_home_keyboard() -> InlineKeyboardMarkup: def _drafts_back_keyboard(page: int) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() - builder.button(text="⬅️ К черновикам", callback_data=f"drafts:{page}") + builder.button(text="📚 К черновикам", callback_data=f"drafts:{page}") return builder.as_markup() @@ -179,30 +260,39 @@ def _draft_detail_keyboard(draft_id: str, page: int) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="✏️ Редактировать", callback_data=f"draft_edit:{draft_id}:{page}") builder.button(text="🗑 Удалить", callback_data=f"draft_delete:{draft_id}") - builder.button(text="⬅️ К черновикам", callback_data=f"drafts:{page}") + builder.button(text="📚 К черновикам", callback_data=f"drafts:{page}") builder.adjust(2, 1) return builder.as_markup() -def _format_value_with_currency(value: str | float | None, currency: str | None) -> str | None: +def _format_value_with_currency( + value: str | float | None, + currency: str | None, +) -> str | None: if value is None: return None - text = str(value).strip() + + text = _clean_number(value, precision=2) if not text: return None + return f"{text} {currency}" if currency else text -def _format_value_with_asset(value: str | float | None, asset: str | None) -> str | None: +def _format_value_with_asset( + value: str | float | None, + asset: str | None, +) -> str | None: if value is None: return None - text = str(value).strip() + + text = _clean_number(value) if not text: return None + return f"{text} {asset}" if asset else text -# Рендерит экран успешного сохранения черновика. def _render_draft_summary( symbol: str, side: str, @@ -213,43 +303,47 @@ def _render_draft_summary( quote_currency: str | None = None, reference_price: str | None = None, notional: float | None = None, + is_edit_mode: bool = False, ) -> str: quantity_text = _format_value_with_asset(quantity, base_currency) + side_line = _side_badge(side) + order_type_text = order_type.upper() + success_text = "✅ Черновик изменён" if is_edit_mode else "✅ Черновик создан" lines = [ "📊 Торговля — Черновик ордера", mode_line().rstrip(), "", - f"Инструмент: {symbol}", - f"Сторона: {side}", - f"Тип: {order_type}", - f"Количество: {quantity_text or quantity}", + f"{symbol}", + "", + f"{side_line} · {order_type_text} · {quantity_text or quantity}", ] if price: price_text = _format_value_with_currency(price, quote_currency) - lines.append(f"Цена: {price_text or price}") + lines.append(f"Цена: {price_text or price}") elif reference_price: reference_price_text = _format_value_with_currency(reference_price, quote_currency) - lines.append(f"Ориентир цены: {reference_price_text or reference_price}") + lines.append(f"Цена: {reference_price_text or reference_price}") if notional is not None: - sum_text = _format_value_with_currency(f"{notional:.2f}", quote_currency) - lines.append(f"Сумма: {sum_text or f'{notional:.2f}'}") + notional_text = _format_value_with_currency(notional, quote_currency) + lines.append(f"Notional: {notional_text or str(notional)}") lines.extend( [ + "", "Статус: draft", "", - "✅ Черновик создан", + success_text, "", "Ордер не отправлялся на биржу", ] ) + return "\n".join(lines) -# Рендерит экран подтверждения черновика. def _render_confirm( symbol: str, side: str, @@ -261,29 +355,31 @@ def _render_confirm( base_currency: str | None = None, quote_currency: str | None = None, reference_price: str | None = None, + order_path: str | None = None, ) -> str: quantity_text = _format_value_with_asset(quantity, base_currency) + side_line = _side_badge(side) + order_type_text = order_type.upper() lines = [ _screen_title(is_edit_mode), mode_line().rstrip(), "", - f"Инструмент: {symbol}", - f"Сторона: {side}", - f"Тип: {order_type}", - f"Количество: {quantity_text or quantity}", + f"{symbol}", + "", + f"{side_line} · {order_type_text} · {quantity_text or quantity}", ] if price: price_text = _format_value_with_currency(price, quote_currency) - lines.append(f"Цена: {price_text or price}") + lines.append(f"Цена: {price_text or price}") elif reference_price: reference_price_text = _format_value_with_currency(reference_price, quote_currency) - lines.append(f"Ориентир цены: {reference_price_text or reference_price}") + lines.append(f"Цена: {reference_price_text or reference_price}") if notional is not None: - sum_text = _format_value_with_currency(f"{notional:.2f}", quote_currency) - lines.append(f"Сумма: {sum_text or f'{notional:.2f}'}") + notional_text = _format_value_with_currency(notional, quote_currency) + lines.append(f"Notional: {notional_text or str(notional)}") lines.extend( [ @@ -415,22 +511,33 @@ def _render_draft_detail( base_currency: str | None = None, quote_currency: str | None = None, ) -> str: - quantity = _format_draft_quantity(draft["quantity"]) + quantity = draft["quantity"] created_at = _format_draft_time(draft["created_at"]) + + if base_currency is None or quote_currency is None: + resolved_base, resolved_quote = _resolve_symbol_assets(str(draft["symbol"])) + base_currency = base_currency or resolved_base + quote_currency = quote_currency or resolved_quote + quantity_text = _format_value_with_asset(quantity, base_currency) + price_text = None + if draft.get("price"): + price_text = _format_value_with_currency(draft["price"], quote_currency) + + side_line = _side_badge(str(draft["side"])) + order_type = str(draft["order_type"]).upper() lines = [ "📊 Торговля — Черновик", mode_line().rstrip(), - f"Инструмент: {draft['symbol']}", - f"Сторона: {draft['side']}", - f"Тип: {draft['order_type']}", - f"Количество: {quantity_text or quantity}", + "", + f"{draft['symbol']}", + "", + f"{side_line} · {order_type} · {quantity_text or str(quantity)}", ] - if draft.get("price"): - price_text = _format_value_with_currency(draft["price"], quote_currency) - lines.append(f"Цена: {price_text or draft['price']}") + if price_text: + lines.append(f"Цена: {price_text}") lines.extend( [ @@ -473,16 +580,66 @@ def _render_quantity_step_screen( balance_currency: str, reference_price: float, quote_currency: str, + order_path: str | None = None, ) -> str: - return ( - f"{title}\n" - f"{mode_line()}" - f"Инструмент: {symbol}\n" - f"Доступно: {available_balance:.8f} {balance_currency}\n" - f"Ориентир цены: {reference_price:.2f} {quote_currency}\n\n" - "Шаг 3/4. Выбери количество" + lines = [ + title, + mode_line().rstrip(), + "", + f"{symbol}", + "", + ] + + if order_path: + lines.append(order_path) + lines.append("") + + lines.extend( + [ + f"Доступно: {available_balance:.8f} {balance_currency}", + f"Ориентир цены: {reference_price:.2f} {quote_currency}", + "", + "Шаг 3/4. Выбери количество", + ] ) + return "\n".join(lines) + + +def _render_quantity_inline_error( + *, + title: str, + symbol: str, + order_path: str, + errors: list[str], + help_text: str, +) -> str: + lines = [ + title, + mode_line().rstrip(), + "", + f"{symbol}", + "", + order_path, + "", + "⚠️ Найдены ошибки", + "", + ] + + for item in errors: + lines.append(f"• {item}") + + lines.extend( + [ + "", + help_text, + "", + "Шаг 3/4. Проверь введённое значение", + ] + ) + + return "\n".join(lines) + # Рендерит экран выбора цены. def _render_price_step_screen( @@ -492,10 +649,14 @@ def _render_price_step_screen( ask: float, last: float, quote_currency: str, + symbol: str, + order_path: str | None = None, ) -> str: return ( f"{title}\n" f"{mode_line()}" + f"{symbol}\n\n" + f"{order_path + '\n' if order_path else ''}" f"Bid: {bid:.2f} {quote_currency}\n" f"Ask: {ask:.2f} {quote_currency}\n" f"Last: {last:.2f} {quote_currency}\n\n" @@ -503,16 +664,53 @@ def _render_price_step_screen( ) +def _render_price_inline_error( + *, + title: str, + symbol: str, + order_path: str, + errors: list[str], + help_text: str, +) -> str: + lines = [ + title, + mode_line().rstrip(), + "", + f"{symbol}", + "", + order_path, + "", + "⚠️ Найдены ошибки", + "", + ] + + for item in errors: + lines.append(f"• {item}") + + lines.extend( + [ + "", + help_text, + "", + "Шаг 4/4. Проверь введённое значение", + ] + ) + + return "\n".join(lines) + + # Рендерит экран ручного ввода количества. def _render_manual_quantity_screen( *, title: str, + symbol: str, reference_price: float | None, quote_currency: str | None, min_qty: str | None, step_size: str | None, min_notional: str | None, example: str, + order_path: str | None = None, ) -> str: estimated_min_qty = _estimate_min_quantity_by_notional( reference_price=reference_price, @@ -532,8 +730,13 @@ def _render_manual_quantity_screen( lines = [ title, mode_line().rstrip(), + symbol, + "", ] + if order_path: + lines.extend([order_path, ""]) + if reference_price is not None and reference_price > 0: lines.extend( [ @@ -576,23 +779,42 @@ def _render_manual_quantity_screen( def _render_manual_price_screen( *, title: str, + symbol: str, tick_size: str | None, example: str, quote_currency: str | None, + order_path: str | None = None, ) -> str: - return ( - f"{title}\n" - f"{mode_line()}" - f"{_render_price_input_help( + lines = [ + title, + mode_line().rstrip(), + f"{symbol}", + "", + ] + + if order_path: + lines.append(order_path) + lines.append("") + + lines.append( + _render_price_input_help( tick_size=tick_size, example=example, quote_currency=quote_currency, - )}\n\n" - "Шаг 4/4. Введи цену" + ) ) + lines.extend( + [ + "", + "Шаг 4/4. Введи цену", + ] + ) -# Показывает список последних черновиков с пагинацией. + return "\n".join(lines) + + +# Показывает компактный список последних черновиков с пагинацией. async def show_recent_drafts( message: Message, edit_mode: bool = False, @@ -630,27 +852,31 @@ async def show_recent_drafts( details_builder = InlineKeyboardBuilder() - for item in drafts: + for local_idx, item in enumerate(drafts, start=1): + global_idx = start + local_idx + quantity = _format_draft_quantity(item["quantity"]) created_at = _format_draft_time(item["created_at"]) - lines.extend( - [ - f"{item['symbol']}", - f"{item['side']} · {item['order_type']}", - f"Количество: {quantity}", - f"Статус: {item['status']}", - f"Время: {created_at}", - "", - ] + base_currency, _quote_currency = _resolve_symbol_assets(str(item["symbol"])) + + quantity_text = _format_value_with_asset(quantity, base_currency) + side_line = _side_badge(str(item["side"])) + order_type = str(item["order_type"]).upper() + + time_short = created_at[11:16] if len(created_at) >= 16 else created_at + + lines.append( + f"{global_idx}. {side_line} · {order_type} · " + f"{quantity_text or quantity} · {time_short}" ) details_builder.button( - text=f"📄 {item['symbol']} {item['side']}", + text=str(global_idx), callback_data=f"draft_open:{item['id']}:{page}", ) - details_builder.adjust(1) + details_builder.adjust(3) pagination_markup = _drafts_pagination_keyboard(page, total_pages) details_builder.attach(InlineKeyboardBuilder.from_markup(pagination_markup)) @@ -661,4 +887,69 @@ async def show_recent_drafts( if edit_mode: await message.edit_text(text, reply_markup=keyboard) else: - await message.answer(text, reply_markup=keyboard) \ No newline at end of file + await message.answer(text, reply_markup=keyboard) + + +# функция формирования “пути ордера” +def _render_order_path( + *, + side: str | None = None, + order_type: str | None = None, + quantity: str | None = None, + price: str | None = None, + base_currency: str | None = None, + quote_currency: str | None = None, +) -> str: + parts: list[str] = [] + + if side: + side_emoji = "🟢" if side.upper() == "BUY" else "🔴" + parts.append(f"{side_emoji} {side.upper()}") + + if order_type: + parts.append(order_type.upper()) + + if quantity: + quantity_text = _format_value_with_asset(quantity, base_currency) + parts.append(quantity_text or str(quantity)) + + if price: + price_text = _format_value_with_currency(price, quote_currency) + parts.append(price_text or str(price)) + + if not parts: + return "" + + return " · ".join(parts) + + +def _render_order_card( + *, + symbol: str, + side: str, + order_type: str, + quantity: str, + price: str | None, + notional: float | None, + base_currency: str | None, + quote_currency: str | None, +) -> list[str]: + side_emoji = "🟢" if side == "BUY" else "🔴" + + quantity_text = _format_value_with_asset(quantity, base_currency) + price_text = _format_value_with_currency(price, quote_currency) if price else None + notional_text = _format_value_with_currency(notional, quote_currency) if notional is not None else None + + lines = [ + f"{symbol}", + "", + f"{side_emoji} {side} · {order_type} · {quantity_text or quantity}", + ] + + if price_text: + lines.append(f"Цена: {price_text}") + + if notional_text: + lines.append(f"Notional: {notional_text}") + + return lines \ No newline at end of file diff --git a/app/src/trading/orders/service.py b/app/src/trading/orders/service.py index 2e2d15c..06119eb 100644 --- a/app/src/trading/orders/service.py +++ b/app/src/trading/orders/service.py @@ -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) diff --git a/docs/stages/stage-03-1-integration-mock.md b/docs/stages/stage-03_1-integration_mock.md similarity index 100% rename from docs/stages/stage-03-1-integration-mock.md rename to docs/stages/stage-03_1-integration_mock.md diff --git a/docs/stages/stage-03-2-real-rest.md b/docs/stages/stage-03_2-real_rest.md similarity index 100% rename from docs/stages/stage-03-2-real-rest.md rename to docs/stages/stage-03_2-real_rest.md diff --git a/docs/stages/stage-03-3-exchange-info.md b/docs/stages/stage-03_3-exchange_info.md similarity index 100% rename from docs/stages/stage-03-3-exchange-info.md rename to docs/stages/stage-03_3-exchange_info.md diff --git a/docs/stages/stage-03-4-auth.md b/docs/stages/stage-03_4-auth.md similarity index 100% rename from docs/stages/stage-03-4-auth.md rename to docs/stages/stage-03_4-auth.md diff --git a/docs/stages/stage-03-5-account-balance.md b/docs/stages/stage-03_5-account_balance.md similarity index 100% rename from docs/stages/stage-03-5-account-balance.md rename to docs/stages/stage-03_5-account_balance.md diff --git a/docs/stages/stage-04-1-storage.md b/docs/stages/stage-04_1-storage.md similarity index 100% rename from docs/stages/stage-04-1-storage.md rename to docs/stages/stage-04_1-storage.md diff --git a/docs/stages/stage-04-2-journal.md b/docs/stages/stage-04_2-journal.md similarity index 100% rename from docs/stages/stage-04-2-journal.md rename to docs/stages/stage-04_2-journal.md diff --git a/docs/stages/stage-04-3-repositories.md b/docs/stages/stage-04_3-repositories.md similarity index 100% rename from docs/stages/stage-04-3-repositories.md rename to docs/stages/stage-04_3-repositories.md diff --git a/docs/stages/stage-05-1-order-draft-flow.md b/docs/stages/stage-05_1-order_draft_flow.md similarity index 100% rename from docs/stages/stage-05-1-order-draft-flow.md rename to docs/stages/stage-05_1-order_draft_flow.md diff --git a/docs/stages/stage-05-2-interactive-draft-builder.md b/docs/stages/stage-05_2-interactive_draft_builder.md similarity index 100% rename from docs/stages/stage-05-2-interactive-draft-builder.md rename to docs/stages/stage-05_2-interactive_draft_builder.md diff --git a/docs/stages/stage-05-3-order-validation.md b/docs/stages/stage-05_3-order_validation.md similarity index 100% rename from docs/stages/stage-05-3-order-validation.md rename to docs/stages/stage-05_3-order_validation.md diff --git a/docs/stages/stage_05-4-runtime-mode-helpers.md b/docs/stages/stage-05_4-runtime_mode_helpers.md similarity index 100% rename from docs/stages/stage_05-4-runtime-mode-helpers.md rename to docs/stages/stage-05_4-runtime_mode_helpers.md diff --git a/docs/stages/stage_05-5-trade-UI-unification.md b/docs/stages/stage-05_5-trade_UI_unification.md similarity index 100% rename from docs/stages/stage_05-5-trade-UI-unification.md rename to docs/stages/stage-05_5-trade_UI_unification.md diff --git a/docs/stages/stage_05-6-order-draft-logic-improvements.md b/docs/stages/stage-05_6-order_draft_logic_improvements.md similarity index 100% rename from docs/stages/stage_05-6-order-draft-logic-improvements.md rename to docs/stages/stage-05_6-order_draft_logic_improvements.md diff --git a/docs/stages/stage_05-7-trade-draft-UI-restructuring.md b/docs/stages/stage-05_7-trade_draft_UI_restructuring.md similarity index 100% rename from docs/stages/stage_05-7-trade-draft-UI-restructuring.md rename to docs/stages/stage-05_7-trade_draft_UI_restructuring.md diff --git a/docs/stages/stage-05_8-quantity_normalization.md b/docs/stages/stage-05_8-quantity_normalization.md new file mode 100644 index 0000000..a6357e6 --- /dev/null +++ b/docs/stages/stage-05_8-quantity_normalization.md @@ -0,0 +1,256 @@ +# Stage 05.8 — Quantity Normalization by Exchange Rules + +# Stage 05.8 — Нормализация количества по правилам биржи + +## Кратко + +Добавлена единая нормализация quantity во всем flow создания и редактирования ордера. + +Количество теперь всегда: +- соответствует stepSize +- не меньше minQty +- учитывает minNotional +- отображается без float-артефактов + +--- + +## Что сделано + +- Нормализация quantity вынесена в сервис (`OrderDraftsService`) +- Одинаковая логика для preset и ручного ввода +- Для LIMIT без цены используется рыночный reference price +- Убраны хвосты типа `0.0050000003` +- Количество стабильно на всех шагах FSM + +--- + +## Поведение + +### Малое значение +Если пользователь вводит слишком маленькое количество: +- система автоматически корректирует до допустимого (если возможно) +- иначе показывает ошибку + +### LIMIT без цены +- minNotional считается через рынок (ask/bid) +- не нужно ждать ввода цены + +--- + +## Затронутые файлы + +- app/src/trading/orders/service.py +- app/src/telegram/handlers/trade/new_order_flow.py + +--- + +## Changelog + +### Added +- Нормализация quantity по stepSize, minQty, minNotional +- Автокоррекция при ручном вводе + +### Changed +- Flow quantity переведен на normalize_entry_quantity +- Preset quantity проходит через те же правила + +### Fixed +- Убраны float-артефакты +- Исправлена рассинхронизация quantity между шагами + +--- + +## Breaking Changes + +- quantity теперь может автоматически изменяться системой + (например: 0.000001 → 0.0002) + +- normalize_quantity больше не используется как финальный результат + +- для LIMIT без цены используется reference price + + + + +⸻ + +Stage 05.8 — quantity normalization by exchange rules + +Что сделано + +На этапе формирования и редактирования ордера добавлена единая нормализация количества по биржевым правилам. + +Теперь количество приводится к корректному виду не только при финальной валидации, но уже в самом flow создания/редактирования черновика. + +Это устраняет проблемы с: + +* хвостами после вычислений и округлений +* значениями, не кратными stepSize +* слишком маленькими значениями, которые должны быть автоматически доведены до допустимого минимума +* расхождениями между количеством на разных экранах одного и того же ордера + +⸻ + +Основные изменения + +1. Добавлена нормализация количества по правилам биржи + +В OrderDraftsService добавлены методы: + +* normalize_entry_quantity(...) +* normalize_preset_quantity(...) +* внутренняя логика _normalize_entry_quantity_with_rules(...) + +Они: + +* принимают “сырое” количество +* приводят его к Decimal +* учитывают stepSize +* учитывают minQty +* учитывают minNotional +* возвращают уже готовую нормализованную строку количества + +⸻ + +2. Для LIMIT-ордера minimum по quantity теперь считается ещё до ввода цены + +Если пользователь находится на этапе ввода количества для LIMIT-ордера и цена ещё не введена, сервис больше не пропускает расчёт minNotional. + +Вместо этого используется рыночный ориентир: + +* для BUY — ask_price +* для SELL — bid_price + +Благодаря этому минимально допустимое количество считается корректно уже на этапе quantity. + +Пример: + +* пользователь вводит 0.000001 +* minQty = 0.0001 +* но по minNotional требуется 0.0002 +* теперь система автоматически приводит quantity к 0.0002 + +⸻ + +3. Нормализация применяется и для preset-кнопок, и для ручного ввода + +Одинаковая логика работает в обоих сценариях: + +* выбор количества через кнопки preset +* ручной ввод количества + +Это гарантирует единое поведение вне зависимости от того, как пользователь выбрал quantity. + +⸻ + +4. Исправлены визуальные артефакты количества + +Убраны случаи, когда в flow отображались значения типа: + +* 0.0050000003 +* 0.001300000000000000 + +После нормализации во всех экранах показывается чистое значение, например: + +* 0.005 +* 0.0013 + +⸻ + +Затронутые файлы + +app/src/trading/orders/service.py + +Добавлена бизнес-логика нормализации количества: + +* отдельные методы для preset/manual quantity +* расчёт минимально допустимого количества по minQty и minNotional +* использование reference price для LIMIT even before explicit price input + +app/src/telegram/handlers/trade/new_order_flow.py + +Flow переключён на новые методы нормализации: + +* process_quantity_callback(...) +* process_order_quantity(...) + +Теперь далее по сценарию передаётся уже нормализованное quantity. + +⸻ + +Поведение после изменений + +Ручной ввод слишком малого количества + +Если пользователь вводит quantity ниже допустимого минимума, система не обязательно сразу показывает ошибку. + +Если значение можно автоматически привести к допустимому: + +* количество округляется по шагу +* при необходимости поднимается до минимального значения +* дальше по flow используется уже скорректированное quantity + +⸻ + +Preset quantity + +Если preset даёт слишком маленькое количество: + +* оно не превращается в 0 +* не остаётся “грязным” +* приводится к допустимому биржей значению + +⸻ + +LIMIT до ввода цены + +Для LIMIT-ордера на этапе quantity: + +* minimum по minNotional считается по рыночному ориентиру +* не требуется дожидаться отдельного ввода цены + +⸻ + +Что проверено + +После изменений вручную были проверены 6 регрессионных сценариев: + +1. BUY + LIMIT + manual quantity below minimum +2. SELL + LIMIT + manual quantity below minimum +3. LIMIT + preset quantity +4. MARKET + manual quantity +5. edit draft + change quantity +6. переходы назад/вперёд после автонормализации + +Все сценарии отработали корректно при стабильной связи с биржей. + +⸻ + +Что не входит в этот этап + +Stage 05.8 не решает задачи отказоустойчивости сети и API биржи. + +Если биржа не отвечает или отвечает с таймаутом: + +* flow по-прежнему может падать на этапе получения market/balance/exchange metadata +* это отдельная задача для следующего этапа + +⸻ + +Итог + +Stage 05.8 завершает блок нормализации количества и делает quantity-flow устойчивым с точки зрения биржевых ограничений. + +Пользователь теперь видит: + +* корректное quantity +* одинаковое quantity на всех этапах +* отсутствие мусорных хвостов +* корректный минимум по minQty и minNotional + +⸻ + +Для коммита можно использовать: + +git add app/src/trading/orders/service.py app/src/telegram/handlers/trade/new_order_flow.py +git commit -m "Stage 05.8 - quantity normalization by exchange rules" \ No newline at end of file