From f48effd9b50d5e5612960c3499f5724d7ed2eeb8 Mon Sep 17 00:00:00 2001 From: Sergey Date: Fri, 17 Apr 2026 09:05:31 +0300 Subject: [PATCH] Stage 05.2 - interactive draft builder --- app/src/integrations/exchange/service.py | 47 +++ app/src/telegram/handlers/trade.py | 12 - app/src/telegram/handlers/trade/new_order.py | 394 +++++++++++++++++- app/src/trading/orders/models.py | 15 + app/src/trading/orders/service.py | 134 +++++- app/src/trading/orders/states.py | 8 + ...013-interactive-draft-before-validation.md | 13 + .../stage-05-2-interactive-draft-builder.md | 179 ++++++++ 8 files changed, 761 insertions(+), 41 deletions(-) delete mode 100644 app/src/telegram/handlers/trade.py create mode 100644 app/src/trading/orders/states.py create mode 100644 docs/decisions/0013-interactive-draft-before-validation.md create mode 100644 docs/stages/stage-05-2-interactive-draft-builder.md diff --git a/app/src/integrations/exchange/service.py b/app/src/integrations/exchange/service.py index 088234f..d14e513 100644 --- a/app/src/integrations/exchange/service.py +++ b/app/src/integrations/exchange/service.py @@ -114,6 +114,53 @@ class ExchangeService: return self._get_real_price(validation.normalized_symbol) + def get_market_snapshot(self, symbol: str | None = None) -> dict[str, object]: + symbol_to_use = symbol or self.settings.default_symbol + + if not self.settings.exchange_enabled: + ticker = mock_ticker_price(symbol_to_use) + return { + "symbol": ticker.symbol, + "last_price": ticker.price, + "bid_price": ticker.price, + "ask_price": ticker.price, + "updated_at": ticker.updated_at, + } + + validation = self.validate_symbol(symbol_to_use) + if not validation.is_valid: + raise ExchangeError(validation.message) + + client = ExchangeRestClient() + payload = client.get_json( + "/api/v2/ticker/24hr", + params={"symbol": validation.normalized_symbol}, + ) + + last_raw = payload.get("lastPrice") + if last_raw is None: + raise ExchangeError("Field 'lastPrice' is missing in ticker response.") + + bid_raw = payload.get("bidPrice") or last_raw + ask_raw = payload.get("askPrice") or last_raw + + close_time = payload.get("closeTime") or payload.get("eventTime") or "" + + if close_time: + dt_utc = datetime.fromtimestamp(int(close_time) / 1000, tz=ZoneInfo("UTC")) + dt_local = dt_utc.astimezone(ZoneInfo(self.settings.tz)) + updated_at = dt_local.strftime("%d.%m.%Y %H:%M:%S") + else: + updated_at = "n/a" + + return { + "symbol": validation.normalized_symbol, + "last_price": float(last_raw), + "bid_price": float(bid_raw), + "ask_price": float(ask_raw), + "updated_at": updated_at, + } + def get_balance_summary(self) -> list[BalanceSummary]: if not self.settings.exchange_enabled: return mock_balance_summary() diff --git a/app/src/telegram/handlers/trade.py b/app/src/telegram/handlers/trade.py deleted file mode 100644 index 5e3c9ef..0000000 --- a/app/src/telegram/handlers/trade.py +++ /dev/null @@ -1,12 +0,0 @@ -from aiogram import F, Router -from aiogram.types import Message - -from src.telegram.menus import TRADE_TEXT - - -router = Router(name="trade") - - -@router.message(F.text == "⚡ Торговля") -async def open_trade(message: Message) -> None: - await message.answer(TRADE_TEXT) diff --git a/app/src/telegram/handlers/trade/new_order.py b/app/src/telegram/handlers/trade/new_order.py index 741c3e2..70418cf 100644 --- a/app/src/telegram/handlers/trade/new_order.py +++ b/app/src/telegram/handlers/trade/new_order.py @@ -1,29 +1,391 @@ from __future__ import annotations -from aiogram import Router +from aiogram import F, Router from aiogram.filters import Command -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.trading.orders.service import OrderDraftsService +from src.trading.orders.states import NewOrderDraftStates + router = Router(name="trade_new_order") -@router.message(Command("new_order")) -async def create_new_order_draft(message: Message) -> None: - service = OrderDraftsService() - draft = service.create_default_draft() +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="order_cancel") + builder.adjust(2, 1) + return builder.as_markup() - text = ( - "📝 Черновик ордера создан\n\n" - f"• инструмент: {draft.symbol}\n" - f"• сторона: {draft.side}\n" - f"• тип: {draft.order_type}\n" - f"• количество: {draft.quantity}\n" - f"• статус: {draft.status}\n\n" - "Это тестовый draft flow. Реальный ордер не отправлялся." + +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_cancel") + builder.adjust(2, 1) + return builder.as_markup() + + +def _cancel_keyboard() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button(text="✖️ Отмена", callback_data="order_cancel") + return builder.as_markup() + + +def _quantity_keyboard(presets: list[str]) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + labels = ["25%", "50%", "75%", "100%"] + + for label, value in zip(labels, presets): + builder.button(text=label, callback_data=f"order_qty:{value}") + + builder.button(text="✍️ Ввести вручную", callback_data="order_qty:manual") + builder.button(text="✖️ Отмена", callback_data="order_cancel") + builder.adjust(2, 2, 1) + return builder.as_markup() + + +def _price_keyboard(bid: float, ask: float, last: float) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button(text=f"Bid {bid:.2f}", callback_data=f"order_price:{bid}") + 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_cancel") + builder.adjust(2, 1, 1, 1) + return builder.as_markup() + + +def _render_draft_summary( + symbol: str, + side: str, + order_type: str, + quantity: str, + price: str | None, +) -> str: + lines = [ + "📝 Черновик ордера создан", + "", + f"• инструмент: {symbol}", + f"• сторона: {side}", + f"• тип: {order_type}", + f"• количество: {quantity}", + ] + if price: + lines.append(f"• цена: {price}") + lines.extend( + [ + "• статус: draft", + "", + "Это тестовый draft flow. Реальный ордер не отправлялся.", + ] + ) + return "\n".join(lines) + + +@router.message(Command("cancel_order")) +async def cancel_order_builder(message: Message, state: FSMContext) -> None: + await state.clear() + await message.answer( + "⚡ Торговля\n\n" + "Создание черновика отменено." + ) + + +@router.callback_query(F.data == "order_cancel") +async def cancel_order_builder_callback( + callback: CallbackQuery, + state: FSMContext, +) -> None: + await state.clear() + await callback.message.edit_text( + "⚡ Торговля\n\n" + "Создание черновика отменено." + ) + await callback.answer() + + +@router.message(Command("new_order")) +async def start_new_order_draft(message: Message, state: FSMContext) -> None: + await state.clear() + await state.set_state(NewOrderDraftStates.waiting_side) + await message.answer( + "⚡ Новый черновик ордера\n\n" + "Шаг 1/4\n" + "Выберите сторону:", + reply_markup=_side_keyboard(), + ) + + +@router.callback_query( + NewOrderDraftStates.waiting_side, + F.data.startswith("order_side:"), +) +async def process_order_side_callback( + callback: CallbackQuery, + state: FSMContext, +) -> None: + side = callback.data.split(":", 1)[1] + + await state.update_data(side=side) + await state.set_state(NewOrderDraftStates.waiting_type) + + await callback.message.edit_text( + "⚡ Новый черновик ордера\n\n" + "Шаг 2/4\n" + "Выберите тип ордера:", + reply_markup=_type_keyboard(), + ) + await callback.answer() + + +@router.message(NewOrderDraftStates.waiting_side) +async def process_order_side_text(message: Message) -> None: + await message.answer( + "Пожалуйста, используйте кнопки для выбора стороны.", + reply_markup=_side_keyboard(), + ) + + +@router.callback_query( + NewOrderDraftStates.waiting_type, + F.data.startswith("order_type:"), +) +async def process_order_type_callback( + callback: CallbackQuery, + state: FSMContext, +) -> None: + order_type = callback.data.split(":", 1)[1] + service = OrderDraftsService() + + data = await state.get_data() + side = data.get("side", "BUY") + + await state.update_data(order_type=order_type) + await state.set_state(NewOrderDraftStates.waiting_quantity) + + context = service.get_entry_context(side=side, order_type=order_type) + + await callback.message.edit_text( + "⚡ Новый черновик ордера\n\n" + "Шаг 3/4\n" + f"Инструмент: {context.symbol}\n" + f"Доступно: {context.available_balance:.8f} {context.balance_currency}\n" + f"Ориентир цены: {context.reference_price:.2f}\n\n" + "Выберите количество или введите его вручную:", + reply_markup=_quantity_keyboard(context.quantity_presets), + ) + await callback.answer() + + +@router.message(NewOrderDraftStates.waiting_type) +async def process_order_type_text(message: Message) -> None: + await message.answer( + "Пожалуйста, используйте кнопки для выбора типа ордера.", + reply_markup=_type_keyboard(), + ) + + +@router.callback_query( + NewOrderDraftStates.waiting_quantity, + F.data.startswith("order_qty:"), +) +async def process_quantity_callback( + callback: CallbackQuery, + state: FSMContext, +) -> None: + value = callback.data.split(":", 1)[1] + + if value == "manual": + await callback.message.edit_text( + "⚡ Новый черновик ордера\n\n" + "Шаг 3/4\n" + "Введите количество вручную, например: 0.001", + reply_markup=_cancel_keyboard(), + ) + await callback.answer() + return + + service = OrderDraftsService() + quantity = service.normalize_quantity(value) + + if quantity is None: + await callback.answer("Некорректное значение количества.", show_alert=True) + return + + data = await state.get_data() + order_type = data.get("order_type", "MARKET") + await state.update_data(quantity=quantity) + + if order_type == "LIMIT": + context = service.get_entry_context(side=data["side"], order_type=order_type) + await state.set_state(NewOrderDraftStates.waiting_price) + await callback.message.edit_text( + "⚡ Новый черновик ордера\n\n" + "Шаг 4/4\n" + f"Bid: {context.bid_price:.2f}\n" + f"Ask: {context.ask_price:.2f}\n" + f"Last: {context.last_price:.2f}\n\n" + "Выберите цену или введите её вручную:", + reply_markup=_price_keyboard( + bid=context.bid_price, + ask=context.ask_price, + last=context.last_price, + ), + ) + await callback.answer() + return + + draft = service.build_draft( + side=data["side"], + order_type=order_type, + quantity=quantity, + ) + service.save_draft(draft) + await state.clear() + + await callback.message.edit_text( + _render_draft_summary( + symbol=draft.symbol, + side=draft.side, + order_type=draft.order_type, + quantity=draft.quantity, + price=draft.price, + ) + ) + await callback.answer() + + +@router.message(NewOrderDraftStates.waiting_quantity) +async def process_order_quantity(message: Message, state: FSMContext) -> None: + service = OrderDraftsService() + quantity = service.normalize_quantity(message.text or "") + if quantity is None: + await message.answer("Введите корректное количество, например: 0.001") + return + + data = await state.get_data() + order_type = data.get("order_type", "MARKET") + + await state.update_data(quantity=quantity) + + if order_type == "LIMIT": + context = service.get_entry_context(side=data["side"], order_type=order_type) + await state.set_state(NewOrderDraftStates.waiting_price) + await message.answer( + "⚡ Новый черновик ордера\n\n" + "Шаг 4/4\n" + f"Bid: {context.bid_price:.2f}\n" + f"Ask: {context.ask_price:.2f}\n" + f"Last: {context.last_price:.2f}\n\n" + "Выберите цену или введите её вручную:", + reply_markup=_price_keyboard( + bid=context.bid_price, + ask=context.ask_price, + last=context.last_price, + ), + ) + return + + draft = service.build_draft( + side=data["side"], + order_type=order_type, + quantity=quantity, + ) + service.save_draft(draft) + await state.clear() + + await message.answer( + _render_draft_summary( + symbol=draft.symbol, + side=draft.side, + order_type=draft.order_type, + quantity=draft.quantity, + price=draft.price, + ) + ) + + +@router.callback_query( + NewOrderDraftStates.waiting_price, + F.data.startswith("order_price:"), +) +async def process_price_callback( + callback: CallbackQuery, + state: FSMContext, +) -> None: + value = callback.data.split(":", 1)[1] + + if value == "manual": + await callback.message.edit_text( + "⚡ Новый черновик ордера\n\n" + "Шаг 4/4\n" + "Введите цену вручную, например: 73000", + reply_markup=_cancel_keyboard(), + ) + await callback.answer() + return + + service = OrderDraftsService() + price = service.normalize_price(value) + if price is None: + await callback.answer("Некорректная цена.", show_alert=True) + return + + data = await state.get_data() + draft = service.build_draft( + side=data["side"], + order_type=data["order_type"], + quantity=data["quantity"], + price=price, + ) + service.save_draft(draft) + await state.clear() + + await callback.message.edit_text( + _render_draft_summary( + symbol=draft.symbol, + side=draft.side, + order_type=draft.order_type, + quantity=draft.quantity, + price=draft.price, + ) + ) + await callback.answer() + + +@router.message(NewOrderDraftStates.waiting_price) +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") + return + + data = await state.get_data() + draft = service.build_draft( + side=data["side"], + order_type=data["order_type"], + quantity=data["quantity"], + price=price, + ) + service.save_draft(draft) + await state.clear() + + await message.answer( + _render_draft_summary( + symbol=draft.symbol, + side=draft.side, + order_type=draft.order_type, + quantity=draft.quantity, + price=draft.price, + ) ) - await message.answer(text) @router.message(Command("drafts")) @@ -50,4 +412,4 @@ async def show_recent_drafts(message: Message) -> None: ] ) - await message.answer("\n".join(lines).rstrip()) + await message.answer("\n".join(lines).rstrip()) \ No newline at end of file diff --git a/app/src/trading/orders/models.py b/app/src/trading/orders/models.py index b46b622..529d85f 100644 --- a/app/src/trading/orders/models.py +++ b/app/src/trading/orders/models.py @@ -9,4 +9,19 @@ class OrderDraft: side: str order_type: str quantity: str + price: str | None = None status: str = "draft" + + +@dataclass(slots=True) +class OrderEntryContext: + symbol: str + side: str + order_type: str + balance_currency: str + available_balance: float + reference_price: float + last_price: float + bid_price: float + ask_price: float + quantity_presets: list[str] \ No newline at end of file diff --git a/app/src/trading/orders/service.py b/app/src/trading/orders/service.py index 5e7611a..dae5057 100644 --- a/app/src/trading/orders/service.py +++ b/app/src/trading/orders/service.py @@ -1,9 +1,10 @@ from __future__ import annotations 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 +from src.trading.orders.models import OrderDraft, OrderEntryContext class OrderDraftsService: @@ -11,29 +12,39 @@ class OrderDraftsService: self.settings = load_settings() self.repository = OrderDraftRepository() self.journal = JournalService() + self.exchange = ExchangeService() - def create_default_draft(self) -> OrderDraft: - draft = OrderDraft( + def build_draft( + self, + *, + side: str, + order_type: str, + quantity: str, + price: str | None = None, + ) -> OrderDraft: + return OrderDraft( symbol=self.settings.default_symbol, - side="BUY", - order_type="MARKET", - quantity="0.001", + side=side.upper(), + order_type=order_type.upper(), + quantity=quantity, + price=price, status="draft", ) - self._save_draft(draft) - return draft - def _save_draft(self, draft: OrderDraft) -> None: + def save_draft(self, draft: OrderDraft) -> None: + payload = { + "source": "trade_screen", + "mode": "draft_only", + "price": draft.price, + } + self.repository.add_draft( symbol=draft.symbol, side=draft.side, order_type=draft.order_type, quantity=draft.quantity, status=draft.status, - payload={ - "source": "trade_screen", - "mode": "draft_only", - }, + payload=payload, ) try: @@ -45,6 +56,7 @@ class OrderDraftsService: "side": draft.side, "order_type": draft.order_type, "quantity": draft.quantity, + "price": draft.price, "status": draft.status, }, ) @@ -53,3 +65,99 @@ class OrderDraftsService: def list_recent_drafts(self, limit: int = 5) -> list[dict[str, str]]: return self.repository.list_recent_drafts(limit=limit) + + def get_entry_context(self, *, side: str, order_type: str) -> OrderEntryContext: + validation = self.exchange.validate_symbol(self.settings.default_symbol) + if not validation.is_valid or validation.symbol_info is None: + raise ValueError(validation.message) + + balances = self.exchange.get_balance_summary() + market = self.exchange.get_market_snapshot(self.settings.default_symbol) + + base_asset = validation.symbol_info.base_asset or "BASE" + quote_asset = validation.symbol_info.quote_asset or "QUOTE" + + available_by_currency = { + item.currency.upper(): float(item.available) + for item in balances + } + + side_upper = side.upper() + order_type_upper = order_type.upper() + + if side_upper == "BUY": + balance_currency = quote_asset.upper() + available_balance = available_by_currency.get(balance_currency, 0.0) + reference_price = float(market["ask_price"]) + max_qty = (available_balance / reference_price) if reference_price > 0 else 0.0 + else: + balance_currency = base_asset.upper() + available_balance = available_by_currency.get(balance_currency, 0.0) + reference_price = float(market["bid_price"]) + max_qty = available_balance + + quantity_presets = [ + self._format_number(max_qty * 0.25), + self._format_number(max_qty * 0.50), + self._format_number(max_qty * 0.75), + self._format_number(max_qty), + ] + + return OrderEntryContext( + symbol=self.settings.default_symbol, + side=side_upper, + order_type=order_type_upper, + balance_currency=balance_currency, + available_balance=available_balance, + reference_price=reference_price, + last_price=float(market["last_price"]), + bid_price=float(market["bid_price"]), + ask_price=float(market["ask_price"]), + quantity_presets=quantity_presets, + ) + + @staticmethod + def normalize_side(raw: str) -> str | None: + value = (raw or "").strip().upper() + if value in {"BUY", "SELL"}: + return value + return None + + @staticmethod + def normalize_order_type(raw: str) -> str | None: + value = (raw or "").strip().upper() + if value in {"MARKET", "LIMIT"}: + return value + return None + + @staticmethod + def normalize_quantity(raw: str) -> str | None: + value = (raw or "").strip().replace(",", ".") + if not value: + return None + try: + quantity = float(value) + except ValueError: + return None + if quantity <= 0: + return None + return value + + @staticmethod + def normalize_price(raw: str) -> str | None: + value = (raw or "").strip().replace(",", ".") + if not value: + return None + try: + price = float(value) + except ValueError: + return None + if price <= 0: + return None + return value + + @staticmethod + def _format_number(value: float) -> str: + text = f"{value:.8f}" + text = text.rstrip("0").rstrip(".") + return text or "0" \ No newline at end of file diff --git a/app/src/trading/orders/states.py b/app/src/trading/orders/states.py new file mode 100644 index 0000000..2a0c553 --- /dev/null +++ b/app/src/trading/orders/states.py @@ -0,0 +1,8 @@ +from aiogram.fsm.state import State, StatesGroup + + +class NewOrderDraftStates(StatesGroup): + waiting_side = State() + waiting_type = State() + waiting_quantity = State() + waiting_price = State() diff --git a/docs/decisions/0013-interactive-draft-before-validation.md b/docs/decisions/0013-interactive-draft-before-validation.md new file mode 100644 index 0000000..cd17b0a --- /dev/null +++ b/docs/decisions/0013-interactive-draft-before-validation.md @@ -0,0 +1,13 @@ +# 0013 — Interactive Draft before Validation + +## Решение +Сначала дать пользователю пошаговый builder, а уже потом строгую валидацию по exchange filters. + +## Причины +- проще проверить UX flow +- быстрее выйти на рабочий сценарий +- можно последовательно наращивать сложность order entry + +## Последствия +- пользовательский сценарий появляется рано +- валидация и confirmation выносятся в следующие этапы diff --git a/docs/stages/stage-05-2-interactive-draft-builder.md b/docs/stages/stage-05-2-interactive-draft-builder.md new file mode 100644 index 0000000..96f3471 --- /dev/null +++ b/docs/stages/stage-05-2-interactive-draft-builder.md @@ -0,0 +1,179 @@ +# Stage 05.2 — Interactive Draft Builder + +## Цель +Сделать первый пошаговый конструктор черновика ордера внутри Telegram и перевести order entry из простой команды в управляемый пользовательский сценарий. + +--- + +## Что реализовано + +### Пошаговый мастер (FSM) + +Пользователь проходит сценарий: + +1. выбор стороны: + - BUY + - SELL + +2. выбор типа ордера: + - MARKET + - LIMIT + +3. ввод количества + +4. для LIMIT — ввод цены + +--- + +### UX улучшения + +#### Кнопки выбора стороны +- 🟢 BUY +- 🔴 SELL +- ✖️ Отмена + +--- + +#### Кнопки выбора типа ордера +- ⚡ MARKET +- 🎯 LIMIT +- ✖️ Отмена + +--- + +#### Отмена сценария +Поддерживается: +- команда `/cancel_order` +- кнопка `✖️ Отмена` + +FSM очищается и сценарий корректно завершается. + +--- + +### Ввод параметров + +- количество — вручную +- цена — вручную (для LIMIT) + +Базовая валидация: +- число +- > 0 + +--- + +### Service слой + +`OrderDraftsService`: + +- build_draft +- save_draft +- list_recent_drafts +- normalize_* методы + +--- + +### Model слой + +`OrderDraft`: + +- symbol +- side +- order_type +- quantity +- price +- status + +--- + +### FSM состояния + +- waiting_side +- waiting_type +- waiting_quantity +- waiting_price + +--- + +### Storage + +Используется таблица: + +- `order_drafts` + +Payload: +- source +- mode +- price + +--- + +### Journal + +Логируется событие: + +- `order_draft_saved` + +--- + +## Что это даёт + +Система получила: + +- управляемый order entry flow +- безопасный draft (без отправки ордера) +- основу для дальнейшей логики торговли + +--- + +## Архитектура + +Telegram → FSM → OrderDraftsService → Repository → PostgreSQL + +--- + +## Принципы + +### Draft first +Сначала создаётся черновик, без отправки в биржу. + +--- + +### Safety first +Пошаговый ввод вместо одной команды. + +--- + +### UX before validation +Сначала UX, потом строгая валидация. + +--- + +## Ограничения + +- один инструмент (DEFAULT_SYMBOL) +- ручной ввод quantity и price +- нет проверки: + - tickSize + - minQty + - minNotional +- нет confirmation screen +- нет live execution + +--- + +## Что дальше + +### Stage 05.3 — Order Validation + +Будет добавлено: +- проверки биржи (filters) +- minQty / tickSize / notional +- подготовка к confirm screen + +--- + +## Итог + +Stage 05.2 завершает переход: + +простая команда → интерактивный order builder