diff --git a/app/src/bootstrap/app_factory.py b/app/src/bootstrap/app_factory.py index d06df21..24bc11d 100644 --- a/app/src/bootstrap/app_factory.py +++ b/app/src/bootstrap/app_factory.py @@ -13,9 +13,26 @@ from src.trading.journal.service import JournalService def create_app() -> tuple[Bot, Dispatcher]: settings = load_settings() setup_logging(settings.log_level) - init_schema() 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: journal.log_info( "app_start", @@ -37,4 +54,4 @@ def create_app() -> tuple[Bot, Dispatcher]: dispatcher = Dispatcher() setup_routers(dispatcher) - return bot, dispatcher + return bot, dispatcher \ No newline at end of file diff --git a/app/src/telegram/handlers/auto.py b/app/src/telegram/handlers/auto.py index 081e148..1d771f5 100644 --- a/app/src/telegram/handlers/auto.py +++ b/app/src/telegram/handlers/auto.py @@ -1,3 +1,5 @@ +# app/src/telegram/handlers/auto.py + from aiogram import F, Router from aiogram.types import Message diff --git a/app/src/telegram/handlers/home.py b/app/src/telegram/handlers/home.py index a57e333..3d5bc0a 100644 --- a/app/src/telegram/handlers/home.py +++ b/app/src/telegram/handlers/home.py @@ -1,3 +1,5 @@ +# app/src/telegram/handlers/home.py + from aiogram import F, Router from aiogram.types import Message diff --git a/app/src/telegram/handlers/journal.py b/app/src/telegram/handlers/journal.py index 31099ac..85717e2 100644 --- a/app/src/telegram/handlers/journal.py +++ b/app/src/telegram/handlers/journal.py @@ -1,7 +1,9 @@ +# app/src/telegram/handlers/journal.py + from __future__ import annotations from aiogram import F, Router -from aiogram.types import Message, CallbackQuery +from aiogram.types import CallbackQuery, Message from aiogram.utils.keyboard import InlineKeyboardBuilder from src.trading.journal.service import JournalService @@ -11,22 +13,25 @@ router = Router(name="journal") PAGE_SIZE = 3 +LEVEL_ICONS = { + "INFO": "ℹ️", + "WARNING": "⚠️", + "ERROR": "❌", + "CRITICAL": "🚨", +} + def build_keyboard(page: int, total_pages: int): kb = InlineKeyboardBuilder() - # кнопка "в начало" if page > 1: kb.button(text="⏮️", callback_data="journal:1") - # назад if page > 1: kb.button(text="⬅️", callback_data=f"journal:{page-1}") - # текущая страница kb.button(text=f"{page}/{total_pages}", callback_data="noop") - # вперед if page < total_pages: kb.button(text="➡️", callback_data=f"journal:{page+1}") @@ -37,17 +42,20 @@ def render(events, page, total_pages): lines = ["📒 Журнал", "", "Последние события", ""] for e in events: + level = str(e.get("level", "INFO")).upper() + icon = LEVEL_ICONS.get(level, "•") + lines.extend( [ - f"ℹ️ {e['event_type']}", - f"• уровень: {e['level']}", + f"{icon} {e['event_type']}", + f"• уровень: {level}", f"• время: {e['created_at']}", f"• сообщение: {e['message']}", "", ] ) - return "\n".join(lines) + return "\n".join(lines).rstrip() @router.message(F.text == "📒 Журнал") diff --git a/app/src/telegram/handlers/market.py b/app/src/telegram/handlers/market.py index 9fda2df..84b1f15 100644 --- a/app/src/telegram/handlers/market.py +++ b/app/src/telegram/handlers/market.py @@ -1,3 +1,5 @@ +# app/src/telegram/handlers/market.py + from __future__ import annotations from aiogram import F, Router diff --git a/app/src/telegram/handlers/portfolio.py b/app/src/telegram/handlers/portfolio.py index 8e2ea6a..20f3deb 100644 --- a/app/src/telegram/handlers/portfolio.py +++ b/app/src/telegram/handlers/portfolio.py @@ -1,3 +1,5 @@ +# app/src/telegram/handlers/portfolio.py + from __future__ import annotations from aiogram import F, Router diff --git a/app/src/telegram/handlers/start.py b/app/src/telegram/handlers/start.py index 0b9dfab..217683f 100644 --- a/app/src/telegram/handlers/start.py +++ b/app/src/telegram/handlers/start.py @@ -1,3 +1,5 @@ +# app/src/telegram/handlers/start.py + from __future__ import annotations from aiogram import F, Router diff --git a/app/src/telegram/handlers/system.py b/app/src/telegram/handlers/system.py index 6548cca..5de4c23 100644 --- a/app/src/telegram/handlers/system.py +++ b/app/src/telegram/handlers/system.py @@ -1,3 +1,5 @@ +# app/src/telegram/handlers/system.py + from __future__ import annotations from aiogram import F, Router diff --git a/app/src/telegram/handlers/trade/main.py b/app/src/telegram/handlers/trade/main.py index 3841738..8970163 100644 --- a/app/src/telegram/handlers/trade/main.py +++ b/app/src/telegram/handlers/trade/main.py @@ -1,18 +1,104 @@ +# app/src/telegram/handlers/trade/main.py + from __future__ import annotations 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") +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 ( + "⚡ Торговля\n\n" + "‼️ Режим черновика" + ) + + @router.message(F.text == "⚡ Торговля") async def open_trade(message: Message) -> None: - text = ( - "⚡ Торговля\n\n" - "Доступные действия:\n" - "• /new_order — создать черновик ордера\n" - "• /drafts — показать последние черновики\n\n" - "На этом этапе реальные ордера ещё не отправляются." + await message.answer( + _trade_home_text(), + reply_markup=_trade_home_keyboard(), ) - await message.answer(text) + + +@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( + "⚡ Торговля — Настройки ордера\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( + "⚡ Торговля — Справка\n\n" + "Режим черновика — ордер не отправляется на биржу.\n\n" + "Сейчас можно:\n" + "• собрать черновик ордера\n" + "• проверить параметры\n" + "• сохранить черновик в базу\n\n" + "Реальная отправка ордера появится позже.", + reply_markup=_trade_back_keyboard(), + ) \ No newline at end of file diff --git a/app/src/telegram/handlers/trade/new_order.py b/app/src/telegram/handlers/trade/new_order.py index 70418cf..911a061 100644 --- a/app/src/telegram/handlers/trade/new_order.py +++ b/app/src/telegram/handlers/trade/new_order.py @@ -1,3 +1,5 @@ +# app/src/telegram/handlers/trade/new_order.py + from __future__ import annotations from aiogram import F, Router @@ -12,13 +14,16 @@ from src.trading.orders.states import NewOrderDraftStates router = Router(name="trade_new_order") +DRAFTS_PAGE_SIZE = 3 + 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.button(text="✖️ Отмена", callback_data="order_cancel") - builder.adjust(2, 1) + builder.adjust(2, 2) return builder.as_markup() @@ -26,14 +31,9 @@ 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="order_cancel") - builder.adjust(2, 1) - return builder.as_markup() - - -def _cancel_keyboard() -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - builder.button(text="✖️ Отмена", callback_data="order_cancel") + builder.adjust(2, 2) 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="✍️ Ввести вручную", callback_data="order_qty:manual") + builder.button(text="⬅️ Назад", callback_data="order_back:type") 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() @@ -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"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="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() @@ -69,7 +117,9 @@ def _render_draft_summary( price: str | None, ) -> str: lines = [ - "📝 Черновик ордера создан", + "⚡ Торговля — Черновик ордера", + "", + "📝 Черновик создан", "", f"• инструмент: {symbol}", f"• сторона: {side}", @@ -82,12 +132,110 @@ def _render_draft_summary( [ "• статус: draft", "", - "Это тестовый draft flow. Реальный ордер не отправлялся.", + "Ордер не отправлялся на биржу", ] ) return "\n".join(lines) +def _render_validation_error(errors: list[str]) -> str: + lines = [ + "⚡ Торговля — Ошибка валидации", + "", + "❌ Черновик не сохранён", + "", + "Причины", + "", + ] + 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 = ( + "⚡ Торговля — Черновики\n\n" + "Черновиков пока нет." + ) + if edit_mode: + await message.edit_text(text, reply_markup=_trade_back_home_keyboard()) + else: + await message.answer(text) + return + + lines = ["⚡ Торговля — Черновики", "", "Последние записи", ""] + + for item in drafts: + quantity = _format_draft_quantity(item["quantity"]) + created_at = _format_draft_time(item["created_at"]) + + lines.extend( + [ + f"{item['symbol']} · {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")) async def cancel_order_builder(message: Message, state: FSMContext) -> None: await state.clear() @@ -105,21 +253,77 @@ async def cancel_order_builder_callback( await state.clear() await callback.message.edit_text( "⚡ Торговля\n\n" - "Создание черновика отменено." + "Создание черновика отменено.", + reply_markup=_trade_back_home_keyboard(), ) await callback.answer() @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.set_state(NewOrderDraftStates.waiting_side) - await message.answer( - "⚡ Новый черновик ордера\n\n" + + text = ( + "⚡ Торговля — Новый ордер\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( + "⚡ Торговля — Новый ордер\n\n" "Шаг 1/4\n" "Выберите сторону:", 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( + "⚡ Торговля — Новый ордер\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( + "⚡ Торговля — Новый ордер\n\n" + "Шаг 3/4\n" + f"Инструмент: {context.symbol}\n" + f"Доступно: {context.available_balance:.8f} {context.balance_currency}\n" + f"Ориентир цены: {context.reference_price:.2f}\n\n" + "Выберите количество или введите его вручную:", + reply_markup=_quantity_keyboard(context.quantity_presets), + ) + await callback.answer() @router.callback_query( @@ -136,7 +340,7 @@ async def process_order_side_callback( await state.set_state(NewOrderDraftStates.waiting_type) await callback.message.edit_text( - "⚡ Новый черновик ордера\n\n" + "⚡ Торговля — Новый ордер\n\n" "Шаг 2/4\n" "Выберите тип ордера:", 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) await callback.message.edit_text( - "⚡ Новый черновик ордера\n\n" + "⚡ Торговля — Новый ордер\n\n" "Шаг 3/4\n" f"Инструмент: {context.symbol}\n" f"Доступно: {context.available_balance:.8f} {context.balance_currency}\n" @@ -203,10 +407,10 @@ async def process_quantity_callback( if value == "manual": await callback.message.edit_text( - "⚡ Новый черновик ордера\n\n" + "⚡ Торговля — Новый ордер\n\n" "Шаг 3/4\n" "Введите количество вручную, например: 0.001", - reply_markup=_cancel_keyboard(), + reply_markup=_quantity_manual_keyboard(), ) await callback.answer() return @@ -226,7 +430,7 @@ async def process_quantity_callback( context = service.get_entry_context(side=data["side"], order_type=order_type) await state.set_state(NewOrderDraftStates.waiting_price) await callback.message.edit_text( - "⚡ Новый черновик ордера\n\n" + "⚡ Торговля — Новый ордер\n\n" "Шаг 4/4\n" f"Bid: {context.bid_price:.2f}\n" f"Ask: {context.ask_price:.2f}\n" @@ -246,9 +450,19 @@ async def process_quantity_callback( order_type=order_type, quantity=quantity, ) - service.save_draft(draft) - await state.clear() + try: + service.save_draft(draft) + except ValueError as exc: + 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( _render_draft_summary( symbol=draft.symbol, @@ -256,7 +470,8 @@ async def process_quantity_callback( order_type=draft.order_type, quantity=draft.quantity, price=draft.price, - ) + ), + reply_markup=_trade_back_home_keyboard(), ) 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) await state.set_state(NewOrderDraftStates.waiting_price) await message.answer( - "⚡ Новый черновик ордера\n\n" + "⚡ Торговля — Новый ордер\n\n" "Шаг 4/4\n" f"Bid: {context.bid_price:.2f}\n" f"Ask: {context.ask_price:.2f}\n" @@ -297,9 +512,15 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None: order_type=order_type, quantity=quantity, ) - service.save_draft(draft) - await state.clear() + try: + service.save_draft(draft) + except ValueError as exc: + 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( _render_draft_summary( symbol=draft.symbol, @@ -323,10 +544,10 @@ async def process_price_callback( if value == "manual": await callback.message.edit_text( - "⚡ Новый черновик ордера\n\n" + "⚡ Торговля — Новый ордер\n\n" "Шаг 4/4\n" - "Введите цену вручную, например: 73000", - reply_markup=_cancel_keyboard(), + "Введите цену вручную, например: 73000.123", + reply_markup=_price_manual_keyboard(), ) await callback.answer() return @@ -344,9 +565,19 @@ async def process_price_callback( quantity=data["quantity"], price=price, ) - service.save_draft(draft) - await state.clear() + try: + service.save_draft(draft) + except ValueError as exc: + 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( _render_draft_summary( symbol=draft.symbol, @@ -354,7 +585,8 @@ async def process_price_callback( order_type=draft.order_type, quantity=draft.quantity, price=draft.price, - ) + ), + reply_markup=_trade_back_home_keyboard(), ) await callback.answer() @@ -364,7 +596,7 @@ async def process_order_price(message: Message, state: FSMContext) -> None: service = OrderDraftsService() price = service.normalize_price(message.text or "") if price is None: - await message.answer("Введите корректную цену, например: 73000") + await message.answer("Введите корректную цену, например: 73000.123") return data = await state.get_data() @@ -374,9 +606,15 @@ async def process_order_price(message: Message, state: FSMContext) -> None: quantity=data["quantity"], price=price, ) - service.save_draft(draft) - await state.clear() + try: + service.save_draft(draft) + except ValueError as exc: + 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( _render_draft_summary( symbol=draft.symbol, @@ -389,27 +627,5 @@ async def process_order_price(message: Message, state: FSMContext) -> None: @router.message(Command("drafts")) -async def show_recent_drafts(message: Message) -> None: - service = OrderDraftsService() - drafts = service.list_recent_drafts(limit=5) - - if not drafts: - await message.answer( - "📝 Черновики ордеров\n\n" - "Черновиков пока нет." - ) - return - - lines = ["📝 Черновики ордеров", "", "Последние записи", ""] - - 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()) \ No newline at end of file +async def drafts_command(message: Message) -> None: + await show_recent_drafts(message, edit_mode=False, page=1) \ No newline at end of file diff --git a/app/src/telegram/menus.py b/app/src/telegram/menus.py index 6723cb9..d9afce9 100644 --- a/app/src/telegram/menus.py +++ b/app/src/telegram/menus.py @@ -1,3 +1,5 @@ +# app/src/telegram/menus.py + MAIN_MENU_TEXT = ( "Dzentra Bot\n\n" "Новый каркас проекта успешно создан.\n\n" @@ -25,6 +27,6 @@ SYSTEM_TEXT = ( MARKET_TEXT = "📈 Рынок\n\nРаздел пока в разработке." PORTFOLIO_TEXT = "💼 Портфель\n\nРаздел пока в разработке." -TRADE_TEXT = "⚡ Торговля\n\nРаздел пока в разработке." +TRADE_TEXT = "⚡ Торговля\n\nВыберите действие:\nDRAFT режим" AUTO_TEXT = "🤖 Авто\n\nРаздел пока в разработке." -JOURNAL_TEXT = "📒 Журнал\n\nРаздел пока в разработке." +JOURNAL_TEXT = "📒 Журнал\n\nРаздел пока в разработке." \ No newline at end of file diff --git a/app/src/trading/journal/service.py b/app/src/trading/journal/service.py index 1300833..128a9bb 100644 --- a/app/src/trading/journal/service.py +++ b/app/src/trading/journal/service.py @@ -49,9 +49,29 @@ class JournalService: 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]]: 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]: db_ok, db_message = check_database_health() if not db_ok: @@ -62,12 +82,4 @@ class JournalService: except Exception as exc: return False, f"Ошибка чтения журнала: {exc}" - 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() \ No newline at end of file + return True, f"Журнал работает. Событий: {total}" \ No newline at end of file diff --git a/app/src/trading/orders/models.py b/app/src/trading/orders/models.py index 529d85f..546dd10 100644 --- a/app/src/trading/orders/models.py +++ b/app/src/trading/orders/models.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field @dataclass(slots=True) @@ -24,4 +24,10 @@ class OrderEntryContext: last_price: float bid_price: float ask_price: float - quantity_presets: list[str] \ No newline at end of file + quantity_presets: list[str] + + +@dataclass(slots=True) +class OrderValidationResult: + is_valid: bool + errors: list[str] = field(default_factory=list) \ No newline at end of file diff --git a/app/src/trading/orders/service.py b/app/src/trading/orders/service.py index dae5057..3015f3b 100644 --- a/app/src/trading/orders/service.py +++ b/app/src/trading/orders/service.py @@ -1,10 +1,12 @@ from __future__ import annotations +from decimal import Decimal, InvalidOperation + from src.core.config import load_settings from src.integrations.exchange.service import ExchangeService from src.storage.repositories.order_drafts import OrderDraftRepository 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: @@ -32,6 +34,25 @@ class OrderDraftsService: ) 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 = { "source": "trade_screen", "mode": "draft_only", @@ -63,6 +84,47 @@ class OrderDraftsService: except Exception: 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]]: return self.repository.list_recent_drafts(limit=limit) @@ -160,4 +222,20 @@ class OrderDraftsService: def _format_number(value: float) -> str: text = f"{value:.8f}" text = text.rstrip("0").rstrip(".") - return text or "0" \ No newline at end of file + 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() \ No newline at end of file diff --git a/docs/stages/stage-05-3-order-validation.md b/docs/stages/stage-05-3-order-validation.md new file mode 100644 index 0000000..5f47ac1 --- /dev/null +++ b/docs/stages/stage-05-3-order-validation.md @@ -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