364 lines
10 KiB
Python
364 lines
10 KiB
Python
# app/src/telegram/handlers/auto.py
|
||
|
||
from __future__ import annotations
|
||
|
||
from aiogram import F, Router
|
||
from aiogram.exceptions import TelegramBadRequest
|
||
from aiogram.fsm.context import FSMContext
|
||
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
|
||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||
|
||
from src.telegram.ui.common import mode_line
|
||
from src.trading.auto.runner import AutoTradeRunner
|
||
from src.trading.auto.service import AutoTradeService
|
||
from src.telegram.handlers.system import open_auto_settings
|
||
from src.integrations.exchange.service import ExchangeService
|
||
from src.telegram.ui.currency_ui import format_usd_amount
|
||
|
||
|
||
router = Router(name="auto")
|
||
|
||
|
||
# красивое отображение стратегии
|
||
def _strategy_label(strategy: str | None) -> str:
|
||
mapping = {
|
||
"TREND": "📈 Trend Following",
|
||
"GRID": "🧩 Grid Trading",
|
||
"SCALP": "⚡ Scalping",
|
||
}
|
||
return mapping.get(strategy or "", "—")
|
||
|
||
|
||
# красивое отображение статуса
|
||
def _status_label(status: str) -> str:
|
||
mapping = {
|
||
"OFF": "⚪ Выключена",
|
||
"OBSERVING": "👀 Наблюдение",
|
||
"RUNNING": "🟢 Активна",
|
||
}
|
||
return mapping.get(status, status)
|
||
|
||
|
||
# красивое отображение сигнала
|
||
def _signal_label(signal: str | None) -> str:
|
||
mapping = {
|
||
"BUY": "🟢 BUY",
|
||
"SELL": "🔴 SELL",
|
||
"HOLD": "🟡 HOLD",
|
||
}
|
||
return mapping.get(signal or "", "—")
|
||
|
||
|
||
# красивое отображение решения
|
||
def _decision_label(status: str) -> str:
|
||
mapping = {
|
||
"WAITING": "🟡 Ожидание",
|
||
"CONFIRMING": "🟠 Подтверждение",
|
||
"READY": "🟢 Готово к входу",
|
||
"BLOCKED": "🔴 Заблокировано",
|
||
}
|
||
return mapping.get(status, status)
|
||
|
||
|
||
# компактное значение или заглушка
|
||
def _value_or_dash(value: object) -> str:
|
||
if value is None:
|
||
return "—"
|
||
return str(value)
|
||
|
||
|
||
# формат цены
|
||
def _price_or_dash(value: float | None) -> str:
|
||
if value is None:
|
||
return "—"
|
||
return f"{value:.2f}"
|
||
|
||
|
||
# текущая цена инструмента
|
||
def _market_price_or_dash(symbol: str | None) -> str:
|
||
if not symbol:
|
||
return "—"
|
||
|
||
try:
|
||
ticker = ExchangeService().get_price(symbol)
|
||
return f"$ {format_usd_amount(ticker.price)}"
|
||
except Exception:
|
||
return "—"
|
||
|
||
|
||
# формат USD
|
||
def _usd_or_dash(value: float | None) -> str:
|
||
if value is None:
|
||
return "—"
|
||
return f"{value:.2f} USD"
|
||
|
||
|
||
# формат размера позиции
|
||
def _size_or_dash(value: float | None) -> str:
|
||
if value is None:
|
||
return "—"
|
||
return f"{value:.8f}".rstrip("0").rstrip(".")
|
||
|
||
|
||
# формат плеча
|
||
def _leverage_or_dash(value: float | None) -> str:
|
||
if value is None:
|
||
return "—"
|
||
return f"{value:.1f}x"
|
||
|
||
|
||
# формат торгового инструмента для UI
|
||
def _format_symbol(symbol: str | None) -> str:
|
||
if not symbol:
|
||
return "—"
|
||
|
||
base_symbol = symbol.split("_", 1)[0]
|
||
parts = base_symbol.split("/", 1)
|
||
|
||
if len(parts) == 2:
|
||
return f"{parts[0]} / {parts[1]}"
|
||
|
||
return base_symbol
|
||
|
||
|
||
# стратегия для компактного UI
|
||
def _compact_strategy(strategy: str | None) -> str:
|
||
if not strategy:
|
||
return "—"
|
||
return strategy.upper()
|
||
|
||
|
||
# плечо для компактного UI
|
||
def _compact_leverage(value: float | None) -> str:
|
||
if value is None:
|
||
return "—"
|
||
return f"x{value:g}"
|
||
|
||
|
||
# проверка, настроена ли автоторговля минимально
|
||
def _is_auto_configured(state) -> bool:
|
||
return bool(
|
||
state.symbol
|
||
and state.strategy
|
||
and state.risk_percent is not None
|
||
)
|
||
|
||
|
||
# строка инструмента / стратегии / плеча
|
||
def _context_line(state) -> str:
|
||
symbol = _format_symbol(state.symbol)
|
||
strategy = _compact_strategy(state.strategy)
|
||
leverage = _compact_leverage(state.leverage)
|
||
|
||
if leverage == "—":
|
||
return f"{symbol} · {strategy}"
|
||
|
||
return f"{symbol} · {strategy} · {leverage}"
|
||
|
||
|
||
# клавиатура автоторговли
|
||
def _auto_keyboard() -> InlineKeyboardMarkup:
|
||
builder = InlineKeyboardBuilder()
|
||
|
||
builder.button(text="▶️ Start", callback_data="auto:start")
|
||
builder.button(text="👀 Watch", callback_data="auto:observe")
|
||
builder.button(text="🛑 Stop", callback_data="auto:stop")
|
||
builder.button(text="🛠️ Настройки", callback_data="settings:auto")
|
||
|
||
builder.adjust(3, 1)
|
||
return builder.as_markup()
|
||
|
||
|
||
# собрать текст экрана
|
||
def _build_auto_text() -> str:
|
||
service = AutoTradeService()
|
||
state = service.get_state()
|
||
|
||
account_mode = "DEMO" if "DEMO" in mode_line().upper() else "LIVE"
|
||
risk = f"{state.risk_percent:.1f}%" if state.risk_percent is not None else "—"
|
||
configured = _is_auto_configured(state)
|
||
price = _market_price_or_dash(state.symbol)
|
||
|
||
status_line = {
|
||
"OFF": "⚪ Off",
|
||
"OBSERVING": "👀 Watch",
|
||
"RUNNING": "🟢 On",
|
||
}.get(state.status, state.status)
|
||
|
||
header = (
|
||
f"<b>🤖 Автоторговля · {status_line}</b>\n"
|
||
f"🔸 {account_mode} аккаунт\n\n"
|
||
)
|
||
|
||
if state.status == "OFF":
|
||
if not configured:
|
||
return (
|
||
f"{header}"
|
||
"⚠️ Не настроена\n"
|
||
"Настрой параметры"
|
||
)
|
||
|
||
return (
|
||
f"{header}"
|
||
f"{_context_line(state)}\n"
|
||
f"Price: {price}\n"
|
||
f"Risk: {risk}"
|
||
)
|
||
|
||
position_line = (
|
||
f"Pos: {_value_or_dash(state.position_side)} | "
|
||
f"PnL: {_usd_or_dash(state.unrealized_pnl_usd)}"
|
||
)
|
||
|
||
if state.position_side != "NONE" and state.entry_price is not None:
|
||
position_line = (
|
||
f"Pos: {_value_or_dash(state.position_side)} | "
|
||
f"Entry: $ {_price_or_dash(state.entry_price)} | "
|
||
f"PnL: {_usd_or_dash(state.unrealized_pnl_usd)}"
|
||
)
|
||
|
||
return (
|
||
f"{header}"
|
||
f"{_context_line(state)}\n"
|
||
f"Price: {price}\n\n"
|
||
f"{_signal_label(state.last_signal)} ×{state.last_signal_repeat_count} "
|
||
f"· {state.decision_status}\n\n"
|
||
f"{position_line}\n"
|
||
f"Risk: {risk}"
|
||
)
|
||
|
||
|
||
# отрисовать live-экран автоторговли
|
||
async def _render_auto_screen(
|
||
target_message: Message,
|
||
*,
|
||
edit_mode: bool,
|
||
) -> None:
|
||
text = _build_auto_text()
|
||
|
||
if edit_mode:
|
||
try:
|
||
await target_message.edit_text(text, reply_markup=_auto_keyboard())
|
||
except TelegramBadRequest as exc:
|
||
if "message is not modified" not in str(exc).lower():
|
||
raise
|
||
|
||
AutoTradeRunner.register_screen(
|
||
bot=target_message.bot,
|
||
chat_id=target_message.chat.id,
|
||
message_id=target_message.message_id,
|
||
render_text=_build_auto_text,
|
||
render_markup=_auto_keyboard,
|
||
)
|
||
return
|
||
|
||
sent_message = await target_message.answer(text, reply_markup=_auto_keyboard())
|
||
|
||
AutoTradeRunner.register_screen(
|
||
bot=sent_message.bot,
|
||
chat_id=sent_message.chat.id,
|
||
message_id=sent_message.message_id,
|
||
render_text=_build_auto_text,
|
||
render_markup=_auto_keyboard,
|
||
)
|
||
|
||
|
||
# открыть экран из главного меню
|
||
@router.message(F.text.in_({"🤖 Автоторговля", "🤖 Авто"}))
|
||
async def open_auto(message: Message, state: FSMContext) -> None:
|
||
await state.clear()
|
||
|
||
AutoTradeRunner.set_current_screen("auto")
|
||
|
||
current_state = AutoTradeService().get_state()
|
||
if current_state.status in {"RUNNING", "OBSERVING"}:
|
||
await AutoTradeRunner.delete_registered_screen(
|
||
bot=message.bot,
|
||
chat_id=message.chat.id,
|
||
)
|
||
|
||
await _render_auto_screen(message, edit_mode=False)
|
||
|
||
|
||
# открыть экран через callback
|
||
@router.callback_query(F.data == "auto:home")
|
||
async def open_auto_from_callback(callback: CallbackQuery, state: FSMContext) -> None:
|
||
await state.clear()
|
||
|
||
if callback.message is None:
|
||
await callback.answer("Сообщение не найдено", show_alert=True)
|
||
return
|
||
|
||
AutoTradeRunner.set_current_screen("auto")
|
||
await _render_auto_screen(callback.message, edit_mode=True)
|
||
await callback.answer()
|
||
|
||
|
||
# включить активную торговлю
|
||
@router.callback_query(F.data == "auto:start")
|
||
async def auto_start(callback: CallbackQuery) -> None:
|
||
service = AutoTradeService()
|
||
state = service.get_state()
|
||
|
||
if not _is_auto_configured(state):
|
||
await callback.answer(
|
||
"Сначала настрой параметры автоторговли",
|
||
show_alert=True,
|
||
)
|
||
|
||
if callback.message is not None:
|
||
await open_auto_settings(callback)
|
||
|
||
return
|
||
|
||
_, message = service.start()
|
||
|
||
AutoTradeRunner.set_current_screen("auto")
|
||
AutoTradeRunner.start()
|
||
|
||
if callback.message is not None:
|
||
await _render_auto_screen(callback.message, edit_mode=True)
|
||
|
||
await callback.answer(message)
|
||
|
||
|
||
# включить наблюдение
|
||
@router.callback_query(F.data == "auto:observe")
|
||
async def auto_observe(callback: CallbackQuery) -> None:
|
||
service = AutoTradeService()
|
||
state = service.get_state()
|
||
|
||
if not _is_auto_configured(state):
|
||
await callback.answer(
|
||
"Сначала настрой параметры автоторговли",
|
||
show_alert=True,
|
||
)
|
||
|
||
if callback.message is not None:
|
||
await open_auto_settings(callback)
|
||
|
||
return
|
||
|
||
_, message = service.observe()
|
||
|
||
AutoTradeRunner.set_current_screen("auto")
|
||
AutoTradeRunner.start()
|
||
|
||
if callback.message is not None:
|
||
await _render_auto_screen(callback.message, edit_mode=True)
|
||
|
||
await callback.answer(message)
|
||
|
||
|
||
# выключить автоторговлю
|
||
@router.callback_query(F.data == "auto:stop")
|
||
async def auto_stop(callback: CallbackQuery) -> None:
|
||
service = AutoTradeService()
|
||
_, message = service.stop()
|
||
|
||
AutoTradeRunner.stop()
|
||
|
||
if callback.message is not None:
|
||
await _render_auto_screen(callback.message, edit_mode=True)
|
||
|
||
await callback.answer(message) |