From df7649078320768a2ea42c2d10f33235ddb53272 Mon Sep 17 00:00:00 2001 From: Sergey Date: Sat, 9 May 2026 01:34:46 +0300 Subject: [PATCH] =?UTF-8?q?07.4.3.14=20=E2=80=94=20Auto=20Trading=20UI.=20?= =?UTF-8?q?Realistic=20Pricing=20&=20Debug=20Live=20Tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/telegram/handlers/auto/main.py | 10 +- app/src/telegram/handlers/auto/risk.py | 74 +- app/src/telegram/handlers/auto/ui.py | 695 ++++++++++++------ app/src/telegram/handlers/debug.py | 659 +++++++++++++++-- app/src/telegram/handlers/system.py | 148 +++- app/src/trading/auto/runner.py | 94 ++- app/src/trading/auto/service.py | 20 + app/src/trading/auto/state.py | 19 +- app/src/trading/execution/engine.py | 163 ++-- app/src/trading/strategies/registry.py | 3 +- app/src/trading/strategies/scalp.py | 156 ++++ app/src/trading/strategies/trend.py | 192 +++-- docs/roadmap/master-roadmap.md | 24 + docs/roadmap/stage-07-auto-trading-roadmap.md | 25 + ..._realistic_pricing_and_debug_live_tools.md | 343 +++++++++ 15 files changed, 2161 insertions(+), 464 deletions(-) create mode 100644 app/src/trading/strategies/scalp.py create mode 100644 docs/stages/stage-07_4_3_14-auto_ui_realistic_pricing_and_debug_live_tools.md diff --git a/app/src/telegram/handlers/auto/main.py b/app/src/telegram/handlers/auto/main.py index fdc60cd..54cf8d7 100644 --- a/app/src/telegram/handlers/auto/main.py +++ b/app/src/telegram/handlers/auto/main.py @@ -60,12 +60,10 @@ async def open_auto(message: Message, state: FSMContext) -> None: AutoTradeRunner.set_current_screen("auto") - current_state = AutoTradeService().get_state() - if current_state.status in {"RUNNING", "OBSERVING"}: - await AutoTradeRunner.delete_registered_screen( - bot=message.bot, - chat_id=message.chat.id, - ) + await AutoTradeRunner.delete_registered_screen( + bot=message.bot, + chat_id=message.chat.id, + ) await render_auto_screen(message, edit_mode=False) diff --git a/app/src/telegram/handlers/auto/risk.py b/app/src/telegram/handlers/auto/risk.py index b9aad36..671ba62 100644 --- a/app/src/telegram/handlers/auto/risk.py +++ b/app/src/telegram/handlers/auto/risk.py @@ -24,30 +24,45 @@ class AutoRiskStates(StatesGroup): waiting_max_loss = State() +def _format_number(value: float | int | None) -> str: + if value is None: + return "—" + + number = float(value) + + if abs(number - round(number)) < 1e-9: + return f"{int(round(number))}" + + return f"{number:.2f}".rstrip("0").rstrip(".") + + def _format_percent(value: float | None) -> str: if value is None: - return "⚪ off" - return f"🟢 {value:g}%" + return "off" + return f"{_format_number(value)}%" def _format_usd(value: float | None) -> str: if value is None: - return "⚪ off" - return f"🟢 {value:g} USD" + return "off" + return f"{_format_number(value)} USD" + + +def _rule_icon(value: float | None) -> str: + return "✅" if value is not None else "⚠️" def _risk_keyboard() -> InlineKeyboardMarkup: - state = AutoTradeService().get_state() builder = InlineKeyboardBuilder() - builder.button(text=f"🛑 Stop Loss", callback_data="auto:risk:set_sl") - builder.button(text=f"🎯 Take Profit", callback_data="auto:risk:set_tp") - builder.button(text=f"💸 Max Loss", callback_data="auto:risk:set_ml") - builder.button(text="♻️ Reset", callback_data="auto:risk:reset") - builder.button(text="⬅️ Назад", callback_data="settings:auto") + builder.button(text="🛑 SL", callback_data="auto:risk:set_sl") + builder.button(text="🎯 TP", callback_data="auto:risk:set_tp") + builder.button(text="💸 ML", callback_data="auto:risk:set_ml") builder.button(text="🤖 Автоторговля", callback_data="auto:home") + builder.button(text="⬅️ Назад", callback_data="settings:auto") + builder.button(text="♻️ Сбросить", callback_data="auto:risk:reset") - builder.adjust(2, 2, 2) + builder.adjust(3, 1, 2) return builder.as_markup() @@ -66,16 +81,13 @@ def _risk_text(status_message: str | None = None) -> str: status = "🟢 Активна" if active_count else "⚪ Выключена" text = ( - "⚠️ Risk Settings\n\n" + "🧯 Защита позиции\n\n" "СИСТЕМА · Настройки · Автоторговля\n\n" f"Статус защиты: {status}\n" f"Активных правил: {active_count}/3\n\n" - f"🛑 Stop Loss: {_format_percent(state.stop_loss_percent)}\n" - f"🎯 Take Profit: {_format_percent(state.take_profit_percent)}\n" - f"💸 Max Loss: {_format_usd(state.max_loss_usd)}\n\n" - "Подсказка:\n" - "Пример: 0.5, 1\n" - "Введите 0, чтобы отключить параметр." + f"{_rule_icon(state.stop_loss_percent)} Stop Loss · {_format_percent(state.stop_loss_percent)}\n" + f"{_rule_icon(state.take_profit_percent)} Take Profit · {_format_percent(state.take_profit_percent)}\n" + f"{_rule_icon(state.max_loss_usd)} Max Loss · {_format_usd(state.max_loss_usd)}\n" ) if status_message: @@ -155,7 +167,7 @@ async def _remember_risk_screen(callback: CallbackQuery, state: FSMContext) -> N def _parse_positive_or_none(raw_text: str | None) -> float | None: value_text = (raw_text or "").strip().replace(",", ".") - if value_text in {"0", "0.0", "off", "OFF", "-"}: + if value_text.lower() in {"0", "0.0", "off", "-"}: return None value = float(value_text) @@ -222,11 +234,11 @@ async def ask_stop_loss(callback: CallbackQuery, state: FSMContext) -> None: if callback.message is not None: await callback.message.edit_text( - "🛑 Stop Loss\n\n" + "Stop Loss\n\n" "СИСТЕМА · Настройки · Автоторговля\n\n" "Введите Stop Loss в процентах.\n" - "Например: 2\n\n" - "Введите 0, чтобы отключить." + "Например: 1, 0.5, 0,5\n\n" + "отключить параметр - 0" ) await callback.answer() @@ -240,11 +252,11 @@ async def ask_take_profit(callback: CallbackQuery, state: FSMContext) -> None: if callback.message is not None: await callback.message.edit_text( - "🎯 Take Profit\n\n" + "Take Profit\n\n" "СИСТЕМА · Настройки · Автоторговля\n\n" "Введите Take Profit в процентах.\n" - "Например: 3\n\n" - "Введите 0, чтобы отключить." + "Например: 2, 1.5, 1,5\n\n" + "отключить параметр - 0" ) await callback.answer() @@ -258,11 +270,11 @@ async def ask_max_loss(callback: CallbackQuery, state: FSMContext) -> None: if callback.message is not None: await callback.message.edit_text( - "💸 Max Loss\n\n" + "Maximum Loss\n\n" "СИСТЕМА · Настройки · Автоторговля\n\n" "Введите максимальный paper-убыток в USD.\n" - "Например: 10\n\n" - "Введите 0, чтобы отключить." + "Например: 100, 50.5, 50,5\n\n" + "отключить параметр - 0" ) await callback.answer() @@ -309,7 +321,7 @@ async def set_stop_loss(message: Message, state: FSMContext) -> None: try: value = _parse_positive_or_none(message.text) except ValueError: - await message.answer("Введите число. Например: 2 или 0 для отключения.") + await message.answer("Введите число. Например: 1, 0.5 или 0 для отключения.") return if not _validate_percent(value): @@ -333,7 +345,7 @@ async def set_take_profit(message: Message, state: FSMContext) -> None: try: value = _parse_positive_or_none(message.text) except ValueError: - await message.answer("Введите число. Например: 3 или 0 для отключения.") + await message.answer("Введите число. Например: 2, 1.5 или 0 для отключения.") return if not _validate_percent(value): @@ -357,7 +369,7 @@ async def set_max_loss(message: Message, state: FSMContext) -> None: try: value = _parse_positive_or_none(message.text) except ValueError: - await message.answer("Введите число. Например: 10 или 0 для отключения.") + await message.answer("Введите число. Например: 100, 50.5 или 0 для отключения.") return if not _validate_max_loss(value): diff --git a/app/src/telegram/handlers/auto/ui.py b/app/src/telegram/handlers/auto/ui.py index 4817a1f..382491f 100644 --- a/app/src/telegram/handlers/auto/ui.py +++ b/app/src/telegram/handlers/auto/ui.py @@ -2,6 +2,9 @@ from __future__ import annotations +import math +import time + from aiogram.types import InlineKeyboardMarkup from aiogram.utils.keyboard import InlineKeyboardBuilder @@ -11,15 +14,6 @@ from src.telegram.ui.currency_ui import format_usd_amount from src.trading.auto.service import AutoTradeService -PAPER_BALANCE_USD = 1000.0 - - -def _format_percent2(value: float | None) -> str: - if value is None: - return "off" - return f"{float(value):.2f}%" - - def auto_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() @@ -27,18 +21,31 @@ def auto_keyboard() -> InlineKeyboardMarkup: 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.button(text="🧯 Защита", callback_data="auto:risk") builder.adjust(3, 2) return builder.as_markup() def is_auto_configured(state) -> bool: - return bool( - state.symbol - and state.strategy - and state.risk_percent is not None - ) + 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: @@ -50,141 +57,258 @@ def build_auto_text() -> str: 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: - 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" - "🛠️ Открой настройки для запуска" + 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), + *_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)}", + "", + ( + 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 _execution_block_lines(state) -> list[str]: lines: list[str] = [] reason = getattr(state, "execution_block_reason", None) if reason: - lines.append(f"🔴 Blocked: {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") + lines.append("Size adjusted by Max Reserved") return lines -def _estimated_margin_text(state, price: float | None) -> str: +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: - return "Est. Margin: —" + if size is None or price is None or price <= 0: + return "Собственные средства · —" leverage = state.leverage or 1.0 if leverage <= 0: - return "Est. Margin: —" + return "Собственные средства · —" - notional = size * price - reserved = notional / leverage + position_size_usd = size * price + own_funds_usd = position_size_usd / leverage - return f"Est. Margin: $ {_format_money0(reserved)}" + return ( + f"Собственные средства · $ {_format_money_compact(own_funds_usd)}" + ) -def _max_reserved_text(state) -> str: - max_percent = getattr(state, "max_reserved_balance_percent", None) +def _market_snapshot(symbol: str | None) -> dict[str, object] | None: + if not symbol: + return None - if max_percent is None or max_percent <= 0: - return "Max Reserved: off" - - max_reserved = PAPER_BALANCE_USD * (max_percent / 100) - - return f"Max Reserved: {max_percent:g}% · $ {_format_money0(max_reserved)}" - - -def _build_waiting_text(state) -> str: - price = _current_price(state.symbol) - signal = state.last_signal or "—" - repeats = state.last_signal_repeat_count or 0 - confidence = state.last_signal_confidence or 0.0 - rr_line = _risk_reward_line(state) - - parts = [ - f"🤖 Автоторговля · {_status_text(state.status)}", - _account_mode_line(), - f"🏦 Balance: $ {_format_money(PAPER_BALANCE_USD)}", - "", - f"{_asset_symbol(state.symbol)} · {_strategy_short(state.strategy)} · {_leverage_text(state.leverage)}", - f"💲 Price: {_format_usd_or_dash(price)}", - "", - _decision_human_text(state.decision_status), - f"{_signal_icon(signal)} {signal} ×{repeats}", - f"Confidence: {confidence:.2f}", - *(_execution_block_lines(state)), - "", - f"Position Risk: {_risk_percent_text(state)} · $ {_format_money0(_target_risk_usd(state))}", - _estimated_size_text(state, price), - _estimated_margin_text(state, price), - _max_reserved_text(state), - "", - f"🛑 SL: {_format_percent2(state.stop_loss_percent)}", - f"🎯 TP: {_format_percent2(state.take_profit_percent)}", - f"💣 ML: {_max_loss_or_off(state.max_loss_usd)}", - ] - - if rr_line: - parts.extend(["", rr_line]) - - return "\n".join(parts) - - -def _build_active_position_text(state) -> str: - current_price = _current_price(state.symbol) - current_price_for_calc = current_price or state.entry_price or 0.0 - - size = state.position_size or 0.0 - leverage = state.leverage or 1.0 - - notional = size * current_price_for_calc - reserved = notional / leverage if leverage > 0 else 0.0 - pnl = state.unrealized_pnl_usd or 0.0 - - rr_line = _risk_reward_line(state) - side_icon = "🟢" if state.position_side == "LONG" else "🔴" - - parts = [ - f"🤖 Автоторговля · {_status_text(state.status)}", - _account_mode_line(), - f"🏦 Balance: $ {_format_money(PAPER_BALANCE_USD)}", - f"💵 Reserved: $ {_format_money(reserved)}", - "", - ( - f"{side_icon} {_asset_symbol(state.symbol)} · " - f"{_strategy_short(state.strategy)} · " - f"{state.position_side} {_leverage_text(state.leverage)}" - ), - f"📦 Size: {_format_crypto_size(size)} ($ {_format_money0(notional)})", - f"💲 Entry: $ {_format_money(state.entry_price)}", - f"💲 Current: {_format_usd_or_dash(current_price)}", - f"⚖️ P&L: {_format_signed_usd(pnl)}", - "Fees: not included", - "", - _active_sl_line(state), - _active_tp_line(state), - _active_ml_line(state), - ] - - if rr_line: - parts.append(rr_line) - - return "\n".join(parts) + try: + return ExchangeService().get_market_snapshot(symbol) + 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 @@ -194,10 +318,32 @@ def _current_price(symbol: str | None) -> float | None: 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 PAPER_BALANCE_USD * (state.risk_percent / 100) + + return _allocated_balance(state) * (state.risk_percent / 100) def _estimated_size(state, price: float | None) -> float | None: @@ -219,93 +365,113 @@ def _estimated_size(state, price: float | None) -> float | None: max_percent = getattr(state, "max_reserved_balance_percent", None) if max_percent is None or max_percent <= 0: - return risk_size + return _round_size(risk_size) leverage = state.leverage or 1.0 if leverage <= 0: - return risk_size + return _round_size(risk_size) - max_reserved_usd = PAPER_BALANCE_USD * (max_percent / 100) + max_reserved_usd = _allocated_balance(state) * (max_percent / 100) max_notional_usd = max_reserved_usd * leverage max_size = max_notional_usd / price - return min(risk_size, max_size) + 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 "Est. Size: —" + return "Количество · —" notional = size * price + return ( - f"Est. Size: {_format_crypto_size(size)} " - f"{_asset_symbol(state.symbol)} ($ {_format_money0(notional)})" + f"Количество · {_format_crypto_size(size)}\n" + f"Размер позиции · $ {_format_money_compact(notional)}" ) -def _active_sl_line(state) -> str: - if ( - state.stop_loss_percent is None - or state.entry_price is None - or state.position_size is None - ): - return "🛑 SL: off" +def _risk_summary_line( + state, + size: float | None, + *, + entry_price_override: float | None = None, +) -> str: + entry_price = entry_price_override or state.entry_price - 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"🛑 SL: {_format_percent2(state.stop_loss_percent)} · " - f"-$ {_format_money0(loss)} ⇢ $ {_format_money0(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", + ] -def _active_tp_line(state) -> str: - if ( - state.take_profit_percent is None - or state.entry_price is None - or state.position_size is None - ): - return "🎯 TP: off" - - move = state.entry_price * (state.take_profit_percent / 100) - profit = move * state.position_size - - if state.position_side == "SHORT": - price = state.entry_price - move - else: - price = state.entry_price + move - - return ( - f"🎯 TP: {_format_percent2(state.take_profit_percent)} · " - f"+$ {_format_money0(profit)} ⇢ $ {_format_money0(price)}" - ) + return " | ".join(items) -def _active_ml_line(state) -> str: - if ( - state.max_loss_usd is None - or state.entry_price is None - or state.position_size is None - or state.position_size <= 0 - ): - return "💣 ML: off" +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))}" - loss = abs(state.max_loss_usd) - price_move = loss / state.position_size + if percent is None: + return "" - if state.position_side == "SHORT": - price = state.entry_price + price_move - else: - price = state.entry_price - price_move + 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 "" - return f"💣 ML: -$ {_format_money0(loss)} ⇢ $ {_format_money0(price)}" + 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: @@ -318,12 +484,82 @@ def _risk_reward_line(state) -> str: return "" ratio = state.take_profit_percent / state.stop_loss_percent - return f"⚖️ R:R = 1 : {_format_ratio_value(ratio)}" + 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(".") @@ -338,16 +574,16 @@ def _status_text(status: str) -> str: def _decision_human_text(status: str) -> str: mapping = { - "WAITING": "🟡 Ожидание сигнала", - "CONFIRMING": "🟠 Подтверждение сигнала", - "READY": "🟢 Сигнал готов", - "BLOCKED": "🔴 Сигнал заблокирован", + "WAITING": "Ожидание сигнала", + "CONFIRMING": "Подтверждение сигнала", + "READY": "Сигнал готов", + "BLOCKED": "Сигнал заблокирован", } return mapping.get(status, status) def _account_mode_line() -> str: - return "🔸 DEMO аккаунт" if "DEMO" in mode_line().upper() else "🔸 LIVE аккаунт" + return "DEMO аккаунт" if "DEMO" in mode_line().upper() else "LIVE аккаунт" def _asset_symbol(symbol: str | None) -> str: @@ -387,51 +623,90 @@ def _leverage_text(value: float | None) -> str: 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)}" + return _format_percent(state.risk_percent) def _required_value(value: str) -> str: if not value or value == "—": - return "required" + 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 "", "⚪") + 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_money0(value: float | int | None) -> str: +def _format_money_compact(value: float | int | None) -> str: if value is None: return "—" - return f"{float(value):,.0f}".replace(",", " ") + + 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)}" @@ -442,16 +717,24 @@ def _format_signed_usd(value: float | int | None) -> str: amount = float(value) if amount > 0: - return f"+$ {_format_money(amount)}" + return f"+$ {_format_money_compact(amount)}" if amount < 0: - return f"−$ {_format_money(abs(amount))}" + return f"−$ {_format_money_compact(abs(amount))}" - return "$ 0.00" + return "$ 0" -def _format_crypto_size(value: float | int | None) -> str: +def _format_signed_usd_with_direction(value: float | int | None) -> str: if value is None: return "—" - return f"{float(value):.4f}".rstrip("0").rstrip(".") \ No newline at end of file + amount = float(value) + + if amount > 0: + return f"🟢 +$ {_format_money_compact(amount)}" + + if amount < 0: + return f"🔴 −$ {_format_money_compact(abs(amount))}" + + return "$ 0" \ No newline at end of file diff --git a/app/src/telegram/handlers/debug.py b/app/src/telegram/handlers/debug.py index 70b0235..8142cf5 100644 --- a/app/src/telegram/handlers/debug.py +++ b/app/src/telegram/handlers/debug.py @@ -2,14 +2,20 @@ from __future__ import annotations +import math +import time +from datetime import datetime + from aiogram import F, Router from aiogram.types import Message from src.core.config import load_settings +from src.integrations.exchange.service import ExchangeService from src.trading.auto.runner import AutoTradeRunner from src.trading.auto.service import AutoTradeService from src.trading.execution.engine import ExecutionEngine from src.trading.journal.service import JournalService +from src.trading.position.state import PositionState router = Router(name="debug") @@ -19,6 +25,566 @@ def _debug_enabled() -> bool: return load_settings().debug_enabled +def _debug_help_text() -> str: + return ( + "🧪 Debug commands\n\n" + "Auto UI states:\n" + "/debug_auto off\n" + "/debug_auto hold 335\n" + "/debug_auto buy 12 0.74\n" + "/debug_auto buy_ready 0.88\n" + "/debug_auto sell 9 0.71\n" + "/debug_auto sell_ready 0.91\n" + "/debug_auto long\n" + "/debug_auto short\n" + "/debug_auto reset\n" + "/debug_auto state\n\n" + "Paper execution:\n" + "/debug_exec buy — открыть LONG\n" + "/debug_exec sell — открыть SHORT\n" + "/debug_exec flip — перевернуть текущую позицию\n" + "/debug_exec flip_buy — перевернуть в LONG\n" + "/debug_exec flip_sell — перевернуть в SHORT\n" + "/debug_exec close — закрыть позицию\n" + "/debug_exec state — состояние позиции\n\n" + "Live paper test:\n" + "/debug_live buy — открыть LONG и запустить мониторинг\n" + "/debug_live sell — открыть SHORT и запустить мониторинг\n" + "/debug_live flip — перевернуть текущую позицию и продолжить мониторинг\n" + "/debug_live close — закрыть позицию\n" + "/debug_live stop — остановить мониторинг, позицию не закрывать\n" + "/debug_live state — состояние live paper test\n\n" + "Legacy:\n" + "/debug_signal BUY 0.95 3\n" + "/debug_signal SELL 0.70 2\n" + "/debug_signal HOLD 0.00 1\n" + "/debug_ready\n" + "/debug_state" + ) + + +@router.message(F.text == "/debug_help") +async def debug_help(message: Message) -> None: + if not _debug_enabled(): + await message.answer("Debug mode выключен.") + return + + await message.answer(_debug_help_text()) + + +@router.message(F.text.startswith("/debug_auto")) +async def debug_auto(message: Message) -> None: + if not _debug_enabled(): + await message.answer("Debug mode выключен.") + return + + parts = (message.text or "").split() + command = parts[1].lower() if len(parts) > 1 else "help" + + service = AutoTradeService() + state = service.get_state() + + if command in {"help", "-h", "--help"}: + await message.answer(_debug_help_text()) + return + + if command == "off": + _clear_debug_position(state) + state.status = "OFF" + state.decision_status = "WAITING" + state.last_signal = "HOLD" + state.last_signal_confidence = 0.0 + state.last_signal_repeat_count = 1 + state.is_signal_confirmed = False + state.is_signal_ready = False + _set_signal_started_at(state) + await _refresh_auto_screen() + await message.answer("✅ Debug Auto: OFF") + return + + if command == "reset": + _clear_debug_position(state) + state.status = "RUNNING" + state.decision_status = "WAITING" + state.decision_reason = None + state.last_signal = "HOLD" + state.last_signal_reason = "DEBUG RESET HOLD" + state.last_signal_confidence = 0.0 + state.last_signal_repeat_count = 1 + state.is_signal_confirmed = False + state.is_signal_ready = False + state.execution_block_reason = None + state.execution_size_adjustment_reason = None + _set_signal_started_at(state) + await _refresh_auto_screen() + await message.answer("✅ Debug Auto: reset to RUNNING HOLD") + return + + if command == "state": + _sync_state_from_position(state) + await message.answer(_debug_state_text(state)) + return + + if command == "hold": + seconds = _parse_int(parts, index=2, default=335) + _clear_debug_position(state) + _set_signal_state( + state=state, + signal="HOLD", + seconds=seconds, + confidence=0.0, + decision_status="WAITING", + ready=False, + ) + await _refresh_auto_screen() + await message.answer(f"✅ Debug Auto: HOLD {seconds}s") + return + + if command == "buy": + seconds = _parse_int(parts, index=2, default=12) + confidence = _parse_float(parts, index=3, default=0.74) + _clear_debug_position(state) + _set_signal_state( + state=state, + signal="BUY", + seconds=seconds, + confidence=confidence, + decision_status="CONFIRMING", + ready=False, + ) + await _refresh_auto_screen() + await message.answer(f"✅ Debug Auto: BUY {seconds}s confidence={confidence:.2f}") + return + + if command == "buy_ready": + confidence = _parse_float(parts, index=2, default=0.88) + _clear_debug_position(state) + _set_signal_state( + state=state, + signal="BUY", + seconds=15, + confidence=confidence, + decision_status="READY", + ready=True, + ) + await _refresh_auto_screen() + await message.answer(f"✅ Debug Auto: BUY READY confidence={confidence:.2f}") + return + + if command == "sell": + seconds = _parse_int(parts, index=2, default=9) + confidence = _parse_float(parts, index=3, default=0.71) + _clear_debug_position(state) + _set_signal_state( + state=state, + signal="SELL", + seconds=seconds, + confidence=confidence, + decision_status="CONFIRMING", + ready=False, + ) + await _refresh_auto_screen() + await message.answer(f"✅ Debug Auto: SELL {seconds}s confidence={confidence:.2f}") + return + + if command == "sell_ready": + confidence = _parse_float(parts, index=2, default=0.91) + _clear_debug_position(state) + _set_signal_state( + state=state, + signal="SELL", + seconds=15, + confidence=confidence, + decision_status="READY", + ready=True, + ) + await _refresh_auto_screen() + await message.answer(f"✅ Debug Auto: SELL READY confidence={confidence:.2f}") + return + + if command == "long": + _set_debug_position(state=state, side="LONG") + await _refresh_auto_screen() + await message.answer("✅ Debug Auto: active LONG position") + return + + if command == "short": + _set_debug_position(state=state, side="SHORT") + await _refresh_auto_screen() + await message.answer("✅ Debug Auto: active SHORT position") + return + + await message.answer(f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}") + + +@router.message(F.text.startswith("/debug_exec")) +async def debug_exec(message: Message) -> None: + if not _debug_enabled(): + await message.answer("Debug mode выключен.") + return + + parts = (message.text or "").split() + command = parts[1].lower() if len(parts) > 1 else "help" + + service = AutoTradeService() + state = service.get_state() + engine = ExecutionEngine() + + if command in {"help", "-h", "--help"}: + await message.answer(_debug_help_text()) + return + + if command == "state": + _sync_state_from_position(state) + await message.answer(_debug_state_text(state)) + return + + if command == "buy": + _prepare_ready_signal(state=state, signal="BUY", confidence=0.95) + result = engine.process(state) + await _after_debug_execution() + await message.answer(_execution_result_text("BUY execution", result, state)) + return + + if command == "sell": + _prepare_ready_signal(state=state, signal="SELL", confidence=0.95) + result = engine.process(state) + await _after_debug_execution() + await message.answer(_execution_result_text("SELL execution", result, state)) + return + + if command == "flip": + position = engine.get_position() + current_side = position.side or state.position_side or "NONE" + + if current_side == "LONG": + target_signal = "SELL" + elif current_side == "SHORT": + target_signal = "BUY" + else: + await message.answer( + "⛔️ Flip невозможен: нет открытой позиции.\n\n" + "Сначала выполните /debug_exec buy или /debug_exec sell." + ) + return + + _prepare_ready_signal(state=state, signal=target_signal, confidence=0.95) + result = engine.process(state) + await _after_debug_execution() + await message.answer(_execution_result_text("AUTO FLIP execution", result, state)) + return + + if command == "flip_buy": + _prepare_ready_signal(state=state, signal="BUY", confidence=0.95) + result = engine.process(state) + await _after_debug_execution() + await message.answer(_execution_result_text("FLIP to LONG execution", result, state)) + return + + if command == "flip_sell": + _prepare_ready_signal(state=state, signal="SELL", confidence=0.95) + result = engine.process(state) + await _after_debug_execution() + await message.answer(_execution_result_text("FLIP to SHORT execution", result, state)) + return + + if command == "close": + result = engine._close_position(state, forced_reason="DEBUG_CLOSE") + await _after_debug_execution() + await message.answer(_execution_result_text("CLOSE execution", result, state)) + return + + await message.answer(f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}") + + +@router.message(F.text.startswith("/debug_live")) +async def debug_live(message: Message) -> None: + if not _debug_enabled(): + await message.answer("Debug mode выключен.") + return + + parts = (message.text or "").split() + command = parts[1].lower() if len(parts) > 1 else "help" + + service = AutoTradeService() + state = service.get_state() + engine = ExecutionEngine() + + if command in {"help", "-h", "--help"}: + await message.answer(_debug_help_text()) + return + + if command == "buy": + _prepare_ready_signal(state=state, signal="BUY", confidence=0.95) + result = engine.process(state) + await _start_live_monitoring() + await message.answer(_execution_result_text("LIVE BUY execution", result, state)) + return + + if command == "sell": + _prepare_ready_signal(state=state, signal="SELL", confidence=0.95) + result = engine.process(state) + await _start_live_monitoring() + await message.answer(_execution_result_text("LIVE SELL execution", result, state)) + return + + if command == "flip": + position = engine.get_position() + current_side = position.side or state.position_side or "NONE" + + if current_side == "LONG": + target_signal = "SELL" + elif current_side == "SHORT": + target_signal = "BUY" + else: + await message.answer( + "⛔️ Live flip невозможен: нет открытой позиции.\n\n" + "Сначала выполните /debug_live buy или /debug_live sell." + ) + return + + _prepare_ready_signal(state=state, signal=target_signal, confidence=0.95) + result = engine.process(state) + await _start_live_monitoring() + await message.answer(_execution_result_text("LIVE AUTO FLIP execution", result, state)) + return + + if command == "close": + result = engine._close_position(state, forced_reason="DEBUG_LIVE_CLOSE") + await _after_debug_execution() + await message.answer(_execution_result_text("LIVE CLOSE execution", result, state)) + return + + if command == "stop": + AutoTradeRunner.stop() + await _refresh_auto_screen() + await message.answer("✅ Debug live stopped. Позиция не закрыта.") + return + + if command == "state": + _sync_state_from_position(state) + await message.answer(_debug_state_text(state)) + return + + await message.answer(f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}") + + +def _prepare_ready_signal(*, state, signal: str, confidence: float) -> None: + state.status = "RUNNING" + state.last_signal = signal + state.last_signal_confidence = max(0.0, min(1.0, confidence)) + state.last_signal_repeat_count = 3 + state.last_signal_reason = f"DEBUG EXEC {signal}" + state.decision_status = "READY" + state.decision_reason = "DEBUG EXEC READY" + state.is_signal_confirmed = True + state.is_signal_ready = True + state.execution_block_reason = None + state.execution_size_adjustment_reason = None + _set_signal_started_at(state) + + +def _set_signal_state( + *, + state, + signal: str, + seconds: int, + confidence: float, + decision_status: str, + ready: bool, +) -> None: + state.status = "RUNNING" + state.last_signal = signal + state.last_signal_confidence = max(0.0, min(1.0, confidence)) + state.last_signal_repeat_count = _seconds_to_repeats(seconds) + state.last_signal_reason = f"DEBUG {signal} {seconds}s" + state.decision_status = decision_status + state.decision_reason = f"DEBUG {decision_status}" + state.is_signal_confirmed = ready + state.is_signal_ready = ready + state.execution_block_reason = None + state.execution_size_adjustment_reason = None + _set_signal_started_at(state, seconds_ago=seconds) + + +def _set_signal_started_at(state, *, seconds_ago: int = 0) -> None: + if hasattr(state, "signal_started_at"): + state.signal_started_at = time.monotonic() - max(0, seconds_ago) + + +def _set_debug_position(*, state, side: str) -> None: + state.status = "RUNNING" + state.last_signal = "BUY" if side == "LONG" else "SELL" + state.last_signal_confidence = 0.90 + state.last_signal_repeat_count = 3 + state.decision_status = "READY" + state.is_signal_confirmed = True + state.is_signal_ready = True + _set_signal_started_at(state, seconds_ago=15) + + entry_price = _debug_entry_price(state.symbol, side) + size = _debug_size_for_notional(entry_price, notional=1000.0) + now = datetime.now().strftime("%H:%M:%S") + + position = PositionState( + side=side, + symbol=state.symbol, + entry_price=entry_price, + size=size, + leverage=state.leverage, + unrealized_pnl_usd=0.0, + opened_at=now, + updated_at=now, + ) + + ExecutionEngine._position = position + _sync_state_from_position(state) + + +def _clear_debug_position(state) -> None: + ExecutionEngine._position = PositionState() + + state.position_side = "NONE" + state.entry_price = None + state.position_size = None + state.unrealized_pnl_usd = None + + +def _sync_state_from_position(state) -> None: + position = ExecutionEngine().get_position() + + state.position_side = position.side + state.entry_price = position.entry_price + state.position_size = position.size + state.unrealized_pnl_usd = position.unrealized_pnl_usd + + +def _debug_entry_price(symbol: str, side: str) -> float: + try: + snapshot = ExchangeService().get_market_snapshot(symbol) + + if side == "LONG": + return float(snapshot.get("ask_price") or snapshot.get("last_price")) + + if side == "SHORT": + return float(snapshot.get("bid_price") or snapshot.get("last_price")) + + return float(snapshot.get("last_price")) + except Exception: + return 100000.0 + + +def _debug_size_for_notional(entry_price: float, *, notional: float) -> float: + if entry_price <= 0: + return 0.0 + + value = notional / entry_price + factor = 10**5 + return math.floor(value * factor) / factor + + +def _seconds_to_repeats(seconds: int) -> int: + return max(1, math.ceil(max(0, seconds) / 5)) + + +def _parse_int(parts: list[str], *, index: int, default: int) -> int: + try: + return int(parts[index]) + except (IndexError, TypeError, ValueError): + return default + + +def _parse_float(parts: list[str], *, index: int, default: float) -> float: + try: + return float(parts[index]) + except (IndexError, TypeError, ValueError): + return default + + +async def _refresh_auto_screen() -> None: + AutoTradeRunner.set_current_screen("auto") + AutoTradeRunner._last_text = None + await AutoTradeRunner._refresh_screen(force=True) + + +async def _start_live_monitoring() -> None: + state = AutoTradeService().get_state() + + state.status = "RUNNING" + _sync_state_from_position(state) + + AutoTradeRunner.set_current_screen("auto") + AutoTradeRunner._last_text = None + + await AutoTradeRunner.process_last_event_now() + await _refresh_auto_screen() + + AutoTradeRunner.start() + + +async def _after_debug_execution() -> None: + state = AutoTradeService().get_state() + + _sync_state_from_position(state) + + AutoTradeRunner.set_current_screen("auto") + AutoTradeRunner._last_text = None + + await AutoTradeRunner.process_last_event_now() + await _refresh_auto_screen() + + +def _execution_result_text(title: str, result, state) -> str: + _sync_state_from_position(state) + + return ( + f"✅ Debug {title}\n\n" + f"Action: {result.action}\n" + f"Can execute: {result.can_execute}\n" + f"Reason: {result.reason}\n\n" + f"Signal: {state.last_signal}\n" + f"Decision: {state.decision_status}\n\n" + f"Position: {state.position_side}\n" + f"Entry: {state.entry_price}\n" + f"Size: {state.position_size}\n" + f"PnL: {state.unrealized_pnl_usd}" + ) + + +def _debug_state_text(state) -> str: + runner_task_running = ( + AutoTradeRunner._task is not None + and not AutoTradeRunner._task.done() + ) + + return ( + "Debug Auto State\n\n" + f"Status: {state.status}\n" + f"Symbol: {state.symbol}\n" + f"Strategy: {state.strategy}\n" + f"Risk: {state.risk_percent}\n" + f"Leverage: {state.leverage}\n\n" + f"Signal: {state.last_signal}\n" + f"Repeats: {state.last_signal_repeat_count}\n" + f"Confidence: {state.last_signal_confidence:.2f}\n" + f"Decision: {state.decision_status}\n" + f"Ready: {state.is_signal_ready}\n" + f"Signal started at: {getattr(state, 'signal_started_at', None)}\n\n" + f"Runner\n" + f"Screen: {AutoTradeRunner._current_screen}\n" + f"Chat ID: {AutoTradeRunner._chat_id}\n" + f"Message ID: {AutoTradeRunner._message_id}\n" + f"Has bot: {AutoTradeRunner._bot is not None}\n" + f"Has render_text: {AutoTradeRunner._render_text is not None}\n" + f"Task running: {runner_task_running}\n\n" + f"Position\n" + f"Side: {state.position_side}\n" + f"Entry: {state.entry_price}\n" + f"Size: {state.position_size}\n" + f"PnL: {state.unrealized_pnl_usd}" + ) + + def _parse_debug_signal_args(raw_text: str | None) -> tuple[str, float, int, str | None]: parts = (raw_text or "").split() @@ -45,34 +611,6 @@ def _parse_debug_signal_args(raw_text: str | None) -> tuple[str, float, int, str return signal, confidence, repeat_count, None -def _debug_help_text() -> str: - return ( - "🧪 Debug commands\n\n" - "Основная команда:\n" - "/debug_signal BUY 0.95 3\n" - "/debug_signal SELL 0.70 2\n" - "/debug_signal HOLD 0.00 1\n\n" - "Быстрые команды:\n" - "/debug_signal — BUY 0.90 2\n" - "/debug_ready — READY BUY\n" - "/debug_state — текущее состояние\n" - "/debug_help — список команд\n\n" - "Priority тест:\n" - "HIGH: confidence >= 0.80 и repeats >= 3\n" - "MEDIUM: confidence >= 0.60 или repeats >= 2\n" - "LOW: всё остальное" - ) - - -@router.message(F.text == "/debug_help") -async def debug_help(message: Message) -> None: - if not _debug_enabled(): - await message.answer("Debug mode выключен.") - return - - await message.answer(_debug_help_text()) - - @router.message(F.text.startswith("/debug_signal")) async def debug_signal(message: Message) -> None: if not _debug_enabled(): @@ -82,10 +620,7 @@ async def debug_signal(message: Message) -> None: signal, confidence, repeat_count, error = _parse_debug_signal_args(message.text) if error is not None: - await message.answer( - f"⛔️ {error}\n\n" - f"{_debug_help_text()}" - ) + await message.answer(f"⛔️ {error}\n\n{_debug_help_text()}") return service = AutoTradeService() @@ -99,13 +634,8 @@ async def debug_signal(message: Message) -> None: if state.status == "OFF": state.status = "RUNNING" - await AutoTradeRunner._handle_important_event(state) - - execution_result = ExecutionEngine().process(state) - - await AutoTradeRunner.process_last_event_now() - - AutoTradeRunner.start() + _set_signal_started_at(state) + await _refresh_auto_screen() JournalService().log_ui_info( event_type="debug_signal_forced", @@ -119,9 +649,6 @@ async def debug_signal(message: Message) -> None: "decision_status": state.decision_status, "confidence": state.last_signal_confidence, "repeat_count": state.last_signal_repeat_count, - "execution_action": execution_result.action, - "execution_can_execute": execution_result.can_execute, - "execution_reason": execution_result.reason, }, ) @@ -130,10 +657,7 @@ async def debug_signal(message: Message) -> None: f"Signal: {state.last_signal}\n" f"Decision: {state.decision_status}\n" f"Confidence: {state.last_signal_confidence:.2f}\n" - f"Repeats: {state.last_signal_repeat_count}\n\n" - f"Execution: {execution_result.action}\n" - f"Can execute: {execution_result.can_execute}\n" - f"Reason: {execution_result.reason}" + f"Repeats: {state.last_signal_repeat_count}" ) @@ -144,30 +668,25 @@ async def debug_ready(message: Message) -> None: return service = AutoTradeService() - state = service.debug_force_signal( + state = service.get_state() + + _clear_debug_position(state) + _set_signal_state( + state=state, signal="BUY", + seconds=15, confidence=0.95, - repeat_count=3, - reason="DEBUG READY BUY 0.95 ×3", + decision_status="READY", + ready=True, ) - if state.status == "OFF": - state.status = "RUNNING" - - await AutoTradeRunner._handle_important_event(state) - - execution_result = ExecutionEngine().process(state) - - await AutoTradeRunner.process_last_event_now() - - AutoTradeRunner.start() + await _refresh_auto_screen() await message.answer( "✅ Debug READY создан\n\n" f"Signal: {state.last_signal}\n" f"Decision: {state.decision_status}\n" - f"Execution: {execution_result.action}\n" - f"Can execute: {execution_result.can_execute}" + f"Confidence: {state.last_signal_confidence:.2f}" ) @@ -178,19 +697,5 @@ async def debug_state(message: Message) -> None: return state = AutoTradeService().get_state() - - await message.answer( - "Debug Auto State\n\n" - f"Status: {state.status}\n" - f"Symbol: {state.symbol}\n" - f"Strategy: {state.strategy}\n" - f"Risk: {state.risk_percent}\n" - f"Leverage: {state.leverage}\n\n" - f"Signal: {state.last_signal}\n" - f"Repeats: {state.last_signal_repeat_count}\n" - f"Confidence: {state.last_signal_confidence:.2f}\n" - f"Decision: {state.decision_status}\n\n" - f"Position: {state.position_side}\n" - f"Entry: {state.entry_price}\n" - f"PnL: {state.unrealized_pnl_usd}" - ) \ No newline at end of file + _sync_state_from_position(state) + await message.answer(_debug_state_text(state)) \ No newline at end of file diff --git a/app/src/telegram/handlers/system.py b/app/src/telegram/handlers/system.py index 6749e8f..8c757f4 100644 --- a/app/src/telegram/handlers/system.py +++ b/app/src/telegram/handlers/system.py @@ -181,9 +181,9 @@ async def open_auto_settings(callback: CallbackQuery) -> None: state = AutoTradeService().get_state() strategy_map = { - "TREND": "📈 Trend Following", - "GRID": "🧩 Grid Trading", - "SCALP": "⚡ Scalping", + "TREND": "TREND FOLLOWING", + "GRID": "GRID TRADING", + "SCALP": "SCALPING", } strategy_ready = state.strategy is not None @@ -191,10 +191,37 @@ async def open_auto_settings(callback: CallbackQuery) -> None: risk_ready = state.risk_percent is not None leverage_ready = state.leverage is not None - is_configured = strategy_ready and symbol_ready and risk_ready and leverage_ready + is_trend_strategy = (state.strategy or "").upper() == "TREND" + sl_ready = ( + state.stop_loss_percent is not None + and state.stop_loss_percent > 0 + ) + + is_configured = ( + strategy_ready + and symbol_ready + and risk_ready + and leverage_ready + and (not is_trend_strategy or sl_ready) + ) strategy = strategy_map.get(state.strategy or "", "—") - symbol = state.symbol or "—" + + symbol = "—" + + if state.symbol: + base = state.symbol.split("_", 1)[0].upper() + + if "/" in base: + symbol = base.split("/", 1)[0] + else: + for suffix in ("USDT", "USD", "EUR", "BTC"): + if base.endswith(suffix) and len(base) > len(suffix): + base = base[: -len(suffix)] + break + + symbol = base + 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 = ( @@ -202,44 +229,70 @@ async def open_auto_settings(callback: CallbackQuery) -> None: 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" - risk_controls = f"SL {sl} · TP {tp} · ML {ml}" + sl = ( + f"{state.stop_loss_percent:g}%" + if state.stop_loss_percent is not None + else "off" + ) - strategy_icon = "✅" if strategy_ready else "👉" - symbol_icon = "✅" if symbol_ready else "👉" - risk_icon = "✅" if risk_ready else "👉" - leverage_icon = "✅" if leverage_ready else "👉" + 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" + ) + + strategy_icon = "✅" if strategy_ready else "⚠️" + symbol_icon = "✅" if symbol_ready else "⚠️" + risk_icon = "✅" if risk_ready else "⚠️" + leverage_icon = "✅" if leverage_ready else "⚠️" + sl_icon = "✅" if sl_ready else "⚠️" + + if is_trend_strategy: + risk_controls_block = ( + "Защита позиции:\n" + f"{sl_icon} Stop Loss · {'required' if not sl_ready else sl}\n" + f"✅ Take Profit · {tp}\n" + f"✅ Max Loss · {ml}" + ) + else: + risk_controls_block = ( + "Защита позиции:\n" + f"✅ Stop Loss · {sl}\n" + f"✅ Take Profit · {tp}\n" + f"✅ Max Loss · {ml}" + ) config_status = ( "✅ Все параметры настроены" if is_configured - else "⛔️ Настрой все параметры" + else "⚠️ Настрой все параметры" ) text = ( "🤖 Автоторговля\n\n" "СИСТЕМА · Настройки\n\n" - f"{strategy_icon} Стратегия: {strategy}\n" - 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"{strategy_icon} Стратегия: {strategy}\n" + f"{symbol_icon} Актив: {symbol}\n" + f"{risk_icon} Риск на сделку: {risk}\n" + f"{leverage_icon} Плечо: {leverage}\n\n" + f"✅ Лимит на сделку: {max_reserved}\n\n" + f"{risk_controls_block}\n\n" f"{config_status}" ) - if not is_configured: - text += "\n\nВыберите настройку:" - builder = InlineKeyboardBuilder() builder.button(text="🧠 Стратегия", callback_data="settings:auto_strategy") - builder.button(text="📈 Инструмент", callback_data="settings:auto_symbol") - builder.button(text="🛡️ Риск на сделку", callback_data="settings:auto_risk") + builder.button(text="💱 Актив", callback_data="settings:auto_symbol") 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="settings:auto_max_reserved") + builder.button(text="🛡️ Риск", callback_data="settings:auto_risk") + builder.button(text="🧯 Защита", callback_data="auto:risk") builder.button(text="🤖 Автоторговля", callback_data="auto:home") builder.button(text="⬅️ Назад", callback_data="system:management") builder.adjust(2, 2, 2, 2) @@ -294,17 +347,36 @@ async def open_auto_symbol_settings(callback: CallbackQuery) -> None: settings = load_settings() text = ( - "📈 Инструмент\n\n" + "💱 Актив\n\n" "СИСТЕМА · Настройки · Автоторговля\n\n" - "Выберите инструмент:" + "Выберите актив:" ) builder = InlineKeyboardBuilder() - builder.button(text=settings.default_symbol, callback_data=f"settings:auto_symbol:{settings.default_symbol}") - builder.button(text="BTCUSDT", callback_data="settings:auto_symbol:BTCUSDT") - builder.button(text="ETHUSDT", callback_data="settings:auto_symbol:ETHUSDT") + + builder.button( + text="BTC", + callback_data="settings:auto_symbol:BTC/USD_LEVERAGE", + ) + + builder.button( + text="ETH", + callback_data="settings:auto_symbol:ETH/USD_LEVERAGE", + ) + + builder.button( + text="LTC", + callback_data="settings:auto_symbol:LTC/USD_LEVERAGE", + ) + + builder.button( + text="XRP", + callback_data="settings:auto_symbol:XRP/USD_LEVERAGE", + ) + builder.button(text="⬅️ Назад", callback_data="settings:auto") - builder.adjust(1, 2, 1) + + builder.adjust(2, 2, 1) await callback.message.edit_text(text, reply_markup=builder.as_markup()) await callback.answer() @@ -319,7 +391,7 @@ async def set_auto_symbol(callback: CallbackQuery) -> None: await open_auto_settings(callback) AutoTradeRunner.set_current_screen("settings_auto") - await callback.answer("Инструмент обновлён") + await callback.answer("Актив обновлён") @router.callback_query(F.data == "settings:auto_risk") @@ -330,7 +402,7 @@ async def open_auto_risk_settings(callback: CallbackQuery) -> None: return text = ( - "🛡️ Риск\n\n" + "🛡️ Риск на сделку\n\n" "СИСТЕМА · Настройки · Автоторговля\n\n" "Выберите риск на сделку:" ) @@ -407,7 +479,7 @@ async def open_trade_settings(callback: CallbackQuery) -> None: text = ( "💹 Торговля\n\n" "СИСТЕМА · Настройки\n\n" - "Инструмент: —\n" + "Актив: —\n" "Тип ордера по умолчанию: —\n" "Пресеты количества: —\n\n" "В разработке." @@ -618,7 +690,7 @@ async def open_system_about(callback: CallbackQuery) -> None: reply_markup=builder.as_markup(), ) await callback.answer() - + @router.callback_query(F.data == "settings:auto_max_reserved") async def open_auto_max_reserved_settings(callback: CallbackQuery) -> None: @@ -629,7 +701,7 @@ async def open_auto_max_reserved_settings(callback: CallbackQuery) -> None: return text = ( - "🏦 Max Reserved\n\n" + "🏦 Лимит на сделку\n\n" "СИСТЕМА · Настройки · Автоторговля\n\n" "Максимальная доля баланса, которую можно зарезервировать под позицию:" ) diff --git a/app/src/trading/auto/runner.py b/app/src/trading/auto/runner.py index 31684ce..0d98e16 100644 --- a/app/src/trading/auto/runner.py +++ b/app/src/trading/auto/runner.py @@ -26,7 +26,7 @@ class AutoTradeRunner: _current_screen: str | None = None _analysis_interval_seconds = 5 - _ui_interval_seconds = 60 + _ui_interval_seconds = 5 _last_text: str | None = None _last_ui_refresh_at: float = 0.0 @@ -550,17 +550,66 @@ class AutoTradeRunner: except (TypeError, ValueError): return "—" + @classmethod + def _log_refresh_skip(cls, reason: str, payload: dict | None = None) -> None: + try: + JournalService().log_ui_info( + event_type="auto_screen_refresh_skipped", + message=f"Auto screen refresh skipped: {reason}", + screen="auto", + action="refresh_screen", + payload=payload or {}, + ) + except Exception: + pass + + @classmethod + def _log_refresh_success(cls, payload: dict | None = None) -> None: + try: + JournalService().log_ui_info( + event_type="auto_screen_refreshed", + message="Auto screen refreshed.", + screen="auto", + action="refresh_screen", + payload=payload or {}, + ) + except Exception: + pass + + @classmethod + def _log_refresh_error(cls, reason: str, payload: dict | None = None) -> None: + try: + JournalService().log_error( + "auto_screen_refresh_error", + f"Auto screen refresh error: {reason}", + payload or {}, + ) + except Exception: + pass + @classmethod async def _refresh_screen(cls, *, force: bool = False) -> None: if cls._current_screen != "auto": + cls._log_refresh_skip("current_screen_not_auto") return now = time.monotonic() if now < cls._retry_after_until: + cls._log_refresh_skip( + "retry_after_active", + {"retry_after_until": cls._retry_after_until, "now": now}, + ) return if not force and now - cls._last_ui_refresh_at < cls._ui_interval_seconds: + cls._log_refresh_skip( + "ui_interval_not_reached", + { + "elapsed": round(now - cls._last_ui_refresh_at, 2), + "interval": cls._ui_interval_seconds, + }, + ) return if not all( @@ -572,11 +621,22 @@ class AutoTradeRunner: cls._render_markup, ] ): + cls._log_refresh_skip( + "screen_not_registered", + { + "has_bot": cls._bot is not None, + "chat_id": cls._chat_id, + "message_id": cls._message_id, + "has_render_text": cls._render_text is not None, + "has_render_markup": cls._render_markup is not None, + }, + ) return text = cls._render_text() if text == cls._last_text: + cls._log_refresh_skip("text_not_changed") return try: @@ -589,8 +649,23 @@ class AutoTradeRunner: cls._last_text = text cls._last_ui_refresh_at = now + cls._log_refresh_success( + { + "chat_id": cls._chat_id, + "message_id": cls._message_id, + "text_length": len(text), + } + ) + except TelegramRetryAfter as exc: cls._retry_after_until = time.monotonic() + exc.retry_after + 5 + cls._log_refresh_error( + "telegram_retry_after", + { + "retry_after": exc.retry_after, + "retry_after_until": cls._retry_after_until, + }, + ) except TelegramBadRequest as exc: error_text = str(exc).lower() @@ -598,6 +673,7 @@ class AutoTradeRunner: if "message is not modified" in error_text: cls._last_text = text cls._last_ui_refresh_at = now + cls._log_refresh_skip("telegram_message_not_modified") return if "message to edit not found" in error_text: @@ -605,7 +681,19 @@ class AutoTradeRunner: cls._render_text = None cls._render_markup = None cls._last_text = None + cls._log_refresh_error( + "telegram_message_to_edit_not_found", + {"error": str(exc)}, + ) return - except Exception: - pass \ No newline at end of file + cls._log_refresh_error( + "telegram_bad_request", + {"error": str(exc)}, + ) + + except Exception as exc: + cls._log_refresh_error( + "unexpected_refresh_error", + {"error": str(exc)}, + ) \ No newline at end of file diff --git a/app/src/trading/auto/service.py b/app/src/trading/auto/service.py index dbb1a17..1e3f9b8 100644 --- a/app/src/trading/auto/service.py +++ b/app/src/trading/auto/service.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import time from datetime import datetime from src.core.config import load_settings @@ -49,6 +50,9 @@ class AutoTradeService: previous_signal = state.last_signal previous_decision_status = state.decision_status + + if previous_signal != normalized_signal or state.signal_started_at is None: + state.signal_started_at = time.monotonic() state.last_signal = normalized_signal state.last_signal_repeat_count = repeat_count @@ -85,6 +89,18 @@ class AutoTradeService: return state + # установить капитал, выделенный под автоторговлю + def set_allocated_balance_usd(self, value: float) -> AutoTradeState: + state = self.get_state() + + if value <= 0: + value = 1000.0 + + state.allocated_balance_usd = value + state.execution_block_reason = None + state.execution_size_adjustment_reason = None + return state + # получить текущее состояние автоторговли def get_state(self) -> AutoTradeState: if not self._state.symbol: @@ -264,6 +280,7 @@ class AutoTradeService: state.is_signal_confirmed = False state.is_signal_ready = False state.execution_block_reason = None + state.signal_started_at = None # собрать контекст для стратегии def _build_strategy_context(self) -> StrategyContext: @@ -397,6 +414,9 @@ class AutoTradeService: previous_signal = state.last_signal previous_decision_status = state.decision_status + if previous_signal != signal or state.signal_started_at is None: + state.signal_started_at = time.monotonic() + state.last_signal = signal state.last_signal_repeat_count = self._same_signal_count state.last_signal_confidence = confidence diff --git a/app/src/trading/auto/state.py b/app/src/trading/auto/state.py index 324e44c..bb04d2c 100644 --- a/app/src/trading/auto/state.py +++ b/app/src/trading/auto/state.py @@ -11,13 +11,13 @@ class AutoTradeState: status: str = "OFF" # выбранная стратегия: TREND / GRID / SCALP - strategy: str | None = None + strategy: str | None = "TREND" # торговый инструмент - symbol: str = "" + symbol: str = "BTC/USD_LEVERAGE" # риск на одну сделку в % - risk_percent: float | None = None + risk_percent: float | None = 1.0 # текущий PnL pnl_usd: float = 0.0 @@ -37,6 +37,9 @@ class AutoTradeState: # причина последнего сигнала last_signal_reason: str | None = None + # время начала текущего сигнала, monotonic timestamp + signal_started_at: float | None = None + # статус торгового решения: WAITING / CONFIRMING / READY / BLOCKED decision_status: str = "WAITING" @@ -68,7 +71,7 @@ class AutoTradeState: leverage: float | None = 2.0 # stop loss по движению цены в % - stop_loss_percent: float | None = None + stop_loss_percent: float | None = 1.0 # take profit по движению цены в % take_profit_percent: float | None = None @@ -83,4 +86,10 @@ class AutoTradeState: execution_block_reason: str | None = None # причина авто-уменьшения размера позиции - execution_size_adjustment_reason: str | None = None \ No newline at end of file + execution_size_adjustment_reason: str | None = None + + # капитал, выделенный только под AutoTrade + allocated_balance_usd: float = 1000.0 + + # зафиксированный результат закрытых paper-сделок + realized_pnl_usd: float = 0.0 \ No newline at end of file diff --git a/app/src/trading/execution/engine.py b/app/src/trading/execution/engine.py index b6f86f6..fd45141 100644 --- a/app/src/trading/execution/engine.py +++ b/app/src/trading/execution/engine.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math from datetime import datetime from src.core.event_bus import EventBus @@ -14,6 +15,7 @@ from src.trading.position.state import PositionState class ExecutionEngine: _position = PositionState() + _size_precision = 5 def get_position(self) -> PositionState: return type(self)._position @@ -58,8 +60,7 @@ class ExecutionEngine: return ExecutionDecision("NONE", False, "Позиция уже открыта.") try: - ticker = ExchangeService().get_price(state.symbol) - entry_price = ticker.price + entry_price = self._entry_price_for_side(state.symbol, side) except Exception as exc: return ExecutionDecision("NONE", False, f"Не удалось получить цену для paper execution: {exc}") @@ -72,13 +73,22 @@ class ExecutionEngine: False, "Позиция не открыта: невозможно рассчитать size без Stop Loss.", ) - + size = self._adjust_size_by_margin_limit( state=state, entry_price=entry_price, size=size, ) + size = self._round_order_size(size) + + if size <= 0: + return ExecutionDecision( + "NONE", + False, + "Позиция не открыта: итоговый size равен 0.", + ) + type(self)._position = PositionState( side=side, symbol=state.symbol, @@ -105,6 +115,7 @@ class ExecutionEngine: "repeat_count": state.last_signal_repeat_count, "reason": state.last_signal_reason, "opened_at": now, + "pricing": "ask_for_long_bid_for_short", } JournalService().log_ui_info( @@ -131,14 +142,14 @@ class ExecutionEngine: return ExecutionDecision("NONE", False, "Нет направления для flip.") try: - ticker = ExchangeService().get_price(state.symbol) - flip_price = ticker.price + exit_price = self._exit_price_for_side(position.symbol or state.symbol, position.side) + new_entry_price = self._entry_price_for_side(state.symbol, new_side) except Exception as exc: return ExecutionDecision("NONE", False, f"Ошибка получения цены для flip: {exc}") now = self._now_time() - pnl = self._calculate_pnl(flip_price) - new_size = self._calculate_position_size(state, entry_price=flip_price) + pnl = self._calculate_pnl(exit_price) + new_size = self._calculate_position_size(state, entry_price=new_entry_price) if new_size <= 0: return ExecutionDecision( @@ -146,13 +157,24 @@ class ExecutionEngine: False, "Flip отменён: невозможно рассчитать size без Stop Loss.", ) - + new_size = self._adjust_size_by_margin_limit( state=state, - entry_price=flip_price, + entry_price=new_entry_price, size=new_size, ) + new_size = self._round_order_size(new_size) + + if new_size <= 0: + return ExecutionDecision( + "NONE", + False, + "Flip отменён: итоговый size равен 0.", + ) + + state.realized_pnl_usd += pnl + old_side = position.side old_entry_price = position.entry_price old_size = position.size @@ -162,7 +184,7 @@ class ExecutionEngine: type(self)._position = PositionState( side=new_side, symbol=state.symbol, - entry_price=flip_price, + entry_price=new_entry_price, size=new_size, leverage=state.leverage, unrealized_pnl_usd=0.0, @@ -180,8 +202,8 @@ class ExecutionEngine: "new_side": new_side, "side": new_side, "entry_price": old_entry_price, - "exit_price": flip_price, - "new_entry_price": flip_price, + "exit_price": exit_price, + "new_entry_price": new_entry_price, "old_size": old_size, "new_size": new_size, "size": new_size, @@ -195,6 +217,7 @@ class ExecutionEngine: "opened_at": old_opened_at, "closed_at": now, "new_opened_at": now, + "pricing": "exit_by_side_then_entry_by_side", } JournalService().log_ui_info( @@ -231,13 +254,14 @@ class ExecutionEngine: exit_price = forced_exit_price else: try: - ticker = ExchangeService().get_price(state.symbol) - exit_price = ticker.price + exit_price = self._exit_price_for_side(position.symbol or state.symbol, position.side) except Exception as exc: return ExecutionDecision("NONE", False, f"Ошибка получения цены для закрытия: {exc}") pnl = forced_pnl if forced_pnl is not None else self._calculate_pnl(exit_price) + state.realized_pnl_usd += pnl + now = self._now_time() payload = { @@ -258,6 +282,7 @@ class ExecutionEngine: "is_forced": forced_reason is not None, "opened_at": position.opened_at, "closed_at": now, + "pricing": "bid_for_long_exit_ask_for_short_exit", } JournalService().log_ui_info( @@ -293,8 +318,7 @@ class ExecutionEngine: return None try: - ticker = ExchangeService().get_price(position.symbol or state.symbol) - current_price = ticker.price + current_price = self._exit_price_for_side(position.symbol or state.symbol, position.side) except Exception: return None @@ -327,34 +351,19 @@ class ExecutionEngine: return None - def _is_stop_loss_hit( - self, - state: AutoTradeState, - price_move_percent: float, - ) -> bool: + def _is_stop_loss_hit(self, state: AutoTradeState, price_move_percent: float) -> bool: if state.stop_loss_percent is None: return False - return price_move_percent <= -abs(state.stop_loss_percent) - def _is_take_profit_hit( - self, - state: AutoTradeState, - price_move_percent: float, - ) -> bool: + def _is_take_profit_hit(self, state: AutoTradeState, price_move_percent: float) -> bool: if state.take_profit_percent is None: return False - return price_move_percent >= abs(state.take_profit_percent) - def _is_max_loss_hit( - self, - state: AutoTradeState, - unrealized_pnl: float, - ) -> bool: + def _is_max_loss_hit(self, state: AutoTradeState, unrealized_pnl: float) -> bool: if state.max_loss_usd is None: return False - return unrealized_pnl <= -abs(state.max_loss_usd) def _calculate_price_move_percent(self, current_price: float) -> float: @@ -371,7 +380,7 @@ class ExecutionEngine: return round(((entry - current_price) / entry) * 100, 4) return 0.0 - + def _should_flip_position(self, state: AutoTradeState) -> bool: position = type(self)._position @@ -403,8 +412,7 @@ class ExecutionEngine: return try: - ticker = ExchangeService().get_price(position.symbol or state.symbol) - current_price = ticker.price + current_price = self._exit_price_for_side(position.symbol or state.symbol, position.side) except Exception: self._sync_state_from_position(state) return @@ -430,15 +438,14 @@ class ExecutionEngine: if price is None: try: - ticker = ExchangeService().get_price(state.symbol) - price = ticker.price + price = self._signal_entry_price(state) except Exception: return 0.0 if price <= 0: return 0.0 - balance_usd = 1000.0 + balance_usd = state.allocated_balance_usd target_risk_usd = balance_usd * (state.risk_percent / 100) stop_loss_distance_usd = price * (state.stop_loss_percent / 100) @@ -446,8 +453,7 @@ class ExecutionEngine: return 0.0 size = target_risk_usd / stop_loss_distance_usd - - return round(size, 8) + return self._round_size(size) def _adjust_size_by_margin_limit( self, @@ -462,26 +468,85 @@ class ExecutionEngine: state.execution_size_adjustment_reason = None if max_percent is None or max_percent <= 0: - return round(size, 8) + return self._round_size(size) 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 + balance_usd = state.allocated_balance_usd 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) + return self._round_size(size) state.execution_size_adjustment_reason = "MARGIN_LIMIT" + return self._round_size(max_size) + + def _signal_entry_price(self, state: AutoTradeState) -> float: + if state.last_signal == "BUY": + return self._entry_price_for_side(state.symbol, "LONG") + + if state.last_signal == "SELL": + return self._entry_price_for_side(state.symbol, "SHORT") + + return self._market_last_price(state.symbol) + + def _entry_price_for_side(self, symbol: str, side: str) -> float: + snapshot = ExchangeService().get_market_snapshot(symbol) + + if side == "LONG": + return self._snapshot_price(snapshot, "ask_price", "last_price") + + if side == "SHORT": + return self._snapshot_price(snapshot, "bid_price", "last_price") + + return self._snapshot_price(snapshot, "last_price") + + def _exit_price_for_side(self, symbol: str, side: str) -> float: + snapshot = ExchangeService().get_market_snapshot(symbol) + + if side == "LONG": + return self._snapshot_price(snapshot, "bid_price", "last_price") + + if side == "SHORT": + return self._snapshot_price(snapshot, "ask_price", "last_price") + + return self._snapshot_price(snapshot, "last_price") + + def _market_last_price(self, symbol: str) -> float: + snapshot = ExchangeService().get_market_snapshot(symbol) + return self._snapshot_price(snapshot, "last_price") + + def _snapshot_price( + self, + snapshot: dict[str, object], + primary_key: str, + fallback_key: str | None = None, + ) -> float: + raw_price = snapshot.get(primary_key) + + if raw_price is None and fallback_key is not None: + raw_price = snapshot.get(fallback_key) + + if raw_price is None: + raise ValueError(f"Market snapshot price '{primary_key}' is missing.") + + price = float(raw_price) + + if price <= 0: + raise ValueError(f"Market snapshot price '{primary_key}' is invalid: {price}") + + return price + + def _round_size(self, size: float) -> float: + factor = 10 ** self._size_precision + return math.floor(float(size) * factor) / factor - return round(max_size, 8) - def _calculate_pnl(self, current_price: float) -> float: position = type(self)._position @@ -504,5 +569,9 @@ class ExecutionEngine: state.position_size = position.size state.unrealized_pnl_usd = position.unrealized_pnl_usd + def _round_order_size(self, value: float) -> float: + factor = 10 ** self._size_precision + return math.floor(float(value) * factor) / factor + def _now_time(self) -> str: return datetime.now().strftime("%H:%M:%S") \ No newline at end of file diff --git a/app/src/trading/strategies/registry.py b/app/src/trading/strategies/registry.py index c861724..13dda73 100644 --- a/app/src/trading/strategies/registry.py +++ b/app/src/trading/strategies/registry.py @@ -4,6 +4,7 @@ from __future__ import annotations from src.trading.strategies.base import BaseStrategy from src.trading.strategies.hold import HoldStrategy +from src.trading.strategies.scalp import ScalpStrategy from src.trading.strategies.trend import TrendStrategy @@ -13,7 +14,7 @@ class StrategyRegistry: "HOLD": HoldStrategy(), "TREND": TrendStrategy(), "GRID": HoldStrategy(), - "SCALP": HoldStrategy(), + "SCALP": ScalpStrategy(), } # получить стратегию по имени diff --git a/app/src/trading/strategies/scalp.py b/app/src/trading/strategies/scalp.py new file mode 100644 index 0000000..d1af93f --- /dev/null +++ b/app/src/trading/strategies/scalp.py @@ -0,0 +1,156 @@ +# app/src/trading/strategies/scalp.py + +from __future__ import annotations + +from src.integrations.exchange.service import ExchangeService +from src.trading.strategies.base import StrategyContext +from src.trading.strategies.signals import SignalResult, SignalType + + +class ScalpStrategy: + name = "SCALP" + + _price_window: dict[str, list[float]] = {} + + # короткое окно = быстрая реакция + _window_size = 4 + + # ниже порог = чувствительнее TREND + _threshold_percent = 0.02 + + # для scalp допускаем чуть больше шума + _min_direction_ratio = 0.55 + + def analyze(self, context: StrategyContext) -> SignalResult: + try: + ticker = ExchangeService().get_price(context.symbol) + except Exception as exc: + return SignalResult( + signal=SignalType.HOLD, + reason="Не удалось получить рыночную цену. Безопасный HOLD.", + confidence=0.0, + payload={ + "strategy": self.name, + "symbol": context.symbol, + "error": str(exc), + }, + ) + + symbol = ticker.symbol + current_price = float(ticker.price) + + prices = self._price_window.setdefault(symbol, []) + prices.append(current_price) + + if len(prices) > self._window_size: + prices.pop(0) + + if len(prices) < self._window_size: + return SignalResult( + signal=SignalType.HOLD, + reason="Недостаточно данных для SCALP.", + confidence=0.0, + payload={ + "strategy": self.name, + "symbol": symbol, + "price": current_price, + "window_size": len(prices), + "required_window_size": self._window_size, + }, + ) + + first_price = prices[0] + last_price = prices[-1] + + if first_price <= 0: + return SignalResult( + signal=SignalType.HOLD, + reason="Некорректная стартовая цена в окне SCALP.", + confidence=0.0, + payload={ + "strategy": self.name, + "symbol": symbol, + "prices": prices, + }, + ) + + change_percent = ((last_price - first_price) / first_price) * 100 + direction_ratio = self._direction_ratio(prices, change_percent) + + payload = { + "strategy": self.name, + "symbol": symbol, + "first_price": first_price, + "current_price": last_price, + "change_percent": round(change_percent, 5), + "direction_ratio": round(direction_ratio, 3), + "window_size": len(prices), + "threshold_percent": self._threshold_percent, + "min_direction_ratio": self._min_direction_ratio, + } + + if ( + change_percent >= self._threshold_percent + and direction_ratio >= self._min_direction_ratio + ): + return SignalResult( + signal=SignalType.BUY, + reason="Быстрый краткосрочный импульс вверх.", + confidence=self._calculate_confidence(change_percent, direction_ratio), + payload=payload, + ) + + if ( + change_percent <= -self._threshold_percent + and direction_ratio >= self._min_direction_ratio + ): + return SignalResult( + signal=SignalType.SELL, + reason="Быстрый краткосрочный импульс вниз.", + confidence=self._calculate_confidence(change_percent, direction_ratio), + payload=payload, + ) + + return SignalResult( + signal=SignalType.HOLD, + reason="SCALP-импульс недостаточно сильный.", + confidence=0.0, + payload=payload, + ) + + def _direction_ratio(self, prices: list[float], change_percent: float) -> float: + if len(prices) < 2: + return 0.0 + + up_moves = 0 + down_moves = 0 + + for previous_price, current_price in zip(prices, prices[1:]): + if current_price > previous_price: + up_moves += 1 + elif current_price < previous_price: + down_moves += 1 + + total_moves = max(1, len(prices) - 1) + + if change_percent >= 0: + return up_moves / total_moves + + return down_moves / total_moves + + def _calculate_confidence( + self, + change_percent: float, + direction_ratio: float, + ) -> float: + strength = abs(change_percent) / self._threshold_percent + + if strength < 1: + return 0.0 + + strength_score = min(1.0, strength / 2) + direction_score = min(1.0, direction_ratio) + + confidence = 0.35 + (strength_score * 0.4) + (direction_score * 0.25) + + return round(min(1.0, confidence), 2) \ No newline at end of file diff --git a/app/src/trading/strategies/trend.py b/app/src/trading/strategies/trend.py index ed28ef0..93eb432 100644 --- a/app/src/trading/strategies/trend.py +++ b/app/src/trading/strategies/trend.py @@ -10,28 +10,24 @@ from src.trading.strategies.signals import SignalResult, SignalType class TrendStrategy: name = "TREND" - _last_prices: dict[str, float] = {} - _threshold_percent = 0.02 + _price_window: dict[str, list[float]] = {} - # рассчитать уверенность сигнала по силе движения цены - def _calculate_confidence(self, change_percent: float) -> float: - strength = abs(change_percent) / self._threshold_percent + # длиннее окно = меньше шума + _window_size = 8 - if strength < 1: - return 0.0 + # общий порог изменения за окно + _threshold_percent = 0.05 - confidence = 0.35 + ((strength - 1) / 2) * 0.65 + # сколько движений внутри окна должно быть в сторону сигнала + _min_direction_ratio = 0.6 - return round(min(1.0, confidence), 2) - - # анализ простого тренда по изменению цены def analyze(self, context: StrategyContext) -> SignalResult: try: - ticker = ExchangeService().get_price(context.symbol) + snapshot = ExchangeService().get_market_snapshot(context.symbol) except Exception as exc: return SignalResult( signal=SignalType.HOLD, - reason="Не удалось получить рыночную цену. Безопасный HOLD.", + reason="Не удалось получить рыночный snapshot. Безопасный HOLD.", confidence=0.0, payload={ "strategy": self.name, @@ -40,63 +36,159 @@ class TrendStrategy: }, ) - symbol = ticker.symbol - current_price = ticker.price - previous_price = self._last_prices.get(symbol) + symbol = str(snapshot.get("symbol") or context.symbol) + current_price = self._analysis_price(snapshot) - self._last_prices[symbol] = current_price - - if previous_price is None or previous_price <= 0: + if current_price <= 0: return SignalResult( signal=SignalType.HOLD, - reason="Недостаточно данных для определения тренда.", + reason="Некорректная рыночная цена. Безопасный HOLD.", + confidence=0.0, + payload={ + "strategy": self.name, + "symbol": symbol, + "snapshot": snapshot, + }, + ) + + prices = self._price_window.setdefault(symbol, []) + prices.append(current_price) + + if len(prices) > self._window_size: + prices.pop(0) + + if len(prices) < self._window_size: + return SignalResult( + signal=SignalType.HOLD, + reason="Недостаточно данных для анализа тренда.", confidence=0.0, payload={ "strategy": self.name, "symbol": symbol, "price": current_price, + "window_size": len(prices), + "required_window_size": self._window_size, }, ) - change_percent = ((current_price - previous_price) / previous_price) * 100 + first_price = prices[0] + last_price = prices[-1] - if change_percent >= self._threshold_percent: + if first_price <= 0: + return SignalResult( + signal=SignalType.HOLD, + reason="Некорректная стартовая цена в окне.", + confidence=0.0, + payload={ + "strategy": self.name, + "symbol": symbol, + "prices": prices, + }, + ) + + change_percent = ((last_price - first_price) / first_price) * 100 + direction_ratio = self._direction_ratio(prices, change_percent) + + payload = { + "strategy": self.name, + "symbol": symbol, + "analysis_price": last_price, + "first_price": first_price, + "current_price": last_price, + "last_price": snapshot.get("last_price"), + "bid_price": snapshot.get("bid_price"), + "ask_price": snapshot.get("ask_price"), + "change_percent": round(change_percent, 5), + "direction_ratio": round(direction_ratio, 3), + "window_size": len(prices), + "threshold_percent": self._threshold_percent, + "min_direction_ratio": self._min_direction_ratio, + } + + if ( + change_percent >= self._threshold_percent + and direction_ratio >= self._min_direction_ratio + ): return SignalResult( signal=SignalType.BUY, - reason="Цена растёт выше порога тренда.", - confidence=self._calculate_confidence(change_percent), - payload={ - "strategy": self.name, - "symbol": symbol, - "previous_price": previous_price, - "current_price": current_price, - "change_percent": round(change_percent, 5), - }, + reason="Устойчивый рост цены в окне TREND.", + confidence=self._calculate_confidence(change_percent, direction_ratio), + payload=payload, ) - if change_percent <= -self._threshold_percent: + if ( + change_percent <= -self._threshold_percent + and direction_ratio >= self._min_direction_ratio + ): return SignalResult( signal=SignalType.SELL, - reason="Цена падает ниже порога тренда.", - confidence=self._calculate_confidence(change_percent), - payload={ - "strategy": self.name, - "symbol": symbol, - "previous_price": previous_price, - "current_price": current_price, - "change_percent": round(change_percent, 5), - }, + reason="Устойчивое снижение цены в окне TREND.", + confidence=self._calculate_confidence(change_percent, direction_ratio), + payload=payload, ) return SignalResult( signal=SignalType.HOLD, - reason="Изменение цены ниже порога тренда.", + reason="Тренд недостаточно устойчивый.", confidence=0.0, - payload={ - "strategy": self.name, - "symbol": symbol, - "previous_price": previous_price, - "current_price": current_price, - "change_percent": round(change_percent, 5), - }, - ) \ No newline at end of file + payload=payload, + ) + + def _analysis_price(self, snapshot: dict[str, object]) -> float: + bid = self._safe_float(snapshot.get("bid_price")) + ask = self._safe_float(snapshot.get("ask_price")) + + if bid is not None and ask is not None and bid > 0 and ask > 0: + return (bid + ask) / 2 + + last = self._safe_float(snapshot.get("last_price")) + if last is not None: + return last + + return 0.0 + + def _safe_float(self, value: object) -> float | None: + if value is None: + return None + + try: + return float(value) + except (TypeError, ValueError): + return None + + def _direction_ratio(self, prices: list[float], change_percent: float) -> float: + if len(prices) < 2: + return 0.0 + + up_moves = 0 + down_moves = 0 + + for previous_price, current_price in zip(prices, prices[1:]): + if current_price > previous_price: + up_moves += 1 + elif current_price < previous_price: + down_moves += 1 + + total_moves = max(1, len(prices) - 1) + + if change_percent >= 0: + return up_moves / total_moves + + return down_moves / total_moves + + def _calculate_confidence( + self, + change_percent: float, + direction_ratio: float, + ) -> float: + strength = abs(change_percent) / self._threshold_percent + + if strength < 1: + return 0.0 + + strength_score = min(1.0, strength / 3) + direction_score = min(1.0, direction_ratio) + + confidence = 0.3 + (strength_score * 0.4) + (direction_score * 0.3) + + return round(min(1.0, confidence), 2) \ No newline at end of file diff --git a/docs/roadmap/master-roadmap.md b/docs/roadmap/master-roadmap.md index c24a6f8..614a349 100644 --- a/docs/roadmap/master-roadmap.md +++ b/docs/roadmap/master-roadmap.md @@ -242,6 +242,30 @@ - risk_percent теперь реально влияет на размер позиции - flip теперь проходит через margin protection +#### 07.4.3.14 — Auto UI, Realistic Pricing & Debug Live Tools ✅ +- redesigned RUNNING auto-trading UI +- HOLD / BUY / SELL / READY state separation +- compact signal rendering with real duration +- confidence hidden for HOLD state +- direction-aware LONG / SHORT UI blocks +- compact active position rendering +- removed zero-value UI noise without position +- realistic bid / ask pricing in auto UI +- realistic bid / ask execution pricing +- TREND strategy switched to mid-price analysis +- corrected own funds / margin calculations +- safer size rounding for margin protection +- signal_started_at support for real-time duration tracking +- improved auto screen refresh handling +- live UI refresh diagnostics in AutoTradeRunner +- new debug UI-state commands +- new paper execution debug commands +- automatic flip direction detection +- live paper execution monitoring commands +- integration testing flow for SL / TP / ML +- integration testing flow for execution alerts +- preparation for isolated debug runtime architecture + ### 07.4.4 ⏳ Grid Strategy diff --git a/docs/roadmap/stage-07-auto-trading-roadmap.md b/docs/roadmap/stage-07-auto-trading-roadmap.md index f9ec6e3..124ae3e 100644 --- a/docs/roadmap/stage-07-auto-trading-roadmap.md +++ b/docs/roadmap/stage-07-auto-trading-roadmap.md @@ -228,6 +228,31 @@ - risk_percent теперь реально влияет на размер позиции - flip теперь проходит через margin protection +#### 07.4.3.14 — Auto UI, Realistic Pricing & Debug Live Tools ✅ + +- redesigned RUNNING auto-trading UI +- HOLD / BUY / SELL / READY state separation +- compact signal rendering with real duration +- confidence hidden for HOLD state +- direction-aware LONG / SHORT UI blocks +- compact active position rendering +- removed zero-value UI noise without position +- realistic bid / ask pricing in auto UI +- realistic bid / ask execution pricing +- TREND strategy switched to mid-price analysis +- corrected own funds / margin calculations +- safer size rounding for margin protection +- signal_started_at support for real-time duration tracking +- improved auto screen refresh handling +- live UI refresh diagnostics in AutoTradeRunner +- new debug UI-state commands +- new paper execution debug commands +- automatic flip direction detection +- live paper execution monitoring commands +- integration testing flow for SL / TP / ML +- integration testing flow for execution alerts +- preparation for isolated debug runtime architecture + --- ### 07.4.4 diff --git a/docs/stages/stage-07_4_3_14-auto_ui_realistic_pricing_and_debug_live_tools.md b/docs/stages/stage-07_4_3_14-auto_ui_realistic_pricing_and_debug_live_tools.md new file mode 100644 index 0000000..6b15e40 --- /dev/null +++ b/docs/stages/stage-07_4_3_14-auto_ui_realistic_pricing_and_debug_live_tools.md @@ -0,0 +1,343 @@ +# 07.4.3.14 — Auto Trading UI, Realistic Pricing & Debug Live Tools + +## Цель + +Привести экран автоторговли к более понятному trading-terminal формату и сделать расчёты paper execution ближе к реальности: + +- обновить UI блока автоторговли +- разделить состояния HOLD / BUY / SELL / READY +- использовать bid / ask цены для входа и выхода +- улучшить отображение позиции +- добавить live debug-команды для проверки paper execution +- подготовить основу для последующей изоляции debug-режима + +--- + +## Что было раньше + +Ранее экран `Автоторговля — Работает` показывал технические данные: + +- `HOLD ×N` +- `Зарезервировано · $ 0` +- `P&L · $ 0` +- confidence даже для HOLD +- расчёты от одной цены `lastPrice` + +Это создавало лишний шум и не отражало реальную механику исполнения. + +Также debug-команды смешивали UI-проверку и execution-проверку. + +--- + +## Что реализовано + +## 1. Новый формат экрана RUNNING + +Для состояния без позиции экран стал компактнее. + +Было: + +```text +📡 Ожидание сигнала +🟡 HOLD ×18 +Уверенность · 0.00 +``` + +Стало: + +```text +Сигнал 🟡 HOLD · 5м 35с +``` + +Для HOLD больше не отображается confidence, так как он не несёт торгового смысла. + +--- + +## 2. BUY / SELL signal UI + +Для BUY: + +```text +Сигнал 🟢 BUY · 12с +Уверенность · 0.74 +``` + +После подтверждения: + +```text +Сигнал 🟢 BUY · READY +Уверенность · 0.88 +``` + +Для SELL: + +```text +Сигнал 🔴 SELL · 9с +Уверенность · 0.71 +``` + +После подтверждения: + +```text +Сигнал 🔴 SELL · READY +Уверенность · 0.91 +``` + +--- + +## 3. Direction-aware order block + +Для HOLD: + +```text +BTC · Trend · x2 +Цена · $ ... +``` + +Для BUY: + +```text +🟢 BTC · Trend · LONG x2 +Цена входа · $ ... +``` + +Для SELL: + +```text +🔴 BTC · Trend · SHORT x2 +Цена входа · $ ... +``` + +--- + +## 4. Убран UI-шум без открытой позиции + +Если позиция не открыта, экран больше не показывает: + +```text +Зарезервировано · $ 0 +P&L · $ 0 +``` + +Эти строки остаются только для активной позиции. + +--- + +## 5. Realistic bid / ask pricing в UI + +UI теперь использует market snapshot: + +```python +ExchangeService().get_market_snapshot(symbol) +``` + +Правила: + +```text +HOLD → last_price +BUY / LONG → ask_price +SELL / SHORT → bid_price +``` + +Если bid / ask недоступны, используется fallback на `last_price`. + +--- + +## 6. TREND strategy переведена на mid price + +Стратегия TREND теперь анализирует рынок по: + +```text +mid_price = (bid_price + ask_price) / 2 +``` + +Fallback: + +```text +last_price +``` + +Это делает сигнал более стабильным и не искажает анализ spread-ом. + +--- + +## 7. Realistic paper execution pricing + +ExecutionEngine теперь использует более реалистичные цены: + +```text +LONG entry → ask_price +SHORT entry → bid_price +LONG exit → bid_price +SHORT exit → ask_price +``` + +Flip теперь состоит из: + +```text +exit текущей позиции по стороне ++ +entry новой позиции по стороне +``` + +--- + +## 8. Own funds / margin calculation fix + +Исправлен расчёт собственных средств: + +```text +own_funds = position_notional / leverage +``` + +А не через статичный процент от allocated balance. + +Также size округляется вниз, чтобы reserved margin не превышал лимит. + +--- + +## 9. Signal duration + +Добавлена поддержка реального времени удержания сигнала через: + +```python +signal_started_at +``` + +Если timestamp недоступен, используется fallback: + +```text +last_signal_repeat_count × 5 sec +``` + +--- + +## 10. Debug UI states + +Добавлены команды для проверки UI-состояний: + +```text +/debug_auto hold 335 +/debug_auto buy 12 0.74 +/debug_auto buy_ready 0.88 +/debug_auto sell 9 0.71 +/debug_auto sell_ready 0.91 +/debug_auto long +/debug_auto short +/debug_auto reset +/debug_auto state +``` + +--- + +## 11. Debug paper execution + +Добавлены команды разового paper execution: + +```text +/debug_exec buy +/debug_exec sell +/debug_exec flip +/debug_exec flip_buy +/debug_exec flip_sell +/debug_exec close +/debug_exec state +``` + +`/debug_exec flip` автоматически определяет текущую позицию: + +```text +LONG → SELL / SHORT +SHORT → BUY / LONG +``` + +--- + +## 12. Debug live paper test + +Добавлен live debug режим: + +```text +/debug_live buy +/debug_live sell +/debug_live flip +/debug_live close +/debug_live stop +/debug_live state +``` + +Этот режим открывает paper-позицию и запускает обычный AutoTradeRunner, чтобы проверить: + +- мониторинг рынка +- обновление UI +- P&L +- SL / TP / ML +- flip по стратегии +- execution alerts + +--- + +## 13. Auto screen runner diagnostics + +В debug state добавлена диагностика AutoTradeRunner: + +```text +Screen +Chat ID +Message ID +Has bot +Has render_text +Task running +``` + +Это помогает быстро понять, зарегистрирован ли экран автоторговли для live refresh. + +--- + +## Архитектурный результат + +Теперь: + +- экран RUNNING стал компактнее и понятнее +- HOLD не перегружает UI +- BUY / SELL отображают направление LONG / SHORT +- расчёты preview используют bid / ask +- strategy анализирует mid price +- paper execution использует bid / ask для входа и выхода +- active position screen обновляется live +- debug-команды позволяют тестировать UI и execution быстрее + +--- + +## Ограничения текущей реализации + +Текущий debug live режим пока не изолирован. + +Он вмешивается в реальные runtime-компоненты: + +```text +AutoTradeState +ExecutionEngine._position +AutoTradeRunner +EventBus +Auto screen +``` + +Это удобно для integration testing, но не является полноценным sandbox. + +--- + +## Следующий этап + +07.4.3.15 — Isolated Debug Runtime + +Планируется: + +- отделить debug runtime от обычной автоторговли +- создать отдельный DebugAutoState +- создать отдельный DebugRunner +- создать отдельный DebugExecutionEngine +- создать отдельный debug screen +- исключить влияние debug-команд на AutoTradeService +- маркировать debug-логи и уведомления как `[DEBUG]` +- оставить `/debug_live` только как временный legacy integration test