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