Compare commits
2 Commits
342182b7d8
...
295bf176e5
| Author | SHA1 | Date | |
|---|---|---|---|
| 295bf176e5 | |||
| 8e696ef582 |
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 == "📒 Журнал")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
"На этом этапе реальные ордера ещё не отправляются."
|
|
||||||
)
|
)
|
||||||
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(
|
||||||
|
"<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(),
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
service.save_draft(draft)
|
try:
|
||||||
await state.clear()
|
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(
|
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,
|
||||||
)
|
)
|
||||||
service.save_draft(draft)
|
try:
|
||||||
await state.clear()
|
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(
|
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,
|
||||||
)
|
)
|
||||||
service.save_draft(draft)
|
try:
|
||||||
await state.clear()
|
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(
|
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,
|
||||||
)
|
)
|
||||||
service.save_draft(draft)
|
try:
|
||||||
await state.clear()
|
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(
|
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())
|
|
||||||
@@ -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Раздел пока в разработке."
|
||||||
@@ -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()
|
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
82
docs/stages/stage-05-2-plus-interactive-draft-builder.md
Normal file
82
docs/stages/stage-05-2-plus-interactive-draft-builder.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Stage 05.2+ — Interactive Draft Builder (Advanced)
|
||||||
|
|
||||||
|
## 📌 Общая цель
|
||||||
|
Реализовать полноценный интерактивный конструктор черновиков ордеров с UX, приближенным к реальному торговому интерфейсу, но без отправки ордеров на биржу.
|
||||||
|
|
||||||
|
## 🚀 Что реализовано
|
||||||
|
|
||||||
|
### 1. FSM Flow создания ордера
|
||||||
|
Пошаговый сценарий:
|
||||||
|
1. Выбор стороны (BUY / SELL)
|
||||||
|
2. Выбор типа (MARKET / LIMIT)
|
||||||
|
3. Выбор количества
|
||||||
|
4. (если LIMIT) выбор цены
|
||||||
|
|
||||||
|
Состояния:
|
||||||
|
- waiting_side
|
||||||
|
- waiting_type
|
||||||
|
- waiting_quantity
|
||||||
|
- waiting_price
|
||||||
|
|
||||||
|
### 2. Экранный UX (edit_message)
|
||||||
|
- Все шаги происходят в одном сообщении (edit_text)
|
||||||
|
- Нет “засорения” чата
|
||||||
|
- Поведение как у мини-приложения внутри Telegram
|
||||||
|
|
||||||
|
### 3. Навигация
|
||||||
|
- Кнопка ⬅️ Назад на каждом этапе
|
||||||
|
- Кнопка ✖️ Отмена
|
||||||
|
- Возврат к предыдущему шагу или в экран "Торговля"
|
||||||
|
|
||||||
|
### 4. Smart Draft Builder
|
||||||
|
Количество:
|
||||||
|
- 25% / 50% / 75% / 100%
|
||||||
|
- ручной ввод
|
||||||
|
|
||||||
|
Цена (LIMIT):
|
||||||
|
- Bid / Ask / Last
|
||||||
|
- ручной ввод
|
||||||
|
|
||||||
|
### 5. Контекст от биржи
|
||||||
|
- инструмент (symbol)
|
||||||
|
- доступный баланс
|
||||||
|
- ориентир цены
|
||||||
|
- Bid / Ask / Last
|
||||||
|
|
||||||
|
### 6. Валидация
|
||||||
|
- проверка количества
|
||||||
|
- проверка цены
|
||||||
|
- ошибки через ValueError
|
||||||
|
|
||||||
|
### 7. Сохранение черновиков
|
||||||
|
- через OrderDraftsService
|
||||||
|
- статус: draft
|
||||||
|
|
||||||
|
### 8. Экран "Черновики"
|
||||||
|
- по 3 записи
|
||||||
|
- пагинация:
|
||||||
|
- ⏮️ ⬅️ X/Y ➡️
|
||||||
|
- кнопка: ⬅️ К торговле
|
||||||
|
|
||||||
|
### 9. Единый стиль экранов
|
||||||
|
Формат:
|
||||||
|
⚡ Торговля — <раздел>
|
||||||
|
|
||||||
|
### 10. Режим работы
|
||||||
|
‼️ Режим черновика
|
||||||
|
- ордера не отправляются
|
||||||
|
- безопасный режим
|
||||||
|
|
||||||
|
## 📊 Итог
|
||||||
|
Stage 05.2+:
|
||||||
|
- FSM builder ✅
|
||||||
|
- UI через кнопки ✅
|
||||||
|
- экранный режим ✅
|
||||||
|
- smart presets ✅
|
||||||
|
- пагинация ✅
|
||||||
|
|
||||||
|
## 🔜 Следующий этап
|
||||||
|
Stage 05.3 или Stage 06
|
||||||
|
|
||||||
|
## 🧾 Commit
|
||||||
|
Stage 05.2+ - advanced interactive draft builder
|
||||||
120
docs/stages/stage-05-3-order-validation-and-logging.md
Normal file
120
docs/stages/stage-05-3-order-validation-and-logging.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Stage 05.3 — Order Validation & Error Logging
|
||||||
|
|
||||||
|
## 📌 Общая цель
|
||||||
|
Усилить надежность системы создания ордеров через строгую валидацию, обработку ошибок и логирование всех критичных событий.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Что реализовано
|
||||||
|
|
||||||
|
### 1. Расширенная валидация ордеров
|
||||||
|
|
||||||
|
Добавлены проверки:
|
||||||
|
|
||||||
|
- корректность количества (quantity > 0)
|
||||||
|
- корректность цены (price > 0 для LIMIT)
|
||||||
|
- обязательность цены для LIMIT ордеров
|
||||||
|
- допустимость значений
|
||||||
|
|
||||||
|
Ошибки агрегируются и возвращаются списком.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Обработка ошибок
|
||||||
|
|
||||||
|
Все ошибки:
|
||||||
|
- перехватываются на уровне handler
|
||||||
|
- отображаются пользователю
|
||||||
|
|
||||||
|
Формат:
|
||||||
|
❌ Черновик не сохранён
|
||||||
|
|
||||||
|
Причины:
|
||||||
|
• ...
|
||||||
|
• ...
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Логирование ошибок (Journal)
|
||||||
|
|
||||||
|
При возникновении ошибок:
|
||||||
|
|
||||||
|
- событие записывается в JournalService
|
||||||
|
- уровень логирования:
|
||||||
|
- ERROR
|
||||||
|
- WARNING
|
||||||
|
- INFO
|
||||||
|
|
||||||
|
Примеры событий:
|
||||||
|
- order_draft_validation_failed
|
||||||
|
- order_draft_saved
|
||||||
|
- order_draft_error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Уровни логирования
|
||||||
|
|
||||||
|
Добавлена градация:
|
||||||
|
|
||||||
|
- ℹ️ INFO — обычные события
|
||||||
|
- ⚠️ WARNING — потенциальные проблемы
|
||||||
|
- ❌ ERROR — ошибки
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Интеграция с Journal UI
|
||||||
|
|
||||||
|
Ошибки отображаются в:
|
||||||
|
📒 Журнал
|
||||||
|
|
||||||
|
Пользователь может:
|
||||||
|
- видеть последние события
|
||||||
|
- листать страницы
|
||||||
|
- различать уровни по emoji
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. UX при ошибках
|
||||||
|
|
||||||
|
- пользователь остаётся в контексте
|
||||||
|
- ошибки отображаются понятно
|
||||||
|
- есть возможность начать заново
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 Архитектура
|
||||||
|
|
||||||
|
Компоненты:
|
||||||
|
|
||||||
|
- OrderDraftsService (валидация)
|
||||||
|
- JournalService (логирование)
|
||||||
|
- Telegram handlers (отображение)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Итог
|
||||||
|
|
||||||
|
Stage 05.3 включает:
|
||||||
|
|
||||||
|
| Функция | Статус |
|
||||||
|
|--------|--------|
|
||||||
|
| Валидация ордера | ✅ |
|
||||||
|
| Агрегация ошибок | ✅ |
|
||||||
|
| Логирование | ✅ |
|
||||||
|
| Уровни логов | ✅ |
|
||||||
|
| Интеграция с UI | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔜 Следующий этап
|
||||||
|
|
||||||
|
Stage 06:
|
||||||
|
- подтверждение ордера
|
||||||
|
- отправка на биржу
|
||||||
|
- обработка ответа
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧾 Commit
|
||||||
|
|
||||||
|
Stage 05.3 - order validation, error handling and journal logging
|
||||||
Reference in New Issue
Block a user