diff --git a/app/src/telegram/handlers/trade/main.py b/app/src/telegram/handlers/trade/main.py index 8970163..164b5cd 100644 --- a/app/src/telegram/handlers/trade/main.py +++ b/app/src/telegram/handlers/trade/main.py @@ -15,30 +15,90 @@ from src.telegram.handlers.trade.new_order import ( router = Router(name="trade_main") +def _mode_line() -> str: + from src.core.system_status import get_runtime_mode_label + return f"Режим: {get_runtime_mode_label()}\n\n" + + +def _trade_screen(title: str) -> str: + return ( + f"📊 Торговля — {title}\n" + f"{_mode_line()}" + "Выбери раздел" + ) + + +# ========================= +# KEYBOARDS +# ========================= + 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.button(text="📝 Ордер", callback_data="trade:new_order") + builder.button(text="📂 Ордера", callback_data="trade:orders") + builder.button(text="📜 История", callback_data="trade:history") + builder.button(text="⚙️ Настройки", callback_data="trade:settings") builder.adjust(2, 2) return builder.as_markup() -def _trade_back_keyboard() -> InlineKeyboardMarkup: +def _trade_home_button() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() - builder.button(text="⬅️ К торговле", callback_data="trade:home") + builder.button(text="🏠 К торговле", callback_data="trade:home") return builder.as_markup() +def _orders_menu_keyboard() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button(text="📂 Черновики", callback_data="trade:orders:drafts") + builder.button(text="🏠 К торговле", callback_data="trade:home") + builder.adjust(2) + return builder.as_markup() + + +def _history_menu_keyboard() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button(text="✅ Исполненные", callback_data="trade:history:filled") + builder.button(text="🚫 Отменённые", callback_data="trade:history:canceled") + builder.button(text="🏠 К торговле", callback_data="trade:home") + builder.adjust(2, 1) + return builder.as_markup() + + +def _settings_menu_keyboard() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button(text="⚙️ Параметры", callback_data="trade:settings:params") + builder.button(text="🔁 Режим", callback_data="trade:settings:mode") + builder.button(text="ℹ️ Справка", callback_data="trade:settings:help") + builder.button(text="🏠 К торговле", callback_data="trade:home") + builder.adjust(2, 2) + return builder.as_markup() + + +# ========================= +# TEXTS +# ========================= + def _trade_home_text() -> str: - return ( - "⚡ Торговля\n\n" - "‼️ Режим черновика" - ) + return _trade_screen("Основной экран") + +def _trade_orders_text() -> str: + return _trade_screen("Ордера") -@router.message(F.text == "⚡ Торговля") +def _trade_history_text() -> str: + return _trade_screen("История") + + +def _trade_settings_text() -> str: + return _trade_screen("Настройки") + + +# ========================= +# ENTRY +# ========================= + +@router.message(F.text.in_({"📊 Торговля", "⚡ Торговля", "Торговля"})) async def open_trade(message: Message) -> None: await message.answer( _trade_home_text(), @@ -47,8 +107,13 @@ async def open_trade(message: Message) -> None: @router.callback_query(F.data == "trade:home") -async def open_trade_home_callback(callback: CallbackQuery) -> None: +async def open_trade_home_callback( + callback: CallbackQuery, + state: FSMContext, +) -> None: + await state.clear() await callback.answer() + if callback.message is not None: await callback.message.edit_text( _trade_home_text(), @@ -56,6 +121,10 @@ async def open_trade_home_callback(callback: CallbackQuery) -> None: ) +# ========================= +# NEW ORDER +# ========================= + @router.callback_query(F.data == "trade:new_order") async def open_new_order_from_trade( callback: CallbackQuery, @@ -66,39 +135,110 @@ async def open_new_order_from_trade( 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: +# ========================= +# ORDERS +# ========================= + +@router.callback_query(F.data == "trade:orders") +async def open_orders_from_trade(callback: CallbackQuery) -> None: + await callback.answer() + if callback.message is not None: + await callback.message.edit_text( + _trade_orders_text(), + reply_markup=_orders_menu_keyboard(), + ) + + +@router.callback_query(F.data == "trade:orders:drafts") +async def open_drafts_from_orders(callback: CallbackQuery) -> None: await callback.answer() if callback.message is not None: await show_recent_drafts(callback.message, edit_mode=True, page=1) +# ========================= +# HISTORY +# ========================= + +@router.callback_query(F.data == "trade:history") +async def open_trade_history(callback: CallbackQuery) -> None: + await callback.answer() + if callback.message is not None: + await callback.message.edit_text( + _trade_history_text(), + reply_markup=_history_menu_keyboard(), + ) + + +@router.callback_query(F.data == "trade:history:filled") +async def open_filled_history(callback: CallbackQuery) -> None: + await callback.answer() + if callback.message is not None: + await callback.message.edit_text( + "📊 Торговля — История\n\n" + "Шаг 1/1: Исполненные\n" + "Раздел в разработке.", + reply_markup=_trade_home_button(), + ) + + +@router.callback_query(F.data == "trade:history:canceled") +async def open_canceled_history(callback: CallbackQuery) -> None: + await callback.answer() + if callback.message is not None: + await callback.message.edit_text( + "📊 Торговля — История\n\n" + "Шаг 1/1: Отменённые\n" + "Раздел в разработке.", + reply_markup=_trade_home_button(), + ) + + +# ========================= +# SETTINGS +# ========================= + @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(), + _trade_settings_text(), + reply_markup=_settings_menu_keyboard(), ) -@router.callback_query(F.data == "trade:help") -async def open_trade_help(callback: CallbackQuery) -> None: +@router.callback_query(F.data == "trade:settings:params") +async def open_trade_settings_params(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(), + "📊 Торговля — Настройки\n\n" + "Шаг 1/1: Параметры ордера\n" + "Раздел в разработке.", + reply_markup=_trade_home_button(), + ) + + +@router.callback_query(F.data == "trade:settings:mode") +async def open_trade_settings_mode(callback: CallbackQuery) -> None: + await callback.answer() + if callback.message is not None: + await callback.message.edit_text( + "📊 Торговля — Настройки\n\n" + "Шаг 1/1: Режим работы\n" + "Текущий режим: demo", + reply_markup=_trade_home_button(), + ) + + +@router.callback_query(F.data == "trade:settings:help") +async def open_trade_settings_help(callback: CallbackQuery) -> None: + await callback.answer() + if callback.message is not None: + await callback.message.edit_text( + "📊 Торговля — Справка\n\n" + "Шаг 1/1: Информация\n" + "Раздел в разработке.", + reply_markup=_trade_home_button(), ) \ 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 911a061..398a33d 100644 --- a/app/src/telegram/handlers/trade/new_order.py +++ b/app/src/telegram/handlers/trade/new_order.py @@ -2,6 +2,9 @@ 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 @@ -21,9 +24,8 @@ 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, 2) + builder.button(text="🏠 К торговле", callback_data="trade:home") + builder.adjust(2, 1) return builder.as_markup() @@ -32,29 +34,45 @@ def _type_keyboard() -> InlineKeyboardMarkup: 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.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"Режим: {get_runtime_mode_label()}\n\n" + + def _quantity_keyboard(presets: list[str]) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() - labels = ["25%", "50%", "75%", "100%"] + + 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="order_cancel") - builder.adjust(2, 2, 1, 2) + 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_back:type") - builder.button(text="✖️ Отмена", callback_data="order_cancel") + builder.button(text="⬅️ Назад", callback_data="order_manual_back:quantity") + builder.button(text="🏠 К торговле", callback_data="trade:home") builder.adjust(2) return builder.as_markup() @@ -66,26 +84,41 @@ def _price_keyboard(bid: float, ask: float, last: float) -> InlineKeyboardMarkup 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.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_back:quantity") - builder.button(text="✖️ Отмена", callback_data="order_cancel") + 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") + builder.button(text="🏠 К торговле", callback_data="trade:home") return builder.as_markup() -def _drafts_keyboard(page: int, total_pages: int) -> InlineKeyboardMarkup: +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: @@ -103,12 +136,21 @@ def _drafts_keyboard(page: int, total_pages: int) -> InlineKeyboardMarkup: if page < total_pages: first_row_count += 1 - builder.button(text="⬅️ К торговле", callback_data="trade:home") + 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, @@ -117,20 +159,23 @@ def _render_draft_summary( price: str | None, ) -> str: lines = [ - "⚡ Торговля — Черновик ордера", + "📊 Торговля — Черновик ордера", + _mode_line().rstrip(), "", - "📝 Черновик создан", - "", - f"• инструмент: {symbol}", - f"• сторона: {side}", - f"• тип: {order_type}", - f"• количество: {quantity}", + f"Инструмент: {symbol}", + f"Сторона: {side}", + f"Тип: {order_type}", + f"Количество: {quantity}", ] + if price: - lines.append(f"• цена: {price}") + lines.append(f"Цена: {price}") + lines.extend( [ - "• статус: draft", + "Статус: draft", + "", + "✅ Черновик создан", "", "Ордер не отправлялся на биржу", ] @@ -138,27 +183,154 @@ def _render_draft_summary( 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"Инструмент: {symbol}", + f"Сторона: {side}", + f"Тип: {order_type}", + f"Количество: {quantity}", + ] + + if price: + lines.append(f"Цена: {price}") + + if notional is not None: + lines.append(f"Сумма: {notional:.2f}") + + lines.extend( + [ + "", + "Шаг 4/4. Подтверди черновик", + ] + ) + + return "\n".join(lines) + + def _render_validation_error(errors: list[str]) -> str: lines = [ - "⚡ Торговля — Ошибка валидации", + "📊 Торговля — Ошибка валидации", + _mode_line().rstrip(), + "Шаг 4/4. Проверь параметры черновика", "", "❌ Черновик не сохранён", "", - "Причины", - "", ] 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, + "", + "⚠️ Найдены ошибки", + "", + ] + + 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 = [ + "📏 Правила ввода количества", + "", + ] + + if min_qty: + lines.append(f"• минимум: {min_qty}") + if step_size: + lines.append(f"• шаг: {step_size}") + if min_notional: + lines.append(f"• мин. сумма: {min_notional}") + + lines.extend(["", f"Пример: {example}"]) + return "\n".join(lines) + + +def _render_price_input_help( + *, + tick_size: str | None, + example: str, +) -> str: + lines = [ + "📏 Правила ввода цены", + "", + ] + + if tick_size: + lines.append(f"• шаг цены: {tick_size}") + + lines.extend(["", f"Пример: {example}"]) + 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 = [ + "📊 Торговля — Черновик", + _mode_line().rstrip(), + f"Инструмент: {draft['symbol']}", + f"Сторона: {draft['side']}", + f"Тип: {draft['order_type']}", + f"Количество: {quantity}", + ] + + if draft.get("price"): + lines.append(f"Цена: {draft['price']}") + + lines.extend( + [ + f"Статус: {draft['status']}", + f"Время: {created_at}", + ] + ) + + 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 + 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: @@ -166,6 +338,12 @@ def _format_draft_quantity(value: str) -> str: return text or "0" +def _screen_title(is_edit_mode: bool) -> str: + if is_edit_mode: + return "📊 Торговля — Редактирование черновика" + return "📊 Торговля — Новый ордер" + + async def show_recent_drafts( message: Message, edit_mode: bool = False, @@ -182,18 +360,28 @@ async def show_recent_drafts( end = start + DRAFTS_PAGE_SIZE drafts = all_drafts[start:end] + # --- если нет черновиков --- if not drafts: text = ( - "⚡ Торговля — Черновики\n\n" + "📊 Торговля — Черновики\n" + f"{_mode_line()}" + "Список пуст\n\n" "Черновиков пока нет." ) if edit_mode: await message.edit_text(text, reply_markup=_trade_back_home_keyboard()) else: - await message.answer(text) + await message.answer(text, reply_markup=_trade_back_home_keyboard()) return - lines = ["⚡ Торговля — Черновики", "", "Последние записи", ""] + # --- список черновиков --- + lines = [ + "📊 Торговля — Черновики", + _mode_line().rstrip(), + "", + ] + + details_builder = InlineKeyboardBuilder() for item in drafts: quantity = _format_draft_quantity(item["quantity"]) @@ -201,16 +389,28 @@ async def show_recent_drafts( lines.extend( [ - f"{item['symbol']} · {item['side']} · {item['order_type']}", - f"• количество: {quantity}", - f"• статус: {item['status']}", - f"• время: {created_at}", + f"{item['symbol']}", + f"{item['side']} · {item['order_type']}", + f"Количество: {quantity}", + f"Статус: {item['status']}", + f"Время: {created_at}", "", ] ) + 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 = _drafts_keyboard(page, total_pages) + keyboard = details_builder.as_markup() if edit_mode: await message.edit_text(text, reply_markup=keyboard) @@ -236,27 +436,102 @@ async def paginate_drafts(callback: CallbackQuery) -> 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: {context.bid_price:.2f}\n" + f"Ask: {context.ask_price:.2f}\n" + f"Last: {context.last_price:.2f}\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"Инструмент: {context.symbol}\n" + f"Доступно: {context.available_balance:.8f} {context.balance_currency}\n" + f"Ориентир цены: {context.reference_price:.2f}\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( - "⚡ Торговля\n\n" - "Создание черновика отменено." - ) - - -@router.callback_query(F.data == "order_cancel") -async def cancel_order_builder_callback( - callback: CallbackQuery, - state: FSMContext, -) -> None: - await state.clear() - await callback.message.edit_text( - "⚡ Торговля\n\n" - "Создание черновика отменено.", + "📊 Торговля — Новый ордер\n" + f"{_mode_line()}" + "⛔ Создание черновика отменено", reply_markup=_trade_back_home_keyboard(), ) - await callback.answer() @router.message(Command("new_order")) @@ -269,9 +544,9 @@ async def start_new_order_draft( await state.set_state(NewOrderDraftStates.waiting_side) text = ( - "⚡ Торговля — Новый ордер\n\n" - "Шаг 1/4\n" - "Выберите сторону:" + "📊 Торговля — Новый ордер\n" + f"{_mode_line()}" + "Шаг 1/4. Выбери сторону" ) if edit_mode: @@ -283,10 +558,15 @@ async def start_new_order_draft( @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 = ( + "📊 Торговля — Новый ордер\n" + f"{_mode_line()}" + "Шаг 1/4. Выбери сторону" + ) + await callback.message.edit_text( - "⚡ Торговля — Новый ордер\n\n" - "Шаг 1/4\n" - "Выберите сторону:", + text, reply_markup=_side_keyboard(), ) await callback.answer() @@ -295,10 +575,13 @@ async def go_back_to_side(callback: CallbackQuery, state: FSMContext) -> None: @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 = ( + "📊 Торговля — Новый ордер\n" + f"{_mode_line()}" + "Шаг 2/4. Выбери тип ордера" + ) await callback.message.edit_text( - "⚡ Торговля — Новый ордер\n\n" - "Шаг 2/4\n" - "Выберите тип ордера:", + text, reply_markup=_type_keyboard(), ) await callback.answer() @@ -310,22 +593,137 @@ async def go_back_to_quantity(callback: CallbackQuery, state: FSMContext) -> Non 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( - "⚡ Торговля — Новый ордер\n\n" - "Шаг 3/4\n" + f"{_screen_title(is_edit_mode)}\n" + f"{_mode_line()}" f"Инструмент: {context.symbol}\n" f"Доступно: {context.available_balance:.8f} {context.balance_currency}\n" f"Ориентир цены: {context.reference_price:.2f}\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( + "📊 Торговля\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: {context.bid_price:.2f}\n" + f"Ask: {context.ask_price:.2f}\n" + f"Last: {context.last_price:.2f}\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"Инструмент: {context.symbol}\n" + f"Доступно: {context.available_balance:.8f} {context.balance_currency}\n" + f"Ориентир цены: {context.reference_price:.2f}\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"Инструмент: {context.symbol}\n" + f"Доступно: {context.available_balance:.8f} {context.balance_currency}\n" + f"Ориентир цены: {context.reference_price:.2f}\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: {context.bid_price:.2f}\n" + f"Ask: {context.ask_price:.2f}\n" + f"Last: {context.last_price:.2f}\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:"), @@ -335,14 +733,15 @@ async def process_order_side_callback( state: FSMContext, ) -> None: side = callback.data.split(":", 1)[1] - await state.update_data(side=side) await state.set_state(NewOrderDraftStates.waiting_type) - + text = ( + "📊 Торговля — Новый ордер\n" + f"{_mode_line()}" + "Шаг 2/4. Выбери тип ордера" + ) await callback.message.edit_text( - "⚡ Торговля — Новый ордер\n\n" - "Шаг 2/4\n" - "Выберите тип ордера:", + text, reply_markup=_type_keyboard(), ) await callback.answer() @@ -369,6 +768,7 @@ async def process_order_type_callback( 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) @@ -376,12 +776,12 @@ async def process_order_type_callback( context = service.get_entry_context(side=side, order_type=order_type) await callback.message.edit_text( - "⚡ Торговля — Новый ордер\n\n" - "Шаг 3/4\n" + f"{_screen_title(is_edit_mode)}\n" + f"{_mode_line()}" f"Инструмент: {context.symbol}\n" f"Доступно: {context.available_balance:.8f} {context.balance_currency}\n" f"Ориентир цены: {context.reference_price:.2f}\n\n" - "Выберите количество или введите его вручную:", + "Шаг 3/4. Выбери количество", reply_markup=_quantity_keyboard(context.quantity_presets), ) await callback.answer() @@ -404,38 +804,55 @@ async def process_quantity_callback( 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( - "⚡ Торговля — Новый ордер\n\n" - "Шаг 3/4\n" - "Введите количество вручную, например: 0.001", + 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 - service = OrderDraftsService() quantity = service.normalize_quantity(value) if quantity is None: await callback.answer("Некорректное значение количества.", show_alert=True) return - data = await state.get_data() 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( - "⚡ Торговля — Новый ордер\n\n" - "Шаг 4/4\n" + f"{title}\n" + f"{_mode_line()}" f"Bid: {context.bid_price:.2f}\n" f"Ask: {context.ask_price:.2f}\n" f"Last: {context.last_price:.2f}\n\n" - "Выберите цену или введите её вручную:", + "Шаг 4/4. Выбери цену", reply_markup=_price_keyboard( bid=context.bid_price, ask=context.ask_price, @@ -450,28 +867,31 @@ async def process_quantity_callback( order_type=order_type, quantity=quantity, ) - 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() + 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_draft_summary( + _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=_trade_back_home_keyboard(), + reply_markup=_confirm_keyboard(), ) await callback.answer() @@ -479,26 +899,65 @@ async def process_quantity_callback( @router.message(NewOrderDraftStates.waiting_quantity) async def process_order_quantity(message: Message, state: FSMContext) -> None: service = OrderDraftsService() - quantity = service.normalize_quantity(message.text or "") - if quantity is None: - await message.answer("Введите корректное количество, например: 0.001") - return + 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": - context = service.get_entry_context(side=data["side"], order_type=order_type) await state.set_state(NewOrderDraftStates.waiting_price) await message.answer( - "⚡ Торговля — Новый ордер\n\n" - "Шаг 4/4\n" + f"{title}\n" + f"{_mode_line()}" f"Bid: {context.bid_price:.2f}\n" f"Ask: {context.ask_price:.2f}\n" f"Last: {context.last_price:.2f}\n\n" - "Выберите цену или введите её вручную:", + "Шаг 4/4. Выбери цену", reply_markup=_price_keyboard( bid=context.bid_price, ask=context.ask_price, @@ -508,27 +967,35 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None: return draft = service.build_draft( - side=data["side"], + side=side, order_type=order_type, quantity=quantity, ) - 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() + 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_draft_summary( + _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(), ) @@ -541,43 +1008,203 @@ async def process_price_callback( 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( - "⚡ Торговля — Новый ордер\n\n" - "Шаг 4/4\n" - "Введите цену вручную, например: 73000.123", + 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 - service = OrderDraftsService() price = service.normalize_price(value) + if price is None: await callback.answer("Некорректная цена.", show_alert=True) return - data = await state.get_data() 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=_trade_back_home_keyboard(), + 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, @@ -586,46 +1213,6 @@ async def process_price_callback( quantity=draft.quantity, price=draft.price, ), - reply_markup=_trade_back_home_keyboard(), + reply_markup=reply_markup, ) - await callback.answer() - - -@router.message(NewOrderDraftStates.waiting_price) -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.123") - return - - data = await state.get_data() - draft = service.build_draft( - side=data["side"], - order_type=data["order_type"], - quantity=data["quantity"], - price=price, - ) - 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, - side=draft.side, - order_type=draft.order_type, - quantity=draft.quantity, - price=draft.price, - ) - ) - - -@router.message(Command("drafts")) -async def drafts_command(message: Message) -> None: - await show_recent_drafts(message, edit_mode=False, page=1) \ No newline at end of file + await callback.answer() \ No newline at end of file diff --git a/app/src/telegram/keyboards/reply.py b/app/src/telegram/keyboards/reply.py index ab0e4a6..fece25a 100644 --- a/app/src/telegram/keyboards/reply.py +++ b/app/src/telegram/keyboards/reply.py @@ -10,7 +10,7 @@ def build_main_menu_keyboard() -> ReplyKeyboardMarkup: KeyboardButton(text="💼 Портфель"), ], [ - KeyboardButton(text="⚡ Торговля"), + KeyboardButton(text="📊 Торговля"), KeyboardButton(text="🤖 Авто"), KeyboardButton(text="📒 Журнал"), ], diff --git a/app/src/telegram/menus.py b/app/src/telegram/menus.py index d9afce9..b099b85 100644 --- a/app/src/telegram/menus.py +++ b/app/src/telegram/menus.py @@ -27,6 +27,6 @@ SYSTEM_TEXT = ( MARKET_TEXT = "📈 Рынок\n\nРаздел пока в разработке." PORTFOLIO_TEXT = "💼 Портфель\n\nРаздел пока в разработке." -TRADE_TEXT = "⚡ Торговля\n\nВыберите действие:\nDRAFT режим" +TRADE_TEXT = "📊 Торговля\n\nВыберите действие:\nDRAFT режим" AUTO_TEXT = "🤖 Авто\n\nРаздел пока в разработке." JOURNAL_TEXT = "📒 Журнал\n\nРаздел пока в разработке." \ No newline at end of file