Stage 05.8 - quantity normalization by exchange rules
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
70
docs/stages/stage-05_8-quantity_normalization.md
Normal file
70
docs/stages/stage-05_8-quantity_normalization.md
Normal 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
|
||||
Reference in New Issue
Block a user