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 # app/src/telegram/handlers/auto.py
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import Message from aiogram.types import Message
from src.telegram.menus import AUTO_TEXT from src.telegram.menus import AUTO_TEXT
@@ -10,5 +11,7 @@ router = Router(name="auto")
@router.message(F.text == "🤖 Авто") @router.message(F.text == "🤖 Авто")
async def open_auto(message: Message) -> None: async def open_auto(message: Message, state: FSMContext) -> None:
await message.answer(AUTO_TEXT) # Глобальный экран: всегда выходим из текущего FSM-сценария.
await state.clear()
await message.answer(AUTO_TEXT)

View File

@@ -1,6 +1,7 @@
# app/src/telegram/handlers/home.py # app/src/telegram/handlers/home.py
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import Message from aiogram.types import Message
from src.telegram.menus import HOME_TEXT from src.telegram.menus import HOME_TEXT
@@ -10,5 +11,8 @@ router = Router(name="home")
@router.message(F.text == "🏠 Главная") @router.message(F.text == "🏠 Главная")
async def open_home(message: Message) -> None: async def open_home(message: Message, state: FSMContext) -> None:
await message.answer(HOME_TEXT) # Глобальный экран: всегда выходим из текущего FSM-сценария.
await state.clear()
await message.answer(HOME_TEXT)

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder 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") kb.button(text="⏮️", callback_data="journal:1")
if page > 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") kb.button(text=f"{page}/{total_pages}", callback_data="noop")
if page < total_pages: 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() return kb.as_markup()
@@ -59,7 +60,10 @@ def render(events, page, total_pages):
@router.message(F.text == "📒 Журнал") @router.message(F.text == "📒 Журнал")
async def open_journal(message: Message): async def open_journal(message: Message, state: FSMContext):
# Глобальный экран: всегда выходим из текущего FSM-сценария.
await state.clear()
service = JournalService() service = JournalService()
total = service.get_total_count() total = service.get_total_count()

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import Message from aiogram.types import Message
from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.exceptions import ExchangeError
@@ -50,7 +51,10 @@ def _safe_log_error(
@router.message(F.text == "📈 Рынок") @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() service = ExchangeService()
journal = JournalService() journal = JournalService()
@@ -120,6 +124,8 @@ async def open_market(message: Message) -> None:
if symbol_info and symbol_info.tick_size is not None if symbol_info and symbol_info.tick_size is not None
else "n/a" 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 name = symbol_info.name if symbol_info and symbol_info.name else ticker.symbol
text = ( text = (
@@ -130,6 +136,8 @@ async def open_market(message: Message) -> None:
f"Статус: {symbol_status}\n" f"Статус: {symbol_status}\n"
f"Тип рынка: {market_type}\n" f"Тип рынка: {market_type}\n"
f"Режимы: {market_modes}\n" f"Режимы: {market_modes}\n"
f"Base asset: {base_asset}\n"
f"Quote asset: {quote_asset}\n"
f"Tick size: {tick_size}\n" f"Tick size: {tick_size}\n"
f"Источник: {ticker.source}\n" f"Источник: {ticker.source}\n"
f"Обновлено: {ticker.updated_at}" f"Обновлено: {ticker.updated_at}"
@@ -144,6 +152,8 @@ async def open_market(message: Message) -> None:
"chat_id": chat_id, "chat_id": chat_id,
"symbol": ticker.symbol, "symbol": ticker.symbol,
"price": ticker.price, "price": ticker.price,
"base_asset": base_asset,
"quote_asset": quote_asset,
}, },
) )

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import Message from aiogram.types import Message
from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.exceptions import ExchangeError
@@ -127,7 +128,10 @@ def _safe_log_error(
@router.message(F.text == "💼 Портфель") @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() service = AccountsService()
journal = JournalService() journal = JournalService()
@@ -234,4 +238,4 @@ async def open_portfolio(message: Message) -> None:
) )
text = "\n".join(lines).rstrip() text = "\n".join(lines).rstrip()
await message.answer(text) await message.answer(text)

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.filters import Command from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import Message from aiogram.types import Message
from src.core.system_status import build_system_text from src.core.system_status import build_system_text
@@ -15,7 +16,9 @@ router = Router(name="start")
@router.message(Command("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( await message.answer(
MAIN_MENU_TEXT, MAIN_MENU_TEXT,
reply_markup=build_main_menu_keyboard(), reply_markup=build_main_menu_keyboard(),
@@ -23,7 +26,9 @@ async def cmd_start(message: Message) -> None:
@router.message(Command("menu")) @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( await message.answer(
MAIN_MENU_TEXT, MAIN_MENU_TEXT,
reply_markup=build_main_menu_keyboard(), reply_markup=build_main_menu_keyboard(),
@@ -31,7 +36,9 @@ async def cmd_menu(message: Message) -> None:
@router.message(Command("help")) @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( await message.answer(
build_system_text(), build_system_text(),
reply_markup=build_main_menu_keyboard(), reply_markup=build_main_menu_keyboard(),
@@ -39,8 +46,10 @@ async def cmd_help(message: Message) -> None:
@router.message(F.text == "Меню") @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( await message.answer(
MAIN_MENU_TEXT, MAIN_MENU_TEXT,
reply_markup=build_main_menu_keyboard(), reply_markup=build_main_menu_keyboard(),
) )

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import Message from aiogram.types import Message
from src.core.system_status import build_system_text from src.core.system_status import build_system_text
@@ -12,5 +13,7 @@ router = Router(name="system")
@router.message(F.text.in_({"⚙️ Система", "⚙ Система"})) @router.message(F.text.in_({"⚙️ Система", "⚙ Система"}))
async def open_system(message: Message) -> None: async def open_system(message: Message, state: FSMContext) -> None:
await message.answer(build_system_text()) # Глобальный экран: всегда выходим из текущего 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.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.telegram.ui.common import mode_line
from src.telegram.handlers.trade.new_order import ( from src.telegram.handlers.trade.new_order import (
show_recent_drafts, show_recent_drafts,
start_new_order_draft, start_new_order_draft,
@@ -15,15 +16,10 @@ from src.telegram.handlers.trade.new_order import (
router = Router(name="trade_main") 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: def _trade_screen(title: str) -> str:
return ( return (
f"<b>📊 Торговля — {title}</b>\n" 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 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 symbol: str
side: str side: str
order_type: str order_type: str
base_currency: str
balance_currency: str balance_currency: str
quote_currency: str
available_balance: float available_balance: float
reference_price: float reference_price: float
last_price: float last_price: float

View File

@@ -181,15 +181,40 @@ class OrderDraftsService:
return self.repository.get_draft_by_id(draft_id) return self.repository.get_draft_by_id(draft_id)
def get_entry_context(self, *, side: str, order_type: str) -> OrderEntryContext: def get_entry_context(self, *, side: str, order_type: str) -> OrderEntryContext:
# Собираем контекст экрана ввода ордера на основе биржевых правил,
# текущего рынка и доступного баланса.
validation = self.exchange.validate_symbol(self.settings.default_symbol) validation = self.exchange.validate_symbol(self.settings.default_symbol)
if not validation.is_valid or validation.symbol_info is None: if not validation.is_valid or validation.symbol_info is None:
raise ValueError(validation.message) raise ValueError(validation.message)
symbol_info = validation.symbol_info
balances = self.exchange.get_balance_summary() balances = self.exchange.get_balance_summary()
market = self.exchange.get_market_snapshot(self.settings.default_symbol) market = self.exchange.get_market_snapshot(self.settings.default_symbol)
base_asset = validation.symbol_info.base_asset or "BASE" base_asset = (symbol_info.base_asset or "").strip()
quote_asset = validation.symbol_info.quote_asset or "QUOTE" 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 = { available_by_currency = {
item.currency.upper(): float(item.available) item.currency.upper(): float(item.available)
@@ -200,12 +225,12 @@ class OrderDraftsService:
order_type_upper = order_type.upper() order_type_upper = order_type.upper()
if side_upper == "BUY": if side_upper == "BUY":
balance_currency = quote_asset.upper() balance_currency = quote_currency
available_balance = available_by_currency.get(balance_currency, 0.0) available_balance = available_by_currency.get(balance_currency, 0.0)
reference_price = float(market["ask_price"]) reference_price = float(market["ask_price"])
max_qty = (available_balance / reference_price) if reference_price > 0 else 0.0 max_qty = (available_balance / reference_price) if reference_price > 0 else 0.0
else: else:
balance_currency = base_asset.upper() balance_currency = base_currency
available_balance = available_by_currency.get(balance_currency, 0.0) available_balance = available_by_currency.get(balance_currency, 0.0)
reference_price = float(market["bid_price"]) reference_price = float(market["bid_price"])
max_qty = available_balance max_qty = available_balance
@@ -213,14 +238,16 @@ class OrderDraftsService:
quantity_presets = self._build_quantity_presets( quantity_presets = self._build_quantity_presets(
max_qty=max_qty, max_qty=max_qty,
reference_price=reference_price, reference_price=reference_price,
symbol_info=validation.symbol_info, symbol_info=symbol_info,
) )
return OrderEntryContext( return OrderEntryContext(
symbol=self.settings.default_symbol, symbol=self.settings.default_symbol,
side=side_upper, side=side_upper,
order_type=order_type_upper, order_type=order_type_upper,
base_currency=base_currency,
balance_currency=balance_currency, balance_currency=balance_currency,
quote_currency=quote_currency,
available_balance=available_balance, available_balance=available_balance,
reference_price=reference_price, reference_price=reference_price,
last_price=float(market["last_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`
---
## 🧭 Итог
Пользователь видит **терминологию биржи**,
система работает на **нормализованной модели**.