Files
dzentra_bot/app/src/telegram/handlers/auto/ui.py

759 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# app/src/telegram/handlers/auto/ui.py
from __future__ import annotations
import math
import time
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
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="🧯 Защита", callback_data="auto:risk")
builder.adjust(3, 2)
return builder.as_markup()
def is_auto_configured(state) -> bool:
if not state.symbol:
return False
if not state.strategy:
return False
if state.risk_percent is None:
return False
strategy = state.strategy.upper()
if strategy == "TREND":
return (
state.stop_loss_percent is not None
and state.stop_loss_percent > 0
)
return True
def build_auto_text() -> str:
state = AutoTradeService().get_state()
if not is_auto_configured(state):
return _build_not_configured_text(state)
if state.position_side != "NONE" and state.entry_price is not None:
return _build_active_position_text(state)
if state.status == "OFF":
return _build_stopped_without_position_text(state)
return _build_waiting_text(state)
def _build_not_configured_text(state) -> str:
symbol_ready = state.symbol is not None
strategy_ready = state.strategy is not None
risk_ready = state.risk_percent is not None
symbol_icon = "" if symbol_ready else "⚠️"
strategy_icon = "" if strategy_ready else "⚠️"
risk_icon = "" if risk_ready else "⚠️"
parts = [
"🤖 Автоторговля ⚪ Не настроена",
_account_mode_line(),
"",
f"{symbol_icon} <b>Актив</b> · {_asset_symbol(state.symbol)}\n"
f"{strategy_icon} <b>Стратегия</b> · {_required_value(_strategy_short(state.strategy))}\n"
f"{risk_icon} <b>Риск на сделку</b> · {_required_value(_risk_percent_text(state))}\n"
]
strategy = (state.strategy or "").upper()
if strategy == "TREND":
sl_value = (
_format_percent(state.stop_loss_percent)
if state.stop_loss_percent is not None
else ""
)
sl_icon = "" if sl_value != "" else "⚠️"
parts.append(f"{sl_icon} <b>SL</b> · {sl_value}")
parts.extend([
"",
"⚠️ Требуется настройка параметров",
])
return "\n".join(parts)
def _build_stopped_without_position_text(state) -> str:
price = _current_price(state.symbol)
available = _allocated_balance(state) + _realized_pnl(state)
estimated_size = _estimated_size(state, price)
rr_line = _risk_reward_line(state)
risk_line = _risk_summary_line(
state,
estimated_size,
entry_price_override=price,
)
parts = [
f"🤖 Автоторговля {_status_text(state.status)}",
_account_mode_line(),
"",
f"<b>Доступно</b> · $ {_format_money_compact(available)}\n",
"🧾 <b>Подготовка ордера</b>",
"",
_order_header_line(state),
f"<b>Цена</b> · {_format_usd_or_dash(price)}",
_estimated_size_text(state, price),
_max_reserved_line(state, price),
f"<b>Риск</b> · {_risk_percent_text(state)} ($ {_format_money_compact(_target_risk_usd(state))})",
]
if rr_line or risk_line:
parts.append("")
if rr_line:
parts.append(rr_line)
if risk_line:
parts.append(risk_line)
return "\n".join(parts)
def _build_waiting_text(state) -> str:
price = _signal_entry_price(state)
available = _allocated_balance(state) + _realized_pnl(state)
estimated_size = _estimated_size(state, price)
rr_line = _risk_reward_line(state)
risk_line = _risk_summary_line(
state,
estimated_size,
entry_price_override=price,
)
parts = [
f"🤖 Автоторговля {_status_text(state.status)}",
_account_mode_line(),
"",
f"<b>Доступно</b> · $ {_format_money_compact(available)}",
"",
_signal_line(state),
_market_state_line(state),
*_signal_confidence_lines(state),
*_execution_block_lines(state),
"",
"🧾 <b>Подготовка ордера</b>",
"",
_order_header_line(state),
f"<b>{_price_label_for_signal(state)}</b> · {_format_usd_or_dash(price)}",
_estimated_size_text(state, price),
_max_reserved_line(state, price),
f"<b>Риск</b> · {_risk_percent_text(state)} ($ {_format_money_compact(_target_risk_usd(state))})",
]
if rr_line or risk_line:
parts.append("")
if rr_line:
parts.append(rr_line)
if risk_line:
parts.append(risk_line)
return "\n".join(parts)
def _build_active_position_text(state) -> str:
current_price = _current_price(state.symbol)
price_for_calc = current_price or state.entry_price or 0.0
size = state.position_size or 0.0
notional = size * price_for_calc
reserved = _position_reserved_usd(state, current_price)
available = _allocated_balance(state) + _realized_pnl(state) - reserved
pnl = state.unrealized_pnl_usd or 0.0
rr_line = _risk_reward_line(state)
risk_line = _risk_summary_line(state, size)
side_icon = "🟢" if state.position_side == "LONG" else "🔴"
parts = [
f"🤖 Автоторговля {_status_text(state.status)}",
_account_mode_line(),
"",
f"<b>Доступно</b> · $ {_format_money_compact(available)}",
f"<b>Зарезервировано</b> · $ {_format_money_compact(reserved)}",
f"<b>P&L</b> {_format_signed_usd_with_direction(pnl)}",
_market_state_line(state),
*_execution_block_lines(state),
"",
(
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"<b>Количество</b> · {_format_crypto_size(size)} ⇢ $ {_format_money_compact(notional)}",
f"<b>Цена входа</b> · $ {_format_money(state.entry_price)}",
f"<b>Текущая цена</b> · {_format_usd_or_dash(current_price)}",
"",
"⚠️ Комиссии не учтены",
]
if rr_line or risk_line:
parts.append("")
if rr_line:
parts.append(rr_line)
if risk_line:
parts.append(risk_line)
return "\n".join(parts)
def _market_state_line(state) -> str:
market_state = getattr(state, "market_state", None)
labels = {
"TREND_UP": "📈 Рынок · Рост",
"TREND_DOWN": "📉 Рынок · Падение",
"RANGE": "🟰 Рынок · Флэт",
"HIGH_VOLATILITY": "⚠️ Рынок · Волатильность",
"LOW_VOLATILITY": "🟰 Рынок · Спокойный",
"UNKNOWN": "⏳ Рынок · Анализ",
None: "⏳ Рынок · Анализ",
}
return labels.get(market_state, "⏳ Рынок · Анализ")
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 _allocated_balance(state) -> float:
return float(getattr(state, "allocated_balance_usd", 1000.0) or 1000.0)
def _realized_pnl(state) -> float:
return float(getattr(state, "realized_pnl_usd", 0.0) or 0.0)
def _position_reserved_usd(state, current_price: float | None) -> float:
if (
state.position_side == "NONE"
or state.position_size is None
or state.position_size <= 0
):
return 0.0
price = current_price or state.entry_price or 0.0
leverage = state.leverage or 1.0
if price <= 0 or leverage <= 0:
return 0.0
return (state.position_size * price) / leverage
def _max_reserved_line(state, price: float | None = None) -> str:
size = _estimated_size(state, price)
if size is None or price is None or price <= 0:
return "<b>Собственные средства</b> · —"
leverage = state.leverage or 1.0
if leverage <= 0:
return "<b>Собственные средства</b> · —"
position_size_usd = size * price
own_funds_usd = position_size_usd / leverage
return (
f"<b>Собственные средства</b> · $ {_format_money_compact(own_funds_usd)}"
)
def _market_snapshot(symbol: str | None) -> dict[str, object] | None:
if not symbol:
return None
try:
return ExchangeService().get_market_snapshot(symbol, runtime_key="auto")
except Exception:
return None
def _current_price(symbol: str | None) -> float | None:
snapshot = _market_snapshot(symbol)
if snapshot is not None:
price = snapshot.get("last_price")
if price is not None:
return float(price)
if not symbol:
return None
try:
return float(ExchangeService().get_price(symbol).price)
except Exception:
return None
def _signal_entry_price(state) -> float | None:
snapshot = _market_snapshot(state.symbol)
if snapshot is None:
return _current_price(state.symbol)
signal = (state.last_signal or "HOLD").upper()
if signal == "BUY":
price = snapshot.get("ask_price")
elif signal == "SELL":
price = snapshot.get("bid_price")
else:
price = snapshot.get("last_price")
if price is None:
return None
return float(price)
def _target_risk_usd(state) -> float:
if state.risk_percent is None:
return 0.0
return _allocated_balance(state) * (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 _round_size(risk_size)
leverage = state.leverage or 1.0
if leverage <= 0:
return _round_size(risk_size)
max_reserved_usd = _allocated_balance(state) * (max_percent / 100)
max_notional_usd = max_reserved_usd * leverage
max_size = max_notional_usd / price
return _round_size(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 "<b>Количество</b> · —"
notional = size * price
return (
f"<b>Количество</b> · {_format_crypto_size(size)}\n"
f"<b>Размер позиции</b> · $ {_format_money_compact(notional)}"
)
def _risk_summary_line(
state,
size: float | None,
*,
entry_price_override: float | None = None,
) -> str:
entry_price = entry_price_override or state.entry_price
sl = _risk_loss_text(
percent=state.stop_loss_percent,
fixed_loss=None,
size=size,
entry_price=entry_price,
)
tp = _risk_profit_text(
percent=state.take_profit_percent,
size=size,
entry_price=entry_price,
)
ml = _risk_loss_text(
percent=None,
fixed_loss=state.max_loss_usd,
size=size,
entry_price=entry_price,
)
items = [
f"<b>SL</b> {sl}" if sl else "<b>SL</b> off",
f"<b>TP</b> {tp}" if tp else "<b>TP</b> off",
f"<b>ML</b> {ml}" if ml else "<b>ML</b> off",
]
return " | ".join(items)
def _risk_loss_text(
*,
percent: float | None,
fixed_loss: float | None,
size: float | None,
entry_price: float | None,
) -> str:
if fixed_loss is not None:
return f"-$ {_format_money_compact(abs(fixed_loss))}"
if percent is None:
return ""
if size is None or size <= 0 or entry_price is None or entry_price <= 0:
loss = _target_loss_by_percent_stub(percent)
return f"-$ {_format_money_compact(loss)}" if loss is not None else ""
move = entry_price * (percent / 100)
loss = move * size
return f"-$ {_format_money_compact(loss)}"
def _risk_profit_text(
*,
percent: float | None,
size: float | None,
entry_price: float | None,
) -> str:
if percent is None:
return ""
if size is None or size <= 0 or entry_price is None or entry_price <= 0:
return ""
move = entry_price * (percent / 100)
profit = move * size
return f"+$ {_format_money_compact(profit)}"
def _target_loss_by_percent_stub(percent: float | None) -> float | None:
if percent is None:
return None
return None
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 _order_header_line(state) -> str:
signal = (state.last_signal or "").upper()
if signal == "BUY":
return (
f"🟢 <b>{_asset_symbol(state.symbol)}</b> · "
f"{_strategy_short(state.strategy)} · "
f"<b>LONG</b> {_leverage_text(state.leverage)}"
)
if signal == "SELL":
return (
f"🔴 <b>{_asset_symbol(state.symbol)}</b> · "
f"{_strategy_short(state.strategy)} · "
f"<b>SHORT</b> {_leverage_text(state.leverage)}"
)
return (
f"<b>{_asset_symbol(state.symbol)}</b> · "
f"{_strategy_short(state.strategy)} · "
f"{_leverage_text(state.leverage)}"
)
def _price_label_for_signal(state) -> str:
signal = (state.last_signal or "").upper()
if signal in {"BUY", "SELL"}:
return "Цена входа"
return "Цена"
def _signal_line(state) -> str:
signal = (state.last_signal or "HOLD").upper()
if signal in {"BUY", "SELL"} and (
state.decision_status == "READY"
or getattr(state, "is_signal_ready", False)
):
return f"Сигнал {_signal_icon(signal)} {signal} · READY"
duration = _signal_duration_text(state)
return f"Сигнал {_signal_icon(signal)} {signal} · {duration}"
def _signal_duration_text(state) -> str:
started_at = getattr(state, "signal_started_at", None)
if started_at is not None:
total_seconds = max(0, int(time.monotonic() - float(started_at)))
else:
repeat_count = state.last_signal_repeat_count or 0
total_seconds = max(0, repeat_count * 5)
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
if hours > 0:
return f"{hours}ч {minutes:02d}м"
if minutes > 0:
return f"{minutes}м {seconds:02d}с"
return f"{seconds}с"
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 _format_percent(state.risk_percent)
def _required_value(value: str) -> str:
if not value or value == "":
return ""
return value
def _signal_confidence_lines(state) -> list[str]:
signal = (state.last_signal or "HOLD").upper()
if signal == "HOLD":
return []
return [
f"Уверенность · {(state.last_signal_confidence or 0.0):.2f}"
]
def _signal_icon(signal: str | None) -> str:
mapping = {
"BUY": "🟢",
"SELL": "🔴",
"HOLD": "🟡",
}
return mapping.get(signal or "", "")
def _round_size(value: float | int | None) -> float | None:
if value is None:
return None
precision = 5
factor = 10 ** precision
return math.floor(float(value) * factor) / factor
def _format_crypto_size(value: float | int | None) -> str:
rounded = _round_size(value)
if rounded is None:
return ""
return f"{rounded:.5f}".rstrip("0").rstrip(".")
def _format_percent(value: float | int | None) -> str:
if value is None:
return "off"
number = float(value)
if abs(number - round(number)) < 1e-9:
return f"{int(round(number))}%"
return f"{number:.2f}".rstrip("0").rstrip(".") + "%"
def _format_money(value: float | int | None) -> str:
if value is None:
return ""
return format_usd_amount(float(value))
def _format_money_compact(value: float | int | None) -> str:
if value is None:
return ""
number = float(value)
if abs(number - round(number)) < 1e-9:
return f"{number:,.0f}".replace(",", " ")
return f"{number:,.2f}".replace(",", " ").rstrip("0").rstrip(".")
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_compact(amount)}"
if amount < 0:
return f"$ {_format_money_compact(abs(amount))}"
return "$ 0"
def _format_signed_usd_with_direction(value: float | int | None) -> str:
if value is None:
return ""
amount = float(value)
if amount > 0:
return f"🟢 +$ {_format_money_compact(amount)}"
if amount < 0:
return f"🔴 $ {_format_money_compact(abs(amount))}"
return "$ 0"