# 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.utils.keyboard import InlineKeyboardBuilder from aiogram.types import ( CallbackQuery, InlineKeyboardMarkup, Message, InaccessibleMessage, ) from src.core.numbers import safe_float from src.core.types import JsonDict, NumericLike 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") def _require_message( callback: CallbackQuery, ) -> Message | None: message = callback.message if ( message is None or isinstance(message, InaccessibleMessage) ): return None return message class AutoRiskStates(StatesGroup): waiting_stop_loss = State() waiting_take_profit = State() waiting_max_loss = State() def _format_number(value: NumericLike | None) -> str: number = safe_float(value) if number is None: return "—" if abs(number - round(number)) < 1e-9: return f"{int(round(number))}" return f"{number:.2f}".rstrip("0").rstrip(".") def _format_percent(value: NumericLike | None) -> str: number = safe_float(value) if number is None: return "off" return f"{_format_number(number)}%" def _format_usd(value: NumericLike | None) -> str: number = safe_float(value) if number is None: return "off" return f"{_format_number(number)} USD" def _rule_icon(value: NumericLike | None) -> str: return "✅" if safe_float(value) is not None else "⚠️" def _risk_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="🛑 SL", callback_data="auto:risk:set_sl") builder.button(text="🎯 TP", callback_data="auto:risk:set_tp") builder.button(text="💸 ML", callback_data="auto:risk:set_ml") builder.button(text="🤖 Автоторговля", callback_data="auto:home") builder.button(text="⬅️ Назад", callback_data="settings:auto") builder.button(text="♻️ Сбросить", callback_data="auto:risk:reset") builder.adjust(3, 1, 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 = ( "🧯 Защита позиции\n\n" "СИСТЕМА · Настройки · Автоторговля\n\n" f"Статус защиты: {status}\n" f"Активных правил: {active_count}/3\n\n" f"{_rule_icon(state.stop_loss_percent)} Stop Loss · {_format_percent(state.stop_loss_percent)}\n" f"{_rule_icon(state.take_profit_percent)} Take Profit · {_format_percent(state.take_profit_percent)}\n" f"{_rule_icon(state.max_loss_usd)} Max Loss · {_format_usd(state.max_loss_usd)}\n" ) 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") message = _require_message(callback) if message is None: await callback.answer( "Сообщение недоступно", show_alert=True, ) return _unregister_auto_screen_message(callback) await 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") bot = message.bot if bot is None: return data: JsonDict = await state.get_data() raw_chat_id = data.get("risk_chat_id") raw_message_id = data.get("risk_message_id") if not isinstance(raw_chat_id, int): await message.answer( _risk_text(status_message=status_message), reply_markup=_risk_keyboard(), ) return if not isinstance(raw_message_id, int): await message.answer( _risk_text(status_message=status_message), reply_markup=_risk_keyboard(), ) return chat_id = raw_chat_id message_id = raw_message_id await 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 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: message = _require_message(callback) if message is None: return await state.update_data( risk_chat_id=message.chat.id, risk_message_id=message.message_id, ) def _unregister_auto_screen_message( callback: CallbackQuery, ) -> None: message = _require_message(callback) if message is None: return AutoTradeRunner.unregister_screen( chat_id=message.chat.id, message_id=message.message_id, ) def _risk_payload(**values: object) -> JsonDict: return dict(values) def _parse_positive_or_none( raw_text: str | None, ) -> float | None: value_text = (raw_text or "").strip().replace(",", ".") if value_text.lower() in { "0", "0.0", "off", "-", }: return None value = safe_float(value_text) if value is None: raise ValueError if value <= 0: return None return value def _validate_percent( value: NumericLike | None, ) -> bool: number = safe_float(value) if number is None: return True return 0 < number <= 100 def _validate_max_loss( value: NumericLike | None, ) -> bool: number = safe_float(value) if number is None: return True return 0 < number <= 10000 def _log_risk_updated(action: str) -> None: state = AutoTradeService().get_state() try: JournalService().log_ui_info( event_type="risk_settings_updated", message=( "Параметры защиты позиции изменены: " f"SL={_format_percent(state.stop_loss_percent)}, " f"TP={_format_percent(state.take_profit_percent)}, " f"ML={_format_usd(state.max_loss_usd)}." ), screen="auto", action=action, payload=_risk_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") _unregister_auto_screen_message(callback) message = _require_message(callback) if message is None: await callback.answer( "Сообщение недоступно", show_alert=True, ) return await state.set_state(AutoRiskStates.waiting_stop_loss) await _remember_risk_screen(callback, state) await message.edit_text( "Stop Loss\n\n" "СИСТЕМА · Настройки · Автоторговля\n\n" "Введите Stop Loss в процентах.\n" "Например: 1, 0.5, 0,5\n\n" "отключить параметр - 0" ) 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") _unregister_auto_screen_message(callback) message = _require_message(callback) if message is None: await callback.answer( "Сообщение недоступно", show_alert=True, ) return await state.set_state(AutoRiskStates.waiting_take_profit) await _remember_risk_screen(callback, state) await message.edit_text( "Take Profit\n\n" "СИСТЕМА · Настройки · Автоторговля\n\n" "Введите Take Profit в процентах.\n" "Например: 2, 1.5, 1,5\n\n" "отключить параметр - 0" ) 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") _unregister_auto_screen_message(callback) message = _require_message(callback) if message is None: await callback.answer( "Сообщение недоступно", show_alert=True, ) return await state.set_state(AutoRiskStates.waiting_max_loss) await _remember_risk_screen(callback, state) await message.edit_text( "Maximum Loss\n\n" "СИСТЕМА · Настройки · Автоторговля\n\n" "Введите максимальный paper-убыток в USD.\n" "Например: 100, 50.5, 50,5\n\n" "отключить параметр - 0" ) 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") _unregister_auto_screen_message(callback) message = _require_message(callback) if message is None: await callback.answer("Сообщение недоступно", show_alert=True) return 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") await message.edit_text( _risk_text(status_message="✅ Risk Controls сброшены"), reply_markup=_risk_keyboard(), ) await callback.answer() await asyncio.sleep(2.5) if getattr(AutoTradeRunner, "_current_screen", None) != "auto_risk": return try: await message.edit_text( _risk_text(), reply_markup=_risk_keyboard(), ) except Exception: pass @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("Введите число. Например: 1, 0.5 или 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("Введите число. Например: 2, 1.5 или 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("Введите число. Например: 100, 50.5 или 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()