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
+