From ee78f9774a9fd62cff4c834d5fe100ac40bcb0c4 Mon Sep 17 00:00:00 2001 From: Sergey Date: Wed, 6 May 2026 16:15:43 +0300 Subject: [PATCH] 07.4.3.13 - Risk-based Sizing and Margin Protection --- .../telegram/handlers/auto/_ui_old_working.py | 280 ++++++++ app/src/telegram/handlers/auto/ui.py | 603 ++++++++++++------ app/src/telegram/handlers/system.py | 52 +- app/src/trading/auto/service.py | 8 + app/src/trading/auto/state.py | 11 +- app/src/trading/execution/engine.py | 101 ++- docs/roadmap/master-roadmap.md | 19 + docs/roadmap/stage-07-auto-trading-roadmap.md | 20 + ...risk-based_sizing_and_margin_protection.md | 242 +++++++ 9 files changed, 1142 insertions(+), 194 deletions(-) create mode 100644 app/src/telegram/handlers/auto/_ui_old_working.py create mode 100644 docs/stages/stage-07_4_3_13-risk-based_sizing_and_margin_protection.md diff --git a/app/src/telegram/handlers/auto/_ui_old_working.py b/app/src/telegram/handlers/auto/_ui_old_working.py new file mode 100644 index 0000000..9636a10 --- /dev/null +++ b/app/src/telegram/handlers/auto/_ui_old_working.py @@ -0,0 +1,280 @@ +# app/src/telegram/handlers/auto/ui.py + +from __future__ import annotations + +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from src.integrations.exchange.service import ExchangeService +from src.telegram.ui.common import mode_line +from src.telegram.ui.currency_ui import format_usd_amount +from src.trading.auto.service import AutoTradeService + + +PAPER_BALANCE_USD = 1000.0 + +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 "—" + + +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" + + +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 + + +def compact_strategy(strategy: str | None) -> str: + if not strategy: + return "—" + return strategy.upper() + + +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.button(text="⚠️ Risk", callback_data="auto:risk") + + builder.adjust(3, 2) + return builder.as_markup() + + +def risk_settings_line(state) -> str: + sl = f"{state.stop_loss_percent:g}%" if state.stop_loss_percent is not None else "off" + tp = f"{state.take_profit_percent:g}%" if state.take_profit_percent is not None else "off" + max_loss = f"{state.max_loss_usd:g} USD" if state.max_loss_usd is not None else "off" + + return f"Controls: SL {sl} · TP {tp} · ML {max_loss}" + + +def target_risk_usd_line(state) -> str: + if state.risk_percent is None: + return "Target Risk: —" + + risk_usd = PAPER_BALANCE_USD * (state.risk_percent / 100) + return f"Target Risk: {risk_usd:.2f} USD" + + +def estimated_size_line(state) -> str: + if ( + state.risk_percent is None + or state.risk_percent <= 0 + or state.stop_loss_percent is None + or state.stop_loss_percent <= 0 + ): + return "Est. Size: —" + + try: + ticker = ExchangeService().get_price(state.symbol) + price = ticker.price + except Exception: + return "Est. Size: —" + + if price <= 0: + return "Est. Size: —" + + target_risk_usd = PAPER_BALANCE_USD * (state.risk_percent / 100) + stop_loss_distance_usd = price * (state.stop_loss_percent / 100) + + if stop_loss_distance_usd <= 0: + return "Est. Size: —" + + size = target_risk_usd / stop_loss_distance_usd + return f"Est. Size: {size:.8f}".rstrip("0").rstrip(".") + + +def position_size_line(state) -> str: + if state.position_size is None or state.position_size == 0: + return "Position Size: —" + + return f"Position Size: {state.position_size:.8f}".rstrip("0").rstrip(".") + + +def actual_risk_usd_line(state) -> str: + if ( + state.position_side == "NONE" + or state.entry_price is None + or state.position_size is None + or state.stop_loss_percent is None + or state.stop_loss_percent <= 0 + ): + return "Actual Risk: —" + + stop_loss_distance_usd = state.entry_price * (state.stop_loss_percent / 100) + actual_risk_usd = stop_loss_distance_usd * state.position_size + + return f"Actual Risk: {actual_risk_usd:.2f} USD" + + +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"🤖 Автоторговля · {status_line}\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"Position Risk: {risk}\n" + f"{target_risk_usd_line(state)}\n" + f"{estimated_size_line(state)}\n" + f"{risk_settings_line(state)}" + ) + + 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"Position Risk: {risk}\n" + f"{target_risk_usd_line(state)}\n" + f"{position_size_line(state)}\n" + f"{actual_risk_usd_line(state)}\n" + f"{risk_settings_line(state)}" + ) \ No newline at end of file diff --git a/app/src/telegram/handlers/auto/ui.py b/app/src/telegram/handlers/auto/ui.py index 33bc87d..4817a1f 100644 --- a/app/src/telegram/handlers/auto/ui.py +++ b/app/src/telegram/handlers/auto/ui.py @@ -11,126 +11,13 @@ from src.telegram.ui.currency_ui import format_usd_amount from src.trading.auto.service import AutoTradeService -def strategy_label(strategy: str | None) -> str: - mapping = { - "TREND": "📈 Trend Following", - "GRID": "🧩 Grid Trading", - "SCALP": "⚡ Scalping", - } - return mapping.get(strategy or "", "—") +PAPER_BALANCE_USD = 1000.0 -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: +def _format_percent2(value: float | None) -> 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 "—" - - -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" - - -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 - - -def compact_strategy(strategy: str | None) -> str: - if not strategy: - return "—" - return strategy.upper() - - -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}" + return "off" + return f"{float(value):.2f}%" def auto_keyboard() -> InlineKeyboardMarkup: @@ -146,79 +33,425 @@ def auto_keyboard() -> InlineKeyboardMarkup: return builder.as_markup() -def risk_settings_line(state) -> str: - sl = f"{state.stop_loss_percent:g}%" if state.stop_loss_percent is not None else "off" - tp = f"{state.take_profit_percent:g}%" if state.take_profit_percent is not None else "off" - max_loss = f"{state.max_loss_usd:g} USD" if state.max_loss_usd is not None else "off" - - return f"Controls: SL {sl} · TP {tp} · ML {max_loss}" - - -def estimated_size_line(state) -> str: - if state.risk_percent is None or state.leverage is None: - return "" - - size = round((state.risk_percent * state.leverage) / 100, 8) - return f"Est. Size: {size}" +def is_auto_configured(state) -> bool: + return bool( + state.symbol + and state.strategy + and state.risk_percent is not None + ) def build_auto_text() -> str: - service = AutoTradeService() - state = service.get_state() + state = AutoTradeService().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"🤖 Автоторговля · {status_line}\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"Position Risk: {risk}\n" - f"{estimated_size_line(state)}\n" - f"{risk_settings_line(state)}" - ) - - position_line = ( - f"Pos: {value_or_dash(state.position_side)} | " - f"PnL: {usd_or_dash(state.unrealized_pnl_usd)}" - ) + if not is_auto_configured(state): + return _build_not_configured_text(state) 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 _build_active_position_text(state) + + return _build_waiting_text(state) + + +def _build_not_configured_text(state) -> str: + return ( + "🤖 Автоторговля · ⚪ Не настроена\n" + f"{_account_mode_line()}\n\n" + "Configuration required\n\n" + f"Pair: {_required_value(_asset_symbol(state.symbol))}\n" + f"Strategy: {_required_value(_strategy_short(state.strategy))}\n" + f"Position Risk: {_required_value(_risk_percent_text(state))}\n\n" + "🛠️ Открой настройки для запуска" + ) + + +def _execution_block_lines(state) -> list[str]: + lines: list[str] = [] + + reason = getattr(state, "execution_block_reason", None) + if reason: + lines.append(f"🔴 Blocked: {reason}") + + adjustment = getattr(state, "execution_size_adjustment_reason", None) + if adjustment == "MARGIN_LIMIT": + lines.append("🟠 Size adjusted by Max Reserved") + + return lines + + +def _estimated_margin_text(state, price: float | None) -> str: + size = _estimated_size(state, price) + + if size is None or price is None: + return "Est. Margin: —" + + leverage = state.leverage or 1.0 + if leverage <= 0: + return "Est. Margin: —" + + notional = size * price + reserved = notional / leverage + + return f"Est. Margin: $ {_format_money0(reserved)}" + + +def _max_reserved_text(state) -> str: + max_percent = getattr(state, "max_reserved_balance_percent", None) + + if max_percent is None or max_percent <= 0: + return "Max Reserved: off" + + max_reserved = PAPER_BALANCE_USD * (max_percent / 100) + + return f"Max Reserved: {max_percent:g}% · $ {_format_money0(max_reserved)}" + + +def _build_waiting_text(state) -> str: + price = _current_price(state.symbol) + signal = state.last_signal or "—" + repeats = state.last_signal_repeat_count or 0 + confidence = state.last_signal_confidence or 0.0 + rr_line = _risk_reward_line(state) + + parts = [ + f"🤖 Автоторговля · {_status_text(state.status)}", + _account_mode_line(), + f"🏦 Balance: $ {_format_money(PAPER_BALANCE_USD)}", + "", + f"{_asset_symbol(state.symbol)} · {_strategy_short(state.strategy)} · {_leverage_text(state.leverage)}", + f"💲 Price: {_format_usd_or_dash(price)}", + "", + _decision_human_text(state.decision_status), + f"{_signal_icon(signal)} {signal} ×{repeats}", + f"Confidence: {confidence:.2f}", + *(_execution_block_lines(state)), + "", + f"Position Risk: {_risk_percent_text(state)} · $ {_format_money0(_target_risk_usd(state))}", + _estimated_size_text(state, price), + _estimated_margin_text(state, price), + _max_reserved_text(state), + "", + f"🛑 SL: {_format_percent2(state.stop_loss_percent)}", + f"🎯 TP: {_format_percent2(state.take_profit_percent)}", + f"💣 ML: {_max_loss_or_off(state.max_loss_usd)}", + ] + + if rr_line: + parts.extend(["", rr_line]) + + return "\n".join(parts) + + +def _build_active_position_text(state) -> str: + current_price = _current_price(state.symbol) + current_price_for_calc = current_price or state.entry_price or 0.0 + + size = state.position_size or 0.0 + leverage = state.leverage or 1.0 + + notional = size * current_price_for_calc + reserved = notional / leverage if leverage > 0 else 0.0 + pnl = state.unrealized_pnl_usd or 0.0 + + rr_line = _risk_reward_line(state) + side_icon = "🟢" if state.position_side == "LONG" else "🔴" + + parts = [ + f"🤖 Автоторговля · {_status_text(state.status)}", + _account_mode_line(), + f"🏦 Balance: $ {_format_money(PAPER_BALANCE_USD)}", + f"💵 Reserved: $ {_format_money(reserved)}", + "", + ( + f"{side_icon} {_asset_symbol(state.symbol)} · " + f"{_strategy_short(state.strategy)} · " + f"{state.position_side} {_leverage_text(state.leverage)}" + ), + f"📦 Size: {_format_crypto_size(size)} ($ {_format_money0(notional)})", + f"💲 Entry: $ {_format_money(state.entry_price)}", + f"💲 Current: {_format_usd_or_dash(current_price)}", + f"⚖️ P&L: {_format_signed_usd(pnl)}", + "Fees: not included", + "", + _active_sl_line(state), + _active_tp_line(state), + _active_ml_line(state), + ] + + if rr_line: + parts.append(rr_line) + + return "\n".join(parts) + + +def _current_price(symbol: str | None) -> float | None: + if not symbol: + return None + + try: + return float(ExchangeService().get_price(symbol).price) + except Exception: + return None + + +def _target_risk_usd(state) -> float: + if state.risk_percent is None: + return 0.0 + return PAPER_BALANCE_USD * (state.risk_percent / 100) + + +def _estimated_size(state, price: float | None) -> float | None: + if ( + price is None + or price <= 0 + or state.risk_percent is None + or state.risk_percent <= 0 + or state.stop_loss_percent is None + or state.stop_loss_percent <= 0 + ): + return None + + stop_loss_distance_usd = price * (state.stop_loss_percent / 100) + if stop_loss_distance_usd <= 0: + return None + + risk_size = _target_risk_usd(state) / stop_loss_distance_usd + + max_percent = getattr(state, "max_reserved_balance_percent", None) + if max_percent is None or max_percent <= 0: + return risk_size + + leverage = state.leverage or 1.0 + if leverage <= 0: + return risk_size + + max_reserved_usd = PAPER_BALANCE_USD * (max_percent / 100) + max_notional_usd = max_reserved_usd * leverage + max_size = max_notional_usd / price + + return min(risk_size, max_size) + + +def _estimated_size_text(state, price: float | None) -> str: + size = _estimated_size(state, price) + if size is None or price is None: + return "Est. Size: —" + + notional = size * price + return ( + f"Est. Size: {_format_crypto_size(size)} " + f"{_asset_symbol(state.symbol)} ($ {_format_money0(notional)})" + ) + + +def _active_sl_line(state) -> str: + if ( + state.stop_loss_percent is None + or state.entry_price is None + or state.position_size is None + ): + return "🛑 SL: off" + + move = state.entry_price * (state.stop_loss_percent / 100) + loss = move * state.position_size + + if state.position_side == "SHORT": + price = state.entry_price + move + else: + price = state.entry_price - move 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"Position Risk: {risk}\n" - f"{estimated_size_line(state)}\n" - f"{risk_settings_line(state)}" - ) \ No newline at end of file + f"🛑 SL: {_format_percent2(state.stop_loss_percent)} · " + f"-$ {_format_money0(loss)} ⇢ $ {_format_money0(price)}" + ) + + +def _active_tp_line(state) -> str: + if ( + state.take_profit_percent is None + or state.entry_price is None + or state.position_size is None + ): + return "🎯 TP: off" + + move = state.entry_price * (state.take_profit_percent / 100) + profit = move * state.position_size + + if state.position_side == "SHORT": + price = state.entry_price - move + else: + price = state.entry_price + move + + return ( + f"🎯 TP: {_format_percent2(state.take_profit_percent)} · " + f"+$ {_format_money0(profit)} ⇢ $ {_format_money0(price)}" + ) + + +def _active_ml_line(state) -> str: + if ( + state.max_loss_usd is None + or state.entry_price is None + or state.position_size is None + or state.position_size <= 0 + ): + return "💣 ML: off" + + loss = abs(state.max_loss_usd) + price_move = loss / state.position_size + + if state.position_side == "SHORT": + price = state.entry_price + price_move + else: + price = state.entry_price - price_move + + return f"💣 ML: -$ {_format_money0(loss)} ⇢ $ {_format_money0(price)}" + + +def _risk_reward_line(state) -> str: + if ( + state.stop_loss_percent is None + or state.stop_loss_percent <= 0 + or state.take_profit_percent is None + or state.take_profit_percent <= 0 + ): + return "" + + ratio = state.take_profit_percent / state.stop_loss_percent + return f"⚖️ R:R = 1 : {_format_ratio_value(ratio)}" + + +def _format_ratio_value(value: float) -> str: + if abs(value - round(value)) < 1e-9: + return str(int(round(value))) + return f"{value:.2f}".rstrip("0").rstrip(".") + + +def _status_text(status: str) -> str: + mapping = { + "OFF": "⚪ Остановлена", + "OBSERVING": "👀 Наблюдение", + "RUNNING": "🟢 Работает", + } + return mapping.get(status, status) + + +def _decision_human_text(status: str) -> str: + mapping = { + "WAITING": "🟡 Ожидание сигнала", + "CONFIRMING": "🟠 Подтверждение сигнала", + "READY": "🟢 Сигнал готов", + "BLOCKED": "🔴 Сигнал заблокирован", + } + return mapping.get(status, status) + + +def _account_mode_line() -> str: + return "🔸 DEMO аккаунт" if "DEMO" in mode_line().upper() else "🔸 LIVE аккаунт" + + +def _asset_symbol(symbol: str | None) -> str: + if not symbol: + return "—" + + base = symbol.split("_", 1)[0].upper() + + if "/" in base: + return base.split("/", 1)[0] + + for suffix in ("USDT", "USD", "EUR", "BTC"): + if base.endswith(suffix) and len(base) > len(suffix): + return base[: -len(suffix)] + + return base + + +def _strategy_short(strategy: str | None) -> str: + if not strategy: + return "—" + + mapping = { + "TREND": "Trend", + "GRID": "Grid", + "SCALP": "Scalp", + } + return mapping.get(strategy.upper(), strategy.title()) + + +def _leverage_text(value: float | None) -> str: + if value is None: + return "x—" + return f"x{value:g}" + + +def _risk_percent_text(state) -> str: + if state.risk_percent is None: + return "—" + return f"{state.risk_percent:g}%" + + +def _percent_or_off(value: float | None) -> str: + if value is None: + return "off" + return f"{value:g}%" + + +def _max_loss_or_off(value: float | None) -> str: + if value is None: + return "off" + return f"$ {_format_money0(value)}" + + +def _required_value(value: str) -> str: + if not value or value == "—": + return "required" + return value + + +def _signal_icon(signal: str | None) -> str: + mapping = { + "BUY": "🟢", + "SELL": "🔴", + "HOLD": "🟡", + } + return mapping.get(signal or "", "⚪") + + +def _format_money(value: float | int | None) -> str: + if value is None: + return "—" + return format_usd_amount(float(value)) + + +def _format_money0(value: float | int | None) -> str: + if value is None: + return "—" + return f"{float(value):,.0f}".replace(",", " ") + + +def _format_usd_or_dash(value: float | None) -> str: + if value is None: + return "—" + return f"$ {_format_money(value)}" + + +def _format_signed_usd(value: float | int | None) -> str: + if value is None: + return "—" + + amount = float(value) + + if amount > 0: + return f"+$ {_format_money(amount)}" + + if amount < 0: + return f"−$ {_format_money(abs(amount))}" + + return "$ 0.00" + + +def _format_crypto_size(value: float | int | None) -> str: + if value is None: + return "—" + + return f"{float(value):.4f}".rstrip("0").rstrip(".") \ No newline at end of file diff --git a/app/src/telegram/handlers/system.py b/app/src/telegram/handlers/system.py index 636fb5f..6749e8f 100644 --- a/app/src/telegram/handlers/system.py +++ b/app/src/telegram/handlers/system.py @@ -197,6 +197,11 @@ async def open_auto_settings(callback: CallbackQuery) -> None: symbol = state.symbol or "—" risk = f"{state.risk_percent:.1f}%" if state.risk_percent is not None else "—" leverage = f"x{state.leverage:g}" if state.leverage is not None else "—" + max_reserved = ( + f"{state.max_reserved_balance_percent:g}%" + if state.max_reserved_balance_percent is not None + else "off" + ) sl = f"{state.stop_loss_percent:g}%" if state.stop_loss_percent is not None else "off" tp = f"{state.take_profit_percent:g}%" if state.take_profit_percent is not None else "off" ml = f"{state.max_loss_usd:g} USD" if state.max_loss_usd is not None else "off" @@ -220,6 +225,7 @@ async def open_auto_settings(callback: CallbackQuery) -> None: f"{symbol_icon} Инструмент: {symbol}\n" f"{risk_icon} Риск на сделку: {risk}\n" f"{leverage_icon} Плечо: {leverage}\n\n" + f"✅ Max Reserved: {max_reserved}\n" f"✅ Risk Controls: {risk_controls}\n\n" f"{config_status}" ) @@ -233,9 +239,10 @@ async def open_auto_settings(callback: CallbackQuery) -> None: builder.button(text="🛡️ Риск на сделку", callback_data="settings:auto_risk") builder.button(text="⚙️ Плечо", callback_data="settings:auto_leverage") builder.button(text="⚠️ Risk Controls", callback_data="auto:risk") + builder.button(text="🏦 Max Reserved", callback_data="settings:auto_max_reserved") builder.button(text="🤖 Автоторговля", callback_data="auto:home") builder.button(text="⬅️ Назад", callback_data="system:management") - builder.adjust(2, 2, 1, 2) + builder.adjust(2, 2, 2, 2) await callback.message.edit_text(text, reply_markup=builder.as_markup()) await callback.answer() @@ -610,4 +617,45 @@ async def open_system_about(callback: CallbackQuery) -> None: text, reply_markup=builder.as_markup(), ) - await callback.answer() \ No newline at end of file + await callback.answer() + + +@router.callback_query(F.data == "settings:auto_max_reserved") +async def open_auto_max_reserved_settings(callback: CallbackQuery) -> None: + AutoTradeRunner.set_current_screen("settings_auto") + + if callback.message is None: + await callback.answer("Сообщение не найдено", show_alert=True) + return + + text = ( + "🏦 Max Reserved\n\n" + "СИСТЕМА · Настройки · Автоторговля\n\n" + "Максимальная доля баланса, которую можно зарезервировать под позицию:" + ) + + builder = InlineKeyboardBuilder() + builder.button(text="25%", callback_data="settings:auto_max_reserved:25") + builder.button(text="50%", callback_data="settings:auto_max_reserved:50") + builder.button(text="75%", callback_data="settings:auto_max_reserved:75") + builder.button(text="100%", callback_data="settings:auto_max_reserved:100") + builder.button(text="off", callback_data="settings:auto_max_reserved:off") + builder.button(text="⬅️ Назад", callback_data="settings:auto") + builder.adjust(2, 2, 1, 1) + + await callback.message.edit_text(text, reply_markup=builder.as_markup()) + await callback.answer() + + +@router.callback_query(F.data.startswith("settings:auto_max_reserved:")) +async def set_auto_max_reserved(callback: CallbackQuery) -> None: + raw_value = callback.data.split(":", 2)[2] + + value = None if raw_value == "off" else float(raw_value) + AutoTradeService().set_max_reserved_balance_percent(value) + + if callback.message is not None: + await open_auto_settings(callback) + + AutoTradeRunner.set_current_screen("settings_auto") + await callback.answer("Max Reserved обновлён") \ No newline at end of file diff --git a/app/src/trading/auto/service.py b/app/src/trading/auto/service.py index 8db3aa4..dbb1a17 100644 --- a/app/src/trading/auto/service.py +++ b/app/src/trading/auto/service.py @@ -238,6 +238,13 @@ class AutoTradeService: state = self.get_state() state.max_loss_usd = value return state + + # установить максимальное использование баланса под маржу + def set_max_reserved_balance_percent(self, value: float | None) -> AutoTradeState: + state = self.get_state() + state.max_reserved_balance_percent = value + state.execution_block_reason = None + return state # сбросить внутренний трекинг сигналов def _reset_signal_tracking(self) -> None: @@ -256,6 +263,7 @@ class AutoTradeService: state.decision_reason = None state.is_signal_confirmed = False state.is_signal_ready = False + state.execution_block_reason = None # собрать контекст для стратегии def _build_strategy_context(self) -> StrategyContext: diff --git a/app/src/trading/auto/state.py b/app/src/trading/auto/state.py index 1dff8d9..324e44c 100644 --- a/app/src/trading/auto/state.py +++ b/app/src/trading/auto/state.py @@ -74,4 +74,13 @@ class AutoTradeState: take_profit_percent: float | None = None # максимальный допустимый paper-убыток в USD - max_loss_usd: float | None = None \ No newline at end of file + max_loss_usd: float | None = None + + # максимальная доля баланса, которую можно зарезервировать под позицию + max_reserved_balance_percent: float | None = 50.0 + + # последняя причина блокировки execution + execution_block_reason: str | None = None + + # причина авто-уменьшения размера позиции + execution_size_adjustment_reason: str | None = None \ No newline at end of file diff --git a/app/src/trading/execution/engine.py b/app/src/trading/execution/engine.py index c4a5ebf..b6f86f6 100644 --- a/app/src/trading/execution/engine.py +++ b/app/src/trading/execution/engine.py @@ -64,7 +64,20 @@ class ExecutionEngine: return ExecutionDecision("NONE", False, f"Не удалось получить цену для paper execution: {exc}") now = self._now_time() - size = self._calculate_position_size(state) + size = self._calculate_position_size(state, entry_price=entry_price) + + if size <= 0: + return ExecutionDecision( + "NONE", + False, + "Позиция не открыта: невозможно рассчитать size без Stop Loss.", + ) + + size = self._adjust_size_by_margin_limit( + state=state, + entry_price=entry_price, + size=size, + ) type(self)._position = PositionState( side=side, @@ -125,7 +138,20 @@ class ExecutionEngine: now = self._now_time() pnl = self._calculate_pnl(flip_price) - new_size = self._calculate_position_size(state) + new_size = self._calculate_position_size(state, entry_price=flip_price) + + if new_size <= 0: + return ExecutionDecision( + "NONE", + False, + "Flip отменён: невозможно рассчитать size без Stop Loss.", + ) + + new_size = self._adjust_size_by_margin_limit( + state=state, + entry_price=flip_price, + size=new_size, + ) old_side = position.side old_entry_price = position.entry_price @@ -388,11 +414,74 @@ class ExecutionEngine: self._sync_state_from_position(state) - def _calculate_position_size(self, state: AutoTradeState) -> float: - risk_percent = state.risk_percent or 0.0 - leverage = state.leverage or 1.0 - return round((risk_percent * leverage) / 100, 8) + def _calculate_position_size( + self, + state: AutoTradeState, + *, + entry_price: float | None = None, + ) -> float: + if state.risk_percent is None or state.risk_percent <= 0: + return 0.0 + if state.stop_loss_percent is None or state.stop_loss_percent <= 0: + return 0.0 + + price = entry_price + + if price is None: + try: + ticker = ExchangeService().get_price(state.symbol) + price = ticker.price + except Exception: + return 0.0 + + if price <= 0: + return 0.0 + + balance_usd = 1000.0 + target_risk_usd = balance_usd * (state.risk_percent / 100) + stop_loss_distance_usd = price * (state.stop_loss_percent / 100) + + if stop_loss_distance_usd <= 0: + return 0.0 + + size = target_risk_usd / stop_loss_distance_usd + + return round(size, 8) + + def _adjust_size_by_margin_limit( + self, + *, + state: AutoTradeState, + entry_price: float, + size: float, + ) -> float: + max_percent = state.max_reserved_balance_percent + + state.execution_block_reason = None + state.execution_size_adjustment_reason = None + + if max_percent is None or max_percent <= 0: + return round(size, 8) + + leverage = state.leverage or 1.0 + if leverage <= 0 or entry_price <= 0: + state.execution_block_reason = "Invalid leverage or entry price." + return 0.0 + + balance_usd = 1000.0 + max_reserved_usd = balance_usd * (max_percent / 100) + + max_notional_usd = max_reserved_usd * leverage + max_size = max_notional_usd / entry_price + + if size <= max_size: + return round(size, 8) + + state.execution_size_adjustment_reason = "MARGIN_LIMIT" + + return round(max_size, 8) + def _calculate_pnl(self, current_price: float) -> float: position = type(self)._position diff --git a/docs/roadmap/master-roadmap.md b/docs/roadmap/master-roadmap.md index 63ed22d..c24a6f8 100644 --- a/docs/roadmap/master-roadmap.md +++ b/docs/roadmap/master-roadmap.md @@ -223,6 +223,25 @@ - Telegram execution alerts с причиной риска - единая точка принятия решений (execution layer) +#### 07.4.3.13 — Risk-Based Position Sizing & Margin Protection ✅ +- risk-based position sizing через SL distance +- размер позиции теперь рассчитывается от Risk % +- execution-level margin validation +- защита от oversized positions +- max reserved balance limit +- execution block reason state +- блокировка ENTRY / FLIP при превышении margin limit +- compact mobile UI redesign +- новый формат SL / TP / ML +- compact position rendering +- estimated margin preview +- max reserved preview +- execution blocked status в UI +- улучшенный mobile formatting +- SL стал обязательным для risk-engine sizing +- risk_percent теперь реально влияет на размер позиции +- flip теперь проходит через margin protection + ### 07.4.4 ⏳ Grid Strategy diff --git a/docs/roadmap/stage-07-auto-trading-roadmap.md b/docs/roadmap/stage-07-auto-trading-roadmap.md index d18cac8..f9ec6e3 100644 --- a/docs/roadmap/stage-07-auto-trading-roadmap.md +++ b/docs/roadmap/stage-07-auto-trading-roadmap.md @@ -208,6 +208,26 @@ - Telegram execution alerts с причиной риска - единая точка принятия решений (execution layer) +#### 07.4.3.13 — Risk-Based Position Sizing & Margin Protection ✅ + +- risk-based position sizing через SL distance +- размер позиции теперь рассчитывается от Risk % +- execution-level margin validation +- защита от oversized positions +- max reserved balance limit +- execution block reason state +- блокировка ENTRY / FLIP при превышении margin limit +- compact mobile UI redesign +- новый формат SL / TP / ML +- compact position rendering +- estimated margin preview +- max reserved preview +- execution blocked status в UI +- улучшенный mobile formatting +- SL стал обязательным для risk-engine sizing +- risk_percent теперь реально влияет на размер позиции +- flip теперь проходит через margin protection + --- ### 07.4.4 diff --git a/docs/stages/stage-07_4_3_13-risk-based_sizing_and_margin_protection.md b/docs/stages/stage-07_4_3_13-risk-based_sizing_and_margin_protection.md new file mode 100644 index 0000000..ffe6a5c --- /dev/null +++ b/docs/stages/stage-07_4_3_13-risk-based_sizing_and_margin_protection.md @@ -0,0 +1,242 @@ +# 07.4.3.13 — Risk-Based Position Sizing & Margin Protection + +## Цель + +Перевести paper execution с “фиксированного размера позиции” на полноценную risk-based модель, где: + +- размер позиции рассчитывается через Risk % +- Stop Loss участвует в sizing +- execution защищён от oversized positions +- margin usage контролируется до открытия сделки +- UI показывает реальную нагрузку на баланс + +--- + +# Что было раньше + +Ранее: + +- risk_percent существовал только как UI-параметр +- размер позиции не ограничивался margin usage +- leverage мог создавать oversized exposure +- позиция могла резервировать 100%+ баланса +- SL / TP использовались только для close logic + +Пример проблемы: + +- balance = 1000 USD +- leverage = x5 +- BTC = 80 000 +- risk = 2% +- SL = 1% + +execution открывал позицию: + +- notional ≈ 10 000 USD +- reserved ≈ 2 000 USD + +что превышало весь paper balance. + +--- + +# Что реализовано + +## 1. Risk-Based Position Sizing + +ExecutionEngine теперь рассчитывает size через риск. + +Добавлена формула: + +``` +target_risk_usd = balance * (risk_percent / 100) +stop_loss_distance_usd = price * (stop_loss_percent / 100) +size = target_risk_usd / stop_loss_distance_usd +``` + +--- + +## 2. SL стал обязательным для execution + +Теперь позиция не открывается без: + +* risk_percent +* stop_loss_percent + +Причина: + +без SL невозможно определить: + +* допустимый убыток +* размер позиции +* риск сделки + +--- + +## 3. Margin Protection + +Добавлено новое поле state: + +`max_reserved_balance_percent` + +По умолчанию: 50.0 + +--- + +## 4. Margin Validation + +Перед: + +* ENTRY +* FLIP + +выполняется: +`_validate_margin_usage()` + +--- + +## 5. Проверка reserved margin + +Execution рассчитывает: +``` +notional_usd = entry_price * size +reserved_usd = notional_usd / leverage +``` + +и сравнивает с лимитом: +``` +max_reserved_usd = +balance * (max_reserved_balance_percent / 100) +``` + +--- + +## 6. Execution Blocking + +Если margin превышает лимит: + +* позиция НЕ открывается +* flip НЕ выполняется +* execution возвращает BLOCK reason + +--- + +## 7. Execution Block State + +Добавлено новое поле: +`execution_block_reason` + +Используется для UI и debugging. + +--- + +## UI Improvements + +Compact Mobile Layout + +Полностью переработан mobile rendering. + +### Новый формат позиции + +Было: +`📦 Size: 0.02447179 · $ 2 000.41` + +Стало: +`📦 Size: 0.0245 ($ 2 000)` + +### Новый формат SL / TP / ML + +Было: +`🛑 SL: −$ 20.00 · $ 82 544.02` + +Стало: +``` +🛑 SL: 1.00% · -$ 20 ⇢ $ 82 544 +🎯 TP: 2.00% · +$ 40 ⇢ $ 80 092 +💣 ML: -$ 100 ⇢ $ 85 813 +``` + +### Добавлено отображение: + +* Est. Margin +* Max Reserved +* Blocked reason + +### Новые UI элементы + +Estimated Margin +`Est. Margin: $ 400` + +Max Reserved +`Max Reserved: 50% · $ 500` + +Execution Blocked +`🔴 Blocked: Margin $ 1000 > limit $ 500` + +--- + +## Изменения Execution Flow + +### ENTRY + +Теперь flow: +signal +→ size calculation +→ margin validation +→ open position + +### FLIP + +Теперь flip: + +* закрывает текущую позицию +* рассчитывает новый size +* валидирует margin +* только потом открывает reverse position + +--- + +## Архитектурный результат + +Теперь: + +* risk_percent реально влияет на trade size +* leverage влияет на reserved balance +* SL участвует в sizing engine +* execution защищён от oversized exposure +* UI показывает реальную margin load + +--- + +## Ограничения текущей реализации + +Пока ещё нет: + +* dynamic balance updates +* equity tracking +* realized pnl balance updates +* liquidation simulation +* fees engine +* funding +* tiered leverage +* maintenance margin +* partial close + +--- + +## Следующий этап + +07.4.3.14 — Strategy Engine Upgrade + +Планируется: + +* улучшение TREND strategy +* rolling candles / history window +* EMA-based trend detection +* ATR volatility filter +* noise reduction +* smarter confidence model +* configurable thresholds +* полноценная SCALP strategy +* anti-whipsaw protection +* multi-confirmation signals +