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)
|
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]:
|
def get_balance_summary(self) -> list[BalanceSummary]:
|
||||||
if not self.settings.exchange_enabled:
|
if not self.settings.exchange_enabled:
|
||||||
return mock_balance_summary()
|
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 __future__ import annotations
|
||||||
|
|
||||||
from aiogram import Router
|
from aiogram import F, Router
|
||||||
from aiogram.filters import Command
|
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.service import OrderDraftsService
|
||||||
|
from src.trading.orders.states import NewOrderDraftStates
|
||||||
|
|
||||||
|
|
||||||
router = Router(name="trade_new_order")
|
router = Router(name="trade_new_order")
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("new_order"))
|
def _side_keyboard() -> InlineKeyboardMarkup:
|
||||||
async def create_new_order_draft(message: Message) -> None:
|
builder = InlineKeyboardBuilder()
|
||||||
service = OrderDraftsService()
|
builder.button(text="🟢 BUY", callback_data="order_side:BUY")
|
||||||
draft = service.create_default_draft()
|
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"
|
def _type_keyboard() -> InlineKeyboardMarkup:
|
||||||
f"• инструмент: {draft.symbol}\n"
|
builder = InlineKeyboardBuilder()
|
||||||
f"• сторона: {draft.side}\n"
|
builder.button(text="⚡ MARKET", callback_data="order_type:MARKET")
|
||||||
f"• тип: {draft.order_type}\n"
|
builder.button(text="🎯 LIMIT", callback_data="order_type:LIMIT")
|
||||||
f"• количество: {draft.quantity}\n"
|
builder.button(text="✖️ Отмена", callback_data="order_cancel")
|
||||||
f"• статус: {draft.status}\n\n"
|
builder.adjust(2, 1)
|
||||||
"Это тестовый draft flow. Реальный ордер не отправлялся."
|
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"))
|
@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
|
side: str
|
||||||
order_type: str
|
order_type: str
|
||||||
quantity: str
|
quantity: str
|
||||||
|
price: str | None = None
|
||||||
status: str = "draft"
|
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 __future__ import annotations
|
||||||
|
|
||||||
from src.core.config import load_settings
|
from src.core.config import load_settings
|
||||||
|
from src.integrations.exchange.service import ExchangeService
|
||||||
from src.storage.repositories.order_drafts import OrderDraftRepository
|
from src.storage.repositories.order_drafts import OrderDraftRepository
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
from src.trading.orders.models import OrderDraft
|
from src.trading.orders.models import OrderDraft, OrderEntryContext
|
||||||
|
|
||||||
|
|
||||||
class OrderDraftsService:
|
class OrderDraftsService:
|
||||||
@@ -11,29 +12,39 @@ class OrderDraftsService:
|
|||||||
self.settings = load_settings()
|
self.settings = load_settings()
|
||||||
self.repository = OrderDraftRepository()
|
self.repository = OrderDraftRepository()
|
||||||
self.journal = JournalService()
|
self.journal = JournalService()
|
||||||
|
self.exchange = ExchangeService()
|
||||||
|
|
||||||
def create_default_draft(self) -> OrderDraft:
|
def build_draft(
|
||||||
draft = OrderDraft(
|
self,
|
||||||
|
*,
|
||||||
|
side: str,
|
||||||
|
order_type: str,
|
||||||
|
quantity: str,
|
||||||
|
price: str | None = None,
|
||||||
|
) -> OrderDraft:
|
||||||
|
return OrderDraft(
|
||||||
symbol=self.settings.default_symbol,
|
symbol=self.settings.default_symbol,
|
||||||
side="BUY",
|
side=side.upper(),
|
||||||
order_type="MARKET",
|
order_type=order_type.upper(),
|
||||||
quantity="0.001",
|
quantity=quantity,
|
||||||
|
price=price,
|
||||||
status="draft",
|
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(
|
self.repository.add_draft(
|
||||||
symbol=draft.symbol,
|
symbol=draft.symbol,
|
||||||
side=draft.side,
|
side=draft.side,
|
||||||
order_type=draft.order_type,
|
order_type=draft.order_type,
|
||||||
quantity=draft.quantity,
|
quantity=draft.quantity,
|
||||||
status=draft.status,
|
status=draft.status,
|
||||||
payload={
|
payload=payload,
|
||||||
"source": "trade_screen",
|
|
||||||
"mode": "draft_only",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -45,6 +56,7 @@ class OrderDraftsService:
|
|||||||
"side": draft.side,
|
"side": draft.side,
|
||||||
"order_type": draft.order_type,
|
"order_type": draft.order_type,
|
||||||
"quantity": draft.quantity,
|
"quantity": draft.quantity,
|
||||||
|
"price": draft.price,
|
||||||
"status": draft.status,
|
"status": draft.status,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -53,3 +65,99 @@ class OrderDraftsService:
|
|||||||
|
|
||||||
def list_recent_drafts(self, limit: int = 5) -> list[dict[str, str]]:
|
def list_recent_drafts(self, limit: int = 5) -> list[dict[str, str]]:
|
||||||
return self.repository.list_recent_drafts(limit=limit)
|
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