Stage 05.2+ - advanced draft builder (FSM, UI, pagination)

This commit is contained in:
2026-04-17 13:05:26 +03:00
parent f48effd9b5
commit b1b9beef78
2 changed files with 370 additions and 68 deletions

View File

@@ -1,18 +1,104 @@
# app/src/telegram/handlers/trade/main.py
from __future__ import annotations
from aiogram import F, Router
from aiogram.types import Message
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.telegram.handlers.trade.new_order import (
show_recent_drafts,
start_new_order_draft,
)
router = Router(name="trade_main")
def _trade_home_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="📝 Новый ордер", callback_data="trade:new_order")
builder.button(text="📂 Черновики", callback_data="trade:drafts")
builder.button(text="⚙️ Настройки ордера", callback_data="trade:settings")
builder.button(text=" Справка", callback_data="trade:help")
builder.adjust(2, 2)
return builder.as_markup()
def _trade_back_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="⬅️ К торговле", callback_data="trade:home")
return builder.as_markup()
def _trade_home_text() -> str:
return (
"<b>⚡ Торговля</b>\n\n"
"<b>‼️ Режим черновика</b>"
)
@router.message(F.text == "⚡ Торговля")
async def open_trade(message: Message) -> None:
text = (
"<b>⚡ Торговля</b>\n\n"
"Доступные действия:\n"
"• /new_order — создать черновик ордера\n"
"• /drafts — показать последние черновики\n\n"
"На этом этапе реальные ордера ещё не отправляются."
await message.answer(
_trade_home_text(),
reply_markup=_trade_home_keyboard(),
)
await message.answer(text)
@router.callback_query(F.data == "trade:home")
async def open_trade_home_callback(callback: CallbackQuery) -> None:
await callback.answer()
if callback.message is not None:
await callback.message.edit_text(
_trade_home_text(),
reply_markup=_trade_home_keyboard(),
)
@router.callback_query(F.data == "trade:new_order")
async def open_new_order_from_trade(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await callback.answer()
if callback.message is not None:
await start_new_order_draft(callback.message, state, edit_mode=True)
@router.callback_query(F.data == "trade:drafts")
async def open_drafts_from_trade(callback: CallbackQuery) -> None:
await callback.answer()
if callback.message is not None:
await show_recent_drafts(callback.message, edit_mode=True, page=1)
@router.callback_query(F.data == "trade:settings")
async def open_trade_settings(callback: CallbackQuery) -> None:
await callback.answer()
if callback.message is not None:
await callback.message.edit_text(
"<b>⚡ Торговля — Настройки ордера</b>\n\n"
"Раздел в разработке.\n\n"
"Планируется добавить:\n"
"• параметры ордера по умолчанию\n"
"• пресеты количества\n"
"• режим цены: Bid / Ask / Last",
reply_markup=_trade_back_keyboard(),
)
@router.callback_query(F.data == "trade:help")
async def open_trade_help(callback: CallbackQuery) -> None:
await callback.answer()
if callback.message is not None:
await callback.message.edit_text(
"<b>⚡ Торговля — Справка</b>\n\n"
"<b>Режим черновика</b> — ордер не отправляется на биржу.\n\n"
"Сейчас можно:\n"
"• собрать черновик ордера\n"
"• проверить параметры\n"
"• сохранить черновик в базу\n\n"
"Реальная отправка ордера появится позже.",
reply_markup=_trade_back_keyboard(),
)

View File

@@ -1,3 +1,5 @@
# app/src/telegram/handlers/trade/new_order.py
from __future__ import annotations
from aiogram import F, Router
@@ -12,13 +14,16 @@ 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.button(text="✖️ Отмена", callback_data="order_cancel")
builder.adjust(2, 1)
builder.adjust(2, 2)
return builder.as_markup()
@@ -26,14 +31,9 @@ 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="order_cancel")
builder.adjust(2, 1)
return builder.as_markup()
def _cancel_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="✖️ Отмена", callback_data="order_cancel")
builder.adjust(2, 2)
return builder.as_markup()
@@ -45,8 +45,17 @@ def _quantity_keyboard(presets: list[str]) -> InlineKeyboardMarkup:
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="order_cancel")
builder.adjust(2, 2, 1)
builder.adjust(2, 2, 1, 2)
return builder.as_markup()
def _quantity_manual_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="⬅️ Назад", callback_data="order_back:type")
builder.button(text="✖️ Отмена", callback_data="order_cancel")
builder.adjust(2)
return builder.as_markup()
@@ -56,8 +65,47 @@ def _price_keyboard(bid: float, ask: float, last: float) -> InlineKeyboardMarkup
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="order_cancel")
builder.adjust(2, 1, 1, 1)
builder.adjust(2, 2, 2)
return builder.as_markup()
def _price_manual_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="⬅️ Назад", callback_data="order_back:quantity")
builder.button(text="✖️ Отмена", callback_data="order_cancel")
builder.adjust(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_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()
@@ -69,7 +117,9 @@ def _render_draft_summary(
price: str | None,
) -> str:
lines = [
"<b>📝 Черновик ордера создан</b>",
"<b>⚡ Торговля — Черновик ордера</b>",
"",
"<b>📝 Черновик создан</b>",
"",
f"• инструмент: {symbol}",
f"• сторона: {side}",
@@ -82,12 +132,110 @@ def _render_draft_summary(
[
"• статус: draft",
"",
"Это тестовый draft flow. Реальный ордер не отправлялся.",
"<i>Ордер не отправлялся на биржу</i>",
]
)
return "\n".join(lines)
def _render_validation_error(errors: list[str]) -> str:
lines = [
"<b>⚡ Торговля — Ошибка валидации</b>",
"",
"<b>❌ Черновик не сохранён</b>",
"",
"<b>Причины</b>",
"",
]
for item in errors:
lines.append(f"{item}")
return "\n".join(lines)
def _format_draft_time(value: str) -> str:
text = str(value).replace("T", " ")
if "+" in text:
text = text.split("+", 1)[0]
if "." in text:
text = text.split(".", 1)[0]
return text
def _format_draft_quantity(value: str) -> str:
text = str(value).rstrip("0").rstrip(".")
return text or "0"
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\n"
"Черновиков пока нет."
)
if edit_mode:
await message.edit_text(text, reply_markup=_trade_back_home_keyboard())
else:
await message.answer(text)
return
lines = ["<b>⚡ Торговля — Черновики</b>", "", "<b>Последние записи</b>", ""]
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> · {item['side']} · {item['order_type']}",
f"• количество: {quantity}",
f"• статус: {item['status']}",
f"• время: {created_at}",
"",
]
)
text = "\n".join(lines).rstrip()
keyboard = _drafts_keyboard(page, total_pages)
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.message(Command("cancel_order"))
async def cancel_order_builder(message: Message, state: FSMContext) -> None:
await state.clear()
@@ -105,21 +253,77 @@ async def cancel_order_builder_callback(
await state.clear()
await callback.message.edit_text(
"<b>⚡ Торговля</b>\n\n"
"Создание черновика отменено."
"Создание черновика отменено.",
reply_markup=_trade_back_home_keyboard(),
)
await callback.answer()
@router.message(Command("new_order"))
async def start_new_order_draft(message: Message, state: FSMContext) -> None:
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)
await message.answer(
"<b>⚡ Новый черновик ордера</b>\n\n"
text = (
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 1/4\n"
"Выберите сторону:"
)
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)
await callback.message.edit_text(
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 1/4\n"
"Выберите сторону:",
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)
await callback.message.edit_text(
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 2/4\n"
"Выберите тип ордера:",
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")
context = service.get_entry_context(side=side, order_type=order_type)
await state.set_state(NewOrderDraftStates.waiting_quantity)
await callback.message.edit_text(
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 3/4\n"
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"
"Выберите количество или введите его вручную:",
reply_markup=_quantity_keyboard(context.quantity_presets),
)
await callback.answer()
@router.callback_query(
@@ -136,7 +340,7 @@ async def process_order_side_callback(
await state.set_state(NewOrderDraftStates.waiting_type)
await callback.message.edit_text(
"<b>⚡ Новый черновик ордера</b>\n\n"
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 2/4\n"
"Выберите тип ордера:",
reply_markup=_type_keyboard(),
@@ -172,7 +376,7 @@ async def process_order_type_callback(
context = service.get_entry_context(side=side, order_type=order_type)
await callback.message.edit_text(
"<b>⚡ Новый черновик ордера</b>\n\n"
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 3/4\n"
f"Инструмент: <b>{context.symbol}</b>\n"
f"Доступно: <b>{context.available_balance:.8f} {context.balance_currency}</b>\n"
@@ -203,10 +407,10 @@ async def process_quantity_callback(
if value == "manual":
await callback.message.edit_text(
"<b>⚡ Новый черновик ордера</b>\n\n"
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 3/4\n"
"Введите количество вручную, например: <b>0.001</b>",
reply_markup=_cancel_keyboard(),
reply_markup=_quantity_manual_keyboard(),
)
await callback.answer()
return
@@ -226,7 +430,7 @@ async def process_quantity_callback(
context = service.get_entry_context(side=data["side"], order_type=order_type)
await state.set_state(NewOrderDraftStates.waiting_price)
await callback.message.edit_text(
"<b>⚡ Новый черновик ордера</b>\n\n"
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 4/4\n"
f"Bid: <b>{context.bid_price:.2f}</b>\n"
f"Ask: <b>{context.ask_price:.2f}</b>\n"
@@ -246,9 +450,19 @@ async def process_quantity_callback(
order_type=order_type,
quantity=quantity,
)
service.save_draft(draft)
await state.clear()
try:
service.save_draft(draft)
except ValueError as exc:
await state.clear()
errors = [item.strip() for item in str(exc).split(";") if item.strip()]
await callback.message.edit_text(
_render_validation_error(errors),
reply_markup=_trade_back_home_keyboard(),
)
await callback.answer()
return
await state.clear()
await callback.message.edit_text(
_render_draft_summary(
symbol=draft.symbol,
@@ -256,7 +470,8 @@ async def process_quantity_callback(
order_type=draft.order_type,
quantity=draft.quantity,
price=draft.price,
)
),
reply_markup=_trade_back_home_keyboard(),
)
await callback.answer()
@@ -278,7 +493,7 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None:
context = service.get_entry_context(side=data["side"], order_type=order_type)
await state.set_state(NewOrderDraftStates.waiting_price)
await message.answer(
"<b>⚡ Новый черновик ордера</b>\n\n"
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 4/4\n"
f"Bid: <b>{context.bid_price:.2f}</b>\n"
f"Ask: <b>{context.ask_price:.2f}</b>\n"
@@ -297,9 +512,15 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None:
order_type=order_type,
quantity=quantity,
)
service.save_draft(draft)
await state.clear()
try:
service.save_draft(draft)
except ValueError as exc:
await state.clear()
errors = [item.strip() for item in str(exc).split(";") if item.strip()]
await message.answer(_render_validation_error(errors))
return
await state.clear()
await message.answer(
_render_draft_summary(
symbol=draft.symbol,
@@ -323,10 +544,10 @@ async def process_price_callback(
if value == "manual":
await callback.message.edit_text(
"<b>⚡ Новый черновик ордера</b>\n\n"
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 4/4\n"
"Введите цену вручную, например: <b>73000</b>",
reply_markup=_cancel_keyboard(),
"Введите цену вручную, например: <b>73000.123</b>",
reply_markup=_price_manual_keyboard(),
)
await callback.answer()
return
@@ -344,9 +565,19 @@ async def process_price_callback(
quantity=data["quantity"],
price=price,
)
service.save_draft(draft)
await state.clear()
try:
service.save_draft(draft)
except ValueError as exc:
await state.clear()
errors = [item.strip() for item in str(exc).split(";") if item.strip()]
await callback.message.edit_text(
_render_validation_error(errors),
reply_markup=_trade_back_home_keyboard(),
)
await callback.answer()
return
await state.clear()
await callback.message.edit_text(
_render_draft_summary(
symbol=draft.symbol,
@@ -354,7 +585,8 @@ async def process_price_callback(
order_type=draft.order_type,
quantity=draft.quantity,
price=draft.price,
)
),
reply_markup=_trade_back_home_keyboard(),
)
await callback.answer()
@@ -364,7 +596,7 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
service = OrderDraftsService()
price = service.normalize_price(message.text or "")
if price is None:
await message.answer("Введите корректную цену, например: 73000")
await message.answer("Введите корректную цену, например: 73000.123")
return
data = await state.get_data()
@@ -374,9 +606,15 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
quantity=data["quantity"],
price=price,
)
service.save_draft(draft)
await state.clear()
try:
service.save_draft(draft)
except ValueError as exc:
await state.clear()
errors = [item.strip() for item in str(exc).split(";") if item.strip()]
await message.answer(_render_validation_error(errors))
return
await state.clear()
await message.answer(
_render_draft_summary(
symbol=draft.symbol,
@@ -389,27 +627,5 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
@router.message(Command("drafts"))
async def show_recent_drafts(message: Message) -> None:
service = OrderDraftsService()
drafts = service.list_recent_drafts(limit=5)
if not drafts:
await message.answer(
"<b>📝 Черновики ордеров</b>\n\n"
"Черновиков пока нет."
)
return
lines = ["<b>📝 Черновики ордеров</b>", "", "<b>Последние записи</b>", ""]
for item in drafts:
lines.extend(
[
f"{item['symbol']} | {item['side']} | {item['order_type']}",
f" qty: {item['quantity']} | status: {item['status']}",
f" time: {item['created_at']}",
"",
]
)
await message.answer("\n".join(lines).rstrip())
async def drafts_command(message: Message) -> None:
await show_recent_drafts(message, edit_mode=False, page=1)