Stage 05.2+ - advanced draft builder (FSM, UI, pagination, smart inputs)

This commit is contained in:
2026-04-17 12:54:05 +03:00
parent f48effd9b5
commit 8e696ef582
15 changed files with 558 additions and 93 deletions

View File

@@ -13,9 +13,26 @@ from src.trading.journal.service import JournalService
def create_app() -> tuple[Bot, Dispatcher]: def create_app() -> tuple[Bot, Dispatcher]:
settings = load_settings() settings = load_settings()
setup_logging(settings.log_level) setup_logging(settings.log_level)
init_schema()
journal = JournalService() journal = JournalService()
try:
init_schema()
except Exception as exc:
try:
journal.log_critical(
"app_bootstrap_failed",
f"Не удалось инициализировать схему БД: {exc}",
{
"env": settings.app_env,
"exchange_name": settings.exchange_name,
"default_symbol": settings.default_symbol,
},
)
except Exception:
pass
raise
try: try:
journal.log_info( journal.log_info(
"app_start", "app_start",

View File

@@ -1,3 +1,5 @@
# app/src/telegram/handlers/auto.py
from aiogram import F, Router from aiogram import F, Router
from aiogram.types import Message from aiogram.types import Message

View File

@@ -1,3 +1,5 @@
# app/src/telegram/handlers/home.py
from aiogram import F, Router from aiogram import F, Router
from aiogram.types import Message from aiogram.types import Message

View File

@@ -1,7 +1,9 @@
# app/src/telegram/handlers/journal.py
from __future__ import annotations from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.types import Message, CallbackQuery from aiogram.types import CallbackQuery, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.trading.journal.service import JournalService from src.trading.journal.service import JournalService
@@ -11,22 +13,25 @@ router = Router(name="journal")
PAGE_SIZE = 3 PAGE_SIZE = 3
LEVEL_ICONS = {
"INFO": "",
"WARNING": "⚠️",
"ERROR": "",
"CRITICAL": "🚨",
}
def build_keyboard(page: int, total_pages: int): def build_keyboard(page: int, total_pages: int):
kb = InlineKeyboardBuilder() kb = InlineKeyboardBuilder()
# кнопка "в начало"
if page > 1: if page > 1:
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}")
@@ -37,17 +42,20 @@ def render(events, page, total_pages):
lines = ["<b>📒 Журнал</b>", "", "<b>Последние события</b>", ""] lines = ["<b>📒 Журнал</b>", "", "<b>Последние события</b>", ""]
for e in events: for e in events:
level = str(e.get("level", "INFO")).upper()
icon = LEVEL_ICONS.get(level, "")
lines.extend( lines.extend(
[ [
f" <b>{e['event_type']}</b>", f"{icon} <b>{e['event_type']}</b>",
f"• уровень: {e['level']}", f"• уровень: {level}",
f"• время: {e['created_at']}", f"• время: {e['created_at']}",
f"• сообщение: {e['message']}", f"• сообщение: {e['message']}",
"", "",
] ]
) )
return "\n".join(lines) return "\n".join(lines).rstrip()
@router.message(F.text == "📒 Журнал") @router.message(F.text == "📒 Журнал")

View File

@@ -1,3 +1,5 @@
# app/src/telegram/handlers/market.py
from __future__ import annotations from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router

View File

@@ -1,3 +1,5 @@
# app/src/telegram/handlers/portfolio.py
from __future__ import annotations from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router

View File

@@ -1,3 +1,5 @@
# app/src/telegram/handlers/start.py
from __future__ import annotations from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router

View File

@@ -1,3 +1,5 @@
# app/src/telegram/handlers/system.py
from __future__ import annotations from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router

View File

@@ -1,18 +1,104 @@
# app/src/telegram/handlers/trade/main.py
from __future__ import annotations from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.types import Message from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.telegram.handlers.trade.new_order import (
show_recent_drafts,
start_new_order_draft,
)
router = Router(name="trade_main") router = Router(name="trade_main")
def _trade_home_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="📝 Новый ордер", callback_data="trade:new_order")
builder.button(text="📂 Черновики", callback_data="trade:drafts")
builder.button(text="⚙️ Настройки ордера", callback_data="trade:settings")
builder.button(text=" Справка", callback_data="trade:help")
builder.adjust(2, 2)
return builder.as_markup()
def _trade_back_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="⬅️ К торговле", callback_data="trade:home")
return builder.as_markup()
def _trade_home_text() -> str:
return (
"<b>⚡ Торговля</b>\n\n"
"<b>‼️ Режим черновика</b>"
)
@router.message(F.text == "⚡ Торговля") @router.message(F.text == "⚡ Торговля")
async def open_trade(message: Message) -> None: async def open_trade(message: Message) -> None:
text = ( await message.answer(
"<b>⚡ Торговля</b>\n\n" _trade_home_text(),
"Доступные действия:\n" reply_markup=_trade_home_keyboard(),
"• /new_order — создать черновик ордера\n" )
"• /drafts — показать последние черновики\n\n"
"На этом этапе реальные ордера ещё не отправляются."
@router.callback_query(F.data == "trade:home")
async def open_trade_home_callback(callback: CallbackQuery) -> None:
await callback.answer()
if callback.message is not None:
await callback.message.edit_text(
_trade_home_text(),
reply_markup=_trade_home_keyboard(),
)
@router.callback_query(F.data == "trade:new_order")
async def open_new_order_from_trade(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await callback.answer()
if callback.message is not None:
await start_new_order_draft(callback.message, state, edit_mode=True)
@router.callback_query(F.data == "trade:drafts")
async def open_drafts_from_trade(callback: CallbackQuery) -> None:
await callback.answer()
if callback.message is not None:
await show_recent_drafts(callback.message, edit_mode=True, page=1)
@router.callback_query(F.data == "trade:settings")
async def open_trade_settings(callback: CallbackQuery) -> None:
await callback.answer()
if callback.message is not None:
await callback.message.edit_text(
"<b>⚡ Торговля — Настройки ордера</b>\n\n"
"Раздел в разработке.\n\n"
"Планируется добавить:\n"
"• параметры ордера по умолчанию\n"
"• пресеты количества\n"
"• режим цены: Bid / Ask / Last",
reply_markup=_trade_back_keyboard(),
)
@router.callback_query(F.data == "trade:help")
async def open_trade_help(callback: CallbackQuery) -> None:
await callback.answer()
if callback.message is not None:
await callback.message.edit_text(
"<b>⚡ Торговля — Справка</b>\n\n"
"<b>Режим черновика</b> — ордер не отправляется на биржу.\n\n"
"Сейчас можно:\n"
"• собрать черновик ордера\n"
"• проверить параметры\n"
"• сохранить черновик в базу\n\n"
"Реальная отправка ордера появится позже.",
reply_markup=_trade_back_keyboard(),
) )
await message.answer(text)

View File

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

View File

@@ -1,3 +1,5 @@
# app/src/telegram/menus.py
MAIN_MENU_TEXT = ( MAIN_MENU_TEXT = (
"<b>Dzentra Bot</b>\n\n" "<b>Dzentra Bot</b>\n\n"
"Новый каркас проекта успешно создан.\n\n" "Новый каркас проекта успешно создан.\n\n"
@@ -25,6 +27,6 @@ SYSTEM_TEXT = (
MARKET_TEXT = "<b>📈 Рынок</b>\n\nРаздел пока в разработке." MARKET_TEXT = "<b>📈 Рынок</b>\n\nРаздел пока в разработке."
PORTFOLIO_TEXT = "<b>💼 Портфель</b>\n\nРаздел пока в разработке." PORTFOLIO_TEXT = "<b>💼 Портфель</b>\n\nРаздел пока в разработке."
TRADE_TEXT = "<b>⚡ Торговля</b>\n\nРаздел пока в разработке." TRADE_TEXT = "<b>⚡ Торговля</b>\n\nВыберите действие:\n<i>DRAFT режим</i>"
AUTO_TEXT = "<b>🤖 Авто</b>\n\nРаздел пока в разработке." AUTO_TEXT = "<b>🤖 Авто</b>\n\nРаздел пока в разработке."
JOURNAL_TEXT = "<b>📒 Журнал</b>\n\nРаздел пока в разработке." JOURNAL_TEXT = "<b>📒 Журнал</b>\n\nРаздел пока в разработке."

View File

@@ -49,9 +49,29 @@ class JournalService:
payload=payload, payload=payload,
) )
def log_critical(
self,
event_type: str,
message: str,
payload: dict[str, Any] | None = None,
) -> None:
self.repository.add_event(
level="CRITICAL",
event_type=event_type,
message=message,
payload=payload,
)
def get_recent(self, limit: int = 10) -> list[dict[str, str]]: def get_recent(self, limit: int = 10) -> list[dict[str, str]]:
return self.repository.list_recent_events(limit=limit) return self.repository.list_recent_events(limit=limit)
def get_page(self, page: int = 1, page_size: int = 3) -> list[dict[str, str]]:
offset = (page - 1) * page_size
return self.repository.list_recent_with_offset(limit=page_size, offset=offset)
def get_total_count(self) -> int:
return self.repository.count_events()
def get_journal_health(self) -> tuple[bool, str]: def get_journal_health(self) -> tuple[bool, str]:
db_ok, db_message = check_database_health() db_ok, db_message = check_database_health()
if not db_ok: if not db_ok:
@@ -63,11 +83,3 @@ class JournalService:
return False, f"Ошибка чтения журнала: {exc}" return False, f"Ошибка чтения журнала: {exc}"
return True, f"Журнал работает. Событий: {total}" return True, f"Журнал работает. Событий: {total}"
def get_page(self, page: int = 1, page_size: int = 3):
offset = (page - 1) * page_size
return self.repository.list_recent_with_offset(limit=page_size, offset=offset)
def get_total_count(self) -> int:
return self.repository.count_events()

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass, field
@dataclass(slots=True) @dataclass(slots=True)
@@ -25,3 +25,9 @@ class OrderEntryContext:
bid_price: float bid_price: float
ask_price: float ask_price: float
quantity_presets: list[str] quantity_presets: list[str]
@dataclass(slots=True)
class OrderValidationResult:
is_valid: bool
errors: list[str] = field(default_factory=list)

View File

@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
from decimal import Decimal, InvalidOperation
from src.core.config import load_settings from src.core.config import load_settings
from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.service import ExchangeService
from src.storage.repositories.order_drafts import OrderDraftRepository from src.storage.repositories.order_drafts import OrderDraftRepository
from src.trading.journal.service import JournalService from src.trading.journal.service import JournalService
from src.trading.orders.models import OrderDraft, OrderEntryContext from src.trading.orders.models import OrderDraft, OrderEntryContext, OrderValidationResult
class OrderDraftsService: class OrderDraftsService:
@@ -32,6 +34,25 @@ class OrderDraftsService:
) )
def save_draft(self, draft: OrderDraft) -> None: def save_draft(self, draft: OrderDraft) -> None:
validation = self.validate_draft(draft)
if not validation.is_valid:
try:
self.journal.log_warning(
"order_draft_validation_failed",
"Черновик ордера не прошёл валидацию.",
{
"symbol": draft.symbol,
"side": draft.side,
"order_type": draft.order_type,
"quantity": draft.quantity,
"price": draft.price,
"errors": validation.errors,
},
)
except Exception:
pass
raise ValueError("; ".join(validation.errors))
payload = { payload = {
"source": "trade_screen", "source": "trade_screen",
"mode": "draft_only", "mode": "draft_only",
@@ -63,6 +84,47 @@ class OrderDraftsService:
except Exception: except Exception:
pass pass
def validate_draft(self, draft: OrderDraft) -> OrderValidationResult:
errors: list[str] = []
if draft.side not in {"BUY", "SELL"}:
errors.append("Сторона ордера должна быть BUY или SELL.")
if draft.order_type not in {"MARKET", "LIMIT"}:
errors.append("Тип ордера должен быть MARKET или LIMIT.")
symbol_validation = self.exchange.validate_symbol(draft.symbol)
if not symbol_validation.is_valid:
errors.append(symbol_validation.message)
quantity = self._to_decimal(draft.quantity)
if quantity is None or quantity <= 0:
errors.append("Количество должно быть числом больше нуля.")
if draft.order_type == "LIMIT":
if not draft.price:
errors.append("Для LIMIT ордера требуется цена.")
else:
price = self._to_decimal(draft.price)
if price is None or price <= 0:
errors.append("Цена должна быть числом больше нуля.")
else:
tick_size = None
if symbol_validation.symbol_info is not None:
tick_size = symbol_validation.symbol_info.tick_size
if tick_size is not None and tick_size > 0:
tick = Decimal(str(tick_size))
if not self._fits_step(price, tick):
errors.append(
f"Цена должна соответствовать шагу tickSize = {tick_size}."
)
return OrderValidationResult(
is_valid=len(errors) == 0,
errors=errors,
)
def list_recent_drafts(self, limit: int = 5) -> list[dict[str, str]]: def list_recent_drafts(self, limit: int = 5) -> list[dict[str, str]]:
return self.repository.list_recent_drafts(limit=limit) return self.repository.list_recent_drafts(limit=limit)
@@ -161,3 +223,19 @@ class OrderDraftsService:
text = f"{value:.8f}" text = f"{value:.8f}"
text = text.rstrip("0").rstrip(".") text = text.rstrip("0").rstrip(".")
return text or "0" return text or "0"
@staticmethod
def _to_decimal(value: str | None) -> Decimal | None:
if value is None:
return None
try:
return Decimal(str(value).strip())
except (InvalidOperation, ValueError):
return None
@staticmethod
def _fits_step(value: Decimal, step: Decimal) -> bool:
if step <= 0:
return True
ratio = value / step
return ratio == ratio.to_integral_value()

View File

@@ -0,0 +1,28 @@
# Stage 05.3 — Order Validation
## Цель
Добавить слой валидации черновика ордера перед сохранением в БД.
## Что реализовано
- `OrderValidationResult`
- `validate_draft()` в `OrderDraftsService`
- проверки:
- сторона BUY / SELL
- тип MARKET / LIMIT
- валидность символа
- количество > 0
- цена для LIMIT
- соответствие цены шагу `tickSize`, если он доступен
## UX
- невалидный draft не сохраняется
- пользователь видит понятный список причин
- в журнале пишется `order_draft_validation_failed`
## Ограничения
- пока нет `minQty`
- пока нет `minNotional`
- пока нет confirm screen
## Следующий этап
- Stage 05.4 — confirmation screen