Stage 05.2 - interactive draft builder

This commit is contained in:
2026-04-17 09:05:31 +03:00
parent f662ff1901
commit f48effd9b5
8 changed files with 761 additions and 41 deletions

View File

@@ -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()

View File

@@ -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)

View File

@@ -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 = (
"<b>📝 Черновик ордера создан</b>\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 = [
"<b>📝 Черновик ордера создан</b>",
"",
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(
"<b>⚡ Торговля</b>\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(
"<b>⚡ Торговля</b>\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(
"<b>⚡ Новый черновик ордера</b>\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(
"<b>⚡ Новый черновик ордера</b>\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(
"<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.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(
"<b>⚡ Новый черновик ордера</b>\n\n"
"Шаг 3/4\n"
"Введите количество вручную, например: <b>0.001</b>",
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(
"<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"
f"Last: <b>{context.last_price:.2f}</b>\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(
"<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"
f"Last: <b>{context.last_price:.2f}</b>\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(
"<b>⚡ Новый черновик ордера</b>\n\n"
"Шаг 4/4\n"
"Введите цену вручную, например: <b>73000</b>",
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())

View File

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

View File

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

View File

@@ -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()

View File

@@ -0,0 +1,13 @@
# 0013 — Interactive Draft before Validation
## Решение
Сначала дать пользователю пошаговый builder, а уже потом строгую валидацию по exchange filters.
## Причины
- проще проверить UX flow
- быстрее выйти на рабочий сценарий
- можно последовательно наращивать сложность order entry
## Последствия
- пользовательский сценарий появляется рано
- валидация и confirmation выносятся в следующие этапы

View File

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