Stage 07.4.3.11 — Risk Settings UI & UX

This commit is contained in:
2026-05-05 19:14:51 +03:00
parent 163e8efe82
commit 3c3f0e846a
13 changed files with 967 additions and 12 deletions

View 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()