Compare commits

...

3 Commits

16 changed files with 732 additions and 93 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

View 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