Files
dzentra_bot/app/src/telegram/handlers/trade/new_order.py

1218 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# app/src/telegram/handlers/trade/new_order.py
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.trading.orders.service import OrderDraftsService
from src.trading.orders.states import NewOrderDraftStates
router = Router(name="trade_new_order")
DRAFTS_PAGE_SIZE = 3
def _side_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🟢 BUY", callback_data="order_side:BUY")
builder.button(text="🔴 SELL", callback_data="order_side:SELL")
builder.button(text="🏠 К торговле", callback_data="trade:home")
builder.adjust(2, 1)
return builder.as_markup()
def _type_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="⚡ MARKET", callback_data="order_type:MARKET")
builder.button(text="🎯 LIMIT", callback_data="order_type:LIMIT")
builder.button(text="⬅️ Назад", callback_data="order_back:side")
builder.button(text="🏠 К торговле", callback_data="trade:home")
builder.adjust(2, 2)
return builder.as_markup()
def _mode_line() -> str:
from src.core.system_status import get_runtime_mode_label
return f"Режим: <b>{get_runtime_mode_label()}</b>\n\n"
def _quantity_keyboard(presets: list[str]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
all_labels = ["1%", "5%", "10%", "25%", "50%", "100%"]
labels = all_labels[: len(presets)]
for label, value in zip(labels, presets):
builder.button(text=label, callback_data=f"order_qty:{value}")
builder.button(text="✍️ Ввести вручную", callback_data="order_qty:manual")
builder.button(text="⬅️ Назад", callback_data="order_back:type")
builder.button(text="🏠 К торговле", callback_data="trade:home")
if len(presets) == 0:
builder.adjust(1, 2)
elif len(presets) <= 4:
builder.adjust(2, 2, 1, 2)
elif len(presets) == 5:
builder.adjust(3, 2, 1, 2)
else:
builder.adjust(3, 3, 1, 2)
return builder.as_markup()
def _quantity_manual_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="⬅️ Назад", callback_data="order_manual_back:quantity")
builder.button(text="🏠 К торговле", callback_data="trade:home")
builder.adjust(2)
return builder.as_markup()
def _price_keyboard(bid: float, ask: float, last: float) -> 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")
builder.adjust(2, 2, 2)
return builder.as_markup()
def _price_manual_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="⬅️ Назад", callback_data="order_manual_back:price")
builder.button(text="🏠 К торговле", callback_data="trade:home")
builder.adjust(2)
return builder.as_markup()
def _confirm_keyboard() -> 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")
builder.adjust(1, 2)
return builder.as_markup()
def _trade_back_home_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🏠 К торговле", callback_data="trade:home")
return builder.as_markup()
def _drafts_back_keyboard(page: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="⬅️ К черновикам", callback_data=f"drafts:{page}")
return builder.as_markup()
def _drafts_pagination_keyboard(page: int, total_pages: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
if page > 1:
builder.button(text="⏮️", callback_data="drafts:1")
builder.button(text="⬅️", callback_data=f"drafts:{page - 1}")
builder.button(text=f"{page}/{total_pages}", callback_data="drafts:noop")
if page < total_pages:
builder.button(text="➡️", callback_data=f"drafts:{page + 1}")
first_row_count = 1
if page > 1:
first_row_count += 2
if page < total_pages:
first_row_count += 1
builder.button(text="🏠 К торговле", callback_data="trade:home")
builder.adjust(first_row_count, 1)
return builder.as_markup()
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.adjust(2, 1)
return builder.as_markup()
def _render_draft_summary(
symbol: str,
side: str,
order_type: str,
quantity: str,
price: str | None,
) -> str:
lines = [
"<b>📊 Торговля — Черновик ордера</b>",
_mode_line().rstrip(),
"",
f"Инструмент: <b>{symbol}</b>",
f"Сторона: <b>{side}</b>",
f"Тип: <b>{order_type}</b>",
f"Количество: <b>{quantity}</b>",
]
if price:
lines.append(f"Цена: <b>{price}</b>")
lines.extend(
[
"Статус: <b>draft</b>",
"",
"<b>✅ Черновик создан</b>",
"",
"<i>Ордер не отправлялся на биржу</i>",
]
)
return "\n".join(lines)
def _render_confirm(
symbol: str,
side: str,
order_type: str,
quantity: str,
price: str | None,
notional: float | None,
is_edit_mode: bool = False,
) -> str:
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}</b>",
]
if price:
lines.append(f"Цена: <b>{price}</b>")
if notional is not None:
lines.append(f"Сумма: <b>{notional:.2f}</b>")
lines.extend(
[
"",
"Шаг 4/4. Подтверди черновик",
]
)
return "\n".join(lines)
def _render_validation_error(errors: list[str]) -> str:
lines = [
"<b>📊 Торговля — Ошибка валидации</b>",
_mode_line().rstrip(),
"Шаг 4/4. Проверь параметры черновика",
"",
"<b>❌ Черновик не сохранён</b>",
"",
]
for item in errors:
lines.append(f"{item}")
return "\n".join(lines)
def _render_inline_error(
title: str,
step_text: str,
errors: list[str],
help_text: str | None = None,
) -> str:
lines = [
title,
_mode_line().rstrip(),
step_text,
"",
"<b>⚠️ Найдены ошибки</b>",
"",
]
for item in errors:
lines.append(f"{item}")
if help_text:
lines.extend(["", help_text])
return "\n".join(lines)
def _render_quantity_input_help(
*,
min_qty: str | None,
step_size: str | None,
min_notional: str | None,
example: str,
) -> str:
lines = [
"<b>📏 Правила ввода количества</b>",
"",
]
if min_qty:
lines.append(f"• минимум: <b>{min_qty}</b>")
if step_size:
lines.append(f"• шаг: <b>{step_size}</b>")
if min_notional:
lines.append(f"• мин. сумма: <b>{min_notional}</b>")
lines.extend(["", f"Пример: <b>{example}</b>"])
return "\n".join(lines)
def _render_price_input_help(
*,
tick_size: str | None,
example: str,
) -> str:
lines = [
"<b>📏 Правила ввода цены</b>",
"",
]
if tick_size:
lines.append(f"• шаг цены: <b>{tick_size}</b>")
lines.extend(["", f"Пример: <b>{example}</b>"])
return "\n".join(lines)
def _render_draft_detail(draft: dict[str, str]) -> str:
quantity = _format_draft_quantity(draft["quantity"])
created_at = _format_draft_time(draft["created_at"])
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}</b>",
]
if draft.get("price"):
lines.append(f"Цена: <b>{draft['price']}</b>")
lines.extend(
[
f"Статус: <b>{draft['status']}</b>",
f"Время: <b>{created_at}</b>",
]
)
return "\n".join(lines)
def _format_draft_time(value: str) -> str:
try:
dt = datetime.fromisoformat(str(value))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
local_dt = dt.astimezone(ZoneInfo("Europe/Minsk"))
return local_dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return str(value)
def _format_draft_quantity(value: str) -> str:
text = str(value).rstrip("0").rstrip(".")
return text or "0"
def _screen_title(is_edit_mode: bool) -> str:
if is_edit_mode:
return "<b>📊 Торговля — Редактирование черновика</b>"
return "<b>📊 Торговля — Новый ордер</b>"
async def show_recent_drafts(
message: Message,
edit_mode: bool = False,
page: int = 1,
) -> None:
service = OrderDraftsService()
all_drafts = service.list_recent_drafts(limit=100)
total = len(all_drafts)
total_pages = max(1, (total + DRAFTS_PAGE_SIZE - 1) // DRAFTS_PAGE_SIZE)
page = max(1, min(page, total_pages))
start = (page - 1) * DRAFTS_PAGE_SIZE
end = start + DRAFTS_PAGE_SIZE
drafts = all_drafts[start:end]
# --- если нет черновиков ---
if not drafts:
text = (
"<b>📊 Торговля — Черновики</b>\n"
f"{_mode_line()}"
"<b>Список пуст</b>\n\n"
"Черновиков пока нет."
)
if edit_mode:
await message.edit_text(text, reply_markup=_trade_back_home_keyboard())
else:
await message.answer(text, reply_markup=_trade_back_home_keyboard())
return
# --- список черновиков ---
lines = [
"<b>📊 Торговля — Черновики</b>",
_mode_line().rstrip(),
"",
]
details_builder = InlineKeyboardBuilder()
for item in drafts:
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>",
"",
]
)
details_builder.button(
text=f"📄 {item['symbol']} {item['side']}",
callback_data=f"draft_open:{item['id']}:{page}",
)
details_builder.adjust(1)
# пагинация + кнопка домой
pagination_markup = _drafts_pagination_keyboard(page, total_pages)
details_builder.attach(InlineKeyboardBuilder.from_markup(pagination_markup))
text = "\n".join(lines).rstrip()
keyboard = details_builder.as_markup()
if edit_mode:
await message.edit_text(text, reply_markup=keyboard)
else:
await message.answer(text, reply_markup=keyboard)
@router.callback_query(F.data == "drafts:noop")
async def drafts_noop(callback: CallbackQuery) -> None:
await callback.answer()
@router.callback_query(F.data.startswith("drafts:"))
async def paginate_drafts(callback: CallbackQuery) -> None:
value = callback.data.split(":", 1)[1]
if value == "noop":
await callback.answer()
return
page = int(value)
await callback.answer()
if callback.message is not None:
await show_recent_drafts(callback.message, edit_mode=True, page=page)
@router.callback_query(F.data.startswith("draft_open:"))
async def open_draft(callback: CallbackQuery) -> None:
_, draft_id, page_raw = callback.data.split(":", 2)
page = int(page_raw)
service = OrderDraftsService()
draft = service.get_draft_by_id(draft_id)
if not draft:
await callback.answer("Черновик не найден", show_alert=True)
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.startswith("draft_edit:"))
async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None:
_, draft_id, page_raw = callback.data.split(":", 2)
page = int(page_raw)
service = OrderDraftsService()
draft = service.get_draft_by_id(draft_id)
if not draft:
await callback.answer("Черновик не найден", show_alert=True)
return
side = str(draft["side"]).upper()
order_type = str(draft["order_type"]).upper()
quantity = str(draft["quantity"])
price = str(draft.get("price") or "")
await state.clear()
await state.update_data(
draft_edit_id=draft_id,
draft_edit_page=page,
side=side,
order_type=order_type,
quantity=quantity,
)
title = _screen_title(is_edit_mode=True)
if order_type == "LIMIT":
context = service.get_entry_context(side=side, order_type=order_type)
await state.set_state(NewOrderDraftStates.waiting_price)
await callback.message.edit_text(
f"{title}\n"
f"{_mode_line()}"
f"Bid: <b>{context.bid_price:.2f}</b>\n"
f"Ask: <b>{context.ask_price:.2f}</b>\n"
f"Last: <b>{context.last_price:.2f}</b>\n\n"
"Шаг 4/4. Выбери цену",
reply_markup=_price_keyboard(
bid=context.bid_price,
ask=context.ask_price,
last=context.last_price,
),
)
await callback.answer()
return
context = service.get_entry_context(side=side, order_type=order_type)
await state.set_state(NewOrderDraftStates.waiting_quantity)
await callback.message.edit_text(
f"{title}\n"
f"{_mode_line()}"
f"Инструмент: <b>{context.symbol}</b>\n"
f"Доступно: <b>{context.available_balance:.8f} {context.balance_currency}</b>\n"
f"Ориентир цены: <b>{context.reference_price:.2f}</b>\n\n"
"Шаг 3/4. Выбери количество",
reply_markup=_quantity_keyboard(context.quantity_presets),
)
await callback.answer()
@router.callback_query(F.data.startswith("draft_delete:"))
async def delete_draft_stub(callback: CallbackQuery) -> None:
await callback.answer("Удаление скоро появится")
@router.message(Command("cancel_order"))
async def cancel_order_builder(message: Message, state: FSMContext) -> None:
await state.clear()
await message.answer(
"<b>📊 Торговля — Новый ордер</b>\n"
f"{_mode_line()}"
"<b>⛔ Создание черновика отменено</b>",
reply_markup=_trade_back_home_keyboard(),
)
@router.message(Command("new_order"))
async def start_new_order_draft(
message: Message,
state: FSMContext,
edit_mode: bool = False,
) -> None:
await state.clear()
await state.set_state(NewOrderDraftStates.waiting_side)
text = (
"<b>📊 Торговля — Новый ордер</b>\n"
f"{_mode_line()}"
"Шаг 1/4. Выбери сторону"
)
if edit_mode:
await message.edit_text(text, reply_markup=_side_keyboard())
else:
await message.answer(text, reply_markup=_side_keyboard())
@router.callback_query(F.data == "order_back:side")
async def go_back_to_side(callback: CallbackQuery, state: FSMContext) -> None:
await state.set_state(NewOrderDraftStates.waiting_side)
text = (
"<b>📊 Торговля — Новый ордер</b>\n"
f"{_mode_line()}"
"Шаг 1/4. Выбери сторону"
)
await callback.message.edit_text(
text,
reply_markup=_side_keyboard(),
)
await callback.answer()
@router.callback_query(F.data == "order_back:type")
async def go_back_to_type(callback: CallbackQuery, state: FSMContext) -> None:
await state.set_state(NewOrderDraftStates.waiting_type)
text = (
"<b>📊 Торговля — Новый ордер</b>\n"
f"{_mode_line()}"
"Шаг 2/4. Выбери тип ордера"
)
await callback.message.edit_text(
text,
reply_markup=_type_keyboard(),
)
await callback.answer()
@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")
is_edit_mode = bool(data.get("draft_edit_id"))
context = service.get_entry_context(side=side, order_type=order_type)
await state.set_state(NewOrderDraftStates.waiting_quantity)
await callback.message.edit_text(
f"{_screen_title(is_edit_mode)}\n"
f"{_mode_line()}"
f"Инструмент: <b>{context.symbol}</b>\n"
f"Доступно: <b>{context.available_balance:.8f} {context.balance_currency}</b>\n"
f"Ориентир цены: <b>{context.reference_price:.2f}</b>\n\n"
"Шаг 3/4. Выбери количество",
reply_markup=_quantity_keyboard(context.quantity_presets),
)
await callback.answer()
@router.callback_query(F.data == "order_back:confirm")
async def go_back_from_confirm(callback: CallbackQuery, state: FSMContext) -> None:
service = OrderDraftsService()
data = await state.get_data()
confirm_draft = data.get("confirm_draft")
if not confirm_draft:
await state.clear()
await callback.message.edit_text(
"<b>📊 Торговля</b>\n\n"
"Не удалось восстановить шаг подтверждения.",
reply_markup=_trade_back_home_keyboard(),
)
await callback.answer()
return
side = confirm_draft["side"]
order_type = confirm_draft["order_type"]
is_edit_mode = bool(data.get("draft_edit_id"))
if order_type == "LIMIT":
context = service.get_entry_context(side=side, order_type=order_type)
await state.set_state(NewOrderDraftStates.waiting_price)
await callback.message.edit_text(
f"{_screen_title(is_edit_mode)}\n"
f"{_mode_line()}"
f"Bid: <b>{context.bid_price:.2f}</b>\n"
f"Ask: <b>{context.ask_price:.2f}</b>\n"
f"Last: <b>{context.last_price:.2f}</b>\n\n"
"Шаг 4/4. Выбери цену",
reply_markup=_price_keyboard(
bid=context.bid_price,
ask=context.ask_price,
last=context.last_price,
),
)
await callback.answer()
return
context = service.get_entry_context(side=side, order_type=order_type)
await state.set_state(NewOrderDraftStates.waiting_quantity)
await callback.message.edit_text(
f"{_screen_title(is_edit_mode)}\n"
f"{_mode_line()}"
f"Инструмент: <b>{context.symbol}</b>\n"
f"Доступно: <b>{context.available_balance:.8f} {context.balance_currency}</b>\n"
f"Ориентир цены: <b>{context.reference_price:.2f}</b>\n\n"
"Шаг 3/4. Выбери количество",
reply_markup=_quantity_keyboard(context.quantity_presets),
)
await callback.answer()
@router.callback_query(F.data == "order_manual_back:quantity")
async def go_back_from_manual_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")
is_edit_mode = bool(data.get("draft_edit_id"))
context = service.get_entry_context(side=side, order_type=order_type)
await state.set_state(NewOrderDraftStates.waiting_quantity)
await callback.message.edit_text(
f"{_screen_title(is_edit_mode)}\n"
f"{_mode_line()}"
f"Инструмент: <b>{context.symbol}</b>\n"
f"Доступно: <b>{context.available_balance:.8f} {context.balance_currency}</b>\n"
f"Ориентир цены: <b>{context.reference_price:.2f}</b>\n\n"
"Шаг 3/4. Выбери количество",
reply_markup=_quantity_keyboard(context.quantity_presets),
)
await callback.answer()
@router.callback_query(F.data == "order_manual_back:price")
async def go_back_from_manual_price(
callback: CallbackQuery,
state: FSMContext,
) -> None:
service = OrderDraftsService()
data = await state.get_data()
side = data.get("side", "BUY")
order_type = data.get("order_type", "LIMIT")
is_edit_mode = bool(data.get("draft_edit_id"))
context = service.get_entry_context(side=side, order_type=order_type)
await state.set_state(NewOrderDraftStates.waiting_price)
await callback.message.edit_text(
f"{_screen_title(is_edit_mode)}\n"
f"{_mode_line()}"
f"Bid: <b>{context.bid_price:.2f}</b>\n"
f"Ask: <b>{context.ask_price:.2f}</b>\n"
f"Last: <b>{context.last_price:.2f}</b>\n\n"
"Шаг 4/4. Выбери цену",
reply_markup=_price_keyboard(
bid=context.bid_price,
ask=context.ask_price,
last=context.last_price,
),
)
await callback.answer()
@router.callback_query(
NewOrderDraftStates.waiting_side,
F.data.startswith("order_side:"),
)
async def process_order_side_callback(
callback: CallbackQuery,
state: FSMContext,
) -> None:
side = callback.data.split(":", 1)[1]
await state.update_data(side=side)
await state.set_state(NewOrderDraftStates.waiting_type)
text = (
"<b>📊 Торговля — Новый ордер</b>\n"
f"{_mode_line()}"
"Шаг 2/4. Выбери тип ордера"
)
await callback.message.edit_text(
text,
reply_markup=_type_keyboard(),
)
await callback.answer()
@router.message(NewOrderDraftStates.waiting_side)
async def process_order_side_text(message: Message) -> None:
await message.answer(
"Пожалуйста, используйте кнопки для выбора стороны.",
reply_markup=_side_keyboard(),
)
@router.callback_query(
NewOrderDraftStates.waiting_type,
F.data.startswith("order_type:"),
)
async def process_order_type_callback(
callback: CallbackQuery,
state: FSMContext,
) -> None:
order_type = callback.data.split(":", 1)[1]
service = OrderDraftsService()
data = await state.get_data()
side = data.get("side", "BUY")
is_edit_mode = bool(data.get("draft_edit_id"))
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)
await callback.message.edit_text(
f"{_screen_title(is_edit_mode)}\n"
f"{_mode_line()}"
f"Инструмент: <b>{context.symbol}</b>\n"
f"Доступно: <b>{context.available_balance:.8f} {context.balance_currency}</b>\n"
f"Ориентир цены: <b>{context.reference_price:.2f}</b>\n\n"
"Шаг 3/4. Выбери количество",
reply_markup=_quantity_keyboard(context.quantity_presets),
)
await callback.answer()
@router.message(NewOrderDraftStates.waiting_type)
async def process_order_type_text(message: Message) -> None:
await message.answer(
"Пожалуйста, используйте кнопки для выбора типа ордера.",
reply_markup=_type_keyboard(),
)
@router.callback_query(
NewOrderDraftStates.waiting_quantity,
F.data.startswith("order_qty:"),
)
async def process_quantity_callback(
callback: CallbackQuery,
state: FSMContext,
) -> None:
value = callback.data.split(":", 1)[1]
data = await state.get_data()
is_edit_mode = bool(data.get("draft_edit_id"))
title = _screen_title(is_edit_mode)
service = OrderDraftsService()
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"
await callback.message.edit_text(
f"{title}\n"
f"{_mode_line()}"
"Шаг 3/4. Введи количество\n\n"
f"{_render_quantity_input_help(
min_qty=rules['min_qty'],
step_size=rules['step_size'],
min_notional=rules['min_notional'],
example=quantity_example,
)}",
reply_markup=_quantity_manual_keyboard(),
)
await callback.answer()
return
quantity = service.normalize_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)
if order_type == "LIMIT":
context = service.get_entry_context(side=data["side"], order_type=order_type)
await state.set_state(NewOrderDraftStates.waiting_price)
await callback.message.edit_text(
f"{title}\n"
f"{_mode_line()}"
f"Bid: <b>{context.bid_price:.2f}</b>\n"
f"Ask: <b>{context.ask_price:.2f}</b>\n"
f"Last: <b>{context.last_price:.2f}</b>\n\n"
"Шаг 4/4. Выбери цену",
reply_markup=_price_keyboard(
bid=context.bid_price,
ask=context.ask_price,
last=context.last_price,
),
)
await callback.answer()
return
draft = service.build_draft(
side=data["side"],
order_type=order_type,
quantity=quantity,
)
notional = service.calculate_notional(quantity, None)
await state.update_data(
confirm_draft={
"symbol": draft.symbol,
"side": draft.side,
"order_type": draft.order_type,
"quantity": draft.quantity,
"price": draft.price,
}
)
await state.set_state(NewOrderDraftStates.waiting_confirm)
await callback.message.edit_text(
_render_confirm(
symbol=draft.symbol,
side=draft.side,
order_type=draft.order_type,
quantity=draft.quantity,
price=draft.price,
notional=notional,
is_edit_mode=is_edit_mode,
),
reply_markup=_confirm_keyboard(),
)
await callback.answer()
@router.message(NewOrderDraftStates.waiting_quantity)
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)
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"],
min_notional=rules["min_notional"],
example=quantity_example,
)
if quantity is None:
await message.answer(
_render_inline_error(
title=title,
step_text="Шаг 3/4. Проверь введённое значение",
errors=[...],
help_text=help_text,
),
reply_markup=_quantity_manual_keyboard(),
)
return
quantity_errors = service.validate_entry_quantity(
side=side,
order_type=order_type,
quantity=quantity,
price=None,
)
if quantity_errors:
await message.answer(
_render_inline_error(
title=title,
errors=quantity_errors,
help_text=help_text,
),
reply_markup=_quantity_manual_keyboard(),
)
return
await state.update_data(quantity=quantity)
if order_type == "LIMIT":
await state.set_state(NewOrderDraftStates.waiting_price)
await message.answer(
f"{title}\n"
f"{_mode_line()}"
f"Bid: <b>{context.bid_price:.2f}</b>\n"
f"Ask: <b>{context.ask_price:.2f}</b>\n"
f"Last: <b>{context.last_price:.2f}</b>\n\n"
"Шаг 4/4. Выбери цену",
reply_markup=_price_keyboard(
bid=context.bid_price,
ask=context.ask_price,
last=context.last_price,
),
)
return
draft = service.build_draft(
side=side,
order_type=order_type,
quantity=quantity,
)
notional = service.calculate_notional(quantity, None)
await state.update_data(
confirm_draft={
"symbol": draft.symbol,
"side": draft.side,
"order_type": draft.order_type,
"quantity": draft.quantity,
"price": draft.price,
}
)
await state.set_state(NewOrderDraftStates.waiting_confirm)
await message.answer(
_render_confirm(
symbol=draft.symbol,
side=draft.side,
order_type=draft.order_type,
quantity=draft.quantity,
price=draft.price,
notional=notional,
is_edit_mode=is_edit_mode,
),
reply_markup=_confirm_keyboard(),
)
@router.callback_query(
NewOrderDraftStates.waiting_price,
F.data.startswith("order_price:"),
)
async def process_price_callback(
callback: CallbackQuery,
state: FSMContext,
) -> None:
value = callback.data.split(":", 1)[1]
data = await state.get_data()
is_edit_mode = bool(data.get("draft_edit_id"))
title = _screen_title(is_edit_mode)
service = OrderDraftsService()
if value == "manual":
rules = service.get_entry_rules()
context = service.get_entry_context(
side=data.get("side", "BUY"),
order_type=data.get("order_type", "LIMIT"),
)
price_example = f"{context.last_price:.2f}"
await callback.message.edit_text(
f"{title}\n"
f"{_mode_line()}"
"Шаг 4/4. Введи цену\n\n"
f"{_render_price_input_help(
tick_size=rules['tick_size'],
example=price_example,
)}",
reply_markup=_price_manual_keyboard(),
)
await callback.answer()
return
price = service.normalize_price(value)
if price is None:
await callback.answer("Некорректная цена.", show_alert=True)
return
draft = service.build_draft(
side=data["side"],
order_type=data["order_type"],
quantity=data["quantity"],
price=price,
)
notional = service.calculate_notional(data["quantity"], price)
await state.update_data(
confirm_draft={
"symbol": draft.symbol,
"side": draft.side,
"order_type": draft.order_type,
"quantity": draft.quantity,
"price": draft.price,
}
)
await state.set_state(NewOrderDraftStates.waiting_confirm)
await callback.message.edit_text(
_render_confirm(
symbol=draft.symbol,
side=draft.side,
order_type=draft.order_type,
quantity=draft.quantity,
price=draft.price,
notional=notional,
is_edit_mode=is_edit_mode,
),
reply_markup=_confirm_keyboard(),
)
await callback.answer()
@router.message(NewOrderDraftStates.waiting_price)
async def process_order_price(message: Message, state: FSMContext) -> None:
service = OrderDraftsService()
raw_price = message.text or ""
price = service.normalize_price(raw_price)
data = await state.get_data()
is_edit_mode = bool(data.get("draft_edit_id"))
title = _screen_title(is_edit_mode)
rules = service.get_entry_rules()
context = service.get_entry_context(
side=data.get("side", "BUY"),
order_type=data.get("order_type", "LIMIT"),
)
price_example = f"{context.last_price:.2f}"
help_text = _render_price_input_help(
tick_size=rules["tick_size"],
example=price_example,
)
if price is None:
await message.answer(
_render_inline_error(
title=title,
step_text="Шаг 4/4. Проверь введённое значение",
errors=[...],
help_text=help_text,
),
reply_markup=_price_manual_keyboard(),
)
return
draft = service.build_draft(
side=data["side"],
order_type=data["order_type"],
quantity=data["quantity"],
price=price,
)
validation = service.validate_draft(draft)
if not validation.is_valid:
await message.answer(
_render_inline_error(
title=title,
errors=validation.errors,
help_text=help_text,
),
reply_markup=_price_manual_keyboard(),
)
return
notional = service.calculate_notional(data["quantity"], price)
await state.update_data(
confirm_draft={
"symbol": draft.symbol,
"side": draft.side,
"order_type": draft.order_type,
"quantity": draft.quantity,
"price": draft.price,
}
)
await state.set_state(NewOrderDraftStates.waiting_confirm)
await message.answer(
_render_confirm(
symbol=draft.symbol,
side=draft.side,
order_type=draft.order_type,
quantity=draft.quantity,
price=draft.price,
notional=notional,
is_edit_mode=is_edit_mode,
),
reply_markup=_confirm_keyboard(),
)
@router.message(Command("drafts"))
async def drafts_command(message: Message) -> None:
await show_recent_drafts(message, edit_mode=False, page=1)
@router.callback_query(NewOrderDraftStates.waiting_confirm, F.data == "order_confirm")
async def confirm_order(callback: CallbackQuery, state: FSMContext) -> None:
service = OrderDraftsService()
data = await state.get_data()
raw = data.get("confirm_draft")
if not raw:
await state.clear()
await callback.answer("Ошибка состояния", show_alert=True)
return
draft = service.build_draft(
side=raw["side"],
order_type=raw["order_type"],
quantity=raw["quantity"],
price=raw.get("price"),
)
try:
service.save_draft(draft)
except ValueError as exc:
edit_page = data.get("draft_edit_page")
await state.clear()
errors = [item.strip() for item in str(exc).split(";") if item.strip()]
reply_markup = (
_drafts_back_keyboard(int(edit_page))
if edit_page
else _trade_back_home_keyboard()
)
await callback.message.edit_text(
_render_validation_error(errors),
reply_markup=reply_markup,
)
await callback.answer()
return
edit_page = data.get("draft_edit_page")
await state.clear()
reply_markup = (
_drafts_back_keyboard(int(edit_page))
if edit_page
else _trade_back_home_keyboard()
)
await callback.message.edit_text(
_render_draft_summary(
symbol=draft.symbol,
side=draft.side,
order_type=draft.order_type,
quantity=draft.quantity,
price=draft.price,
),
reply_markup=reply_markup,
)
await callback.answer()