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