From cec7c761be355539b328428d9dc0bdf3fae13b81 Mon Sep 17 00:00:00 2001 From: Sergey Date: Sun, 19 Apr 2026 15:43:22 +0300 Subject: [PATCH] Stage 05.7 - trade draft UI restructuring and order context display --- app/src/telegram/handlers/auto.py | 7 +- app/src/telegram/handlers/home.py | 8 +- app/src/telegram/handlers/journal.py | 10 +- app/src/telegram/handlers/market.py | 12 +- app/src/telegram/handlers/portfolio.py | 8 +- app/src/telegram/handlers/start.py | 19 +- app/src/telegram/handlers/system.py | 7 +- app/src/telegram/handlers/trade/main.py | 8 +- app/src/telegram/handlers/trade/new_order.py | 1226 +---------------- .../telegram/handlers/trade/new_order_core.py | 9 + .../telegram/handlers/trade/new_order_flow.py | 751 ++++++++++ .../handlers/trade/new_order_navigation.py | 187 +++ .../telegram/handlers/trade/new_order_ui.py | 664 +++++++++ app/src/telegram/keyboards/reply.py | 2 + app/src/telegram/ui/__init__.py | 1 + app/src/telegram/ui/common.py | 8 + app/src/trading/orders/models.py | 2 + app/src/trading/orders/service.py | 37 +- docs/stages/stage-03-3-exchange-info.txt | 1 - ...stage_05-7-trade-draft-UI-restructuring.md | 241 ++++ docs/terminology.md | 65 + 21 files changed, 2030 insertions(+), 1243 deletions(-) create mode 100644 app/src/telegram/handlers/trade/new_order_core.py create mode 100644 app/src/telegram/handlers/trade/new_order_flow.py create mode 100644 app/src/telegram/handlers/trade/new_order_navigation.py create mode 100644 app/src/telegram/handlers/trade/new_order_ui.py create mode 100644 app/src/telegram/ui/__init__.py create mode 100644 app/src/telegram/ui/common.py delete mode 100644 docs/stages/stage-03-3-exchange-info.txt create mode 100644 docs/stages/stage_05-7-trade-draft-UI-restructuring.md create mode 100644 docs/terminology.md diff --git a/app/src/telegram/handlers/auto.py b/app/src/telegram/handlers/auto.py index 1d771f5..0708641 100644 --- a/app/src/telegram/handlers/auto.py +++ b/app/src/telegram/handlers/auto.py @@ -1,6 +1,7 @@ # app/src/telegram/handlers/auto.py from aiogram import F, Router +from aiogram.fsm.context import FSMContext from aiogram.types import Message from src.telegram.menus import AUTO_TEXT @@ -10,5 +11,7 @@ router = Router(name="auto") @router.message(F.text == "🤖 Авто") -async def open_auto(message: Message) -> None: - await message.answer(AUTO_TEXT) +async def open_auto(message: Message, state: FSMContext) -> None: + # Глобальный экран: всегда выходим из текущего FSM-сценария. + await state.clear() + await message.answer(AUTO_TEXT) \ No newline at end of file diff --git a/app/src/telegram/handlers/home.py b/app/src/telegram/handlers/home.py index 3d5bc0a..07cf7ff 100644 --- a/app/src/telegram/handlers/home.py +++ b/app/src/telegram/handlers/home.py @@ -1,6 +1,7 @@ # app/src/telegram/handlers/home.py from aiogram import F, Router +from aiogram.fsm.context import FSMContext from aiogram.types import Message from src.telegram.menus import HOME_TEXT @@ -10,5 +11,8 @@ router = Router(name="home") @router.message(F.text == "🏠 Главная") -async def open_home(message: Message) -> None: - await message.answer(HOME_TEXT) +async def open_home(message: Message, state: FSMContext) -> None: + # Глобальный экран: всегда выходим из текущего FSM-сценария. + await state.clear() + + await message.answer(HOME_TEXT) \ No newline at end of file diff --git a/app/src/telegram/handlers/journal.py b/app/src/telegram/handlers/journal.py index 85717e2..d010a4c 100644 --- a/app/src/telegram/handlers/journal.py +++ b/app/src/telegram/handlers/journal.py @@ -3,6 +3,7 @@ from __future__ import annotations from aiogram import F, Router +from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, Message from aiogram.utils.keyboard import InlineKeyboardBuilder @@ -28,12 +29,12 @@ def build_keyboard(page: int, total_pages: int): kb.button(text="⏮️", callback_data="journal:1") if page > 1: - kb.button(text="⬅️", callback_data=f"journal:{page-1}") + kb.button(text="⬅️", callback_data=f"journal:{page - 1}") kb.button(text=f"{page}/{total_pages}", callback_data="noop") if page < total_pages: - kb.button(text="➡️", callback_data=f"journal:{page+1}") + kb.button(text="➡️", callback_data=f"journal:{page + 1}") return kb.as_markup() @@ -59,7 +60,10 @@ def render(events, page, total_pages): @router.message(F.text == "📒 Журнал") -async def open_journal(message: Message): +async def open_journal(message: Message, state: FSMContext): + # Глобальный экран: всегда выходим из текущего FSM-сценария. + await state.clear() + service = JournalService() total = service.get_total_count() diff --git a/app/src/telegram/handlers/market.py b/app/src/telegram/handlers/market.py index 84b1f15..878e0df 100644 --- a/app/src/telegram/handlers/market.py +++ b/app/src/telegram/handlers/market.py @@ -3,6 +3,7 @@ from __future__ import annotations from aiogram import F, Router +from aiogram.fsm.context import FSMContext from aiogram.types import Message from src.integrations.exchange.exceptions import ExchangeError @@ -50,7 +51,10 @@ def _safe_log_error( @router.message(F.text == "📈 Рынок") -async def open_market(message: Message) -> None: +async def open_market(message: Message, state: FSMContext) -> None: + # Глобальный экран: всегда выходим из текущего FSM-сценария. + await state.clear() + service = ExchangeService() journal = JournalService() @@ -120,6 +124,8 @@ async def open_market(message: Message) -> None: if symbol_info and symbol_info.tick_size is not None else "n/a" ) + base_asset = symbol_info.base_asset if symbol_info and symbol_info.base_asset else "n/a" + quote_asset = symbol_info.quote_asset if symbol_info and symbol_info.quote_asset else "n/a" name = symbol_info.name if symbol_info and symbol_info.name else ticker.symbol text = ( @@ -130,6 +136,8 @@ async def open_market(message: Message) -> None: f"Статус: {symbol_status}\n" f"Тип рынка: {market_type}\n" f"Режимы: {market_modes}\n" + f"Base asset: {base_asset}\n" + f"Quote asset: {quote_asset}\n" f"Tick size: {tick_size}\n" f"Источник: {ticker.source}\n" f"Обновлено: {ticker.updated_at}" @@ -144,6 +152,8 @@ async def open_market(message: Message) -> None: "chat_id": chat_id, "symbol": ticker.symbol, "price": ticker.price, + "base_asset": base_asset, + "quote_asset": quote_asset, }, ) diff --git a/app/src/telegram/handlers/portfolio.py b/app/src/telegram/handlers/portfolio.py index 20f3deb..1748665 100644 --- a/app/src/telegram/handlers/portfolio.py +++ b/app/src/telegram/handlers/portfolio.py @@ -3,6 +3,7 @@ from __future__ import annotations from aiogram import F, Router +from aiogram.fsm.context import FSMContext from aiogram.types import Message from src.integrations.exchange.exceptions import ExchangeError @@ -127,7 +128,10 @@ def _safe_log_error( @router.message(F.text == "💼 Портфель") -async def open_portfolio(message: Message) -> None: +async def open_portfolio(message: Message, state: FSMContext) -> None: + # Глобальный экран: всегда выходим из текущего FSM-сценария. + await state.clear() + service = AccountsService() journal = JournalService() @@ -234,4 +238,4 @@ async def open_portfolio(message: Message) -> None: ) text = "\n".join(lines).rstrip() - await message.answer(text) + await message.answer(text) \ No newline at end of file diff --git a/app/src/telegram/handlers/start.py b/app/src/telegram/handlers/start.py index 217683f..66bdda5 100644 --- a/app/src/telegram/handlers/start.py +++ b/app/src/telegram/handlers/start.py @@ -4,6 +4,7 @@ from __future__ import annotations from aiogram import F, Router from aiogram.filters import Command +from aiogram.fsm.context import FSMContext from aiogram.types import Message from src.core.system_status import build_system_text @@ -15,7 +16,9 @@ router = Router(name="start") @router.message(Command("start")) -async def cmd_start(message: Message) -> None: +async def cmd_start(message: Message, state: FSMContext) -> None: + # Глобальный экран: всегда выходим из текущего FSM-сценария. + await state.clear() await message.answer( MAIN_MENU_TEXT, reply_markup=build_main_menu_keyboard(), @@ -23,7 +26,9 @@ async def cmd_start(message: Message) -> None: @router.message(Command("menu")) -async def cmd_menu(message: Message) -> None: +async def cmd_menu(message: Message, state: FSMContext) -> None: + # Глобальный экран: всегда выходим из текущего FSM-сценария. + await state.clear() await message.answer( MAIN_MENU_TEXT, reply_markup=build_main_menu_keyboard(), @@ -31,7 +36,9 @@ async def cmd_menu(message: Message) -> None: @router.message(Command("help")) -async def cmd_help(message: Message) -> None: +async def cmd_help(message: Message, state: FSMContext) -> None: + # Глобальный экран: всегда выходим из текущего FSM-сценария. + await state.clear() await message.answer( build_system_text(), reply_markup=build_main_menu_keyboard(), @@ -39,8 +46,10 @@ async def cmd_help(message: Message) -> None: @router.message(F.text == "Меню") -async def menu_shortcut(message: Message) -> None: +async def menu_shortcut(message: Message, state: FSMContext) -> None: + # Глобальный экран: всегда выходим из текущего FSM-сценария. + await state.clear() await message.answer( MAIN_MENU_TEXT, reply_markup=build_main_menu_keyboard(), - ) + ) \ No newline at end of file diff --git a/app/src/telegram/handlers/system.py b/app/src/telegram/handlers/system.py index 5de4c23..a201340 100644 --- a/app/src/telegram/handlers/system.py +++ b/app/src/telegram/handlers/system.py @@ -3,6 +3,7 @@ from __future__ import annotations from aiogram import F, Router +from aiogram.fsm.context import FSMContext from aiogram.types import Message from src.core.system_status import build_system_text @@ -12,5 +13,7 @@ router = Router(name="system") @router.message(F.text.in_({"⚙️ Система", "⚙ Система"})) -async def open_system(message: Message) -> None: - await message.answer(build_system_text()) +async def open_system(message: Message, state: FSMContext) -> None: + # Глобальный экран: всегда выходим из текущего FSM-сценария. + await state.clear() + await message.answer(build_system_text()) \ No newline at end of file diff --git a/app/src/telegram/handlers/trade/main.py b/app/src/telegram/handlers/trade/main.py index 164b5cd..c05a897 100644 --- a/app/src/telegram/handlers/trade/main.py +++ b/app/src/telegram/handlers/trade/main.py @@ -7,6 +7,7 @@ from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message from aiogram.utils.keyboard import InlineKeyboardBuilder +from src.telegram.ui.common import mode_line from src.telegram.handlers.trade.new_order import ( show_recent_drafts, start_new_order_draft, @@ -15,15 +16,10 @@ 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()}" + f"{mode_line()}" "Выбери раздел" ) diff --git a/app/src/telegram/handlers/trade/new_order.py b/app/src/telegram/handlers/trade/new_order.py index 398a33d..0301ff2 100644 --- a/app/src/telegram/handlers/trade/new_order.py +++ b/app/src/telegram/handlers/trade/new_order.py @@ -1,1218 +1,16 @@ # app/src/telegram/handlers/trade/new_order.py +# Точка сборки всех роутеров сценария нового ордера (flow, navigation, drafts). 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"Режим: {get_runtime_mode_label()}\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 = [ - "📊 Торговля — Черновик ордера", - _mode_line().rstrip(), - "", - f"Инструмент: {symbol}", - f"Сторона: {side}", - f"Тип: {order_type}", - f"Количество: {quantity}", - ] - - if price: - lines.append(f"Цена: {price}") - - lines.extend( - [ - "Статус: draft", - "", - "✅ Черновик создан", - "", - "Ордер не отправлялся на биржу", - ] - ) - 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: - 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 "📊 Торговля — Редактирование черновика" - return "📊 Торговля — Новый ордер" - - -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" - f"{_mode_line()}" - "Список пуст\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 = [ - "📊 Торговля — Черновики", - _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"{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 = 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: {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" - f"{_mode_line()}" - "⛔ Создание черновика отменено", - 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 = ( - "📊 Торговля — Новый ордер\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 = ( - "📊 Торговля — Новый ордер\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 = ( - "📊 Торговля — Новый ордер\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"Инструмент: {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:"), -) -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 = ( - "📊 Торговля — Новый ордер\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"Инструмент: {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.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: {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 - - 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: {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, - ), - ) - 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() \ No newline at end of file +from src.telegram.handlers.trade.new_order_core import router +from src.telegram.handlers.trade import new_order_navigation as _new_order_navigation # noqa +from src.telegram.handlers.trade import new_order_flow as _new_order_flow # noqa +from src.telegram.handlers.trade.new_order_flow import start_new_order_draft +from src.telegram.handlers.trade.new_order_ui import show_recent_drafts + +__all__ = [ + "router", + "show_recent_drafts", + "start_new_order_draft", +] \ No newline at end of file diff --git a/app/src/telegram/handlers/trade/new_order_core.py b/app/src/telegram/handlers/trade/new_order_core.py new file mode 100644 index 0000000..11a5681 --- /dev/null +++ b/app/src/telegram/handlers/trade/new_order_core.py @@ -0,0 +1,9 @@ +# app/src/telegram/handlers/trade/new_order_core.py + +from __future__ import annotations + +from aiogram import Router + +router = Router(name="trade_new_order") + +DRAFTS_PAGE_SIZE = 3 \ No newline at end of file diff --git a/app/src/telegram/handlers/trade/new_order_flow.py b/app/src/telegram/handlers/trade/new_order_flow.py new file mode 100644 index 0000000..f0450cc --- /dev/null +++ b/app/src/telegram/handlers/trade/new_order_flow.py @@ -0,0 +1,751 @@ +# app/src/telegram/handlers/trade/new_order_flow.py + +from __future__ import annotations + +from aiogram import F +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message + +from src.telegram.handlers.trade.new_order_core import router +from src.telegram.handlers.trade.new_order_ui import ( + _confirm_keyboard, + _draft_detail_keyboard, + _drafts_back_keyboard, + _price_keyboard, + _price_manual_keyboard, + _quantity_keyboard, + _quantity_manual_keyboard, + _render_confirm, + _render_draft_detail, + _render_draft_summary, + _render_inline_error, + _render_manual_price_screen, + _render_manual_quantity_screen, + _render_price_input_help, + _render_price_step_screen, + _render_quantity_input_help, + _render_quantity_step_screen, + _render_validation_error, + _screen_title, + _side_keyboard, + _trade_back_home_keyboard, + _type_keyboard, + show_recent_drafts, + mode_line, +) +from src.trading.orders.service import OrderDraftsService +from src.trading.orders.states import NewOrderDraftStates + + +MAIN_MENU_BUTTONS = { + "🏠 Главная", + "📈 Рынок", + "💼 Портфель", + "📊 Торговля", + "⚡ Торговля", + "Торговля", + "🤖 Авто", + "📒 Журнал", + "⚙️ Система", + "⚙ Система", + "Меню", +} + + +@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: + service = OrderDraftsService() + _, draft_id, page_raw = callback.data.split(":", 2) + page = int(page_raw) + + draft = service.get_draft_by_id(draft_id) + if not draft: + await callback.answer("Черновик не найден", show_alert=True) + return + + context = service.get_entry_context( + side=str(draft["side"]).upper(), + order_type=str(draft["order_type"]).upper(), + ) + + await callback.message.edit_text( + _render_draft_detail( + draft, + base_currency=context.base_currency, + quote_currency=context.quote_currency, + ), + 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: + service = OrderDraftsService() + _, draft_id, page_raw = callback.data.split(":", 2) + page = int(page_raw) + + 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"]) + + 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) + context = service.get_entry_context(side=side, order_type=order_type) + + if order_type == "LIMIT": + await state.set_state(NewOrderDraftStates.waiting_price) + await callback.message.edit_text( + _render_price_step_screen( + title=title, + bid=context.bid_price, + ask=context.ask_price, + last=context.last_price, + quote_currency=context.quote_currency, + ), + reply_markup=_price_keyboard( + bid=context.bid_price, + ask=context.ask_price, + last=context.last_price, + ), + ) + await callback.answer() + return + + await state.set_state(NewOrderDraftStates.waiting_quantity) + await callback.message.edit_text( + _render_quantity_step_screen( + title=title, + symbol=context.symbol, + available_balance=context.available_balance, + balance_currency=context.balance_currency, + reference_price=context.reference_price, + quote_currency=context.quote_currency, + ), + 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" + f"{mode_line()}" + "⛔ Создание черновика отменено", + 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 = ( + "📊 Торговля — Новый ордер\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( + 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 = ( + "📊 Торговля — Новый ордер\n" + f"{mode_line()}" + "Шаг 2/4. Выбери тип ордера" + ) + + await callback.message.edit_text(text, reply_markup=_type_keyboard()) + await callback.answer() + + +@router.message( + NewOrderDraftStates.waiting_side, + ~F.text.in_(MAIN_MENU_BUTTONS), +) +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: + service = OrderDraftsService() + order_type = callback.data.split(":", 1)[1] + + 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( + _render_quantity_step_screen( + title=_screen_title(is_edit_mode), + symbol=context.symbol, + available_balance=context.available_balance, + balance_currency=context.balance_currency, + reference_price=context.reference_price, + quote_currency=context.quote_currency, + ), + reply_markup=_quantity_keyboard(context.quantity_presets), + ) + await callback.answer() + + +@router.message( + NewOrderDraftStates.waiting_type, + ~F.text.in_(MAIN_MENU_BUTTONS), +) +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: + service = OrderDraftsService() + 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) + + 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( + _render_manual_quantity_screen( + title=title, + reference_price=context.reference_price, + quote_currency=context.quote_currency, + 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) + + context = service.get_entry_context(side=data["side"], order_type=order_type) + + if order_type == "LIMIT": + await state.set_state(NewOrderDraftStates.waiting_price) + await callback.message.edit_text( + _render_price_step_screen( + title=title, + bid=context.bid_price, + ask=context.ask_price, + last=context.last_price, + quote_currency=context.quote_currency, + ), + 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, f"{context.reference_price:.2f}") + + await state.update_data( + confirm_draft={ + "symbol": draft.symbol, + "side": draft.side, + "order_type": draft.order_type, + "quantity": draft.quantity, + "price": draft.price, + "reference_price": f"{context.reference_price:.2f}", + "base_currency": context.base_currency, + "quote_currency": context.quote_currency, + "notional": notional, + } + ) + 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, + base_currency=context.base_currency, + quote_currency=context.quote_currency, + reference_price=f"{context.reference_price:.2f}", + ), + reply_markup=_confirm_keyboard(), + ) + await callback.answer() + + +# Обрабатывает ручной ввод количества. +@router.message( + NewOrderDraftStates.waiting_quantity, + ~F.text.in_(MAIN_MENU_BUTTONS), +) +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"], + price=context.reference_price, + quote_currency=context.quote_currency, + 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, + step_text="Шаг 3/4. Проверь введённое значение", + 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( + _render_price_step_screen( + title=title, + bid=context.bid_price, + ask=context.ask_price, + last=context.last_price, + quote_currency=context.quote_currency, + ), + 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, f"{context.reference_price:.2f}") + + await state.update_data( + confirm_draft={ + "symbol": draft.symbol, + "side": draft.side, + "order_type": draft.order_type, + "quantity": draft.quantity, + "price": draft.price, + "reference_price": f"{context.reference_price:.2f}", + "base_currency": context.base_currency, + "quote_currency": context.quote_currency, + "notional": notional, + } + ) + 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, + base_currency=context.base_currency, + quote_currency=context.quote_currency, + reference_price=f"{context.reference_price:.2f}", + ), + 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: + service = OrderDraftsService() + 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) + + context = service.get_entry_context( + side=data.get("side", "BUY"), + order_type=data.get("order_type", "LIMIT"), + ) + + if value == "manual": + rules = service.get_entry_rules() + price_example = f"{context.last_price:.2f}" + + await callback.message.edit_text( + _render_manual_price_screen( + title=title, + tick_size=rules["tick_size"], + example=price_example, + quote_currency=context.quote_currency, + ), + 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, + "base_currency": context.base_currency, + "quote_currency": context.quote_currency, + "notional": notional, + } + ) + 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, + base_currency=context.base_currency, + quote_currency=context.quote_currency, + ), + reply_markup=_confirm_keyboard(), + ) + await callback.answer() + + +# Обрабатывает ручной ввод цены. +@router.message( + NewOrderDraftStates.waiting_price, + ~F.text.in_(MAIN_MENU_BUTTONS), +) +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, + quote_currency=context.quote_currency, + ) + + 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, + step_text="Шаг 4/4. Проверь введённое значение", + 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, + "base_currency": context.base_currency, + "quote_currency": context.quote_currency, + "notional": notional, + } + ) + 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, + base_currency=context.base_currency, + quote_currency=context.quote_currency, + ), + 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 + + reference_price = raw.get("reference_price") + base_currency = raw.get("base_currency") + quote_currency = raw.get("quote_currency") + notional = raw.get("notional") + + 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, + base_currency=base_currency, + quote_currency=quote_currency, + reference_price=reference_price, + notional=notional, + ), + reply_markup=reply_markup, + ) + await callback.answer() \ No newline at end of file diff --git a/app/src/telegram/handlers/trade/new_order_navigation.py b/app/src/telegram/handlers/trade/new_order_navigation.py new file mode 100644 index 0000000..f127dd1 --- /dev/null +++ b/app/src/telegram/handlers/trade/new_order_navigation.py @@ -0,0 +1,187 @@ +# app/src/telegram/handlers/trade/new_order_navigation.py + +from __future__ import annotations + +from aiogram import F +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery + +from src.telegram.handlers.trade.new_order_core import router +from src.telegram.handlers.trade.new_order_ui import ( + mode_line, + _price_keyboard, + _quantity_keyboard, + _render_price_step_screen, + _render_quantity_step_screen, + _screen_title, + _trade_back_home_keyboard, + _type_keyboard, + _side_keyboard, +) +from src.trading.orders.service import OrderDraftsService +from src.trading.orders.states import NewOrderDraftStates + + +@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(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 = ( + "📊 Торговля — Новый ордер\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( + _render_quantity_step_screen( + title=_screen_title(is_edit_mode), + symbol=context.symbol, + available_balance=context.available_balance, + balance_currency=context.balance_currency, + reference_price=context.reference_price, + ), + 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( + _render_price_step_screen( + title=_screen_title(is_edit_mode), + bid=context.bid_price, + ask=context.ask_price, + last=context.last_price, + ), + 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( + _render_quantity_step_screen( + title=_screen_title(is_edit_mode), + symbol=context.symbol, + available_balance=context.available_balance, + balance_currency=context.balance_currency, + reference_price=context.reference_price, + ), + 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( + _render_quantity_step_screen( + title=_screen_title(is_edit_mode), + symbol=context.symbol, + available_balance=context.available_balance, + balance_currency=context.balance_currency, + reference_price=context.reference_price, + ), + 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( + _render_price_step_screen( + title=_screen_title(is_edit_mode), + bid=context.bid_price, + ask=context.ask_price, + last=context.last_price, + ), + reply_markup=_price_keyboard( + bid=context.bid_price, + ask=context.ask_price, + last=context.last_price, + ), + ) + await callback.answer() \ No newline at end of file diff --git a/app/src/telegram/handlers/trade/new_order_ui.py b/app/src/telegram/handlers/trade/new_order_ui.py new file mode 100644 index 0000000..511f11f --- /dev/null +++ b/app/src/telegram/handlers/trade/new_order_ui.py @@ -0,0 +1,664 @@ +# app/src/telegram/handlers/trade/new_order_ui.py + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal, InvalidOperation, ROUND_UP +from zoneinfo import ZoneInfo + +from aiogram.types import InlineKeyboardMarkup, Message +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from src.telegram.handlers.trade.new_order_core import DRAFTS_PAGE_SIZE +from src.telegram.ui.common import mode_line +from src.trading.orders.service import OrderDraftsService + + +def _to_decimal(value: str | float | int | None) -> Decimal | None: + if value is None: + return None + try: + return Decimal(str(value).strip()) + except (InvalidOperation, ValueError): + return None + + +def _ceil_to_step(value: Decimal, step: Decimal) -> Decimal: + if step <= 0: + return value + ratio = (value / step).to_integral_value(rounding=ROUND_UP) + return ratio * step + + +def _format_decimal_text(value: Decimal) -> str: + text = f"{value:.8f}" + text = text.rstrip("0").rstrip(".") + return text or "0" + + +# Оценивает минимально допустимое количество по правилу minNotional. +def _estimate_min_quantity_by_notional( + *, + reference_price: float | None, + min_notional: str | None, + step_size: str | None, +) -> str | None: + ref = _to_decimal(reference_price) + notional = _to_decimal(min_notional) + step = _to_decimal(step_size) + + if ref is None or ref <= 0 or notional is None or notional <= 0: + return None + + raw_qty = notional / ref + + if step is not None and step > 0: + raw_qty = _ceil_to_step(raw_qty, step) + + return _format_decimal_text(raw_qty) + + +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 _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 _format_value_with_currency(value: str | float | None, currency: str | None) -> str | None: + if value is None: + return None + text = str(value).strip() + if not text: + return None + return f"{text} {currency}" if currency else text + + +def _format_value_with_asset(value: str | float | None, asset: str | None) -> str | None: + if value is None: + return None + text = str(value).strip() + if not text: + return None + return f"{text} {asset}" if asset else text + + +# Рендерит экран успешного сохранения черновика. +def _render_draft_summary( + symbol: str, + side: str, + order_type: str, + quantity: str, + price: str | None, + base_currency: str | None = None, + quote_currency: str | None = None, + reference_price: str | None = None, + notional: float | None = None, +) -> str: + quantity_text = _format_value_with_asset(quantity, base_currency) + + lines = [ + "📊 Торговля — Черновик ордера", + mode_line().rstrip(), + "", + f"Инструмент: {symbol}", + f"Сторона: {side}", + f"Тип: {order_type}", + f"Количество: {quantity_text or quantity}", + ] + + if price: + price_text = _format_value_with_currency(price, quote_currency) + lines.append(f"Цена: {price_text or price}") + elif reference_price: + reference_price_text = _format_value_with_currency(reference_price, quote_currency) + lines.append(f"Ориентир цены: {reference_price_text or reference_price}") + + if notional is not None: + sum_text = _format_value_with_currency(f"{notional:.2f}", quote_currency) + lines.append(f"Сумма: {sum_text or f'{notional:.2f}'}") + + lines.extend( + [ + "Статус: draft", + "", + "✅ Черновик создан", + "", + "Ордер не отправлялся на биржу", + ] + ) + 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, + base_currency: str | None = None, + quote_currency: str | None = None, + reference_price: str | None = None, +) -> str: + quantity_text = _format_value_with_asset(quantity, base_currency) + + lines = [ + _screen_title(is_edit_mode), + mode_line().rstrip(), + "", + f"Инструмент: {symbol}", + f"Сторона: {side}", + f"Тип: {order_type}", + f"Количество: {quantity_text or quantity}", + ] + + if price: + price_text = _format_value_with_currency(price, quote_currency) + lines.append(f"Цена: {price_text or price}") + elif reference_price: + reference_price_text = _format_value_with_currency(reference_price, quote_currency) + lines.append(f"Ориентир цены: {reference_price_text or reference_price}") + + if notional is not None: + sum_text = _format_value_with_currency(f"{notional:.2f}", quote_currency) + lines.append(f"Сумма: {sum_text or 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, + price: float | None, + quote_currency: str | None, + example: str, +) -> str: + lines = [ + "👉 Правила ввода количества", + "", + ] + + min_quantity = None + estimated_notional = None + + try: + if min_notional and price: + min_from_notional = float(min_notional) / float(price) + min_quantity = max(float(min_qty or 0), min_from_notional) + elif min_qty: + min_quantity = float(min_qty) + except Exception: + min_quantity = float(min_qty) if min_qty else None + + if min_quantity and step_size: + step = float(step_size) + min_quantity = (int(min_quantity / step + 0.9999999)) * step + + if min_quantity and price: + estimated_notional = min_quantity * price + + currency = quote_currency or "?" + + if min_quantity: + qty_text = f"{min_quantity:.6f}".rstrip("0").rstrip(".") + lines.append(f"• минимум для ввода: {qty_text}") + + if step_size: + lines.append(f"• шаг: {step_size}") + + if estimated_notional: + lines.append(f"≈ сумма: {estimated_notional:.2f} {currency}") + + lines.extend( + [ + "", + f"👉 Пример: {example}", + ] + ) + + return "\n".join(lines) + + +# Формирует блок правил ручного ввода цены. +def _render_price_input_help( + *, + tick_size: str | None, + example: str, + quote_currency: str | None = None, +) -> str: + currency = quote_currency or "?" + lines = [ + "👉 Правила ввода цены", + "", + ] + + if tick_size: + lines.append(f"• шаг цены: {tick_size} {currency}") + + lines.extend(["", f"Пример: {example} {currency}"]) + return "\n".join(lines) + + +# Рендерит экран детального просмотра черновика. +def _render_draft_detail( + draft: dict[str, str], + base_currency: str | None = None, + quote_currency: str | None = None, +) -> str: + quantity = _format_draft_quantity(draft["quantity"]) + created_at = _format_draft_time(draft["created_at"]) + quantity_text = _format_value_with_asset(quantity, base_currency) + + lines = [ + "📊 Торговля — Черновик", + mode_line().rstrip(), + f"Инструмент: {draft['symbol']}", + f"Сторона: {draft['side']}", + f"Тип: {draft['order_type']}", + f"Количество: {quantity_text or quantity}", + ] + + if draft.get("price"): + price_text = _format_value_with_currency(draft["price"], quote_currency) + lines.append(f"Цена: {price_text or draft['price']}") + + lines.extend( + [ + f"Статус: {draft['status']}", + f"Время: {created_at}", + ] + ) + + 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 "📊 Торговля — Редактирование черновика" + return "📊 Торговля — Новый ордер" + + +# Рендерит экран выбора количества. +def _render_quantity_step_screen( + *, + title: str, + symbol: str, + available_balance: float, + balance_currency: str, + reference_price: float, + quote_currency: str, +) -> str: + return ( + f"{title}\n" + f"{mode_line()}" + f"Инструмент: {symbol}\n" + f"Доступно: {available_balance:.8f} {balance_currency}\n" + f"Ориентир цены: {reference_price:.2f} {quote_currency}\n\n" + "Шаг 3/4. Выбери количество" + ) + + +# Рендерит экран выбора цены. +def _render_price_step_screen( + *, + title: str, + bid: float, + ask: float, + last: float, + quote_currency: str, +) -> str: + return ( + f"{title}\n" + f"{mode_line()}" + f"Bid: {bid:.2f} {quote_currency}\n" + f"Ask: {ask:.2f} {quote_currency}\n" + f"Last: {last:.2f} {quote_currency}\n\n" + "Шаг 4/4. Выбери цену" + ) + + +# Рендерит экран ручного ввода количества. +def _render_manual_quantity_screen( + *, + title: str, + reference_price: float | None, + quote_currency: str | None, + min_qty: str | None, + step_size: str | None, + min_notional: str | None, + example: str, +) -> str: + estimated_min_qty = _estimate_min_quantity_by_notional( + reference_price=reference_price, + min_notional=min_notional, + step_size=step_size, + ) + + estimated_notional = None + if estimated_min_qty is not None and reference_price is not None and reference_price > 0: + try: + estimated_notional = float(estimated_min_qty) * float(reference_price) + except Exception: + estimated_notional = None + + currency = quote_currency or "?" + + lines = [ + title, + mode_line().rstrip(), + ] + + if reference_price is not None and reference_price > 0: + lines.extend( + [ + f"Ориентир цены: {reference_price:.2f} {currency}", + "", + ] + ) + + lines.extend( + [ + "👉 Правила ввода количества", + "", + ] + ) + + if estimated_min_qty: + lines.append(f"• минимум для ввода: {estimated_min_qty}") + elif min_qty: + lines.append(f"• минимум для ввода: {min_qty}") + + if step_size: + lines.append(f"• шаг: {step_size}") + + if estimated_notional is not None: + lines.append(f"≈ сумма: {estimated_notional:.2f} {currency}") + + lines.extend( + [ + "", + f"👉 Пример: {example}", + "", + "Шаг 3/4. Введи количество", + ] + ) + + return "\n".join(lines) + + +# Рендерит экран ручного ввода цены. +def _render_manual_price_screen( + *, + title: str, + tick_size: str | None, + example: str, + quote_currency: str | None, +) -> str: + return ( + f"{title}\n" + f"{mode_line()}" + f"{_render_price_input_help( + tick_size=tick_size, + example=example, + quote_currency=quote_currency, + )}\n\n" + "Шаг 4/4. Введи цену" + ) + + +# Показывает список последних черновиков с пагинацией. +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" + f"{mode_line()}" + "Список пуст\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 = [ + "📊 Торговля — Черновики", + 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"{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 = details_builder.as_markup() + + if edit_mode: + await message.edit_text(text, reply_markup=keyboard) + else: + await message.answer(text, reply_markup=keyboard) \ No newline at end of file diff --git a/app/src/telegram/keyboards/reply.py b/app/src/telegram/keyboards/reply.py index fece25a..063382c 100644 --- a/app/src/telegram/keyboards/reply.py +++ b/app/src/telegram/keyboards/reply.py @@ -1,3 +1,5 @@ +# app/src/telegram/keyboards/reply.py + from aiogram.types import KeyboardButton, ReplyKeyboardMarkup diff --git a/app/src/telegram/ui/__init__.py b/app/src/telegram/ui/__init__.py new file mode 100644 index 0000000..d8df7b8 --- /dev/null +++ b/app/src/telegram/ui/__init__.py @@ -0,0 +1 @@ +"""Package marker.""" diff --git a/app/src/telegram/ui/common.py b/app/src/telegram/ui/common.py new file mode 100644 index 0000000..9bb5d73 --- /dev/null +++ b/app/src/telegram/ui/common.py @@ -0,0 +1,8 @@ +# app/src/telegram/ui/common.py + +from src.core.system_status import get_runtime_mode_label + + +def mode_line() -> str: + label = get_runtime_mode_label() + return f"🔸 {label}\n\n" \ No newline at end of file diff --git a/app/src/trading/orders/models.py b/app/src/trading/orders/models.py index 78cb0fb..8671a76 100644 --- a/app/src/trading/orders/models.py +++ b/app/src/trading/orders/models.py @@ -20,7 +20,9 @@ class OrderEntryContext: symbol: str side: str order_type: str + base_currency: str balance_currency: str + quote_currency: str available_balance: float reference_price: float last_price: float diff --git a/app/src/trading/orders/service.py b/app/src/trading/orders/service.py index 276279f..2e2d15c 100644 --- a/app/src/trading/orders/service.py +++ b/app/src/trading/orders/service.py @@ -181,15 +181,40 @@ class OrderDraftsService: return self.repository.get_draft_by_id(draft_id) def get_entry_context(self, *, side: str, order_type: str) -> OrderEntryContext: + # Собираем контекст экрана ввода ордера на основе биржевых правил, + # текущего рынка и доступного баланса. validation = self.exchange.validate_symbol(self.settings.default_symbol) if not validation.is_valid or validation.symbol_info is None: raise ValueError(validation.message) + symbol_info = validation.symbol_info balances = self.exchange.get_balance_summary() market = self.exchange.get_market_snapshot(self.settings.default_symbol) - base_asset = validation.symbol_info.base_asset or "BASE" - quote_asset = validation.symbol_info.quote_asset or "QUOTE" + base_asset = (symbol_info.base_asset or "").strip() + quote_asset = (symbol_info.quote_asset or "").strip() + + if not base_asset or not quote_asset: + message = ( + "Биржа не вернула base/quote валюту для инструмента. " + "Невозможно корректно рассчитать контекст ордера." + ) + try: + self.journal.log_error( + "order_entry_context_assets_missing", + message, + { + "symbol": self.settings.default_symbol, + "base_asset": base_asset or None, + "quote_asset": quote_asset or None, + }, + ) + except Exception: + pass + raise ValueError(message) + + base_currency = base_asset.upper() + quote_currency = quote_asset.upper() available_by_currency = { item.currency.upper(): float(item.available) @@ -200,12 +225,12 @@ class OrderDraftsService: order_type_upper = order_type.upper() if side_upper == "BUY": - balance_currency = quote_asset.upper() + balance_currency = quote_currency available_balance = available_by_currency.get(balance_currency, 0.0) reference_price = float(market["ask_price"]) max_qty = (available_balance / reference_price) if reference_price > 0 else 0.0 else: - balance_currency = base_asset.upper() + balance_currency = base_currency available_balance = available_by_currency.get(balance_currency, 0.0) reference_price = float(market["bid_price"]) max_qty = available_balance @@ -213,14 +238,16 @@ class OrderDraftsService: quantity_presets = self._build_quantity_presets( max_qty=max_qty, reference_price=reference_price, - symbol_info=validation.symbol_info, + symbol_info=symbol_info, ) return OrderEntryContext( symbol=self.settings.default_symbol, side=side_upper, order_type=order_type_upper, + base_currency=base_currency, balance_currency=balance_currency, + quote_currency=quote_currency, available_balance=available_balance, reference_price=reference_price, last_price=float(market["last_price"]), diff --git a/docs/stages/stage-03-3-exchange-info.txt b/docs/stages/stage-03-3-exchange-info.txt deleted file mode 100644 index 8972acd..0000000 --- a/docs/stages/stage-03-3-exchange-info.txt +++ /dev/null @@ -1 +0,0 @@ -docs_stage-03-3-exchange-info \ No newline at end of file diff --git a/docs/stages/stage_05-7-trade-draft-UI-restructuring.md b/docs/stages/stage_05-7-trade-draft-UI-restructuring.md new file mode 100644 index 0000000..e826386 --- /dev/null +++ b/docs/stages/stage_05-7-trade-draft-UI-restructuring.md @@ -0,0 +1,241 @@ +# Stage 05.7 — trade draft UI restructuring and order context display + +## Что сделано + +На этом этапе был завершён крупный блок улучшений сценария создания черновика ордера в разделе **Торговля**. + +Основные изменения: + +- переработан UI сценария нового ордера +- вынесены и унифицированы экранные рендеры +- вынесены части логики в отдельные файлы +- добавлено отображение контекста ордера на ключевых экранах +- добавлены валюта котировки и базовый актив в отображение параметров ордера +- улучшена работа экранов черновиков +- введена общая UI-линия режима аккаунта +- подготовлена база для дальнейшей декомпозиции trade-модуля + +--- + +## Структурные изменения + +### Разделение сценария нового ордера + +Монолитный сценарий `new_order.py` был разложен на логические части: + +- `new_order_core.py` — общая точка ядра сценария +- `new_order_flow.py` — основной FSM flow создания/редактирования черновика +- `new_order_navigation.py` — навигационные переходы и back-логика +- `new_order_ui.py` — клавиатуры, рендеры экранов, форматирование и список черновиков +- `new_order.py` — точка сборки роутеров сценария + +Это позволило уменьшить связанность кода и подготовить модуль к дальнейшему безопасному рефакторингу. + +--- + +## UI и UX улучшения + +### 1. Единый стиль экранов Trade + +Интерфейс экранов нового ордера приведён к единому стилю: + +- единый заголовок +- единая строка режима аккаунта +- единый формат шагов сценария +- единый стиль ошибок и подтверждений + +### 2. Отображение контекста ордера + +На экранах сценария теперь показываются: + +- количество с базовым активом + пример: `0.01 BTC` +- цена с валютой котировки + пример: `84500.00 USD` +- сумма ордера с валютой котировки + пример: `845.00 USD` + +### 3. Улучшение экранов ручного ввода + +Для ручного ввода количества и цены теперь отображаются: + +- шаг +- минимальный допустимый ввод +- оценочная сумма ордера +- пример корректного значения +- ориентир цены с валютой котировки + +### 4. Улучшение экрана подтверждения + +На шаге подтверждения черновика теперь выводятся: + +- инструмент +- сторона +- тип ордера +- количество с активом +- цена или ориентир цены +- итоговая сумма ордера + +--- + +## Логика ордера + +### Контекст входа в ордер + +Сервис формирования черновика теперь возвращает расширенный `OrderEntryContext`, в котором учитываются: + +- `balance_currency` +- `quote_currency` +- `base_currency` +- `reference_price` +- `available_balance` +- `bid_price` +- `ask_price` +- `last_price` + +### Валюты и активы + +Добавлена корректная работа с: + +- `base_currency` — актив инструмента +- `quote_currency` — валюта котировки + +Это позволило явно показывать пользователю, в каких единицах отображаются: + +- количество +- цена +- сумма + +### Защита от некорректного символа инструмента + +Если биржа не возвращает `base_asset` или `quote_asset`, сервис больше не продолжает работу молча, а: + +- логирует ошибку +- поднимает исключение +- предотвращает некорректный расчёт контекста ордера + +--- + +## Черновики + +### Список черновиков + +Экран последних черновиков был обновлён и приведён к общему стилю. + +Поддерживается: + +- список последних записей +- пагинация +- открытие карточки черновика +- переход в редактирование +- возврат назад + +### Детальный экран черновика + +На экране конкретного черновика теперь корректно отображаются: + +- количество с активом +- цена с валютой +- статус +- время создания + +--- + +## Навигация и FSM + +### Улучшение сценарных переходов + +Были исправлены и стабилизированы переходы между шагами: + +- side → type +- type → quantity +- quantity → price +- price → confirm +- manual quantity back +- manual price back +- confirm back + +### Поддержка edit mode + +Сценарий редактирования черновика теперь использует тот же улучшенный UI и повторно использует общий flow. + +--- + +## Общая UI инфраструктура + +### Единая mode line + +Строка режима аккаунта была унифицирована через общую функцию: + +- один источник правды +- единое отображение на разных экранах +- отсутствие дублирования в нескольких обработчиках + +--- + +## Документация + +Добавлен файл: + +- `docs/terminology.md` + +Он фиксирует соответствие между: + +- терминологией биржи +- терминологией UI бота +- внутренними именами в коде + +Это нужно для снижения путаницы перед следующими этапами развития торгового модуля. + +--- + +## Какие файлы были затронуты + +### Trade / order draft +- `app/src/telegram/handlers/trade/new_order.py` +- `app/src/telegram/handlers/trade/new_order_core.py` +- `app/src/telegram/handlers/trade/new_order_flow.py` +- `app/src/telegram/handlers/trade/new_order_navigation.py` +- `app/src/telegram/handlers/trade/new_order_ui.py` +- `app/src/telegram/handlers/trade/main.py` + +### Trading domain +- `app/src/trading/orders/models.py` +- `app/src/trading/orders/service.py` + +### Общий UI / menu / handlers +- `app/src/telegram/ui/` +- `app/src/telegram/keyboards/reply.py` +- `app/src/telegram/handlers/start.py` +- `app/src/telegram/handlers/home.py` +- `app/src/telegram/handlers/system.py` +- `app/src/telegram/handlers/market.py` +- `app/src/telegram/handlers/portfolio.py` +- `app/src/telegram/handlers/auto.py` +- `app/src/telegram/handlers/journal.py` + +### Docs +- `docs/terminology.md` + +--- + +## Результат этапа + +После завершения Stage 05.7 модуль trade draft flow стал: + +- понятнее по структуре +- стабильнее по FSM-переходам +- чище по UI +- информативнее для пользователя +- готовым к следующему этапу декомпозиции и UX-улучшений + +--- + +## Что подготовлено для следующего этапа + +Этот этап подготовил основу для: + +- отображения пути формируемого ордера на каждом шаге +- дальнейшего дробления trade-модуля +- выноса navigation/drafts/ui в ещё более чистую структуру +- перехода к терминологии, ближе к терминологии биржи \ No newline at end of file diff --git a/docs/terminology.md b/docs/terminology.md new file mode 100644 index 0000000..63d9f41 --- /dev/null +++ b/docs/terminology.md @@ -0,0 +1,65 @@ +# 🧭 Терминология: биржа → UI бота → код + +| 🏦 Термин биржи | 🤖 UI бота (как показываем) | 🧠 В коде (модель) | 💬 Комментарий | +|------------------------------|------------------------------------|--------------------------------|---------------| +| Купить | Купить (LONG) | `side = "BUY"` | Открытие long | +| Продать | Продать (SHORT) | `side = "SELL"` | Открытие short | +| Режим левереджа | 📈 Левередж | `trade_mode = "leverage"` | Основной режим | +| Режим торгов | 💱 Торги / Спот | `trade_mode = "spot"` | Без плеча | +| Купить сейчас | ⚡ MARKET | `order_type = "MARKET"` | Исполнение сразу | +| Купить когда цена = X | 🎯 LIMIT | `order_type = "LIMIT"` | Отложенный ордер | +| Цена | Цена | `price` | Только для LIMIT | +| Количество / Размер | Количество | `quantity` | Базовая величина | +| % от баланса | 5% / 10% / ... | `quantity` (расчёт) | UI-обёртка | +| Левередж | Плечо | `leverage` | Пока нет в модели | +| Доступно | Доступно | `available_balance` | из context | +| Ориентир цены (Last/Bid/Ask) | Ориентир цены | `reference_price` | для UI | +| Стоп-лосс | Стоп-лосс | `stop_loss` | будущий параметр | +| Тейк-профит | Тейк-профит | `take_profit` | будущий параметр | +| Сумма ордера | Сумма | `notional` | `price * quantity` | +| Мин. сумма | Мин. сумма | `min_notional` | правило биржи | +| Шаг количества | Шаг | `step_size` | правило | +| Мин. количество | Минимум | `min_qty` | правило | +| Черновик | Черновик | `draft` | статус | +| Подтвердить | Подтвердить | — | UI-действие | + +--- + +## 🔥 Основные принципы + +### 1. UI ≠ код +- UI: язык биржи (понятный пользователю) +- Код: строгая техническая модель + +--- + +### 2. MARKET / LIMIT — это тип ордера +- не путать с режимом торговли (левередж / спот) + +--- + +### 3. LONG / SHORT +- LONG = `BUY` +- SHORT = `SELL` + +--- + +### 4. Текущий статус модели + +✔ Уже есть: +- `side` +- `order_type` +- `quantity` +- `price` + +🔜 Нужно добавить: +- `leverage` +- `stop_loss` +- `take_profit` + +--- + +## 🧭 Итог + +Пользователь видит **терминологию биржи**, +система работает на **нормализованной модели**. \ No newline at end of file