Stage 05.7 - trade draft UI restructuring and order context display

This commit is contained in:
2026-04-19 15:43:22 +03:00
parent 39b35d742a
commit cec7c761be
21 changed files with 2030 additions and 1243 deletions

View File

@@ -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:
async def open_auto(message: Message, state: FSMContext) -> None:
# Глобальный экран: всегда выходим из текущего FSM-сценария.
await state.clear()
await message.answer(AUTO_TEXT)

View File

@@ -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:
async def open_home(message: Message, state: FSMContext) -> None:
# Глобальный экран: всегда выходим из текущего FSM-сценария.
await state.clear()
await message.answer(HOME_TEXT)

View File

@@ -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
@@ -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()

View File

@@ -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,
},
)

View File

@@ -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()

View File

@@ -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,7 +46,9 @@ 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(),

View File

@@ -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:
async def open_system(message: Message, state: FSMContext) -> None:
# Глобальный экран: всегда выходим из текущего FSM-сценария.
await state.clear()
await message.answer(build_system_text())

View File

@@ -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"Режим: <b>{get_runtime_mode_label()}</b>\n\n"
def _trade_screen(title: str) -> str:
return (
f"<b>📊 Торговля — {title}</b>\n"
f"{_mode_line()}"
f"{mode_line()}"
"Выбери раздел"
)

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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(
"<b>📊 Торговля — Новый ордер</b>\n"
f"{mode_line()}"
"<b>⛔ Создание черновика отменено</b>",
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 = (
"<b>📊 Торговля — Новый ордер</b>\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 = (
"<b>📊 Торговля — Новый ордер</b>\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()

View File

@@ -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 = (
"<b>📊 Торговля — Новый ордер</b>\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 = (
"<b>📊 Торговля — Новый ордер</b>\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(
"<b>📊 Торговля</b>\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()

View File

@@ -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 = [
"<b>📊 Торговля — Черновик ордера</b>",
mode_line().rstrip(),
"",
f"Инструмент: <b>{symbol}</b>",
f"Сторона: <b>{side}</b>",
f"Тип: <b>{order_type}</b>",
f"Количество: <b>{quantity_text or quantity}</b>",
]
if price:
price_text = _format_value_with_currency(price, quote_currency)
lines.append(f"Цена: <b>{price_text or price}</b>")
elif reference_price:
reference_price_text = _format_value_with_currency(reference_price, quote_currency)
lines.append(f"Ориентир цены: <b>{reference_price_text or reference_price}</b>")
if notional is not None:
sum_text = _format_value_with_currency(f"{notional:.2f}", quote_currency)
lines.append(f"Сумма: <b>{sum_text or f'{notional:.2f}'}</b>")
lines.extend(
[
"Статус: <b>draft</b>",
"",
"<b>✅ Черновик создан</b>",
"",
"<i>Ордер не отправлялся на биржу</i>",
]
)
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"Инструмент: <b>{symbol}</b>",
f"Сторона: <b>{side}</b>",
f"Тип: <b>{order_type}</b>",
f"Количество: <b>{quantity_text or quantity}</b>",
]
if price:
price_text = _format_value_with_currency(price, quote_currency)
lines.append(f"Цена: <b>{price_text or price}</b>")
elif reference_price:
reference_price_text = _format_value_with_currency(reference_price, quote_currency)
lines.append(f"Ориентир цены: <b>{reference_price_text or reference_price}</b>")
if notional is not None:
sum_text = _format_value_with_currency(f"{notional:.2f}", quote_currency)
lines.append(f"Сумма: <b>{sum_text or f'{notional:.2f}'}</b>")
lines.extend(
[
"",
"Шаг 4/4. Подтверди черновик",
]
)
return "\n".join(lines)
def _render_validation_error(errors: list[str]) -> str:
lines = [
"<b>📊 Торговля — Ошибка валидации</b>",
mode_line().rstrip(),
"Шаг 4/4. Проверь параметры черновика",
"",
"<b>❌ Черновик не сохранён</b>",
"",
]
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,
"",
"<b>⚠️ Найдены ошибки</b>",
"",
]
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 = [
"👉 <b>Правила ввода количества</b>",
"",
]
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"• минимум для ввода: <b>{qty_text}</b>")
if step_size:
lines.append(f"• шаг: <b>{step_size}</b>")
if estimated_notional:
lines.append(f"≈ сумма: <b>{estimated_notional:.2f} {currency}</b>")
lines.extend(
[
"",
f"👉 Пример: <b>{example}</b>",
]
)
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 = [
"<b>👉 Правила ввода цены</b>",
"",
]
if tick_size:
lines.append(f"• шаг цены: <b>{tick_size} {currency}</b>")
lines.extend(["", f"Пример: <b>{example} {currency}</b>"])
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 = [
"<b>📊 Торговля — Черновик</b>",
mode_line().rstrip(),
f"Инструмент: <b>{draft['symbol']}</b>",
f"Сторона: <b>{draft['side']}</b>",
f"Тип: <b>{draft['order_type']}</b>",
f"Количество: <b>{quantity_text or quantity}</b>",
]
if draft.get("price"):
price_text = _format_value_with_currency(draft["price"], quote_currency)
lines.append(f"Цена: <b>{price_text or draft['price']}</b>")
lines.extend(
[
f"Статус: <b>{draft['status']}</b>",
f"Время: <b>{created_at}</b>",
]
)
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 "<b>📊 Торговля — Редактирование черновика</b>"
return "<b>📊 Торговля — Новый ордер</b>"
# Рендерит экран выбора количества.
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"Инструмент: <b>{symbol}</b>\n"
f"Доступно: <b>{available_balance:.8f} {balance_currency}</b>\n"
f"Ориентир цены: <b>{reference_price:.2f} {quote_currency}</b>\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: <b>{bid:.2f} {quote_currency}</b>\n"
f"Ask: <b>{ask:.2f} {quote_currency}</b>\n"
f"Last: <b>{last:.2f} {quote_currency}</b>\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"Ориентир цены: <b>{reference_price:.2f} {currency}</b>",
"",
]
)
lines.extend(
[
"👉 <b>Правила ввода количества</b>",
"",
]
)
if estimated_min_qty:
lines.append(f"• минимум для ввода: <b>{estimated_min_qty}</b>")
elif min_qty:
lines.append(f"• минимум для ввода: <b>{min_qty}</b>")
if step_size:
lines.append(f"• шаг: <b>{step_size}</b>")
if estimated_notional is not None:
lines.append(f"≈ сумма: <b>{estimated_notional:.2f} {currency}</b>")
lines.extend(
[
"",
f"👉 Пример: <b>{example}</b>",
"",
"Шаг 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 = (
"<b>📊 Торговля — Черновики</b>\n"
f"{mode_line()}"
"<b>Список пуст</b>\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 = [
"<b>📊 Торговля — Черновики</b>",
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"<b>{item['symbol']}</b>",
f"{item['side']} · {item['order_type']}",
f"Количество: <b>{quantity}</b>",
f"Статус: <b>{item['status']}</b>",
f"Время: <b>{created_at}</b>",
"",
]
)
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)

View File

@@ -1,3 +1,5 @@
# app/src/telegram/keyboards/reply.py
from aiogram.types import KeyboardButton, ReplyKeyboardMarkup

View File

@@ -0,0 +1 @@
"""Package marker."""

View File

@@ -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"🔸 <b>{label}</b>\n\n"

View File

@@ -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

View File

@@ -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"]),

View File

@@ -1 +0,0 @@
docs_stage-03-3-exchange-info

View File

@@ -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 в ещё более чистую структуру
- перехода к терминологии, ближе к терминологии биржи

65
docs/terminology.md Normal file
View File

@@ -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`
---
## 🧭 Итог
Пользователь видит **терминологию биржи**,
система работает на **нормализованной модели**.