# 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} Актив · {_asset_symbol(state.symbol)}\n" f"{strategy_icon} Стратегия · {_required_value(_strategy_short(state.strategy))}\n" f"{risk_icon} Риск на сделку · {_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} SL · {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"Доступно · $ {_format_money_compact(available)}\n", "🧾 Подготовка ордера", "", _order_header_line(state), f"Цена · {_format_usd_or_dash(price)}", _estimated_size_text(state, price), _max_reserved_line(state, price), f"Риск · {_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"Доступно · $ {_format_money_compact(available)}", "", _signal_line(state), _market_state_line(state), *_signal_confidence_lines(state), *_execution_block_lines(state), "", "🧾 Подготовка ордера", "", _order_header_line(state), f"{_price_label_for_signal(state)} · {_format_usd_or_dash(price)}", _estimated_size_text(state, price), _max_reserved_line(state, price), f"Риск · {_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"Доступно · $ {_format_money_compact(available)}", f"Зарезервировано · $ {_format_money_compact(reserved)}", f"P&L {_format_signed_usd_with_direction(pnl)}", _market_state_line(state), *_execution_block_lines(state), "", ( f"{side_icon} {_asset_symbol(state.symbol)} · " f"{_strategy_short(state.strategy)} · " f"{state.position_side} {_leverage_text(state.leverage)}" ), "", f"Количество · {_format_crypto_size(size)} ⇢ $ {_format_money_compact(notional)}", f"Цена входа · $ {_format_money(state.entry_price)}", f"Текущая цена · {_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 "Собственные средства · —" leverage = state.leverage or 1.0 if leverage <= 0: return "Собственные средства · —" position_size_usd = size * price own_funds_usd = position_size_usd / leverage return ( f"Собственные средства · $ {_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 "Количество · —" notional = size * price return ( f"Количество · {_format_crypto_size(size)}\n" f"Размер позиции · $ {_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"SL {sl}" if sl else "SL off", f"TP {tp}" if tp else "TP off", f"ML {ml}" if ml else "ML 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"R:R = 1 : {_format_ratio_value(ratio)}" def _order_header_line(state) -> str: signal = (state.last_signal or "").upper() if signal == "BUY": return ( f"🟢 {_asset_symbol(state.symbol)} · " f"{_strategy_short(state.strategy)} · " f"LONG {_leverage_text(state.leverage)}" ) if signal == "SELL": return ( f"🔴 {_asset_symbol(state.symbol)} · " f"{_strategy_short(state.strategy)} · " f"SHORT {_leverage_text(state.leverage)}" ) return ( f"{_asset_symbol(state.symbol)} · " 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"