Stage 05.7 - trade draft UI restructuring and order context display
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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())
|
||||
@@ -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
9
app/src/telegram/handlers/trade/new_order_core.py
Normal file
9
app/src/telegram/handlers/trade/new_order_core.py
Normal 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
|
||||
751
app/src/telegram/handlers/trade/new_order_flow.py
Normal file
751
app/src/telegram/handlers/trade/new_order_flow.py
Normal 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()
|
||||
187
app/src/telegram/handlers/trade/new_order_navigation.py
Normal file
187
app/src/telegram/handlers/trade/new_order_navigation.py
Normal 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()
|
||||
664
app/src/telegram/handlers/trade/new_order_ui.py
Normal file
664
app/src/telegram/handlers/trade/new_order_ui.py
Normal 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)
|
||||
@@ -1,3 +1,5 @@
|
||||
# app/src/telegram/keyboards/reply.py
|
||||
|
||||
from aiogram.types import KeyboardButton, ReplyKeyboardMarkup
|
||||
|
||||
|
||||
|
||||
1
app/src/telegram/ui/__init__.py
Normal file
1
app/src/telegram/ui/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Package marker."""
|
||||
8
app/src/telegram/ui/common.py
Normal file
8
app/src/telegram/ui/common.py
Normal 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"
|
||||
@@ -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
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
docs_stage-03-3-exchange-info
|
||||
241
docs/stages/stage_05-7-trade-draft-UI-restructuring.md
Normal file
241
docs/stages/stage_05-7-trade-draft-UI-restructuring.md
Normal 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
65
docs/terminology.md
Normal 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`
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Итог
|
||||
|
||||
Пользователь видит **терминологию биржи**,
|
||||
система работает на **нормализованной модели**.
|
||||
Reference in New Issue
Block a user