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