diff --git a/app/src/telegram/handlers/trade/main.py b/app/src/telegram/handlers/trade/main.py
index 3841738..8970163 100644
--- a/app/src/telegram/handlers/trade/main.py
+++ b/app/src/telegram/handlers/trade/main.py
@@ -1,18 +1,104 @@
+# app/src/telegram/handlers/trade/main.py
+
from __future__ import annotations
from aiogram import F, Router
-from aiogram.types import Message
+from aiogram.fsm.context import FSMContext
+from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+
+from src.telegram.handlers.trade.new_order import (
+ show_recent_drafts,
+ start_new_order_draft,
+)
router = Router(name="trade_main")
+def _trade_home_keyboard() -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(text="📝 Новый ордер", callback_data="trade:new_order")
+ builder.button(text="📂 Черновики", callback_data="trade:drafts")
+ builder.button(text="⚙️ Настройки ордера", callback_data="trade:settings")
+ builder.button(text="ℹ️ Справка", callback_data="trade:help")
+ builder.adjust(2, 2)
+ return builder.as_markup()
+
+
+def _trade_back_keyboard() -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(text="⬅️ К торговле", callback_data="trade:home")
+ return builder.as_markup()
+
+
+def _trade_home_text() -> str:
+ return (
+ "⚡ Торговля\n\n"
+ "‼️ Режим черновика"
+ )
+
+
@router.message(F.text == "⚡ Торговля")
async def open_trade(message: Message) -> None:
- text = (
- "⚡ Торговля\n\n"
- "Доступные действия:\n"
- "• /new_order — создать черновик ордера\n"
- "• /drafts — показать последние черновики\n\n"
- "На этом этапе реальные ордера ещё не отправляются."
+ await message.answer(
+ _trade_home_text(),
+ reply_markup=_trade_home_keyboard(),
)
- await message.answer(text)
+
+
+@router.callback_query(F.data == "trade:home")
+async def open_trade_home_callback(callback: CallbackQuery) -> None:
+ await callback.answer()
+ if callback.message is not None:
+ await callback.message.edit_text(
+ _trade_home_text(),
+ reply_markup=_trade_home_keyboard(),
+ )
+
+
+@router.callback_query(F.data == "trade:new_order")
+async def open_new_order_from_trade(
+ callback: CallbackQuery,
+ state: FSMContext,
+) -> None:
+ await callback.answer()
+ if callback.message is not None:
+ await start_new_order_draft(callback.message, state, edit_mode=True)
+
+
+@router.callback_query(F.data == "trade:drafts")
+async def open_drafts_from_trade(callback: CallbackQuery) -> None:
+ await callback.answer()
+ if callback.message is not None:
+ await show_recent_drafts(callback.message, edit_mode=True, page=1)
+
+
+@router.callback_query(F.data == "trade:settings")
+async def open_trade_settings(callback: CallbackQuery) -> None:
+ await callback.answer()
+ if callback.message is not None:
+ await callback.message.edit_text(
+ "⚡ Торговля — Настройки ордера\n\n"
+ "Раздел в разработке.\n\n"
+ "Планируется добавить:\n"
+ "• параметры ордера по умолчанию\n"
+ "• пресеты количества\n"
+ "• режим цены: Bid / Ask / Last",
+ reply_markup=_trade_back_keyboard(),
+ )
+
+
+@router.callback_query(F.data == "trade:help")
+async def open_trade_help(callback: CallbackQuery) -> None:
+ await callback.answer()
+ if callback.message is not None:
+ await callback.message.edit_text(
+ "⚡ Торговля — Справка\n\n"
+ "Режим черновика — ордер не отправляется на биржу.\n\n"
+ "Сейчас можно:\n"
+ "• собрать черновик ордера\n"
+ "• проверить параметры\n"
+ "• сохранить черновик в базу\n\n"
+ "Реальная отправка ордера появится позже.",
+ reply_markup=_trade_back_keyboard(),
+ )
\ No newline at end of file
diff --git a/app/src/telegram/handlers/trade/new_order.py b/app/src/telegram/handlers/trade/new_order.py
index 70418cf..911a061 100644
--- a/app/src/telegram/handlers/trade/new_order.py
+++ b/app/src/telegram/handlers/trade/new_order.py
@@ -1,3 +1,5 @@
+# app/src/telegram/handlers/trade/new_order.py
+
from __future__ import annotations
from aiogram import F, Router
@@ -12,13 +14,16 @@ from src.trading.orders.states import NewOrderDraftStates
router = Router(name="trade_new_order")
+DRAFTS_PAGE_SIZE = 3
+
def _side_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🟢 BUY", callback_data="order_side:BUY")
builder.button(text="🔴 SELL", callback_data="order_side:SELL")
+ builder.button(text="⬅️ Назад", callback_data="trade:home")
builder.button(text="✖️ Отмена", callback_data="order_cancel")
- builder.adjust(2, 1)
+ builder.adjust(2, 2)
return builder.as_markup()
@@ -26,14 +31,9 @@ def _type_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="⚡ MARKET", callback_data="order_type:MARKET")
builder.button(text="🎯 LIMIT", callback_data="order_type:LIMIT")
+ builder.button(text="⬅️ Назад", callback_data="order_back:side")
builder.button(text="✖️ Отмена", callback_data="order_cancel")
- builder.adjust(2, 1)
- return builder.as_markup()
-
-
-def _cancel_keyboard() -> InlineKeyboardMarkup:
- builder = InlineKeyboardBuilder()
- builder.button(text="✖️ Отмена", callback_data="order_cancel")
+ builder.adjust(2, 2)
return builder.as_markup()
@@ -45,8 +45,17 @@ def _quantity_keyboard(presets: list[str]) -> InlineKeyboardMarkup:
builder.button(text=label, callback_data=f"order_qty:{value}")
builder.button(text="✍️ Ввести вручную", callback_data="order_qty:manual")
+ builder.button(text="⬅️ Назад", callback_data="order_back:type")
builder.button(text="✖️ Отмена", callback_data="order_cancel")
- builder.adjust(2, 2, 1)
+ builder.adjust(2, 2, 1, 2)
+ return builder.as_markup()
+
+
+def _quantity_manual_keyboard() -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(text="⬅️ Назад", callback_data="order_back:type")
+ builder.button(text="✖️ Отмена", callback_data="order_cancel")
+ builder.adjust(2)
return builder.as_markup()
@@ -56,8 +65,47 @@ def _price_keyboard(bid: float, ask: float, last: float) -> InlineKeyboardMarkup
builder.button(text=f"Ask {ask:.2f}", callback_data=f"order_price:{ask}")
builder.button(text=f"Last {last:.2f}", callback_data=f"order_price:{last}")
builder.button(text="✍️ Ввести вручную", callback_data="order_price:manual")
+ builder.button(text="⬅️ Назад", callback_data="order_back:quantity")
builder.button(text="✖️ Отмена", callback_data="order_cancel")
- builder.adjust(2, 1, 1, 1)
+ builder.adjust(2, 2, 2)
+ return builder.as_markup()
+
+
+def _price_manual_keyboard() -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(text="⬅️ Назад", callback_data="order_back:quantity")
+ builder.button(text="✖️ Отмена", callback_data="order_cancel")
+ builder.adjust(2)
+ return builder.as_markup()
+
+
+def _trade_back_home_keyboard() -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(text="⬅️ К торговле", callback_data="trade:home")
+ return builder.as_markup()
+
+
+def _drafts_keyboard(page: int, total_pages: int) -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+
+ if page > 1:
+ builder.button(text="⏮️", callback_data="drafts:1")
+ builder.button(text="⬅️", callback_data=f"drafts:{page - 1}")
+
+ builder.button(text=f"{page}/{total_pages}", callback_data="drafts:noop")
+
+ if page < total_pages:
+ builder.button(text="➡️", callback_data=f"drafts:{page + 1}")
+
+ first_row_count = 1
+ if page > 1:
+ first_row_count += 2
+ if page < total_pages:
+ first_row_count += 1
+
+ builder.button(text="⬅️ К торговле", callback_data="trade:home")
+ builder.adjust(first_row_count, 1)
+
return builder.as_markup()
@@ -69,7 +117,9 @@ def _render_draft_summary(
price: str | None,
) -> str:
lines = [
- "📝 Черновик ордера создан",
+ "⚡ Торговля — Черновик ордера",
+ "",
+ "📝 Черновик создан",
"",
f"• инструмент: {symbol}",
f"• сторона: {side}",
@@ -82,12 +132,110 @@ def _render_draft_summary(
[
"• статус: draft",
"",
- "Это тестовый draft flow. Реальный ордер не отправлялся.",
+ "Ордер не отправлялся на биржу",
]
)
return "\n".join(lines)
+def _render_validation_error(errors: list[str]) -> str:
+ lines = [
+ "⚡ Торговля — Ошибка валидации",
+ "",
+ "❌ Черновик не сохранён",
+ "",
+ "Причины",
+ "",
+ ]
+ for item in errors:
+ lines.append(f"• {item}")
+ return "\n".join(lines)
+
+
+def _format_draft_time(value: str) -> str:
+ text = str(value).replace("T", " ")
+ if "+" in text:
+ text = text.split("+", 1)[0]
+ if "." in text:
+ text = text.split(".", 1)[0]
+ return text
+
+
+def _format_draft_quantity(value: str) -> str:
+ text = str(value).rstrip("0").rstrip(".")
+ return text or "0"
+
+
+async def show_recent_drafts(
+ message: Message,
+ edit_mode: bool = False,
+ page: int = 1,
+) -> None:
+ service = OrderDraftsService()
+ all_drafts = service.list_recent_drafts(limit=100)
+
+ total = len(all_drafts)
+ total_pages = max(1, (total + DRAFTS_PAGE_SIZE - 1) // DRAFTS_PAGE_SIZE)
+ page = max(1, min(page, total_pages))
+
+ start = (page - 1) * DRAFTS_PAGE_SIZE
+ end = start + DRAFTS_PAGE_SIZE
+ drafts = all_drafts[start:end]
+
+ if not drafts:
+ text = (
+ "⚡ Торговля — Черновики\n\n"
+ "Черновиков пока нет."
+ )
+ if edit_mode:
+ await message.edit_text(text, reply_markup=_trade_back_home_keyboard())
+ else:
+ await message.answer(text)
+ return
+
+ lines = ["⚡ Торговля — Черновики", "", "Последние записи", ""]
+
+ for item in drafts:
+ quantity = _format_draft_quantity(item["quantity"])
+ created_at = _format_draft_time(item["created_at"])
+
+ lines.extend(
+ [
+ f"{item['symbol']} · {item['side']} · {item['order_type']}",
+ f"• количество: {quantity}",
+ f"• статус: {item['status']}",
+ f"• время: {created_at}",
+ "",
+ ]
+ )
+
+ text = "\n".join(lines).rstrip()
+ keyboard = _drafts_keyboard(page, total_pages)
+
+ if edit_mode:
+ await message.edit_text(text, reply_markup=keyboard)
+ else:
+ await message.answer(text, reply_markup=keyboard)
+
+
+@router.callback_query(F.data == "drafts:noop")
+async def drafts_noop(callback: CallbackQuery) -> None:
+ await callback.answer()
+
+
+@router.callback_query(F.data.startswith("drafts:"))
+async def paginate_drafts(callback: CallbackQuery) -> None:
+ value = callback.data.split(":", 1)[1]
+ if value == "noop":
+ await callback.answer()
+ return
+
+ page = int(value)
+ await callback.answer()
+ if callback.message is not None:
+ await show_recent_drafts(callback.message, edit_mode=True, page=page)
+
+
@router.message(Command("cancel_order"))
async def cancel_order_builder(message: Message, state: FSMContext) -> None:
await state.clear()
@@ -105,21 +253,77 @@ async def cancel_order_builder_callback(
await state.clear()
await callback.message.edit_text(
"⚡ Торговля\n\n"
- "Создание черновика отменено."
+ "Создание черновика отменено.",
+ reply_markup=_trade_back_home_keyboard(),
)
await callback.answer()
@router.message(Command("new_order"))
-async def start_new_order_draft(message: Message, state: FSMContext) -> None:
+async def start_new_order_draft(
+ message: Message,
+ state: FSMContext,
+ edit_mode: bool = False,
+) -> None:
await state.clear()
await state.set_state(NewOrderDraftStates.waiting_side)
- await message.answer(
- "⚡ Новый черновик ордера\n\n"
+
+ text = (
+ "⚡ Торговля — Новый ордер\n\n"
+ "Шаг 1/4\n"
+ "Выберите сторону:"
+ )
+
+ if edit_mode:
+ await message.edit_text(text, reply_markup=_side_keyboard())
+ else:
+ await message.answer(text, reply_markup=_side_keyboard())
+
+
+@router.callback_query(F.data == "order_back:side")
+async def go_back_to_side(callback: CallbackQuery, state: FSMContext) -> None:
+ await state.set_state(NewOrderDraftStates.waiting_side)
+ await callback.message.edit_text(
+ "⚡ Торговля — Новый ордер\n\n"
"Шаг 1/4\n"
"Выберите сторону:",
reply_markup=_side_keyboard(),
)
+ await callback.answer()
+
+
+@router.callback_query(F.data == "order_back:type")
+async def go_back_to_type(callback: CallbackQuery, state: FSMContext) -> None:
+ await state.set_state(NewOrderDraftStates.waiting_type)
+ await callback.message.edit_text(
+ "⚡ Торговля — Новый ордер\n\n"
+ "Шаг 2/4\n"
+ "Выберите тип ордера:",
+ reply_markup=_type_keyboard(),
+ )
+ await callback.answer()
+
+
+@router.callback_query(F.data == "order_back:quantity")
+async def go_back_to_quantity(callback: CallbackQuery, state: FSMContext) -> None:
+ service = OrderDraftsService()
+ data = await state.get_data()
+ side = data.get("side", "BUY")
+ order_type = data.get("order_type", "MARKET")
+
+ context = service.get_entry_context(side=side, order_type=order_type)
+ await state.set_state(NewOrderDraftStates.waiting_quantity)
+
+ await callback.message.edit_text(
+ "⚡ Торговля — Новый ордер\n\n"
+ "Шаг 3/4\n"
+ f"Инструмент: {context.symbol}\n"
+ f"Доступно: {context.available_balance:.8f} {context.balance_currency}\n"
+ f"Ориентир цены: {context.reference_price:.2f}\n\n"
+ "Выберите количество или введите его вручную:",
+ reply_markup=_quantity_keyboard(context.quantity_presets),
+ )
+ await callback.answer()
@router.callback_query(
@@ -136,7 +340,7 @@ async def process_order_side_callback(
await state.set_state(NewOrderDraftStates.waiting_type)
await callback.message.edit_text(
- "⚡ Новый черновик ордера\n\n"
+ "⚡ Торговля — Новый ордер\n\n"
"Шаг 2/4\n"
"Выберите тип ордера:",
reply_markup=_type_keyboard(),
@@ -172,7 +376,7 @@ async def process_order_type_callback(
context = service.get_entry_context(side=side, order_type=order_type)
await callback.message.edit_text(
- "⚡ Новый черновик ордера\n\n"
+ "⚡ Торговля — Новый ордер\n\n"
"Шаг 3/4\n"
f"Инструмент: {context.symbol}\n"
f"Доступно: {context.available_balance:.8f} {context.balance_currency}\n"
@@ -203,10 +407,10 @@ async def process_quantity_callback(
if value == "manual":
await callback.message.edit_text(
- "⚡ Новый черновик ордера\n\n"
+ "⚡ Торговля — Новый ордер\n\n"
"Шаг 3/4\n"
"Введите количество вручную, например: 0.001",
- reply_markup=_cancel_keyboard(),
+ reply_markup=_quantity_manual_keyboard(),
)
await callback.answer()
return
@@ -226,7 +430,7 @@ async def process_quantity_callback(
context = service.get_entry_context(side=data["side"], order_type=order_type)
await state.set_state(NewOrderDraftStates.waiting_price)
await callback.message.edit_text(
- "⚡ Новый черновик ордера\n\n"
+ "⚡ Торговля — Новый ордер\n\n"
"Шаг 4/4\n"
f"Bid: {context.bid_price:.2f}\n"
f"Ask: {context.ask_price:.2f}\n"
@@ -246,9 +450,19 @@ async def process_quantity_callback(
order_type=order_type,
quantity=quantity,
)
- service.save_draft(draft)
- await state.clear()
+ try:
+ service.save_draft(draft)
+ except ValueError as exc:
+ await state.clear()
+ errors = [item.strip() for item in str(exc).split(";") if item.strip()]
+ await callback.message.edit_text(
+ _render_validation_error(errors),
+ reply_markup=_trade_back_home_keyboard(),
+ )
+ await callback.answer()
+ return
+ await state.clear()
await callback.message.edit_text(
_render_draft_summary(
symbol=draft.symbol,
@@ -256,7 +470,8 @@ async def process_quantity_callback(
order_type=draft.order_type,
quantity=draft.quantity,
price=draft.price,
- )
+ ),
+ reply_markup=_trade_back_home_keyboard(),
)
await callback.answer()
@@ -278,7 +493,7 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None:
context = service.get_entry_context(side=data["side"], order_type=order_type)
await state.set_state(NewOrderDraftStates.waiting_price)
await message.answer(
- "⚡ Новый черновик ордера\n\n"
+ "⚡ Торговля — Новый ордер\n\n"
"Шаг 4/4\n"
f"Bid: {context.bid_price:.2f}\n"
f"Ask: {context.ask_price:.2f}\n"
@@ -297,9 +512,15 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None:
order_type=order_type,
quantity=quantity,
)
- service.save_draft(draft)
- await state.clear()
+ try:
+ service.save_draft(draft)
+ except ValueError as exc:
+ await state.clear()
+ errors = [item.strip() for item in str(exc).split(";") if item.strip()]
+ await message.answer(_render_validation_error(errors))
+ return
+ await state.clear()
await message.answer(
_render_draft_summary(
symbol=draft.symbol,
@@ -323,10 +544,10 @@ async def process_price_callback(
if value == "manual":
await callback.message.edit_text(
- "⚡ Новый черновик ордера\n\n"
+ "⚡ Торговля — Новый ордер\n\n"
"Шаг 4/4\n"
- "Введите цену вручную, например: 73000",
- reply_markup=_cancel_keyboard(),
+ "Введите цену вручную, например: 73000.123",
+ reply_markup=_price_manual_keyboard(),
)
await callback.answer()
return
@@ -344,9 +565,19 @@ async def process_price_callback(
quantity=data["quantity"],
price=price,
)
- service.save_draft(draft)
- await state.clear()
+ try:
+ service.save_draft(draft)
+ except ValueError as exc:
+ await state.clear()
+ errors = [item.strip() for item in str(exc).split(";") if item.strip()]
+ await callback.message.edit_text(
+ _render_validation_error(errors),
+ reply_markup=_trade_back_home_keyboard(),
+ )
+ await callback.answer()
+ return
+ await state.clear()
await callback.message.edit_text(
_render_draft_summary(
symbol=draft.symbol,
@@ -354,7 +585,8 @@ async def process_price_callback(
order_type=draft.order_type,
quantity=draft.quantity,
price=draft.price,
- )
+ ),
+ reply_markup=_trade_back_home_keyboard(),
)
await callback.answer()
@@ -364,7 +596,7 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
service = OrderDraftsService()
price = service.normalize_price(message.text or "")
if price is None:
- await message.answer("Введите корректную цену, например: 73000")
+ await message.answer("Введите корректную цену, например: 73000.123")
return
data = await state.get_data()
@@ -374,9 +606,15 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
quantity=data["quantity"],
price=price,
)
- service.save_draft(draft)
- await state.clear()
+ try:
+ service.save_draft(draft)
+ except ValueError as exc:
+ await state.clear()
+ errors = [item.strip() for item in str(exc).split(";") if item.strip()]
+ await message.answer(_render_validation_error(errors))
+ return
+ await state.clear()
await message.answer(
_render_draft_summary(
symbol=draft.symbol,
@@ -389,27 +627,5 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
@router.message(Command("drafts"))
-async def show_recent_drafts(message: Message) -> None:
- service = OrderDraftsService()
- drafts = service.list_recent_drafts(limit=5)
-
- if not drafts:
- await message.answer(
- "📝 Черновики ордеров\n\n"
- "Черновиков пока нет."
- )
- return
-
- lines = ["📝 Черновики ордеров", "", "Последние записи", ""]
-
- for item in drafts:
- lines.extend(
- [
- f"• {item['symbol']} | {item['side']} | {item['order_type']}",
- f" qty: {item['quantity']} | status: {item['status']}",
- f" time: {item['created_at']}",
- "",
- ]
- )
-
- await message.answer("\n".join(lines).rstrip())
\ No newline at end of file
+async def drafts_command(message: Message) -> None:
+ await show_recent_drafts(message, edit_mode=False, page=1)
\ No newline at end of file