diff --git a/app/src/telegram/handlers/trade/main.py b/app/src/telegram/handlers/trade/main.py index 3841738..8970163 100644 --- a/app/src/telegram/handlers/trade/main.py +++ b/app/src/telegram/handlers/trade/main.py @@ -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 ( + "⚡ Торговля\n\n" + "‼️ Режим черновика" + ) + + @router.message(F.text == "⚡ Торговля") async def open_trade(message: Message) -> None: - text = ( - "⚡ Торговля\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( + "⚡ Торговля — Настройки ордера\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( + "⚡ Торговля — Справка\n\n" + "Режим черновика — ордер не отправляется на биржу.\n\n" + "Сейчас можно:\n" + "• собрать черновик ордера\n" + "• проверить параметры\n" + "• сохранить черновик в базу\n\n" + "Реальная отправка ордера появится позже.", + reply_markup=_trade_back_keyboard(), + ) \ No newline at end of file diff --git a/app/src/telegram/handlers/trade/new_order.py b/app/src/telegram/handlers/trade/new_order.py index 70418cf..911a061 100644 --- a/app/src/telegram/handlers/trade/new_order.py +++ b/app/src/telegram/handlers/trade/new_order.py @@ -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 = [ - "📝 Черновик ордера создан", + "⚡ Торговля — Черновик ордера", + "", + "📝 Черновик создан", "", f"• инструмент: {symbol}", f"• сторона: {side}", @@ -82,12 +132,110 @@ def _render_draft_summary( [ "• статус: draft", "", - "Это тестовый draft flow. Реальный ордер не отправлялся.", + "Ордер не отправлялся на биржу", ] ) return "\n".join(lines) +def _render_validation_error(errors: list[str]) -> str: + lines = [ + "⚡ Торговля — Ошибка валидации", + "", + "❌ Черновик не сохранён", + "", + "Причины", + "", + ] + 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 = ( + "⚡ Торговля — Черновики\n\n" + "Черновиков пока нет." + ) + if edit_mode: + await message.edit_text(text, reply_markup=_trade_back_home_keyboard()) + else: + await message.answer(text) + return + + lines = ["⚡ Торговля — Черновики", "", "Последние записи", ""] + + for item in drafts: + quantity = _format_draft_quantity(item["quantity"]) + created_at = _format_draft_time(item["created_at"]) + + lines.extend( + [ + f"{item['symbol']} · {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( "⚡ Торговля\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( - "⚡ Новый черновик ордера\n\n" + + text = ( + "⚡ Торговля — Новый ордер\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( + "⚡ Торговля — Новый ордер\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( + "⚡ Торговля — Новый ордер\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( + "⚡ Торговля — Новый ордер\n\n" + "Шаг 3/4\n" + f"Инструмент: {context.symbol}\n" + f"Доступно: {context.available_balance:.8f} {context.balance_currency}\n" + f"Ориентир цены: {context.reference_price:.2f}\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( - "⚡ Новый черновик ордера\n\n" + "⚡ Торговля — Новый ордер\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( - "⚡ Новый черновик ордера\n\n" + "⚡ Торговля — Новый ордер\n\n" "Шаг 3/4\n" f"Инструмент: {context.symbol}\n" f"Доступно: {context.available_balance:.8f} {context.balance_currency}\n" @@ -203,10 +407,10 @@ async def process_quantity_callback( if value == "manual": await callback.message.edit_text( - "⚡ Новый черновик ордера\n\n" + "⚡ Торговля — Новый ордер\n\n" "Шаг 3/4\n" "Введите количество вручную, например: 0.001", - 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( - "⚡ Новый черновик ордера\n\n" + "⚡ Торговля — Новый ордер\n\n" "Шаг 4/4\n" f"Bid: {context.bid_price:.2f}\n" f"Ask: {context.ask_price:.2f}\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( - "⚡ Новый черновик ордера\n\n" + "⚡ Торговля — Новый ордер\n\n" "Шаг 4/4\n" f"Bid: {context.bid_price:.2f}\n" f"Ask: {context.ask_price:.2f}\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( - "⚡ Новый черновик ордера\n\n" + "⚡ Торговля — Новый ордер\n\n" "Шаг 4/4\n" - "Введите цену вручную, например: 73000", - reply_markup=_cancel_keyboard(), + "Введите цену вручную, например: 73000.123", + 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( - "📝 Черновики ордеров\n\n" - "Черновиков пока нет." - ) - return - - lines = ["📝 Черновики ордеров", "", "Последние записи", ""] - - 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()) \ No newline at end of file +async def drafts_command(message: Message) -> None: + await show_recent_drafts(message, edit_mode=False, page=1) \ No newline at end of file