Compare commits
3 Commits
604a8c0069
...
39b35d742a
| Author | SHA1 | Date | |
|---|---|---|---|
| 39b35d742a | |||
| 2be2ac1d30 | |||
| e9fd3ea4a0 |
@@ -107,9 +107,13 @@ def _build_journal_status() -> ComponentStatus:
|
|||||||
return ComponentStatus(name="Журнал", state="🔴", details=message)
|
return ComponentStatus(name="Журнал", state="🔴", details=message)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_mode_label(settings) -> str:
|
def get_runtime_mode_key() -> str:
|
||||||
is_demo = "demo" in settings.exchange_base_url.lower()
|
settings = load_settings()
|
||||||
return "ДЕМО аккаунт" if is_demo else "РЕАЛЬНЫЙ аккаунт"
|
return "demo" if "demo" in settings.exchange_base_url.lower() else "real"
|
||||||
|
|
||||||
|
|
||||||
|
def get_runtime_mode_label() -> str:
|
||||||
|
return "ДЕМО аккаунт" if get_runtime_mode_key() == "demo" else "РЕАЛЬНЫЙ аккаунт"
|
||||||
|
|
||||||
|
|
||||||
def get_system_snapshot() -> SystemSnapshot:
|
def get_system_snapshot() -> SystemSnapshot:
|
||||||
@@ -135,7 +139,7 @@ def get_system_snapshot() -> SystemSnapshot:
|
|||||||
app_version=APP_VERSION,
|
app_version=APP_VERSION,
|
||||||
db_label=db_label,
|
db_label=db_label,
|
||||||
timezone_name=settings.tz,
|
timezone_name=settings.tz,
|
||||||
mode_label=_resolve_mode_label(settings),
|
mode_label=get_runtime_mode_label(),
|
||||||
default_symbol=settings.default_symbol,
|
default_symbol=settings.default_symbol,
|
||||||
components=components,
|
components=components,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# app/src/integrations/exchange/models.py
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -36,6 +38,9 @@ class ExchangeSymbol:
|
|||||||
market_modes: list[str]
|
market_modes: list[str]
|
||||||
market_type: str
|
market_type: str
|
||||||
tick_size: float | None
|
tick_size: float | None
|
||||||
|
step_size: float | None
|
||||||
|
min_qty: float | None
|
||||||
|
min_notional: float | None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# app/src/integrations/exchange/service.py
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -227,28 +229,85 @@ class ExchangeService:
|
|||||||
if isinstance(inner, dict) and isinstance(inner.get("symbols"), list):
|
if isinstance(inner, dict) and isinstance(inner.get("symbols"), list):
|
||||||
symbols_raw = inner["symbols"]
|
symbols_raw = inner["symbols"]
|
||||||
else:
|
else:
|
||||||
raise ExchangeError(
|
raise ExchangeError("Field 'symbols' is missing in exchangeInfo response.")
|
||||||
"Field 'symbols' is missing in exchangeInfo response."
|
|
||||||
)
|
|
||||||
|
|
||||||
def _safe_str(value: object, default: str = "") -> str:
|
def _safe_str(value: object, default: str = "") -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
return default
|
return default
|
||||||
return str(value).strip()
|
return str(value).strip()
|
||||||
|
|
||||||
|
def _safe_float(value: object) -> float | None:
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(str(value).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _extract_filter_value(
|
||||||
|
filters: object,
|
||||||
|
filter_names: list[str],
|
||||||
|
keys: list[str],
|
||||||
|
) -> float | None:
|
||||||
|
if not isinstance(filters, list):
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized_filter_names = {name.upper() for name in filter_names}
|
||||||
|
|
||||||
|
for entry in filters:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
filter_type = str(entry.get("filterType", "")).strip().upper()
|
||||||
|
if filter_type not in normalized_filter_names:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
value = _safe_float(entry.get(key))
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
items: list[ExchangeSymbol] = []
|
items: list[ExchangeSymbol] = []
|
||||||
|
|
||||||
for item in symbols_raw:
|
for item in symbols_raw:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
tick_size_raw = item.get("tickSize")
|
filters = item.get("filters")
|
||||||
tick_size = None
|
|
||||||
if tick_size_raw not in (None, ""):
|
tick_size = _safe_float(item.get("tickSize"))
|
||||||
try:
|
if tick_size is None:
|
||||||
tick_size = float(str(tick_size_raw))
|
tick_size = _extract_filter_value(
|
||||||
except (TypeError, ValueError):
|
filters,
|
||||||
tick_size = None
|
filter_names=["PRICE_FILTER"],
|
||||||
|
keys=["tickSize"],
|
||||||
|
)
|
||||||
|
|
||||||
|
step_size = _safe_float(item.get("stepSize"))
|
||||||
|
if step_size is None:
|
||||||
|
step_size = _extract_filter_value(
|
||||||
|
filters,
|
||||||
|
filter_names=["LOT_SIZE", "MARKET_LOT_SIZE"],
|
||||||
|
keys=["stepSize"],
|
||||||
|
)
|
||||||
|
|
||||||
|
min_qty = _safe_float(item.get("minQty"))
|
||||||
|
if min_qty is None:
|
||||||
|
min_qty = _extract_filter_value(
|
||||||
|
filters,
|
||||||
|
filter_names=["LOT_SIZE", "MARKET_LOT_SIZE"],
|
||||||
|
keys=["minQty"],
|
||||||
|
)
|
||||||
|
|
||||||
|
min_notional = _safe_float(item.get("minNotional"))
|
||||||
|
if min_notional is None:
|
||||||
|
min_notional = _extract_filter_value(
|
||||||
|
filters,
|
||||||
|
filter_names=["MIN_NOTIONAL", "NOTIONAL"],
|
||||||
|
keys=["minNotional", "notional"],
|
||||||
|
)
|
||||||
|
|
||||||
market_modes_raw = item.get("marketModes")
|
market_modes_raw = item.get("marketModes")
|
||||||
if isinstance(market_modes_raw, list):
|
if isinstance(market_modes_raw, list):
|
||||||
@@ -259,7 +318,11 @@ class ExchangeService:
|
|||||||
market_modes = []
|
market_modes = []
|
||||||
|
|
||||||
market_type_raw = item.get("marketType")
|
market_type_raw = item.get("marketType")
|
||||||
market_type = str(market_type_raw).strip() if market_type_raw is not None else "unknown"
|
market_type = (
|
||||||
|
str(market_type_raw).strip()
|
||||||
|
if market_type_raw is not None
|
||||||
|
else "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
items.append(
|
items.append(
|
||||||
ExchangeSymbol(
|
ExchangeSymbol(
|
||||||
@@ -271,6 +334,9 @@ class ExchangeService:
|
|||||||
market_modes=market_modes,
|
market_modes=market_modes,
|
||||||
market_type=market_type,
|
market_type=market_type,
|
||||||
tick_size=tick_size,
|
tick_size=tick_size,
|
||||||
|
step_size=step_size,
|
||||||
|
min_qty=min_qty,
|
||||||
|
min_notional=min_notional,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# app/src/storage/repositories/order_drafts.py
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -34,7 +36,7 @@ class OrderDraftRepository:
|
|||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
'''
|
'''
|
||||||
SELECT created_at, symbol, side, order_type, quantity::text, status
|
SELECT id, created_at, symbol, side, order_type, quantity::text, status
|
||||||
FROM order_drafts
|
FROM order_drafts
|
||||||
ORDER BY created_at DESC, id DESC
|
ORDER BY created_at DESC, id DESC
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
@@ -47,16 +49,59 @@ class OrderDraftRepository:
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
items.append(
|
items.append(
|
||||||
{
|
{
|
||||||
"created_at": str(row[0]),
|
"id": str(row[0]),
|
||||||
"symbol": str(row[1]),
|
"created_at": str(row[1]),
|
||||||
"side": str(row[2]),
|
"symbol": str(row[2]),
|
||||||
"order_type": str(row[3]),
|
"side": str(row[3]),
|
||||||
"quantity": str(row[4]),
|
"order_type": str(row[4]),
|
||||||
"status": str(row[5]),
|
"quantity": str(row[5]),
|
||||||
|
"status": str(row[6]),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
def get_draft_by_id(self, draft_id: str) -> dict[str, str] | None:
|
||||||
|
with get_connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
'''
|
||||||
|
SELECT id, created_at, symbol, side, order_type, quantity::text, status, payload_json
|
||||||
|
FROM order_drafts
|
||||||
|
WHERE id = %s
|
||||||
|
LIMIT 1
|
||||||
|
''',
|
||||||
|
(draft_id,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload_raw = row[7]
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if isinstance(payload_raw, dict):
|
||||||
|
payload = payload_raw
|
||||||
|
elif payload_raw:
|
||||||
|
try:
|
||||||
|
payload = json.loads(str(payload_raw))
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
price = payload.get("price")
|
||||||
|
price_text = str(price) if price not in (None, "") else ""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(row[0]),
|
||||||
|
"created_at": str(row[1]),
|
||||||
|
"symbol": str(row[2]),
|
||||||
|
"side": str(row[3]),
|
||||||
|
"order_type": str(row[4]),
|
||||||
|
"quantity": str(row[5]),
|
||||||
|
"status": str(row[6]),
|
||||||
|
"price": price_text,
|
||||||
|
}
|
||||||
|
|
||||||
def count_drafts(self) -> int:
|
def count_drafts(self) -> int:
|
||||||
with get_connection() as connection:
|
with get_connection() as connection:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
|
|||||||
@@ -15,30 +15,90 @@ from src.telegram.handlers.trade.new_order import (
|
|||||||
router = Router(name="trade_main")
|
router = Router(name="trade_main")
|
||||||
|
|
||||||
|
|
||||||
|
def _mode_line() -> str:
|
||||||
|
from src.core.system_status import get_runtime_mode_label
|
||||||
|
return f"Режим: <b>{get_runtime_mode_label()}</b>\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _trade_screen(title: str) -> str:
|
||||||
|
return (
|
||||||
|
f"<b>📊 Торговля — {title}</b>\n"
|
||||||
|
f"{_mode_line()}"
|
||||||
|
"Выбери раздел"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# KEYBOARDS
|
||||||
|
# =========================
|
||||||
|
|
||||||
def _trade_home_keyboard() -> InlineKeyboardMarkup:
|
def _trade_home_keyboard() -> InlineKeyboardMarkup:
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text="📝 Новый ордер", callback_data="trade:new_order")
|
builder.button(text="📝 Ордер", callback_data="trade:new_order")
|
||||||
builder.button(text="📂 Черновики", callback_data="trade:drafts")
|
builder.button(text="📂 Ордера", callback_data="trade:orders")
|
||||||
builder.button(text="⚙️ Настройки ордера", callback_data="trade:settings")
|
builder.button(text="📜 История", callback_data="trade:history")
|
||||||
builder.button(text="ℹ️ Справка", callback_data="trade:help")
|
builder.button(text="⚙️ Настройки", callback_data="trade:settings")
|
||||||
builder.adjust(2, 2)
|
builder.adjust(2, 2)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def _trade_back_keyboard() -> InlineKeyboardMarkup:
|
def _trade_home_button() -> InlineKeyboardMarkup:
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text="⬅️ К торговле", callback_data="trade:home")
|
builder.button(text="🏠 К торговле", callback_data="trade:home")
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def _orders_menu_keyboard() -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(text="📂 Черновики", callback_data="trade:orders:drafts")
|
||||||
|
builder.button(text="🏠 К торговле", callback_data="trade:home")
|
||||||
|
builder.adjust(2)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def _history_menu_keyboard() -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(text="✅ Исполненные", callback_data="trade:history:filled")
|
||||||
|
builder.button(text="🚫 Отменённые", callback_data="trade:history:canceled")
|
||||||
|
builder.button(text="🏠 К торговле", callback_data="trade:home")
|
||||||
|
builder.adjust(2, 1)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_menu_keyboard() -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(text="⚙️ Параметры", callback_data="trade:settings:params")
|
||||||
|
builder.button(text="🔁 Режим", callback_data="trade:settings:mode")
|
||||||
|
builder.button(text="ℹ️ Справка", callback_data="trade:settings:help")
|
||||||
|
builder.button(text="🏠 К торговле", callback_data="trade:home")
|
||||||
|
builder.adjust(2, 2)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# TEXTS
|
||||||
|
# =========================
|
||||||
|
|
||||||
def _trade_home_text() -> str:
|
def _trade_home_text() -> str:
|
||||||
return (
|
return _trade_screen("Основной экран")
|
||||||
"<b>⚡ Торговля</b>\n\n"
|
|
||||||
"<b>‼️ Режим черновика</b>"
|
def _trade_orders_text() -> str:
|
||||||
)
|
return _trade_screen("Ордера")
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.text == "⚡ Торговля")
|
def _trade_history_text() -> str:
|
||||||
|
return _trade_screen("История")
|
||||||
|
|
||||||
|
|
||||||
|
def _trade_settings_text() -> str:
|
||||||
|
return _trade_screen("Настройки")
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# ENTRY
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
@router.message(F.text.in_({"📊 Торговля", "⚡ Торговля", "Торговля"}))
|
||||||
async def open_trade(message: Message) -> None:
|
async def open_trade(message: Message) -> None:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
_trade_home_text(),
|
_trade_home_text(),
|
||||||
@@ -47,8 +107,13 @@ async def open_trade(message: Message) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "trade:home")
|
@router.callback_query(F.data == "trade:home")
|
||||||
async def open_trade_home_callback(callback: CallbackQuery) -> None:
|
async def open_trade_home_callback(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
) -> None:
|
||||||
|
await state.clear()
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
if callback.message is not None:
|
if callback.message is not None:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
_trade_home_text(),
|
_trade_home_text(),
|
||||||
@@ -56,6 +121,10 @@ async def open_trade_home_callback(callback: CallbackQuery) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# NEW ORDER
|
||||||
|
# =========================
|
||||||
|
|
||||||
@router.callback_query(F.data == "trade:new_order")
|
@router.callback_query(F.data == "trade:new_order")
|
||||||
async def open_new_order_from_trade(
|
async def open_new_order_from_trade(
|
||||||
callback: CallbackQuery,
|
callback: CallbackQuery,
|
||||||
@@ -66,39 +135,110 @@ async def open_new_order_from_trade(
|
|||||||
await start_new_order_draft(callback.message, state, edit_mode=True)
|
await start_new_order_draft(callback.message, state, edit_mode=True)
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "trade:drafts")
|
# =========================
|
||||||
async def open_drafts_from_trade(callback: CallbackQuery) -> None:
|
# ORDERS
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "trade:orders")
|
||||||
|
async def open_orders_from_trade(callback: CallbackQuery) -> None:
|
||||||
|
await callback.answer()
|
||||||
|
if callback.message is not None:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
_trade_orders_text(),
|
||||||
|
reply_markup=_orders_menu_keyboard(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "trade:orders:drafts")
|
||||||
|
async def open_drafts_from_orders(callback: CallbackQuery) -> None:
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
if callback.message is not None:
|
if callback.message is not None:
|
||||||
await show_recent_drafts(callback.message, edit_mode=True, page=1)
|
await show_recent_drafts(callback.message, edit_mode=True, page=1)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# HISTORY
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "trade:history")
|
||||||
|
async def open_trade_history(callback: CallbackQuery) -> None:
|
||||||
|
await callback.answer()
|
||||||
|
if callback.message is not None:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
_trade_history_text(),
|
||||||
|
reply_markup=_history_menu_keyboard(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "trade:history:filled")
|
||||||
|
async def open_filled_history(callback: CallbackQuery) -> None:
|
||||||
|
await callback.answer()
|
||||||
|
if callback.message is not None:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"<b>📊 Торговля — История</b>\n\n"
|
||||||
|
"Шаг 1/1: Исполненные\n"
|
||||||
|
"Раздел в разработке.",
|
||||||
|
reply_markup=_trade_home_button(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "trade:history:canceled")
|
||||||
|
async def open_canceled_history(callback: CallbackQuery) -> None:
|
||||||
|
await callback.answer()
|
||||||
|
if callback.message is not None:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"<b>📊 Торговля — История</b>\n\n"
|
||||||
|
"Шаг 1/1: Отменённые\n"
|
||||||
|
"Раздел в разработке.",
|
||||||
|
reply_markup=_trade_home_button(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# SETTINGS
|
||||||
|
# =========================
|
||||||
|
|
||||||
@router.callback_query(F.data == "trade:settings")
|
@router.callback_query(F.data == "trade:settings")
|
||||||
async def open_trade_settings(callback: CallbackQuery) -> None:
|
async def open_trade_settings(callback: CallbackQuery) -> None:
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
if callback.message is not None:
|
if callback.message is not None:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
"<b>⚡ Торговля — Настройки ордера</b>\n\n"
|
_trade_settings_text(),
|
||||||
"Раздел в разработке.\n\n"
|
reply_markup=_settings_menu_keyboard(),
|
||||||
"Планируется добавить:\n"
|
|
||||||
"• параметры ордера по умолчанию\n"
|
|
||||||
"• пресеты количества\n"
|
|
||||||
"• режим цены: Bid / Ask / Last",
|
|
||||||
reply_markup=_trade_back_keyboard(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "trade:help")
|
@router.callback_query(F.data == "trade:settings:params")
|
||||||
async def open_trade_help(callback: CallbackQuery) -> None:
|
async def open_trade_settings_params(callback: CallbackQuery) -> None:
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
if callback.message is not None:
|
if callback.message is not None:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
"<b>⚡ Торговля — Справка</b>\n\n"
|
"<b>📊 Торговля — Настройки</b>\n\n"
|
||||||
"<b>Режим черновика</b> — ордер не отправляется на биржу.\n\n"
|
"Шаг 1/1: Параметры ордера\n"
|
||||||
"Сейчас можно:\n"
|
"Раздел в разработке.",
|
||||||
"• собрать черновик ордера\n"
|
reply_markup=_trade_home_button(),
|
||||||
"• проверить параметры\n"
|
)
|
||||||
"• сохранить черновик в базу\n\n"
|
|
||||||
"Реальная отправка ордера появится позже.",
|
|
||||||
reply_markup=_trade_back_keyboard(),
|
@router.callback_query(F.data == "trade:settings:mode")
|
||||||
|
async def open_trade_settings_mode(callback: CallbackQuery) -> None:
|
||||||
|
await callback.answer()
|
||||||
|
if callback.message is not None:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"<b>📊 Торговля — Настройки</b>\n\n"
|
||||||
|
"Шаг 1/1: Режим работы\n"
|
||||||
|
"Текущий режим: <b>demo</b>",
|
||||||
|
reply_markup=_trade_home_button(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "trade:settings:help")
|
||||||
|
async def open_trade_settings_help(callback: CallbackQuery) -> None:
|
||||||
|
await callback.answer()
|
||||||
|
if callback.message is not None:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"<b>📊 Торговля — Справка</b>\n\n"
|
||||||
|
"Шаг 1/1: Информация\n"
|
||||||
|
"Раздел в разработке.",
|
||||||
|
reply_markup=_trade_home_button(),
|
||||||
)
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ def build_main_menu_keyboard() -> ReplyKeyboardMarkup:
|
|||||||
KeyboardButton(text="💼 Портфель"),
|
KeyboardButton(text="💼 Портфель"),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
KeyboardButton(text="⚡ Торговля"),
|
KeyboardButton(text="📊 Торговля"),
|
||||||
KeyboardButton(text="🤖 Авто"),
|
KeyboardButton(text="🤖 Авто"),
|
||||||
KeyboardButton(text="📒 Журнал"),
|
KeyboardButton(text="📒 Журнал"),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -27,6 +27,6 @@ SYSTEM_TEXT = (
|
|||||||
|
|
||||||
MARKET_TEXT = "<b>📈 Рынок</b>\n\nРаздел пока в разработке."
|
MARKET_TEXT = "<b>📈 Рынок</b>\n\nРаздел пока в разработке."
|
||||||
PORTFOLIO_TEXT = "<b>💼 Портфель</b>\n\nРаздел пока в разработке."
|
PORTFOLIO_TEXT = "<b>💼 Портфель</b>\n\nРаздел пока в разработке."
|
||||||
TRADE_TEXT = "<b>⚡ Торговля</b>\n\nВыберите действие:\n<i>DRAFT режим</i>"
|
TRADE_TEXT = "<b>📊 Торговля</b>\n\nВыберите действие:\n<i>DRAFT режим</i>"
|
||||||
AUTO_TEXT = "<b>🤖 Авто</b>\n\nРаздел пока в разработке."
|
AUTO_TEXT = "<b>🤖 Авто</b>\n\nРаздел пока в разработке."
|
||||||
JOURNAL_TEXT = "<b>📒 Журнал</b>\n\nРаздел пока в разработке."
|
JOURNAL_TEXT = "<b>📒 Журнал</b>\n\nРаздел пока в разработке."
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# app/src/trading/orders/models.py
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -24,7 +26,7 @@ class OrderEntryContext:
|
|||||||
last_price: float
|
last_price: float
|
||||||
bid_price: float
|
bid_price: float
|
||||||
ask_price: float
|
ask_price: float
|
||||||
quantity_presets: list[str]
|
quantity_presets: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
# /app/src/trading/orders/service.py
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation, ROUND_DOWN
|
||||||
|
|
||||||
from src.core.config import load_settings
|
from src.core.config import load_settings
|
||||||
|
from src.integrations.exchange.models import ExchangeSymbol
|
||||||
from src.integrations.exchange.service import ExchangeService
|
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
|
||||||
@@ -33,6 +36,30 @@ class OrderDraftsService:
|
|||||||
status="draft",
|
status="draft",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_entry_rules(self) -> dict[str, str | None]:
|
||||||
|
validation = self.exchange.validate_symbol(self.settings.default_symbol)
|
||||||
|
symbol_info = validation.symbol_info
|
||||||
|
|
||||||
|
if symbol_info is None:
|
||||||
|
return {
|
||||||
|
"min_qty": None,
|
||||||
|
"step_size": None,
|
||||||
|
"min_notional": None,
|
||||||
|
"tick_size": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
min_qty = getattr(symbol_info, "min_qty", None)
|
||||||
|
step_size = getattr(symbol_info, "step_size", None)
|
||||||
|
min_notional = getattr(symbol_info, "min_notional", None)
|
||||||
|
tick_size = getattr(symbol_info, "tick_size", None)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"min_qty": str(min_qty) if min_qty not in (None, "") else None,
|
||||||
|
"step_size": str(step_size) if step_size not in (None, "") else None,
|
||||||
|
"min_notional": str(min_notional) if min_notional not in (None, "") else None,
|
||||||
|
"tick_size": str(tick_size) if tick_size not in (None, "") else None,
|
||||||
|
}
|
||||||
|
|
||||||
def save_draft(self, draft: OrderDraft) -> None:
|
def save_draft(self, draft: OrderDraft) -> None:
|
||||||
validation = self.validate_draft(draft)
|
validation = self.validate_draft(draft)
|
||||||
if not validation.is_valid:
|
if not validation.is_valid:
|
||||||
@@ -101,6 +128,21 @@ class OrderDraftsService:
|
|||||||
if quantity is None or quantity <= 0:
|
if quantity is None or quantity <= 0:
|
||||||
errors.append("Количество должно быть числом больше нуля.")
|
errors.append("Количество должно быть числом больше нуля.")
|
||||||
|
|
||||||
|
symbol_info = symbol_validation.symbol_info
|
||||||
|
|
||||||
|
if quantity is not None and quantity > 0 and symbol_info is not None:
|
||||||
|
min_qty = self._to_decimal(getattr(symbol_info, "min_qty", None))
|
||||||
|
if min_qty is not None and min_qty > 0 and quantity < min_qty:
|
||||||
|
errors.append(
|
||||||
|
f"Количество должно быть не меньше minQty = {getattr(symbol_info, 'min_qty', None)}."
|
||||||
|
)
|
||||||
|
|
||||||
|
step_size = self._to_decimal(getattr(symbol_info, "step_size", None))
|
||||||
|
if step_size is not None and step_size > 0 and not self._fits_step(quantity, step_size):
|
||||||
|
errors.append(
|
||||||
|
f"Количество должно соответствовать шагу stepSize = {getattr(symbol_info, 'step_size', None)}."
|
||||||
|
)
|
||||||
|
|
||||||
if draft.order_type == "LIMIT":
|
if draft.order_type == "LIMIT":
|
||||||
if not draft.price:
|
if not draft.price:
|
||||||
errors.append("Для LIMIT ордера требуется цена.")
|
errors.append("Для LIMIT ордера требуется цена.")
|
||||||
@@ -109,25 +151,35 @@ class OrderDraftsService:
|
|||||||
if price is None or price <= 0:
|
if price is None or price <= 0:
|
||||||
errors.append("Цена должна быть числом больше нуля.")
|
errors.append("Цена должна быть числом больше нуля.")
|
||||||
else:
|
else:
|
||||||
tick_size = None
|
tick_size = self._to_decimal(getattr(symbol_info, "tick_size", None))
|
||||||
if symbol_validation.symbol_info is not None:
|
|
||||||
tick_size = symbol_validation.symbol_info.tick_size
|
|
||||||
|
|
||||||
if tick_size is not None and tick_size > 0:
|
if tick_size is not None and tick_size > 0:
|
||||||
tick = Decimal(str(tick_size))
|
if not self._fits_step(price, tick_size):
|
||||||
if not self._fits_step(price, tick):
|
|
||||||
errors.append(
|
errors.append(
|
||||||
f"Цена должна соответствовать шагу tickSize = {tick_size}."
|
f"Цена должна соответствовать шагу tickSize = {getattr(symbol_info, 'tick_size', None)}."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if quantity is not None and quantity > 0 and symbol_info is not None:
|
||||||
|
reference_price = self._resolve_reference_price_for_validation(draft, symbol_info)
|
||||||
|
if reference_price is not None:
|
||||||
|
min_notional = self._to_decimal(getattr(symbol_info, "min_notional", None))
|
||||||
|
if min_notional is not None and min_notional > 0:
|
||||||
|
notional = quantity * reference_price
|
||||||
|
if notional < min_notional:
|
||||||
|
errors.append(
|
||||||
|
f"Сумма ордера должна быть не меньше minNotional = {getattr(symbol_info, 'min_notional', None)}."
|
||||||
|
)
|
||||||
|
|
||||||
return OrderValidationResult(
|
return OrderValidationResult(
|
||||||
is_valid=len(errors) == 0,
|
is_valid=len(errors) == 0,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
def list_recent_drafts(self, limit: int = 5) -> list[dict[str, str]]:
|
def list_recent_drafts(self, limit: int = 5) -> list[dict[str, str | int]]:
|
||||||
return self.repository.list_recent_drafts(limit=limit)
|
return self.repository.list_recent_drafts(limit=limit)
|
||||||
|
|
||||||
|
def get_draft_by_id(self, draft_id: str) -> dict[str, str] | None:
|
||||||
|
return self.repository.get_draft_by_id(draft_id)
|
||||||
|
|
||||||
def get_entry_context(self, *, side: str, order_type: str) -> OrderEntryContext:
|
def get_entry_context(self, *, side: str, order_type: str) -> OrderEntryContext:
|
||||||
validation = self.exchange.validate_symbol(self.settings.default_symbol)
|
validation = self.exchange.validate_symbol(self.settings.default_symbol)
|
||||||
if not validation.is_valid or validation.symbol_info is None:
|
if not validation.is_valid or validation.symbol_info is None:
|
||||||
@@ -158,12 +210,11 @@ class OrderDraftsService:
|
|||||||
reference_price = float(market["bid_price"])
|
reference_price = float(market["bid_price"])
|
||||||
max_qty = available_balance
|
max_qty = available_balance
|
||||||
|
|
||||||
quantity_presets = [
|
quantity_presets = self._build_quantity_presets(
|
||||||
self._format_number(max_qty * 0.25),
|
max_qty=max_qty,
|
||||||
self._format_number(max_qty * 0.50),
|
reference_price=reference_price,
|
||||||
self._format_number(max_qty * 0.75),
|
symbol_info=validation.symbol_info,
|
||||||
self._format_number(max_qty),
|
)
|
||||||
]
|
|
||||||
|
|
||||||
return OrderEntryContext(
|
return OrderEntryContext(
|
||||||
symbol=self.settings.default_symbol,
|
symbol=self.settings.default_symbol,
|
||||||
@@ -178,6 +229,127 @@ class OrderDraftsService:
|
|||||||
quantity_presets=quantity_presets,
|
quantity_presets=quantity_presets,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def validate_entry_quantity(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
side: str,
|
||||||
|
order_type: str,
|
||||||
|
quantity: str,
|
||||||
|
price: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
validation = self.exchange.validate_symbol(self.settings.default_symbol)
|
||||||
|
if not validation.is_valid or validation.symbol_info is None:
|
||||||
|
errors.append(validation.message)
|
||||||
|
return errors
|
||||||
|
|
||||||
|
symbol_info = validation.symbol_info
|
||||||
|
quantity_dec = self._to_decimal(quantity)
|
||||||
|
|
||||||
|
if quantity_dec is None or quantity_dec <= 0:
|
||||||
|
errors.append("Количество должно быть числом больше нуля.")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
min_qty = self._to_decimal(getattr(symbol_info, "min_qty", None))
|
||||||
|
if min_qty is not None and min_qty > 0 and quantity_dec < min_qty:
|
||||||
|
errors.append(
|
||||||
|
f"Количество должно быть не меньше minQty = {getattr(symbol_info, 'min_qty', None)}."
|
||||||
|
)
|
||||||
|
|
||||||
|
step_size = self._to_decimal(getattr(symbol_info, "step_size", None))
|
||||||
|
if step_size is not None and step_size > 0 and not self._fits_step(quantity_dec, step_size):
|
||||||
|
errors.append(
|
||||||
|
f"Количество должно соответствовать шагу stepSize = {getattr(symbol_info, 'step_size', None)}."
|
||||||
|
)
|
||||||
|
|
||||||
|
reference_price = self._resolve_reference_price_for_entry(
|
||||||
|
side=side,
|
||||||
|
order_type=order_type,
|
||||||
|
price=price,
|
||||||
|
)
|
||||||
|
|
||||||
|
if reference_price is not None:
|
||||||
|
min_notional = self._to_decimal(getattr(symbol_info, "min_notional", None))
|
||||||
|
if min_notional is not None and min_notional > 0:
|
||||||
|
notional = quantity_dec * reference_price
|
||||||
|
if notional < min_notional:
|
||||||
|
errors.append(
|
||||||
|
f"Сумма ордера должна быть не меньше minNotional = {getattr(symbol_info, 'min_notional', None)}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
def _build_quantity_presets(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
max_qty: float,
|
||||||
|
reference_price: float,
|
||||||
|
symbol_info: ExchangeSymbol,
|
||||||
|
) -> list[str]:
|
||||||
|
percents = [0.01, 0.05, 0.10, 0.25, 0.50, 1.00]
|
||||||
|
|
||||||
|
max_qty_dec = self._to_decimal(max_qty)
|
||||||
|
reference_price_dec = self._to_decimal(reference_price)
|
||||||
|
|
||||||
|
if max_qty_dec is None or max_qty_dec <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
step_size = self._to_decimal(getattr(symbol_info, "step_size", None))
|
||||||
|
min_qty = self._to_decimal(getattr(symbol_info, "min_qty", None))
|
||||||
|
min_notional = self._to_decimal(getattr(symbol_info, "min_notional", None))
|
||||||
|
|
||||||
|
result: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
for percent in percents:
|
||||||
|
qty = max_qty_dec * Decimal(str(percent))
|
||||||
|
qty = self._normalize_quantity_to_exchange_rules(
|
||||||
|
quantity=qty,
|
||||||
|
step_size=step_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
if qty is None or qty <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if min_qty is not None and min_qty > 0 and qty < min_qty:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if reference_price_dec is not None and reference_price_dec > 0:
|
||||||
|
if min_notional is not None and min_notional > 0:
|
||||||
|
if qty * reference_price_dec < min_notional:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if qty > max_qty_dec:
|
||||||
|
continue
|
||||||
|
|
||||||
|
text = self._format_decimal(qty)
|
||||||
|
if text == "0" or text in seen:
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen.add(text)
|
||||||
|
result.append(text)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
|
||||||
|
fallback = self._normalize_quantity_to_exchange_rules(
|
||||||
|
quantity=max_qty_dec,
|
||||||
|
step_size=step_size,
|
||||||
|
)
|
||||||
|
if fallback is None or fallback <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if min_qty is not None and min_qty > 0 and fallback < min_qty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if reference_price_dec is not None and reference_price_dec > 0:
|
||||||
|
if min_notional is not None and min_notional > 0:
|
||||||
|
if fallback * reference_price_dec < min_notional:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [self._format_decimal(fallback)]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def normalize_side(raw: str) -> str | None:
|
def normalize_side(raw: str) -> str | None:
|
||||||
value = (raw or "").strip().upper()
|
value = (raw or "").strip().upper()
|
||||||
@@ -225,7 +397,13 @@ class OrderDraftsService:
|
|||||||
return text or "0"
|
return text or "0"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _to_decimal(value: str | None) -> Decimal | None:
|
def _format_decimal(value: Decimal) -> str:
|
||||||
|
text = f"{value:.8f}"
|
||||||
|
text = text.rstrip("0").rstrip(".")
|
||||||
|
return text or "0"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_decimal(value: str | float | Decimal | None) -> Decimal | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -239,3 +417,79 @@ class OrderDraftsService:
|
|||||||
return True
|
return True
|
||||||
ratio = value / step
|
ratio = value / step
|
||||||
return ratio == ratio.to_integral_value()
|
return ratio == ratio.to_integral_value()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
|
||||||
|
if step <= 0:
|
||||||
|
return value
|
||||||
|
ratio = (value / step).to_integral_value(rounding=ROUND_DOWN)
|
||||||
|
return ratio * step
|
||||||
|
|
||||||
|
def _normalize_quantity_to_exchange_rules(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
quantity: Decimal,
|
||||||
|
step_size: Decimal | None,
|
||||||
|
) -> Decimal | None:
|
||||||
|
if quantity <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if step_size is not None and step_size > 0:
|
||||||
|
quantity = self._floor_to_step(quantity, step_size)
|
||||||
|
|
||||||
|
if quantity <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return quantity
|
||||||
|
|
||||||
|
def _resolve_reference_price_for_validation(
|
||||||
|
self,
|
||||||
|
draft: OrderDraft,
|
||||||
|
symbol_info: ExchangeSymbol | None,
|
||||||
|
) -> Decimal | None:
|
||||||
|
price = self._to_decimal(draft.price)
|
||||||
|
if price is not None and price > 0:
|
||||||
|
return price
|
||||||
|
|
||||||
|
if symbol_info is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
market = self.exchange.get_market_snapshot(draft.symbol)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if draft.side.upper() == "BUY":
|
||||||
|
return self._to_decimal(market.get("ask_price"))
|
||||||
|
return self._to_decimal(market.get("bid_price"))
|
||||||
|
|
||||||
|
def _resolve_reference_price_for_entry(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
side: str,
|
||||||
|
order_type: str,
|
||||||
|
price: str | None = None,
|
||||||
|
) -> Decimal | None:
|
||||||
|
if order_type.upper() == "LIMIT":
|
||||||
|
return self._to_decimal(price)
|
||||||
|
|
||||||
|
try:
|
||||||
|
market = self.exchange.get_market_snapshot(self.settings.default_symbol)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if side.upper() == "BUY":
|
||||||
|
return self._to_decimal(market.get("ask_price"))
|
||||||
|
return self._to_decimal(market.get("bid_price"))
|
||||||
|
|
||||||
|
def calculate_notional(self, quantity: str, price: str | None) -> float | None:
|
||||||
|
q = self._to_decimal(quantity)
|
||||||
|
p = self._to_decimal(price) if price else None
|
||||||
|
|
||||||
|
if q is None or p is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return float(q * p)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# /app/src/trading/orders/states.py
|
||||||
|
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
|
||||||
|
|
||||||
@@ -6,3 +8,4 @@ class NewOrderDraftStates(StatesGroup):
|
|||||||
waiting_type = State()
|
waiting_type = State()
|
||||||
waiting_quantity = State()
|
waiting_quantity = State()
|
||||||
waiting_price = State()
|
waiting_price = State()
|
||||||
|
waiting_confirm = State()
|
||||||
Reference in New Issue
Block a user