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