Stage 05.2 - interactive draft builder
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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]
|
||||
@@ -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"
|
||||
8
app/src/trading/orders/states.py
Normal file
8
app/src/trading/orders/states.py
Normal 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()
|
||||
13
docs/decisions/0013-interactive-draft-before-validation.md
Normal file
13
docs/decisions/0013-interactive-draft-before-validation.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# 0013 — Interactive Draft before Validation
|
||||
|
||||
## Решение
|
||||
Сначала дать пользователю пошаговый builder, а уже потом строгую валидацию по exchange filters.
|
||||
|
||||
## Причины
|
||||
- проще проверить UX flow
|
||||
- быстрее выйти на рабочий сценарий
|
||||
- можно последовательно наращивать сложность order entry
|
||||
|
||||
## Последствия
|
||||
- пользовательский сценарий появляется рано
|
||||
- валидация и confirmation выносятся в следующие этапы
|
||||
179
docs/stages/stage-05-2-interactive-draft-builder.md
Normal file
179
docs/stages/stage-05-2-interactive-draft-builder.md
Normal 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
|
||||
Reference in New Issue
Block a user