07.4.3.13 - Risk-based Sizing and Margin Protection
This commit is contained in:
280
app/src/telegram/handlers/auto/_ui_old_working.py
Normal file
280
app/src/telegram/handlers/auto/_ui_old_working.py
Normal 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)}"
|
||||||
|
)
|
||||||
@@ -11,126 +11,13 @@ from src.telegram.ui.currency_ui import format_usd_amount
|
|||||||
from src.trading.auto.service import AutoTradeService
|
from src.trading.auto.service import AutoTradeService
|
||||||
|
|
||||||
|
|
||||||
def strategy_label(strategy: str | None) -> str:
|
PAPER_BALANCE_USD = 1000.0
|
||||||
mapping = {
|
|
||||||
"TREND": "📈 Trend Following",
|
|
||||||
"GRID": "🧩 Grid Trading",
|
|
||||||
"SCALP": "⚡ Scalping",
|
|
||||||
}
|
|
||||||
return mapping.get(strategy or "", "—")
|
|
||||||
|
|
||||||
|
|
||||||
def status_label(status: str) -> str:
|
def _format_percent2(value: float | None) -> 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:
|
if value is None:
|
||||||
return "—"
|
return "off"
|
||||||
return str(value)
|
return f"{float(value):.2f}%"
|
||||||
|
|
||||||
|
|
||||||
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:
|
def auto_keyboard() -> InlineKeyboardMarkup:
|
||||||
@@ -146,79 +33,425 @@ def auto_keyboard() -> InlineKeyboardMarkup:
|
|||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def risk_settings_line(state) -> str:
|
def is_auto_configured(state) -> bool:
|
||||||
sl = f"{state.stop_loss_percent:g}%" if state.stop_loss_percent is not None else "off"
|
return bool(
|
||||||
tp = f"{state.take_profit_percent:g}%" if state.take_profit_percent is not None else "off"
|
state.symbol
|
||||||
max_loss = f"{state.max_loss_usd:g} USD" if state.max_loss_usd is not None else "off"
|
and state.strategy
|
||||||
|
and state.risk_percent is not None
|
||||||
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 build_auto_text() -> str:
|
def build_auto_text() -> str:
|
||||||
service = AutoTradeService()
|
state = AutoTradeService().get_state()
|
||||||
state = service.get_state()
|
|
||||||
|
|
||||||
account_mode = "DEMO" if "DEMO" in mode_line().upper() else "LIVE"
|
if not is_auto_configured(state):
|
||||||
risk = f"{state.risk_percent:.1f}%" if state.risk_percent is not None else "—"
|
return _build_not_configured_text(state)
|
||||||
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 state.position_side != "NONE" and state.entry_price is not None:
|
if state.position_side != "NONE" and state.entry_price is not None:
|
||||||
position_line = (
|
return _build_active_position_text(state)
|
||||||
f"Pos: {value_or_dash(state.position_side)} | "
|
|
||||||
f"Entry: $ {price_or_dash(state.entry_price)} | "
|
return _build_waiting_text(state)
|
||||||
f"PnL: {usd_or_dash(state.unrealized_pnl_usd)}"
|
|
||||||
)
|
|
||||||
|
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 (
|
return (
|
||||||
f"{header}"
|
f"<b>🛑 SL:</b> {_format_percent2(state.stop_loss_percent)} · "
|
||||||
f"{context_line(state)}\n"
|
f"-$ {_format_money0(loss)} ⇢ $ {_format_money0(price)}"
|
||||||
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"
|
def _active_tp_line(state) -> str:
|
||||||
f"Position Risk: {risk}\n"
|
if (
|
||||||
f"{estimated_size_line(state)}\n"
|
state.take_profit_percent is None
|
||||||
f"{risk_settings_line(state)}"
|
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(".")
|
||||||
@@ -197,6 +197,11 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
|
|||||||
symbol = state.symbol or "—"
|
symbol = state.symbol or "—"
|
||||||
risk = f"{state.risk_percent:.1f}%" if state.risk_percent is not None else "—"
|
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 "—"
|
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"
|
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"
|
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"
|
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"{symbol_icon} Инструмент: {symbol}\n"
|
||||||
f"{risk_icon} Риск на сделку: {risk}\n"
|
f"{risk_icon} Риск на сделку: {risk}\n"
|
||||||
f"{leverage_icon} Плечо: {leverage}\n\n"
|
f"{leverage_icon} Плечо: {leverage}\n\n"
|
||||||
|
f"✅ Max Reserved: {max_reserved}\n"
|
||||||
f"✅ Risk Controls: {risk_controls}\n\n"
|
f"✅ Risk Controls: {risk_controls}\n\n"
|
||||||
f"{config_status}"
|
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_risk")
|
||||||
builder.button(text="⚙️ Плечо", callback_data="settings:auto_leverage")
|
builder.button(text="⚙️ Плечо", callback_data="settings:auto_leverage")
|
||||||
builder.button(text="⚠️ Risk Controls", callback_data="auto:risk")
|
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="auto:home")
|
||||||
builder.button(text="⬅️ Назад", callback_data="system:management")
|
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.message.edit_text(text, reply_markup=builder.as_markup())
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
@@ -610,4 +617,45 @@ async def open_system_about(callback: CallbackQuery) -> None:
|
|||||||
text,
|
text,
|
||||||
reply_markup=builder.as_markup(),
|
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 обновлён")
|
||||||
@@ -238,6 +238,13 @@ class AutoTradeService:
|
|||||||
state = self.get_state()
|
state = self.get_state()
|
||||||
state.max_loss_usd = value
|
state.max_loss_usd = value
|
||||||
return state
|
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:
|
def _reset_signal_tracking(self) -> None:
|
||||||
@@ -256,6 +263,7 @@ class AutoTradeService:
|
|||||||
state.decision_reason = None
|
state.decision_reason = None
|
||||||
state.is_signal_confirmed = False
|
state.is_signal_confirmed = False
|
||||||
state.is_signal_ready = False
|
state.is_signal_ready = False
|
||||||
|
state.execution_block_reason = None
|
||||||
|
|
||||||
# собрать контекст для стратегии
|
# собрать контекст для стратегии
|
||||||
def _build_strategy_context(self) -> StrategyContext:
|
def _build_strategy_context(self) -> StrategyContext:
|
||||||
|
|||||||
@@ -74,4 +74,13 @@ class AutoTradeState:
|
|||||||
take_profit_percent: float | None = None
|
take_profit_percent: float | None = None
|
||||||
|
|
||||||
# максимальный допустимый paper-убыток в USD
|
# максимальный допустимый 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
|
||||||
@@ -64,7 +64,20 @@ class ExecutionEngine:
|
|||||||
return ExecutionDecision("NONE", False, f"Не удалось получить цену для paper execution: {exc}")
|
return ExecutionDecision("NONE", False, f"Не удалось получить цену для paper execution: {exc}")
|
||||||
|
|
||||||
now = self._now_time()
|
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(
|
type(self)._position = PositionState(
|
||||||
side=side,
|
side=side,
|
||||||
@@ -125,7 +138,20 @@ class ExecutionEngine:
|
|||||||
|
|
||||||
now = self._now_time()
|
now = self._now_time()
|
||||||
pnl = self._calculate_pnl(flip_price)
|
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_side = position.side
|
||||||
old_entry_price = position.entry_price
|
old_entry_price = position.entry_price
|
||||||
@@ -388,11 +414,74 @@ class ExecutionEngine:
|
|||||||
|
|
||||||
self._sync_state_from_position(state)
|
self._sync_state_from_position(state)
|
||||||
|
|
||||||
def _calculate_position_size(self, state: AutoTradeState) -> float:
|
def _calculate_position_size(
|
||||||
risk_percent = state.risk_percent or 0.0
|
self,
|
||||||
leverage = state.leverage or 1.0
|
state: AutoTradeState,
|
||||||
return round((risk_percent * leverage) / 100, 8)
|
*,
|
||||||
|
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:
|
def _calculate_pnl(self, current_price: float) -> float:
|
||||||
position = type(self)._position
|
position = type(self)._position
|
||||||
|
|
||||||
|
|||||||
@@ -223,6 +223,25 @@
|
|||||||
- Telegram execution alerts с причиной риска
|
- Telegram execution alerts с причиной риска
|
||||||
- единая точка принятия решений (execution layer)
|
- единая точка принятия решений (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
|
### 07.4.4
|
||||||
⏳ Grid Strategy
|
⏳ Grid Strategy
|
||||||
|
|
||||||
|
|||||||
@@ -208,6 +208,26 @@
|
|||||||
- Telegram execution alerts с причиной риска
|
- Telegram execution alerts с причиной риска
|
||||||
- единая точка принятия решений (execution layer)
|
- единая точка принятия решений (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
|
### 07.4.4
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
Reference in New Issue
Block a user