07.4.3.13 - Risk-based Sizing and Margin Protection

This commit is contained in:
2026-05-06 16:15:43 +03:00
parent b1513a28ef
commit ee78f9774a
9 changed files with 1142 additions and 194 deletions

View File

@@ -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"<b>🤖 Автоторговля · {status_line}</b>\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)}"
)

View File

@@ -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"<b>🤖 Автоторговля · {status_line}</b>\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"<b>{_asset_symbol(state.symbol)}</b> · {_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"<b>🛑 SL:</b> {_format_percent2(state.stop_loss_percent)}",
f"<b>🎯 TP:</b> {_format_percent2(state.take_profit_percent)}",
f"<b>💣 ML:</b> {_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} <b>{_asset_symbol(state.symbol)}</b> · "
f"{_strategy_short(state.strategy)} · "
f"<b>{state.position_side}</b> {_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 "<b>🛑 SL:</b> 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)}"
)
f"<b>🛑 SL:</b> {_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 "<b>🎯 TP:</b> 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"<b>🎯 TP:</b> {_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 "<b>💣 ML:</b> 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"<b>💣 ML:</b> -$ {_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"<b>⚖️ R:R</b> = 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(".")

View File

@@ -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()
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 = (
"<b>🏦 Max Reserved</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\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 обновлён")

View File

@@ -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:

View File

@@ -74,4 +74,13 @@ class AutoTradeState:
take_profit_percent: float | None = None
# максимальный допустимый paper-убыток в USD
max_loss_usd: float | None = None
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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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