1218 lines
38 KiB
Python
1218 lines
38 KiB
Python
# 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() |