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]:
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

View File

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

View File

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

View File

@@ -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 = ["<b>📒 Журнал</b>", "", "<b>Последние события</b>", ""]
for e in events:
level = str(e.get("level", "INFO")).upper()
icon = LEVEL_ICONS.get(level, "")
lines.extend(
[
f" <b>{e['event_type']}</b>",
f"• уровень: {e['level']}",
f"{icon} <b>{e['event_type']}</b>",
f"• уровень: {level}",
f"• время: {e['created_at']}",
f"• сообщение: {e['message']}",
"",
]
)
return "\n".join(lines)
return "\n".join(lines).rstrip()
@router.message(F.text == "📒 Журнал")

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (
"<b>⚡ Торговля</b>\n\n"
"<b>‼️ Режим черновика</b>"
)
@router.message(F.text == "⚡ Торговля")
async def open_trade(message: Message) -> None:
text = (
"<b>⚡ Торговля</b>\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(
"<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 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 = [
"<b>📝 Черновик ордера создан</b>",
"<b>⚡ Торговля — Черновик ордера</b>",
"",
"<b>📝 Черновик создан</b>",
"",
f"• инструмент: {symbol}",
f"• сторона: {side}",
@@ -82,12 +132,110 @@ def _render_draft_summary(
[
"• статус: draft",
"",
"Это тестовый draft flow. Реальный ордер не отправлялся.",
"<i>Ордер не отправлялся на биржу</i>",
]
)
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"))
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(
"<b>⚡ Торговля</b>\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(
"<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"
"Выберите сторону:",
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(
@@ -136,7 +340,7 @@ async def process_order_side_callback(
await state.set_state(NewOrderDraftStates.waiting_type)
await callback.message.edit_text(
"<b>⚡ Новый черновик ордера</b>\n\n"
"<b>⚡ Торговля — Новый ордер</b>\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(
"<b>⚡ Новый черновик ордера</b>\n\n"
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 3/4\n"
f"Инструмент: <b>{context.symbol}</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":
await callback.message.edit_text(
"<b>⚡ Новый черновик ордера</b>\n\n"
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 3/4\n"
"Введите количество вручную, например: <b>0.001</b>",
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(
"<b>⚡ Новый черновик ордера</b>\n\n"
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 4/4\n"
f"Bid: <b>{context.bid_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,
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(
"<b>⚡ Новый черновик ордера</b>\n\n"
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 4/4\n"
f"Bid: <b>{context.bid_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,
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(
"<b>⚡ Новый черновик ордера</b>\n\n"
"<b>⚡ Торговля — Новый ордер</b>\n\n"
"Шаг 4/4\n"
"Введите цену вручную, например: <b>73000</b>",
reply_markup=_cancel_keyboard(),
"Введите цену вручную, например: <b>73000.123</b>",
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(
"<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())
async def drafts_command(message: Message) -> None:
await show_recent_drafts(message, edit_mode=False, page=1)

View File

@@ -1,3 +1,5 @@
# app/src/telegram/menus.py
MAIN_MENU_TEXT = (
"<b>Dzentra Bot</b>\n\n"
"Новый каркас проекта успешно создан.\n\n"
@@ -25,6 +27,6 @@ SYSTEM_TEXT = (
MARKET_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Раздел пока в разработке."
JOURNAL_TEXT = "<b>📒 Журнал</b>\n\nРаздел пока в разработке."
JOURNAL_TEXT = "<b>📒 Журнал</b>\n\nРаздел пока в разработке."

View File

@@ -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()
return True, f"Журнал работает. Событий: {total}"

View File

@@ -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]
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 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"
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