Stage 07.4.3.11 — Risk Settings UI & UX
This commit is contained in:
376
app/src/telegram/handlers/auto/risk.py
Normal file
376
app/src/telegram/handlers/auto/risk.py
Normal file
@@ -0,0 +1,376 @@
|
||||
# app/src/telegram/handlers/auto/risk.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from src.trading.auto.runner import AutoTradeRunner
|
||||
from src.trading.auto.service import AutoTradeService
|
||||
from src.trading.journal.service import JournalService
|
||||
|
||||
|
||||
router = Router(name="auto_risk")
|
||||
|
||||
|
||||
class AutoRiskStates(StatesGroup):
|
||||
waiting_stop_loss = State()
|
||||
waiting_take_profit = State()
|
||||
waiting_max_loss = State()
|
||||
|
||||
|
||||
def _format_percent(value: float | None) -> str:
|
||||
if value is None:
|
||||
return "⚪ off"
|
||||
return f"🟢 {value:g}%"
|
||||
|
||||
|
||||
def _format_usd(value: float | None) -> str:
|
||||
if value is None:
|
||||
return "⚪ off"
|
||||
return f"🟢 {value:g} USD"
|
||||
|
||||
|
||||
def _risk_keyboard() -> InlineKeyboardMarkup:
|
||||
state = AutoTradeService().get_state()
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text=f"🛑 Stop Loss", callback_data="auto:risk:set_sl")
|
||||
builder.button(text=f"🎯 Take Profit", callback_data="auto:risk:set_tp")
|
||||
builder.button(text=f"💸 Max Loss", callback_data="auto:risk:set_ml")
|
||||
builder.button(text="♻️ Reset", callback_data="auto:risk:reset")
|
||||
builder.button(text="⬅️ Назад", callback_data="settings:auto")
|
||||
builder.button(text="🤖 Автоторговля", callback_data="auto:home")
|
||||
|
||||
builder.adjust(2, 2, 2)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def _risk_text(status_message: str | None = None) -> str:
|
||||
state = AutoTradeService().get_state()
|
||||
|
||||
active_count = sum(
|
||||
value is not None
|
||||
for value in (
|
||||
state.stop_loss_percent,
|
||||
state.take_profit_percent,
|
||||
state.max_loss_usd,
|
||||
)
|
||||
)
|
||||
|
||||
status = "🟢 Активна" if active_count else "⚪ Выключена"
|
||||
|
||||
text = (
|
||||
"<b>⚠️ Risk Settings</b>\n\n"
|
||||
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
|
||||
f"Статус защиты: {status}\n"
|
||||
f"Активных правил: {active_count}/3\n\n"
|
||||
f"🛑 Stop Loss: {_format_percent(state.stop_loss_percent)}\n"
|
||||
f"🎯 Take Profit: {_format_percent(state.take_profit_percent)}\n"
|
||||
f"💸 Max Loss: {_format_usd(state.max_loss_usd)}\n\n"
|
||||
"<b>Подсказка:</b>\n"
|
||||
"Пример: <code>0.5</code>, <code>1</code>\n"
|
||||
"Введите <code>0</code>, чтобы отключить параметр."
|
||||
)
|
||||
|
||||
if status_message:
|
||||
text += f"\n\n{status_message}"
|
||||
|
||||
return text
|
||||
|
||||
|
||||
async def _render_risk_screen(callback: CallbackQuery) -> None:
|
||||
AutoTradeRunner.set_current_screen("auto_risk")
|
||||
|
||||
if callback.message is None:
|
||||
await callback.answer("Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
_risk_text(),
|
||||
reply_markup=_risk_keyboard(),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_risk_screen_by_message(
|
||||
message: Message,
|
||||
*,
|
||||
state: FSMContext,
|
||||
status_message: str | None = None,
|
||||
auto_clear: bool = False,
|
||||
) -> None:
|
||||
AutoTradeRunner.set_current_screen("auto_risk")
|
||||
|
||||
data = await state.get_data()
|
||||
chat_id = data.get("risk_chat_id")
|
||||
message_id = data.get("risk_message_id")
|
||||
|
||||
if chat_id is None or message_id is None:
|
||||
await message.answer(
|
||||
_risk_text(status_message=status_message),
|
||||
reply_markup=_risk_keyboard(),
|
||||
)
|
||||
return
|
||||
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
text=_risk_text(status_message=status_message),
|
||||
reply_markup=_risk_keyboard(),
|
||||
)
|
||||
|
||||
if status_message and auto_clear:
|
||||
await asyncio.sleep(2.5)
|
||||
|
||||
if getattr(AutoTradeRunner, "_current_screen", None) != "auto_risk":
|
||||
return
|
||||
|
||||
try:
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
text=_risk_text(),
|
||||
reply_markup=_risk_keyboard(),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _remember_risk_screen(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
if callback.message is None:
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
risk_chat_id=callback.message.chat.id,
|
||||
risk_message_id=callback.message.message_id,
|
||||
)
|
||||
|
||||
|
||||
def _parse_positive_or_none(raw_text: str | None) -> float | None:
|
||||
value_text = (raw_text or "").strip().replace(",", ".")
|
||||
|
||||
if value_text in {"0", "0.0", "off", "OFF", "-"}:
|
||||
return None
|
||||
|
||||
value = float(value_text)
|
||||
|
||||
if value <= 0:
|
||||
return None
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _validate_percent(value: float | None) -> bool:
|
||||
if value is None:
|
||||
return True
|
||||
return 0 < value <= 100
|
||||
|
||||
|
||||
def _validate_max_loss(value: float | None) -> bool:
|
||||
if value is None:
|
||||
return True
|
||||
return 0 < value <= 10000
|
||||
|
||||
|
||||
def _log_risk_updated(action: str) -> None:
|
||||
state = AutoTradeService().get_state()
|
||||
|
||||
try:
|
||||
JournalService().log_ui_info(
|
||||
event_type="auto_risk_settings_updated",
|
||||
message=(
|
||||
"Risk settings updated: "
|
||||
f"SL={state.stop_loss_percent}, "
|
||||
f"TP={state.take_profit_percent}, "
|
||||
f"ML={state.max_loss_usd}"
|
||||
),
|
||||
screen="auto",
|
||||
action=action,
|
||||
payload={
|
||||
"stop_loss_percent": state.stop_loss_percent,
|
||||
"take_profit_percent": state.take_profit_percent,
|
||||
"max_loss_usd": state.max_loss_usd,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data == "auto:risk")
|
||||
async def open_auto_risk(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
await state.clear()
|
||||
await _render_risk_screen(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "settings:auto_risk_controls")
|
||||
async def open_auto_risk_from_settings(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
await state.clear()
|
||||
await _render_risk_screen(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "auto:risk:set_sl")
|
||||
async def ask_stop_loss(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
AutoTradeRunner.set_current_screen("auto_risk")
|
||||
await state.set_state(AutoRiskStates.waiting_stop_loss)
|
||||
await _remember_risk_screen(callback, state)
|
||||
|
||||
if callback.message is not None:
|
||||
await callback.message.edit_text(
|
||||
"<b>🛑 Stop Loss</b>\n\n"
|
||||
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
|
||||
"Введите Stop Loss в процентах.\n"
|
||||
"Например: <code>2</code>\n\n"
|
||||
"Введите <code>0</code>, чтобы отключить."
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "auto:risk:set_tp")
|
||||
async def ask_take_profit(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
AutoTradeRunner.set_current_screen("auto_risk")
|
||||
await state.set_state(AutoRiskStates.waiting_take_profit)
|
||||
await _remember_risk_screen(callback, state)
|
||||
|
||||
if callback.message is not None:
|
||||
await callback.message.edit_text(
|
||||
"<b>🎯 Take Profit</b>\n\n"
|
||||
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
|
||||
"Введите Take Profit в процентах.\n"
|
||||
"Например: <code>3</code>\n\n"
|
||||
"Введите <code>0</code>, чтобы отключить."
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "auto:risk:set_ml")
|
||||
async def ask_max_loss(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
AutoTradeRunner.set_current_screen("auto_risk")
|
||||
await state.set_state(AutoRiskStates.waiting_max_loss)
|
||||
await _remember_risk_screen(callback, state)
|
||||
|
||||
if callback.message is not None:
|
||||
await callback.message.edit_text(
|
||||
"<b>💸 Max Loss</b>\n\n"
|
||||
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
|
||||
"Введите максимальный paper-убыток в USD.\n"
|
||||
"Например: <code>10</code>\n\n"
|
||||
"Введите <code>0</code>, чтобы отключить."
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "auto:risk:reset")
|
||||
async def reset_risk(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
await state.clear()
|
||||
AutoTradeRunner.set_current_screen("auto_risk")
|
||||
|
||||
service = AutoTradeService()
|
||||
service.set_stop_loss_percent(None)
|
||||
service.set_take_profit_percent(None)
|
||||
service.set_max_loss_usd(None)
|
||||
|
||||
_log_risk_updated("risk_reset")
|
||||
|
||||
if callback.message is not None:
|
||||
await callback.message.edit_text(
|
||||
_risk_text(status_message="✅ Risk Controls сброшены"),
|
||||
reply_markup=_risk_keyboard(),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
await asyncio.sleep(5.5)
|
||||
|
||||
if getattr(AutoTradeRunner, "_current_screen", None) != "auto_risk":
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
_risk_text(),
|
||||
reply_markup=_risk_keyboard(),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.message(AutoRiskStates.waiting_stop_loss)
|
||||
async def set_stop_loss(message: Message, state: FSMContext) -> None:
|
||||
try:
|
||||
value = _parse_positive_or_none(message.text)
|
||||
except ValueError:
|
||||
await message.answer("Введите число. Например: 2 или 0 для отключения.")
|
||||
return
|
||||
|
||||
if not _validate_percent(value):
|
||||
await message.answer("Stop Loss должен быть от 0 до 100%.")
|
||||
return
|
||||
|
||||
AutoTradeService().set_stop_loss_percent(value)
|
||||
_log_risk_updated("set_stop_loss")
|
||||
|
||||
await _render_risk_screen_by_message(
|
||||
message,
|
||||
state=state,
|
||||
status_message=f"✅ Stop Loss обновлён: {_format_percent(value)}",
|
||||
auto_clear=True,
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(AutoRiskStates.waiting_take_profit)
|
||||
async def set_take_profit(message: Message, state: FSMContext) -> None:
|
||||
try:
|
||||
value = _parse_positive_or_none(message.text)
|
||||
except ValueError:
|
||||
await message.answer("Введите число. Например: 3 или 0 для отключения.")
|
||||
return
|
||||
|
||||
if not _validate_percent(value):
|
||||
await message.answer("Take Profit должен быть от 0 до 100%.")
|
||||
return
|
||||
|
||||
AutoTradeService().set_take_profit_percent(value)
|
||||
_log_risk_updated("set_take_profit")
|
||||
|
||||
await _render_risk_screen_by_message(
|
||||
message,
|
||||
state=state,
|
||||
status_message=f"✅ Take Profit обновлён: {_format_percent(value)}",
|
||||
auto_clear=True,
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(AutoRiskStates.waiting_max_loss)
|
||||
async def set_max_loss(message: Message, state: FSMContext) -> None:
|
||||
try:
|
||||
value = _parse_positive_or_none(message.text)
|
||||
except ValueError:
|
||||
await message.answer("Введите число. Например: 10 или 0 для отключения.")
|
||||
return
|
||||
|
||||
if not _validate_max_loss(value):
|
||||
await message.answer("Max Loss должен быть от 0 до 10000 USD.")
|
||||
return
|
||||
|
||||
AutoTradeService().set_max_loss_usd(value)
|
||||
_log_risk_updated("set_max_loss")
|
||||
|
||||
await _render_risk_screen_by_message(
|
||||
message,
|
||||
state=state,
|
||||
status_message=f"✅ Max Loss обновлён: {_format_usd(value)}",
|
||||
auto_clear=True,
|
||||
)
|
||||
await state.clear()
|
||||
Reference in New Issue
Block a user