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_confirm,
|
||||||
_render_draft_detail,
|
_render_draft_detail,
|
||||||
_render_draft_summary,
|
_render_draft_summary,
|
||||||
_render_inline_error,
|
# _render_inline_error,
|
||||||
_render_manual_price_screen,
|
_render_manual_price_screen,
|
||||||
_render_manual_quantity_screen,
|
_render_manual_quantity_screen,
|
||||||
|
_render_order_path,
|
||||||
_render_price_input_help,
|
_render_price_input_help,
|
||||||
_render_price_step_screen,
|
_render_price_step_screen,
|
||||||
|
_render_price_inline_error,
|
||||||
|
_render_quantity_inline_error,
|
||||||
_render_quantity_input_help,
|
_render_quantity_input_help,
|
||||||
_render_quantity_step_screen,
|
_render_quantity_step_screen,
|
||||||
_render_validation_error,
|
_render_validation_error,
|
||||||
@@ -84,22 +87,14 @@ async def open_draft(callback: CallbackQuery) -> None:
|
|||||||
await callback.answer("Черновик не найден", show_alert=True)
|
await callback.answer("Черновик не найден", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
context = service.get_entry_context(
|
|
||||||
side=str(draft["side"]).upper(),
|
|
||||||
order_type=str(draft["order_type"]).upper(),
|
|
||||||
)
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
_render_draft_detail(
|
_render_draft_detail(draft),
|
||||||
draft,
|
|
||||||
base_currency=context.base_currency,
|
|
||||||
quote_currency=context.quote_currency,
|
|
||||||
),
|
|
||||||
reply_markup=_draft_detail_keyboard(draft_id, page),
|
reply_markup=_draft_detail_keyboard(draft_id, page),
|
||||||
)
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Переводит черновик в режим редактирования.
|
# Переводит черновик в режим редактирования.
|
||||||
@router.callback_query(F.data.startswith("draft_edit:"))
|
@router.callback_query(F.data.startswith("draft_edit:"))
|
||||||
async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None:
|
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()
|
side = str(draft["side"]).upper()
|
||||||
order_type = str(draft["order_type"]).upper()
|
order_type = str(draft["order_type"]).upper()
|
||||||
quantity = str(draft["quantity"])
|
quantity = str(draft["quantity"])
|
||||||
|
price = str(draft.get("price") or "") or None
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
await state.update_data(
|
await state.update_data(
|
||||||
@@ -123,29 +119,18 @@ async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None:
|
|||||||
side=side,
|
side=side,
|
||||||
order_type=order_type,
|
order_type=order_type,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
|
price=price,
|
||||||
)
|
)
|
||||||
|
|
||||||
title = _screen_title(is_edit_mode=True)
|
title = _screen_title(is_edit_mode=True)
|
||||||
context = service.get_entry_context(side=side, order_type=order_type)
|
context = service.get_entry_context(side=side, order_type=order_type)
|
||||||
|
|
||||||
if order_type == "LIMIT":
|
path = _render_order_path(
|
||||||
await state.set_state(NewOrderDraftStates.waiting_price)
|
side=side,
|
||||||
await callback.message.edit_text(
|
order_type=order_type,
|
||||||
_render_price_step_screen(
|
quantity=quantity,
|
||||||
title=title,
|
base_currency=context.base_currency,
|
||||||
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
|
|
||||||
|
|
||||||
await state.set_state(NewOrderDraftStates.waiting_quantity)
|
await state.set_state(NewOrderDraftStates.waiting_quantity)
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
@@ -156,8 +141,12 @@ async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None:
|
|||||||
balance_currency=context.balance_currency,
|
balance_currency=context.balance_currency,
|
||||||
reference_price=context.reference_price,
|
reference_price=context.reference_price,
|
||||||
quote_currency=context.quote_currency,
|
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()
|
await callback.answer()
|
||||||
|
|
||||||
@@ -189,9 +178,13 @@ async def start_new_order_draft(
|
|||||||
await state.clear()
|
await state.clear()
|
||||||
await state.set_state(NewOrderDraftStates.waiting_side)
|
await state.set_state(NewOrderDraftStates.waiting_side)
|
||||||
|
|
||||||
|
service = OrderDraftsService()
|
||||||
|
context = service.get_entry_context(side="BUY", order_type="MARKET")
|
||||||
|
|
||||||
text = (
|
text = (
|
||||||
"<b>📊 Торговля — Новый ордер</b>\n"
|
"<b>📊 Торговля — Новый ордер</b>\n"
|
||||||
f"{mode_line()}"
|
f"{mode_line()}"
|
||||||
|
f"{context.symbol}\n\n"
|
||||||
"Шаг 1/4. Выбери сторону"
|
"Шаг 1/4. Выбери сторону"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -211,13 +204,18 @@ async def process_order_side_callback(
|
|||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
) -> None:
|
) -> None:
|
||||||
side = callback.data.split(":", 1)[1]
|
side = callback.data.split(":", 1)[1]
|
||||||
|
|
||||||
await state.update_data(side=side)
|
await state.update_data(side=side)
|
||||||
await state.set_state(NewOrderDraftStates.waiting_type)
|
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 = (
|
text = (
|
||||||
"<b>📊 Торговля — Новый ордер</b>\n"
|
"<b>📊 Торговля — Новый ордер</b>\n"
|
||||||
f"{mode_line()}"
|
f"{mode_line()}"
|
||||||
|
f"{context.symbol}\n\n"
|
||||||
|
f"{path}\n\n"
|
||||||
"Шаг 2/4. Выбери тип ордера"
|
"Шаг 2/4. Выбери тип ордера"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -251,12 +249,20 @@ async def process_order_type_callback(
|
|||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
side = data.get("side", "BUY")
|
side = data.get("side", "BUY")
|
||||||
is_edit_mode = bool(data.get("draft_edit_id"))
|
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.update_data(order_type=order_type)
|
||||||
await state.set_state(NewOrderDraftStates.waiting_quantity)
|
await state.set_state(NewOrderDraftStates.waiting_quantity)
|
||||||
|
|
||||||
context = service.get_entry_context(side=side, order_type=order_type)
|
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(
|
await callback.message.edit_text(
|
||||||
_render_quantity_step_screen(
|
_render_quantity_step_screen(
|
||||||
title=_screen_title(is_edit_mode),
|
title=_screen_title(is_edit_mode),
|
||||||
@@ -265,8 +271,12 @@ async def process_order_type_callback(
|
|||||||
balance_currency=context.balance_currency,
|
balance_currency=context.balance_currency,
|
||||||
reference_price=context.reference_price,
|
reference_price=context.reference_price,
|
||||||
quote_currency=context.quote_currency,
|
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()
|
await callback.answer()
|
||||||
|
|
||||||
@@ -297,64 +307,87 @@ async def process_quantity_callback(
|
|||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
is_edit_mode = bool(data.get("draft_edit_id"))
|
is_edit_mode = bool(data.get("draft_edit_id"))
|
||||||
title = _screen_title(is_edit_mode)
|
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":
|
if value == "manual":
|
||||||
rules = service.get_entry_rules()
|
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"
|
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(
|
await callback.message.edit_text(
|
||||||
_render_manual_quantity_screen(
|
_render_manual_quantity_screen(
|
||||||
title=title,
|
title=title,
|
||||||
|
symbol=context.symbol,
|
||||||
reference_price=context.reference_price,
|
reference_price=context.reference_price,
|
||||||
quote_currency=context.quote_currency,
|
quote_currency=context.quote_currency,
|
||||||
min_qty=rules["min_qty"],
|
min_qty=rules["min_qty"],
|
||||||
step_size=rules["step_size"],
|
step_size=rules["step_size"],
|
||||||
min_notional=rules["min_notional"],
|
min_notional=rules["min_notional"],
|
||||||
example=quantity_example,
|
example=quantity_example,
|
||||||
|
order_path=path,
|
||||||
),
|
),
|
||||||
reply_markup=_quantity_manual_keyboard(),
|
reply_markup=_quantity_manual_keyboard(drafts_page=drafts_page),
|
||||||
)
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
return
|
return
|
||||||
|
|
||||||
quantity = service.normalize_quantity(value)
|
quantity = service.normalize_preset_quantity(
|
||||||
|
side=side,
|
||||||
|
order_type=order_type,
|
||||||
|
raw_quantity=value,
|
||||||
|
)
|
||||||
if quantity is None:
|
if quantity is None:
|
||||||
await callback.answer("Некорректное значение количества.", show_alert=True)
|
await callback.answer("Некорректное значение количества.", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
order_type = data.get("order_type", "MARKET")
|
|
||||||
await state.update_data(quantity=quantity)
|
await state.update_data(quantity=quantity)
|
||||||
|
|
||||||
context = service.get_entry_context(side=data["side"], order_type=order_type)
|
|
||||||
|
|
||||||
if order_type == "LIMIT":
|
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 state.set_state(NewOrderDraftStates.waiting_price)
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
_render_price_step_screen(
|
_render_price_step_screen(
|
||||||
title=title,
|
title=title,
|
||||||
|
symbol=context.symbol,
|
||||||
bid=context.bid_price,
|
bid=context.bid_price,
|
||||||
ask=context.ask_price,
|
ask=context.ask_price,
|
||||||
last=context.last_price,
|
last=context.last_price,
|
||||||
quote_currency=context.quote_currency,
|
quote_currency=context.quote_currency,
|
||||||
|
order_path=path,
|
||||||
),
|
),
|
||||||
reply_markup=_price_keyboard(
|
reply_markup=_price_keyboard(
|
||||||
bid=context.bid_price,
|
bid=context.bid_price,
|
||||||
ask=context.ask_price,
|
ask=context.ask_price,
|
||||||
last=context.last_price,
|
last=context.last_price,
|
||||||
|
drafts_page=drafts_page,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
return
|
return
|
||||||
|
|
||||||
draft = service.build_draft(
|
draft = service.build_draft(
|
||||||
side=data["side"],
|
side=side,
|
||||||
order_type=order_type,
|
order_type=order_type,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
)
|
)
|
||||||
|
|
||||||
notional = service.calculate_notional(quantity, f"{context.reference_price:.2f}")
|
notional = service.calculate_notional(quantity, f"{context.reference_price:.2f}")
|
||||||
|
|
||||||
await state.update_data(
|
await state.update_data(
|
||||||
@@ -370,6 +403,7 @@ async def process_quantity_callback(
|
|||||||
"notional": notional,
|
"notional": notional,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
await state.set_state(NewOrderDraftStates.waiting_confirm)
|
await state.set_state(NewOrderDraftStates.waiting_confirm)
|
||||||
|
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
@@ -385,7 +419,7 @@ async def process_quantity_callback(
|
|||||||
quote_currency=context.quote_currency,
|
quote_currency=context.quote_currency,
|
||||||
reference_price=f"{context.reference_price:.2f}",
|
reference_price=f"{context.reference_price:.2f}",
|
||||||
),
|
),
|
||||||
reply_markup=_confirm_keyboard(),
|
reply_markup=_confirm_keyboard(drafts_page=drafts_page),
|
||||||
)
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
@@ -398,17 +432,25 @@ async def process_quantity_callback(
|
|||||||
async def process_order_quantity(message: Message, state: FSMContext) -> None:
|
async def process_order_quantity(message: Message, state: FSMContext) -> None:
|
||||||
service = OrderDraftsService()
|
service = OrderDraftsService()
|
||||||
raw_quantity = message.text or ""
|
raw_quantity = message.text or ""
|
||||||
quantity = service.normalize_quantity(raw_quantity)
|
|
||||||
|
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
side = data.get("side", "BUY")
|
side = data.get("side", "BUY")
|
||||||
order_type = data.get("order_type", "MARKET")
|
order_type = data.get("order_type", "MARKET")
|
||||||
is_edit_mode = bool(data.get("draft_edit_id"))
|
is_edit_mode = bool(data.get("draft_edit_id"))
|
||||||
title = _screen_title(is_edit_mode)
|
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)
|
context = service.get_entry_context(side=side, order_type=order_type)
|
||||||
rules = service.get_entry_rules()
|
rules = service.get_entry_rules()
|
||||||
quantity_example = context.quantity_presets[0] if context.quantity_presets else "0.001"
|
quantity_example = context.quantity_presets[0] if context.quantity_presets else "0.001"
|
||||||
|
|
||||||
help_text = _render_quantity_input_help(
|
help_text = _render_quantity_input_help(
|
||||||
min_qty=rules["min_qty"],
|
min_qty=rules["min_qty"],
|
||||||
step_size=rules["step_size"],
|
step_size=rules["step_size"],
|
||||||
@@ -419,14 +461,21 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if quantity is None:
|
if quantity is None:
|
||||||
|
path = _render_order_path(
|
||||||
|
side=side,
|
||||||
|
order_type=order_type,
|
||||||
|
base_currency=context.base_currency,
|
||||||
|
)
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
_render_inline_error(
|
_render_quantity_inline_error(
|
||||||
title=title,
|
title=title,
|
||||||
step_text="Шаг 3/4. Проверь введённое значение",
|
symbol=context.symbol,
|
||||||
|
order_path=path,
|
||||||
errors=["Количество должно быть числом больше нуля."],
|
errors=["Количество должно быть числом больше нуля."],
|
||||||
help_text=help_text,
|
help_text=help_text,
|
||||||
),
|
),
|
||||||
reply_markup=_quantity_manual_keyboard(),
|
reply_markup=_quantity_manual_keyboard(drafts_page=drafts_page),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -437,33 +486,50 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None:
|
|||||||
price=None,
|
price=None,
|
||||||
)
|
)
|
||||||
if quantity_errors:
|
if quantity_errors:
|
||||||
|
path = _render_order_path(
|
||||||
|
side=side,
|
||||||
|
order_type=order_type,
|
||||||
|
base_currency=context.base_currency,
|
||||||
|
)
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
_render_inline_error(
|
_render_quantity_inline_error(
|
||||||
title=title,
|
title=title,
|
||||||
step_text="Шаг 3/4. Проверь введённое значение",
|
symbol=context.symbol,
|
||||||
|
order_path=path,
|
||||||
errors=quantity_errors,
|
errors=quantity_errors,
|
||||||
help_text=help_text,
|
help_text=help_text,
|
||||||
),
|
),
|
||||||
reply_markup=_quantity_manual_keyboard(),
|
reply_markup=_quantity_manual_keyboard(drafts_page=drafts_page),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await state.update_data(quantity=quantity)
|
await state.update_data(quantity=quantity)
|
||||||
|
|
||||||
if order_type == "LIMIT":
|
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 state.set_state(NewOrderDraftStates.waiting_price)
|
||||||
await message.answer(
|
await message.answer(
|
||||||
_render_price_step_screen(
|
_render_price_step_screen(
|
||||||
title=title,
|
title=title,
|
||||||
|
symbol=context.symbol,
|
||||||
bid=context.bid_price,
|
bid=context.bid_price,
|
||||||
ask=context.ask_price,
|
ask=context.ask_price,
|
||||||
last=context.last_price,
|
last=context.last_price,
|
||||||
quote_currency=context.quote_currency,
|
quote_currency=context.quote_currency,
|
||||||
|
order_path=path,
|
||||||
),
|
),
|
||||||
reply_markup=_price_keyboard(
|
reply_markup=_price_keyboard(
|
||||||
bid=context.bid_price,
|
bid=context.bid_price,
|
||||||
ask=context.ask_price,
|
ask=context.ask_price,
|
||||||
last=context.last_price,
|
last=context.last_price,
|
||||||
|
drafts_page=drafts_page,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -503,7 +569,7 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None:
|
|||||||
quote_currency=context.quote_currency,
|
quote_currency=context.quote_currency,
|
||||||
reference_price=f"{context.reference_price:.2f}",
|
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()
|
data = await state.get_data()
|
||||||
is_edit_mode = bool(data.get("draft_edit_id"))
|
is_edit_mode = bool(data.get("draft_edit_id"))
|
||||||
title = _screen_title(is_edit_mode)
|
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(
|
context = service.get_entry_context(
|
||||||
side=data.get("side", "BUY"),
|
side=data.get("side", "BUY"),
|
||||||
@@ -532,14 +600,23 @@ async def process_price_callback(
|
|||||||
rules = service.get_entry_rules()
|
rules = service.get_entry_rules()
|
||||||
price_example = f"{context.last_price:.2f}"
|
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(
|
await callback.message.edit_text(
|
||||||
_render_manual_price_screen(
|
_render_manual_price_screen(
|
||||||
title=title,
|
title=title,
|
||||||
|
symbol=context.symbol,
|
||||||
tick_size=rules["tick_size"],
|
tick_size=rules["tick_size"],
|
||||||
example=price_example,
|
example=price_example,
|
||||||
quote_currency=context.quote_currency,
|
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()
|
await callback.answer()
|
||||||
return
|
return
|
||||||
@@ -555,6 +632,7 @@ async def process_price_callback(
|
|||||||
quantity=data["quantity"],
|
quantity=data["quantity"],
|
||||||
price=price,
|
price=price,
|
||||||
)
|
)
|
||||||
|
|
||||||
notional = service.calculate_notional(data["quantity"], price)
|
notional = service.calculate_notional(data["quantity"], price)
|
||||||
|
|
||||||
await state.update_data(
|
await state.update_data(
|
||||||
@@ -569,6 +647,7 @@ async def process_price_callback(
|
|||||||
"notional": notional,
|
"notional": notional,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
await state.set_state(NewOrderDraftStates.waiting_confirm)
|
await state.set_state(NewOrderDraftStates.waiting_confirm)
|
||||||
|
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
@@ -583,7 +662,7 @@ async def process_price_callback(
|
|||||||
base_currency=context.base_currency,
|
base_currency=context.base_currency,
|
||||||
quote_currency=context.quote_currency,
|
quote_currency=context.quote_currency,
|
||||||
),
|
),
|
||||||
reply_markup=_confirm_keyboard(),
|
reply_markup=_confirm_keyboard(drafts_page=drafts_page),
|
||||||
)
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
@@ -601,6 +680,8 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
|
|||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
is_edit_mode = bool(data.get("draft_edit_id"))
|
is_edit_mode = bool(data.get("draft_edit_id"))
|
||||||
title = _screen_title(is_edit_mode)
|
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()
|
rules = service.get_entry_rules()
|
||||||
context = service.get_entry_context(
|
context = service.get_entry_context(
|
||||||
@@ -615,14 +696,22 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if price is 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(
|
await message.answer(
|
||||||
_render_inline_error(
|
_render_price_inline_error(
|
||||||
title=title,
|
title=title,
|
||||||
step_text="Шаг 4/4. Проверь введённое значение",
|
symbol=context.symbol,
|
||||||
|
order_path=path,
|
||||||
errors=["Цена должна быть числом больше нуля."],
|
errors=["Цена должна быть числом больше нуля."],
|
||||||
help_text=help_text,
|
help_text=help_text,
|
||||||
),
|
),
|
||||||
reply_markup=_price_manual_keyboard(),
|
reply_markup=_price_manual_keyboard(drafts_page=drafts_page),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -635,14 +724,22 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
|
|||||||
|
|
||||||
validation = service.validate_draft(draft)
|
validation = service.validate_draft(draft)
|
||||||
if not validation.is_valid:
|
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(
|
await message.answer(
|
||||||
_render_inline_error(
|
_render_price_inline_error(
|
||||||
title=title,
|
title=title,
|
||||||
step_text="Шаг 4/4. Проверь введённое значение",
|
symbol=context.symbol,
|
||||||
|
order_path=path,
|
||||||
errors=validation.errors,
|
errors=validation.errors,
|
||||||
help_text=help_text,
|
help_text=help_text,
|
||||||
),
|
),
|
||||||
reply_markup=_price_manual_keyboard(),
|
reply_markup=_price_manual_keyboard(drafts_page=drafts_page),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -674,7 +771,7 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
|
|||||||
base_currency=context.base_currency,
|
base_currency=context.base_currency,
|
||||||
quote_currency=context.quote_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,
|
quote_currency=quote_currency,
|
||||||
reference_price=reference_price,
|
reference_price=reference_price,
|
||||||
notional=notional,
|
notional=notional,
|
||||||
|
is_edit_mode=bool(edit_page),
|
||||||
),
|
),
|
||||||
reply_markup=reply_markup,
|
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_core import router
|
||||||
from src.telegram.handlers.trade.new_order_ui import (
|
from src.telegram.handlers.trade.new_order_ui import (
|
||||||
mode_line,
|
mode_line,
|
||||||
|
_draft_detail_keyboard,
|
||||||
_price_keyboard,
|
_price_keyboard,
|
||||||
_quantity_keyboard,
|
_quantity_keyboard,
|
||||||
|
_render_draft_detail,
|
||||||
|
_render_order_path,
|
||||||
_render_price_step_screen,
|
_render_price_step_screen,
|
||||||
_render_quantity_step_screen,
|
_render_quantity_step_screen,
|
||||||
_screen_title,
|
_screen_title,
|
||||||
|
_side_keyboard,
|
||||||
_trade_back_home_keyboard,
|
_trade_back_home_keyboard,
|
||||||
_type_keyboard,
|
_type_keyboard,
|
||||||
_side_keyboard,
|
|
||||||
)
|
)
|
||||||
from src.trading.orders.service import OrderDraftsService
|
from src.trading.orders.service import OrderDraftsService
|
||||||
from src.trading.orders.states import NewOrderDraftStates
|
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")
|
@router.callback_query(F.data == "order_back:side")
|
||||||
async def go_back_to_side(callback: CallbackQuery, state: FSMContext) -> None:
|
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)
|
await state.set_state(NewOrderDraftStates.waiting_side)
|
||||||
text = (
|
text = (
|
||||||
"<b>📊 Торговля — Новый ордер</b>\n"
|
"<b>📊 Торговля — Новый ордер</b>\n"
|
||||||
f"{mode_line()}"
|
f"{mode_line()}"
|
||||||
|
f"{context.symbol}\n\n"
|
||||||
"Шаг 1/4. Выбери сторону"
|
"Шаг 1/4. Выбери сторону"
|
||||||
)
|
)
|
||||||
await callback.message.edit_text(text, reply_markup=_side_keyboard())
|
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")
|
@router.callback_query(F.data == "order_back:type")
|
||||||
async def go_back_to_type(callback: CallbackQuery, state: FSMContext) -> None:
|
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)
|
await state.set_state(NewOrderDraftStates.waiting_type)
|
||||||
text = (
|
text = (
|
||||||
"<b>📊 Торговля — Новый ордер</b>\n"
|
"<b>📊 Торговля — Новый ордер</b>\n"
|
||||||
f"{mode_line()}"
|
f"{mode_line()}"
|
||||||
|
f"{context.symbol}\n\n"
|
||||||
|
f"{path}\n\n"
|
||||||
"Шаг 2/4. Выбери тип ордера"
|
"Шаг 2/4. Выбери тип ордера"
|
||||||
)
|
)
|
||||||
await callback.message.edit_text(text, reply_markup=_type_keyboard())
|
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")
|
@router.callback_query(F.data == "order_back:quantity")
|
||||||
async def go_back_to_quantity(callback: CallbackQuery, state: FSMContext) -> None:
|
async def go_back_to_quantity(callback: CallbackQuery, state: FSMContext) -> None:
|
||||||
|
"""
|
||||||
|
Возвращает пользователя на шаг выбора количества.
|
||||||
|
Используется как возврат со шага цены.
|
||||||
|
"""
|
||||||
service = OrderDraftsService()
|
service = OrderDraftsService()
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
|
|
||||||
side = data.get("side", "BUY")
|
side = data.get("side", "BUY")
|
||||||
order_type = data.get("order_type", "MARKET")
|
order_type = data.get("order_type", "MARKET")
|
||||||
|
quantity = data.get("quantity")
|
||||||
is_edit_mode = bool(data.get("draft_edit_id"))
|
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)
|
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 state.set_state(NewOrderDraftStates.waiting_quantity)
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
_render_quantity_step_screen(
|
_render_quantity_step_screen(
|
||||||
@@ -66,8 +139,13 @@ async def go_back_to_quantity(callback: CallbackQuery, state: FSMContext) -> Non
|
|||||||
available_balance=context.available_balance,
|
available_balance=context.available_balance,
|
||||||
balance_currency=context.balance_currency,
|
balance_currency=context.balance_currency,
|
||||||
reference_price=context.reference_price,
|
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()
|
await callback.answer()
|
||||||
|
|
||||||
@@ -90,29 +168,48 @@ async def go_back_from_confirm(callback: CallbackQuery, state: FSMContext) -> No
|
|||||||
|
|
||||||
side = confirm_draft["side"]
|
side = confirm_draft["side"]
|
||||||
order_type = confirm_draft["order_type"]
|
order_type = confirm_draft["order_type"]
|
||||||
|
quantity = confirm_draft.get("quantity")
|
||||||
is_edit_mode = bool(data.get("draft_edit_id"))
|
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":
|
if order_type == "LIMIT":
|
||||||
context = service.get_entry_context(side=side, order_type=order_type)
|
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 state.set_state(NewOrderDraftStates.waiting_price)
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
_render_price_step_screen(
|
_render_price_step_screen(
|
||||||
title=_screen_title(is_edit_mode),
|
title=_screen_title(is_edit_mode),
|
||||||
|
symbol=context.symbol,
|
||||||
bid=context.bid_price,
|
bid=context.bid_price,
|
||||||
ask=context.ask_price,
|
ask=context.ask_price,
|
||||||
last=context.last_price,
|
last=context.last_price,
|
||||||
|
quote_currency=context.quote_currency,
|
||||||
|
order_path=path,
|
||||||
),
|
),
|
||||||
reply_markup=_price_keyboard(
|
reply_markup=_price_keyboard(
|
||||||
bid=context.bid_price,
|
bid=context.bid_price,
|
||||||
ask=context.ask_price,
|
ask=context.ask_price,
|
||||||
last=context.last_price,
|
last=context.last_price,
|
||||||
|
drafts_page=drafts_page,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
return
|
return
|
||||||
|
|
||||||
context = service.get_entry_context(side=side, order_type=order_type)
|
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 state.set_state(NewOrderDraftStates.waiting_quantity)
|
||||||
await callback.message.edit_text(
|
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,
|
available_balance=context.available_balance,
|
||||||
balance_currency=context.balance_currency,
|
balance_currency=context.balance_currency,
|
||||||
reference_price=context.reference_price,
|
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()
|
await callback.answer()
|
||||||
|
|
||||||
@@ -138,9 +240,25 @@ async def go_back_from_manual_quantity(
|
|||||||
|
|
||||||
side = data.get("side", "BUY")
|
side = data.get("side", "BUY")
|
||||||
order_type = data.get("order_type", "MARKET")
|
order_type = data.get("order_type", "MARKET")
|
||||||
|
quantity = data.get("quantity")
|
||||||
is_edit_mode = bool(data.get("draft_edit_id"))
|
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)
|
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 state.set_state(NewOrderDraftStates.waiting_quantity)
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
@@ -150,8 +268,13 @@ async def go_back_from_manual_quantity(
|
|||||||
available_balance=context.available_balance,
|
available_balance=context.available_balance,
|
||||||
balance_currency=context.balance_currency,
|
balance_currency=context.balance_currency,
|
||||||
reference_price=context.reference_price,
|
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()
|
await callback.answer()
|
||||||
|
|
||||||
@@ -166,22 +289,35 @@ async def go_back_from_manual_price(
|
|||||||
|
|
||||||
side = data.get("side", "BUY")
|
side = data.get("side", "BUY")
|
||||||
order_type = data.get("order_type", "LIMIT")
|
order_type = data.get("order_type", "LIMIT")
|
||||||
|
quantity = data.get("quantity")
|
||||||
is_edit_mode = bool(data.get("draft_edit_id"))
|
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)
|
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 state.set_state(NewOrderDraftStates.waiting_price)
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
_render_price_step_screen(
|
_render_price_step_screen(
|
||||||
title=_screen_title(is_edit_mode),
|
title=_screen_title(is_edit_mode),
|
||||||
|
symbol=context.symbol,
|
||||||
bid=context.bid_price,
|
bid=context.bid_price,
|
||||||
ask=context.ask_price,
|
ask=context.ask_price,
|
||||||
last=context.last_price,
|
last=context.last_price,
|
||||||
|
quote_currency=context.quote_currency,
|
||||||
|
order_path=path,
|
||||||
),
|
),
|
||||||
reply_markup=_price_keyboard(
|
reply_markup=_price_keyboard(
|
||||||
bid=context.bid_price,
|
bid=context.bid_price,
|
||||||
ask=context.ask_price,
|
ask=context.ask_price,
|
||||||
last=context.last_price,
|
last=context.last_price,
|
||||||
|
drafts_page=drafts_page,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
@@ -14,6 +14,45 @@ from src.telegram.ui.common import mode_line
|
|||||||
from src.trading.orders.service import OrderDraftsService
|
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:
|
def _to_decimal(value: str | float | int | None) -> Decimal | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -36,6 +75,10 @@ def _format_decimal_text(value: Decimal) -> str:
|
|||||||
return text or "0"
|
return text or "0"
|
||||||
|
|
||||||
|
|
||||||
|
def _side_badge(side: str) -> str:
|
||||||
|
return "🟢 <b>BUY</b>" if side.upper() == "BUY" else "🔴 <b>SELL</b>"
|
||||||
|
|
||||||
|
|
||||||
# Оценивает минимально допустимое количество по правилу minNotional.
|
# Оценивает минимально допустимое количество по правилу minNotional.
|
||||||
def _estimate_min_quantity_by_notional(
|
def _estimate_min_quantity_by_notional(
|
||||||
*,
|
*,
|
||||||
@@ -77,7 +120,10 @@ def _type_keyboard() -> InlineKeyboardMarkup:
|
|||||||
return builder.as_markup()
|
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()
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
all_labels = ["1%", "5%", "10%", "25%", "50%", "100%"]
|
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_qty:manual")
|
||||||
builder.button(text="⬅️ Назад", callback_data="order_back:type")
|
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:
|
if len(presets) == 0:
|
||||||
builder.adjust(1, 2)
|
builder.adjust(1, 2)
|
||||||
@@ -102,39 +152,70 @@ def _quantity_keyboard(presets: list[str]) -> InlineKeyboardMarkup:
|
|||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def _quantity_manual_keyboard() -> InlineKeyboardMarkup:
|
def _quantity_manual_keyboard(
|
||||||
|
drafts_page: int | None = None,
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text="⬅️ Назад", callback_data="order_manual_back:quantity")
|
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)
|
builder.adjust(2)
|
||||||
return builder.as_markup()
|
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 = InlineKeyboardBuilder()
|
||||||
builder.button(text=f"Bid {bid:.2f}", callback_data=f"order_price:{bid}")
|
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"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=f"Last {last:.2f}", callback_data=f"order_price:{last}")
|
||||||
builder.button(text="✍️ Ввести вручную", callback_data="order_price:manual")
|
builder.button(text="✍️ Ввести вручную", callback_data="order_price:manual")
|
||||||
builder.button(text="⬅️ Назад", callback_data="order_back:quantity")
|
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)
|
builder.adjust(2, 2, 2)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def _price_manual_keyboard() -> InlineKeyboardMarkup:
|
def _price_manual_keyboard(
|
||||||
|
drafts_page: int | None = None,
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text="⬅️ Назад", callback_data="order_manual_back:price")
|
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)
|
builder.adjust(2)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def _confirm_keyboard() -> InlineKeyboardMarkup:
|
def _confirm_keyboard(
|
||||||
|
drafts_page: int | None = None,
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text="✅ Подтвердить", callback_data="order_confirm")
|
builder.button(text="✅ Подтвердить", callback_data="order_confirm")
|
||||||
builder.button(text="⬅️ Назад", callback_data="order_back: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)
|
builder.adjust(1, 2)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
@@ -147,7 +228,7 @@ def _trade_back_home_keyboard() -> InlineKeyboardMarkup:
|
|||||||
|
|
||||||
def _drafts_back_keyboard(page: int) -> InlineKeyboardMarkup:
|
def _drafts_back_keyboard(page: int) -> InlineKeyboardMarkup:
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text="⬅️ К черновикам", callback_data=f"drafts:{page}")
|
builder.button(text="📚 К черновикам", callback_data=f"drafts:{page}")
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
@@ -179,30 +260,39 @@ def _draft_detail_keyboard(draft_id: str, page: int) -> InlineKeyboardMarkup:
|
|||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text="✏️ Редактировать", callback_data=f"draft_edit:{draft_id}:{page}")
|
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"draft_delete:{draft_id}")
|
||||||
builder.button(text="⬅️ К черновикам", callback_data=f"drafts:{page}")
|
builder.button(text="📚 К черновикам", callback_data=f"drafts:{page}")
|
||||||
builder.adjust(2, 1)
|
builder.adjust(2, 1)
|
||||||
return builder.as_markup()
|
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:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
text = str(value).strip()
|
|
||||||
|
text = _clean_number(value, precision=2)
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return f"{text} {currency}" if currency else text
|
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:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
text = str(value).strip()
|
|
||||||
|
text = _clean_number(value)
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return f"{text} {asset}" if asset else text
|
return f"{text} {asset}" if asset else text
|
||||||
|
|
||||||
|
|
||||||
# Рендерит экран успешного сохранения черновика.
|
|
||||||
def _render_draft_summary(
|
def _render_draft_summary(
|
||||||
symbol: str,
|
symbol: str,
|
||||||
side: str,
|
side: str,
|
||||||
@@ -213,43 +303,47 @@ def _render_draft_summary(
|
|||||||
quote_currency: str | None = None,
|
quote_currency: str | None = None,
|
||||||
reference_price: str | None = None,
|
reference_price: str | None = None,
|
||||||
notional: float | None = None,
|
notional: float | None = None,
|
||||||
|
is_edit_mode: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
quantity_text = _format_value_with_asset(quantity, base_currency)
|
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 = [
|
lines = [
|
||||||
"<b>📊 Торговля — Черновик ордера</b>",
|
"<b>📊 Торговля — Черновик ордера</b>",
|
||||||
mode_line().rstrip(),
|
mode_line().rstrip(),
|
||||||
"",
|
"",
|
||||||
f"Инструмент: <b>{symbol}</b>",
|
f"{symbol}",
|
||||||
f"Сторона: <b>{side}</b>",
|
"",
|
||||||
f"Тип: <b>{order_type}</b>",
|
f"{side_line} · {order_type_text} · {quantity_text or quantity}",
|
||||||
f"Количество: <b>{quantity_text or quantity}</b>",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if price:
|
if price:
|
||||||
price_text = _format_value_with_currency(price, quote_currency)
|
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:
|
elif reference_price:
|
||||||
reference_price_text = _format_value_with_currency(reference_price, quote_currency)
|
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:
|
if notional is not None:
|
||||||
sum_text = _format_value_with_currency(f"{notional:.2f}", quote_currency)
|
notional_text = _format_value_with_currency(notional, quote_currency)
|
||||||
lines.append(f"Сумма: <b>{sum_text or f'{notional:.2f}'}</b>")
|
lines.append(f"Notional: {notional_text or str(notional)}")
|
||||||
|
|
||||||
lines.extend(
|
lines.extend(
|
||||||
[
|
[
|
||||||
|
"",
|
||||||
"Статус: <b>draft</b>",
|
"Статус: <b>draft</b>",
|
||||||
"",
|
"",
|
||||||
"<b>✅ Черновик создан</b>",
|
success_text,
|
||||||
"",
|
"",
|
||||||
"<i>Ордер не отправлялся на биржу</i>",
|
"<i>Ордер не отправлялся на биржу</i>",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
# Рендерит экран подтверждения черновика.
|
|
||||||
def _render_confirm(
|
def _render_confirm(
|
||||||
symbol: str,
|
symbol: str,
|
||||||
side: str,
|
side: str,
|
||||||
@@ -261,29 +355,31 @@ def _render_confirm(
|
|||||||
base_currency: str | None = None,
|
base_currency: str | None = None,
|
||||||
quote_currency: str | None = None,
|
quote_currency: str | None = None,
|
||||||
reference_price: str | None = None,
|
reference_price: str | None = None,
|
||||||
|
order_path: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
quantity_text = _format_value_with_asset(quantity, base_currency)
|
quantity_text = _format_value_with_asset(quantity, base_currency)
|
||||||
|
side_line = _side_badge(side)
|
||||||
|
order_type_text = order_type.upper()
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
_screen_title(is_edit_mode),
|
_screen_title(is_edit_mode),
|
||||||
mode_line().rstrip(),
|
mode_line().rstrip(),
|
||||||
"",
|
"",
|
||||||
f"Инструмент: <b>{symbol}</b>",
|
f"<b>{symbol}</b>",
|
||||||
f"Сторона: <b>{side}</b>",
|
"",
|
||||||
f"Тип: <b>{order_type}</b>",
|
f"{side_line} · {order_type_text} · {quantity_text or quantity}",
|
||||||
f"Количество: <b>{quantity_text or quantity}</b>",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if price:
|
if price:
|
||||||
price_text = _format_value_with_currency(price, quote_currency)
|
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:
|
elif reference_price:
|
||||||
reference_price_text = _format_value_with_currency(reference_price, quote_currency)
|
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:
|
if notional is not None:
|
||||||
sum_text = _format_value_with_currency(f"{notional:.2f}", quote_currency)
|
notional_text = _format_value_with_currency(notional, quote_currency)
|
||||||
lines.append(f"Сумма: <b>{sum_text or f'{notional:.2f}'}</b>")
|
lines.append(f"Notional: {notional_text or str(notional)}")
|
||||||
|
|
||||||
lines.extend(
|
lines.extend(
|
||||||
[
|
[
|
||||||
@@ -415,22 +511,33 @@ def _render_draft_detail(
|
|||||||
base_currency: str | None = None,
|
base_currency: str | None = None,
|
||||||
quote_currency: str | None = None,
|
quote_currency: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
quantity = _format_draft_quantity(draft["quantity"])
|
quantity = draft["quantity"]
|
||||||
created_at = _format_draft_time(draft["created_at"])
|
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)
|
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 = [
|
lines = [
|
||||||
"<b>📊 Торговля — Черновик</b>",
|
"<b>📊 Торговля — Черновик</b>",
|
||||||
mode_line().rstrip(),
|
mode_line().rstrip(),
|
||||||
f"Инструмент: <b>{draft['symbol']}</b>",
|
"",
|
||||||
f"Сторона: <b>{draft['side']}</b>",
|
f"<b>{draft['symbol']}</b>",
|
||||||
f"Тип: <b>{draft['order_type']}</b>",
|
"",
|
||||||
f"Количество: <b>{quantity_text or quantity}</b>",
|
f"{side_line} · {order_type} · {quantity_text or str(quantity)}",
|
||||||
]
|
]
|
||||||
|
|
||||||
if draft.get("price"):
|
if price_text:
|
||||||
price_text = _format_value_with_currency(draft["price"], quote_currency)
|
lines.append(f"Цена: {price_text}")
|
||||||
lines.append(f"Цена: <b>{price_text or draft['price']}</b>")
|
|
||||||
|
|
||||||
lines.extend(
|
lines.extend(
|
||||||
[
|
[
|
||||||
@@ -473,16 +580,66 @@ def _render_quantity_step_screen(
|
|||||||
balance_currency: str,
|
balance_currency: str,
|
||||||
reference_price: float,
|
reference_price: float,
|
||||||
quote_currency: str,
|
quote_currency: str,
|
||||||
|
order_path: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
return (
|
lines = [
|
||||||
f"{title}\n"
|
title,
|
||||||
f"{mode_line()}"
|
mode_line().rstrip(),
|
||||||
f"Инструмент: <b>{symbol}</b>\n"
|
"",
|
||||||
f"Доступно: <b>{available_balance:.8f} {balance_currency}</b>\n"
|
f"{symbol}",
|
||||||
f"Ориентир цены: <b>{reference_price:.2f} {quote_currency}</b>\n\n"
|
"",
|
||||||
"Шаг 3/4. Выбери количество"
|
]
|
||||||
|
|
||||||
|
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(
|
def _render_price_step_screen(
|
||||||
@@ -492,10 +649,14 @@ def _render_price_step_screen(
|
|||||||
ask: float,
|
ask: float,
|
||||||
last: float,
|
last: float,
|
||||||
quote_currency: str,
|
quote_currency: str,
|
||||||
|
symbol: str,
|
||||||
|
order_path: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
return (
|
return (
|
||||||
f"{title}\n"
|
f"{title}\n"
|
||||||
f"{mode_line()}"
|
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"Bid: <b>{bid:.2f} {quote_currency}</b>\n"
|
||||||
f"Ask: <b>{ask:.2f} {quote_currency}</b>\n"
|
f"Ask: <b>{ask:.2f} {quote_currency}</b>\n"
|
||||||
f"Last: <b>{last:.2f} {quote_currency}</b>\n\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(
|
def _render_manual_quantity_screen(
|
||||||
*,
|
*,
|
||||||
title: str,
|
title: str,
|
||||||
|
symbol: str,
|
||||||
reference_price: float | None,
|
reference_price: float | None,
|
||||||
quote_currency: str | None,
|
quote_currency: str | None,
|
||||||
min_qty: str | None,
|
min_qty: str | None,
|
||||||
step_size: str | None,
|
step_size: str | None,
|
||||||
min_notional: str | None,
|
min_notional: str | None,
|
||||||
example: str,
|
example: str,
|
||||||
|
order_path: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
estimated_min_qty = _estimate_min_quantity_by_notional(
|
estimated_min_qty = _estimate_min_quantity_by_notional(
|
||||||
reference_price=reference_price,
|
reference_price=reference_price,
|
||||||
@@ -532,8 +730,13 @@ def _render_manual_quantity_screen(
|
|||||||
lines = [
|
lines = [
|
||||||
title,
|
title,
|
||||||
mode_line().rstrip(),
|
mode_line().rstrip(),
|
||||||
|
symbol,
|
||||||
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if order_path:
|
||||||
|
lines.extend([order_path, ""])
|
||||||
|
|
||||||
if reference_price is not None and reference_price > 0:
|
if reference_price is not None and reference_price > 0:
|
||||||
lines.extend(
|
lines.extend(
|
||||||
[
|
[
|
||||||
@@ -576,23 +779,42 @@ def _render_manual_quantity_screen(
|
|||||||
def _render_manual_price_screen(
|
def _render_manual_price_screen(
|
||||||
*,
|
*,
|
||||||
title: str,
|
title: str,
|
||||||
|
symbol: str,
|
||||||
tick_size: str | None,
|
tick_size: str | None,
|
||||||
example: str,
|
example: str,
|
||||||
quote_currency: str | None,
|
quote_currency: str | None,
|
||||||
|
order_path: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
return (
|
lines = [
|
||||||
f"{title}\n"
|
title,
|
||||||
f"{mode_line()}"
|
mode_line().rstrip(),
|
||||||
f"{_render_price_input_help(
|
f"{symbol}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
if order_path:
|
||||||
|
lines.append(order_path)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append(
|
||||||
|
_render_price_input_help(
|
||||||
tick_size=tick_size,
|
tick_size=tick_size,
|
||||||
example=example,
|
example=example,
|
||||||
quote_currency=quote_currency,
|
quote_currency=quote_currency,
|
||||||
)}\n\n"
|
)
|
||||||
"Шаг 4/4. Введи цену"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"Шаг 4/4. Введи цену",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
# Показывает список последних черновиков с пагинацией.
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# Показывает компактный список последних черновиков с пагинацией.
|
||||||
async def show_recent_drafts(
|
async def show_recent_drafts(
|
||||||
message: Message,
|
message: Message,
|
||||||
edit_mode: bool = False,
|
edit_mode: bool = False,
|
||||||
@@ -630,27 +852,31 @@ async def show_recent_drafts(
|
|||||||
|
|
||||||
details_builder = InlineKeyboardBuilder()
|
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"])
|
quantity = _format_draft_quantity(item["quantity"])
|
||||||
created_at = _format_draft_time(item["created_at"])
|
created_at = _format_draft_time(item["created_at"])
|
||||||
|
|
||||||
lines.extend(
|
base_currency, _quote_currency = _resolve_symbol_assets(str(item["symbol"]))
|
||||||
[
|
|
||||||
f"<b>{item['symbol']}</b>",
|
quantity_text = _format_value_with_asset(quantity, base_currency)
|
||||||
f"{item['side']} · {item['order_type']}",
|
side_line = _side_badge(str(item["side"]))
|
||||||
f"Количество: <b>{quantity}</b>",
|
order_type = str(item["order_type"]).upper()
|
||||||
f"Статус: <b>{item['status']}</b>",
|
|
||||||
f"Время: <b>{created_at}</b>",
|
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(
|
details_builder.button(
|
||||||
text=f"📄 {item['symbol']} {item['side']}",
|
text=str(global_idx),
|
||||||
callback_data=f"draft_open:{item['id']}:{page}",
|
callback_data=f"draft_open:{item['id']}:{page}",
|
||||||
)
|
)
|
||||||
|
|
||||||
details_builder.adjust(1)
|
details_builder.adjust(3)
|
||||||
|
|
||||||
pagination_markup = _drafts_pagination_keyboard(page, total_pages)
|
pagination_markup = _drafts_pagination_keyboard(page, total_pages)
|
||||||
details_builder.attach(InlineKeyboardBuilder.from_markup(pagination_markup))
|
details_builder.attach(InlineKeyboardBuilder.from_markup(pagination_markup))
|
||||||
@@ -661,4 +887,69 @@ async def show_recent_drafts(
|
|||||||
if edit_mode:
|
if edit_mode:
|
||||||
await message.edit_text(text, reply_markup=keyboard)
|
await message.edit_text(text, reply_markup=keyboard)
|
||||||
else:
|
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 __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.core.config import load_settings
|
||||||
from src.integrations.exchange.models import ExchangeSymbol
|
from src.integrations.exchange.models import ExchangeSymbol
|
||||||
@@ -181,8 +181,6 @@ class OrderDraftsService:
|
|||||||
return self.repository.get_draft_by_id(draft_id)
|
return self.repository.get_draft_by_id(draft_id)
|
||||||
|
|
||||||
def get_entry_context(self, *, side: str, order_type: str) -> OrderEntryContext:
|
def get_entry_context(self, *, side: str, order_type: str) -> OrderEntryContext:
|
||||||
# Собираем контекст экрана ввода ордера на основе биржевых правил,
|
|
||||||
# текущего рынка и доступного баланса.
|
|
||||||
validation = self.exchange.validate_symbol(self.settings.default_symbol)
|
validation = self.exchange.validate_symbol(self.settings.default_symbol)
|
||||||
if not validation.is_valid or validation.symbol_info is None:
|
if not validation.is_valid or validation.symbol_info is None:
|
||||||
raise ValueError(validation.message)
|
raise ValueError(validation.message)
|
||||||
@@ -307,6 +305,103 @@ class OrderDraftsService:
|
|||||||
|
|
||||||
return errors
|
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(
|
def _build_quantity_presets(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -452,6 +547,13 @@ class OrderDraftsService:
|
|||||||
ratio = (value / step).to_integral_value(rounding=ROUND_DOWN)
|
ratio = (value / step).to_integral_value(rounding=ROUND_DOWN)
|
||||||
return ratio * step
|
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(
|
def _normalize_quantity_to_exchange_rules(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -498,7 +600,9 @@ class OrderDraftsService:
|
|||||||
price: str | None = None,
|
price: str | None = None,
|
||||||
) -> Decimal | None:
|
) -> Decimal | None:
|
||||||
if order_type.upper() == "LIMIT":
|
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:
|
try:
|
||||||
market = self.exchange.get_market_snapshot(self.settings.default_symbol)
|
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