Stage 05.8 - quantity normalization by exchange rules

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

View File

@@ -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 = (
"<b>📊 Торговля — Новый ордер</b>\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 = (
"<b>📊 Торговля — Новый ордер</b>\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,
)

View File

@@ -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(
"<b>📊 Торговля</b>\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 = (
"<b>📊 Торговля — Новый ордер</b>\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 = (
"<b>📊 Торговля — Новый ордер</b>\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()

View File

@@ -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 "🟢 <b>BUY</b>" if side.upper() == "BUY" else "🔴 <b>SELL</b>"
# Оценивает минимально допустимое количество по правилу 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 = "✅ <b>Черновик изменён</b>" if is_edit_mode else "✅ <b>Черновик создан</b>"
lines = [
"<b>📊 Торговля — Черновик ордера</b>",
mode_line().rstrip(),
"",
f"Инструмент: <b>{symbol}</b>",
f"Сторона: <b>{side}</b>",
f"Тип: <b>{order_type}</b>",
f"Количество: <b>{quantity_text or quantity}</b>",
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"Цена: <b>{price_text or price}</b>")
lines.append(f"Цена: {price_text or price}")
elif reference_price:
reference_price_text = _format_value_with_currency(reference_price, quote_currency)
lines.append(f"Ориентир цены: <b>{reference_price_text or reference_price}</b>")
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"Сумма: <b>{sum_text or f'{notional:.2f}'}</b>")
notional_text = _format_value_with_currency(notional, quote_currency)
lines.append(f"Notional: {notional_text or str(notional)}")
lines.extend(
[
"",
"Статус: <b>draft</b>",
"",
"<b>✅ Черновик создан</b>",
success_text,
"",
"<i>Ордер не отправлялся на биржу</i>",
]
)
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"Инструмент: <b>{symbol}</b>",
f"Сторона: <b>{side}</b>",
f"Тип: <b>{order_type}</b>",
f"Количество: <b>{quantity_text or quantity}</b>",
f"<b>{symbol}</b>",
"",
f"{side_line} · {order_type_text} · {quantity_text or quantity}",
]
if price:
price_text = _format_value_with_currency(price, quote_currency)
lines.append(f"Цена: <b>{price_text or price}</b>")
lines.append(f"Цена: {price_text or price}")
elif reference_price:
reference_price_text = _format_value_with_currency(reference_price, quote_currency)
lines.append(f"Ориентир цены: <b>{reference_price_text or reference_price}</b>")
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"Сумма: <b>{sum_text or f'{notional:.2f}'}</b>")
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 = [
"<b>📊 Торговля — Черновик</b>",
mode_line().rstrip(),
f"Инструмент: <b>{draft['symbol']}</b>",
f"Сторона: <b>{draft['side']}</b>",
f"Тип: <b>{draft['order_type']}</b>",
f"Количество: <b>{quantity_text or quantity}</b>",
"",
f"<b>{draft['symbol']}</b>",
"",
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"Цена: <b>{price_text or draft['price']}</b>")
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"Инструмент: <b>{symbol}</b>\n"
f"Доступно: <b>{available_balance:.8f} {balance_currency}</b>\n"
f"Ориентир цены: <b>{reference_price:.2f} {quote_currency}</b>\n\n"
"Шаг 3/4. Выбери количество"
lines = [
title,
mode_line().rstrip(),
"",
f"{symbol}",
"",
]
if order_path:
lines.append(order_path)
lines.append("")
lines.extend(
[
f"Доступно: <b>{available_balance:.8f} {balance_currency}</b>",
f"Ориентир цены: <b>{reference_price:.2f} {quote_currency}</b>",
"",
"Шаг 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,
"",
"⚠️ <b>Найдены ошибки</b>",
"",
]
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: <b>{bid:.2f} {quote_currency}</b>\n"
f"Ask: <b>{ask:.2f} {quote_currency}</b>\n"
f"Last: <b>{last:.2f} {quote_currency}</b>\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,
"",
"⚠️ <b>Найдены ошибки</b>",
"",
]
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"<b>{item['symbol']}</b>",
f"{item['side']} · {item['order_type']}",
f"Количество: <b>{quantity}</b>",
f"Статус: <b>{item['status']}</b>",
f"Время: <b>{created_at}</b>",
"",
]
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)
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} <b>{side.upper()}</b>")
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"<b>{symbol}</b>",
"",
f"{side_emoji} <b>{side}</b> · {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

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from decimal import Decimal, InvalidOperation, ROUND_DOWN
from decimal import Decimal, InvalidOperation, ROUND_DOWN, ROUND_UP
from src.core.config import load_settings
from src.integrations.exchange.models import ExchangeSymbol
@@ -181,8 +181,6 @@ class OrderDraftsService:
return self.repository.get_draft_by_id(draft_id)
def get_entry_context(self, *, side: str, order_type: str) -> OrderEntryContext:
# Собираем контекст экрана ввода ордера на основе биржевых правил,
# текущего рынка и доступного баланса.
validation = self.exchange.validate_symbol(self.settings.default_symbol)
if not validation.is_valid or validation.symbol_info is None:
raise ValueError(validation.message)
@@ -307,6 +305,103 @@ class OrderDraftsService:
return errors
def normalize_preset_quantity(
self,
*,
side: str,
order_type: str,
raw_quantity: str,
price: str | None = None,
) -> str | None:
return self._normalize_entry_quantity_with_rules(
side=side,
order_type=order_type,
raw_quantity=raw_quantity,
price=price,
raise_to_minimum=True,
)
def normalize_entry_quantity(
self,
*,
side: str,
order_type: str,
raw_quantity: str,
price: str | None = None,
) -> str | None:
return self._normalize_entry_quantity_with_rules(
side=side,
order_type=order_type,
raw_quantity=raw_quantity,
price=price,
raise_to_minimum=True,
)
def _normalize_entry_quantity_with_rules(
self,
*,
side: str,
order_type: str,
raw_quantity: str,
price: str | None = None,
raise_to_minimum: bool,
) -> str | None:
validation = self.exchange.validate_symbol(self.settings.default_symbol)
if not validation.is_valid or validation.symbol_info is None:
return self.normalize_quantity(raw_quantity)
original_quantity = self._to_decimal((raw_quantity or "").strip().replace(",", "."))
if original_quantity is None or original_quantity <= 0:
return None
symbol_info = validation.symbol_info
step_size = self._to_decimal(getattr(symbol_info, "step_size", None))
min_qty = self._to_decimal(getattr(symbol_info, "min_qty", None))
min_notional = self._to_decimal(getattr(symbol_info, "min_notional", None))
minimum_allowed = min_qty if min_qty is not None and min_qty > 0 else None
reference_price = self._resolve_reference_price_for_entry(
side=side,
order_type=order_type,
price=price,
)
if (
reference_price is not None
and reference_price > 0
and min_notional is not None
and min_notional > 0
):
min_by_notional = min_notional / reference_price
if step_size is not None and step_size > 0:
min_by_notional = self._ceil_to_step(min_by_notional, step_size)
if minimum_allowed is None or min_by_notional > minimum_allowed:
minimum_allowed = min_by_notional
quantity = original_quantity
if step_size is not None and step_size > 0:
quantity = self._floor_to_step(quantity, step_size)
if quantity <= 0:
if raise_to_minimum and minimum_allowed is not None and minimum_allowed > 0:
quantity = minimum_allowed
if step_size is not None and step_size > 0 and not self._fits_step(quantity, step_size):
quantity = self._ceil_to_step(quantity, step_size)
else:
return None
if raise_to_minimum and minimum_allowed is not None and quantity < minimum_allowed:
quantity = minimum_allowed
if step_size is not None and step_size > 0 and not self._fits_step(quantity, step_size):
quantity = self._ceil_to_step(quantity, step_size)
if quantity <= 0:
return None
return self._format_decimal(quantity)
def _build_quantity_presets(
self,
*,
@@ -452,6 +547,13 @@ class OrderDraftsService:
ratio = (value / step).to_integral_value(rounding=ROUND_DOWN)
return ratio * step
@staticmethod
def _ceil_to_step(value: Decimal, step: Decimal) -> Decimal:
if step <= 0:
return value
ratio = (value / step).to_integral_value(rounding=ROUND_UP)
return ratio * step
def _normalize_quantity_to_exchange_rules(
self,
*,
@@ -498,7 +600,9 @@ class OrderDraftsService:
price: str | None = None,
) -> Decimal | None:
if order_type.upper() == "LIMIT":
return self._to_decimal(price)
explicit_price = self._to_decimal(price)
if explicit_price is not None and explicit_price > 0:
return explicit_price
try:
market = self.exchange.get_market_snapshot(self.settings.default_symbol)

View File

@@ -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"