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