518 lines
14 KiB
Python
518 lines
14 KiB
Python
# 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 = (
|
|
"<b>🧯 Защита позиции</b>\n\n"
|
|
"<b>СИСТЕМА</b> · Настройки · Автоторговля\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(
|
|
"<b>Stop Loss</b>\n\n"
|
|
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
|
|
"Введите Stop Loss в процентах.\n"
|
|
"Например: <code>1</code>, <code>0.5</code>, <code>0,5</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")
|
|
_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(
|
|
"<b>Take Profit</b>\n\n"
|
|
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
|
|
"Введите Take Profit в процентах.\n"
|
|
"Например: <code>2</code>, <code>1.5</code>, <code>1,5</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")
|
|
_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(
|
|
"<b>Maximum Loss</b>\n\n"
|
|
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
|
|
"Введите максимальный paper-убыток в USD.\n"
|
|
"Например: <code>100</code>, <code>50.5</code>, <code>50,5</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")
|
|
_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() |