Stage 05.8 - quantity normalization by exchange rules

This commit is contained in:
2026-04-20 20:18:03 +03:00
parent c36e43f5e8
commit 034b455059
20 changed files with 839 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,70 @@
# 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