From 71cf206e3275b5d8f1e32c48964a4c4f8ba5d608 Mon Sep 17 00:00:00 2001 From: Sergey Date: Sat, 9 May 2026 09:17:34 +0300 Subject: [PATCH] =?UTF-8?q?Stage=2007.4.3.15=20=E2=80=94=20Isolated=20debu?= =?UTF-8?q?g=20runtime=20and=20debug=20auto=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/integrations/exchange/service.py | 57 ++ app/src/telegram/handlers/debug.py | 804 +++++++----------- app/src/telegram/handlers/debug_auto/main.py | 161 ++++ app/src/telegram/handlers/debug_auto/ui.py | 228 +++++ app/src/telegram/routers.py | 4 +- app/src/trading/debug/__init__.py | 1 + app/src/trading/debug/execution.py | 443 ++++++++++ app/src/trading/debug/runner.py | 170 ++++ app/src/trading/debug/service.py | 219 +++++ app/src/trading/debug/state.py | 57 ++ docs/roadmap/master-roadmap.md | 24 + docs/roadmap/stage-07-auto-trading-roadmap.md | 23 + .../stage-07_4_3_15-isolated_debug_runtime.md | 176 ++++ 13 files changed, 1875 insertions(+), 492 deletions(-) create mode 100644 app/src/telegram/handlers/debug_auto/main.py create mode 100644 app/src/telegram/handlers/debug_auto/ui.py create mode 100644 app/src/trading/debug/__init__.py create mode 100644 app/src/trading/debug/execution.py create mode 100644 app/src/trading/debug/runner.py create mode 100644 app/src/trading/debug/service.py create mode 100644 app/src/trading/debug/state.py create mode 100644 docs/stages/stage-07_4_3_15-isolated_debug_runtime.md diff --git a/app/src/integrations/exchange/service.py b/app/src/integrations/exchange/service.py index 0f9b348..22857ef 100644 --- a/app/src/integrations/exchange/service.py +++ b/app/src/integrations/exchange/service.py @@ -312,6 +312,63 @@ class ExchangeService: "updated_at": self._format_exchange_time(close_time), } + # получить свежий market snapshot напрямую через REST, без WebSocket cache + def get_fresh_market_snapshot(self, symbol: str | None = None) -> dict[str, object]: + symbol_to_use = symbol or self.settings.default_symbol + + if not self.settings.exchange_enabled: + ticker = mock_ticker_price(symbol_to_use) + return { + "symbol": ticker.symbol, + "last_price": ticker.price, + "bid_price": ticker.price, + "ask_price": ticker.price, + "updated_at": ticker.updated_at, + "source": "mock", + } + + validation = self.validate_symbol(symbol_to_use) + if not validation.is_valid: + raise ExchangeError(validation.message) + + client = ExchangeRestClient() + + try: + payload = client.get_json( + "/api/v2/ticker/24hr", + params={"symbol": validation.normalized_symbol}, + ) + except Exception as exc: + self._log_exchange_error( + endpoint="ticker/24hr", + exc=exc, + symbol=validation.normalized_symbol, + ) + raise ExchangeError(str(exc)) from exc + + last_raw = payload.get("lastPrice") + if last_raw is None: + exc = ExchangeError("Field 'lastPrice' is missing in ticker response.") + self._log_exchange_error( + endpoint="ticker/24hr", + exc=exc, + symbol=validation.normalized_symbol, + ) + raise exc + + bid_raw = payload.get("bidPrice") or last_raw + ask_raw = payload.get("askPrice") or last_raw + close_time = payload.get("closeTime") or payload.get("eventTime") or "" + + return { + "symbol": validation.normalized_symbol, + "last_price": float(last_raw), + "bid_price": float(bid_raw), + "ask_price": float(ask_raw), + "updated_at": self._format_exchange_time(close_time), + "source": "fresh_rest", + } + def get_balance_summary(self) -> list[BalanceSummary]: if not self.settings.exchange_enabled: return mock_balance_summary() diff --git a/app/src/telegram/handlers/debug.py b/app/src/telegram/handlers/debug.py index 8142cf5..8f0a6ff 100644 --- a/app/src/telegram/handlers/debug.py +++ b/app/src/telegram/handlers/debug.py @@ -3,19 +3,15 @@ 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 +from src.trading.debug.execution import DebugExecutionEngine +from src.trading.debug.service import DebugTradeService +from src.trading.debug.state import DebugTradeState +from src.trading.execution.models import ExecutionDecision router = Router(name="debug") @@ -28,7 +24,8 @@ def _debug_enabled() -> bool: def _debug_help_text() -> str: return ( "🧪 Debug commands\n\n" - "Auto UI states:\n" + "Isolated Debug Runtime:\n" + "/debug_auto reset\n" "/debug_auto off\n" "/debug_auto hold 335\n" "/debug_auto buy 12 0.74\n" @@ -37,29 +34,23 @@ def _debug_help_text() -> str: "/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" + "Isolated Debug Execution:\n" + "/debug_exec buy — открыть DEBUG LONG\n" + "/debug_exec sell — открыть DEBUG SHORT\n" + "/debug_exec flip — перевернуть DEBUG позицию\n" + "/debug_exec close — закрыть DEBUG позицию\n" + "/debug_exec process — один цикл DEBUG execution\n" + "/debug_exec update — обновить DEBUG PnL\n" + "/debug_exec state — состояние DEBUG runtime\n\n" + "Legacy aliases:\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" + "/debug_state\n\n" + "⚠️ Все команды работают в изолированном [DEBUG] runtime " + "и не меняют обычную автоторговлю." ) @@ -81,137 +72,122 @@ async def debug_auto(message: Message) -> None: parts = (message.text or "").split() command = parts[1].lower() if len(parts) > 1 else "help" - service = AutoTradeService() - state = service.get_state() + service = DebugTradeService() 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") + if command == "reset": + state = service.reset() + await message.answer( + "✅ [DEBUG] Runtime reset\n\n" + f"{_debug_state_text(state)}" + ) 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") + if command == "off": + state = service.stop() + await message.answer( + "✅ [DEBUG] Runtime stopped\n\n" + f"{_debug_state_text(state)}" + ) return if command == "state": - _sync_state_from_position(state) + state = service.get_state() + service.update_market() 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, + state = service.set_signal_duration( signal="HOLD", seconds=seconds, confidence=0.0, - decision_status="WAITING", - ready=False, + force_ready=False, + ) + await message.answer( + f"✅ [DEBUG] HOLD {seconds}s\n\n" + f"{_debug_state_text(state)}" ) - 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, + + state = service.set_signal_duration( signal="BUY", seconds=seconds, confidence=confidence, - decision_status="CONFIRMING", - ready=False, + force_ready=False, + ) + + await message.answer( + f"✅ [DEBUG] BUY {seconds}s confidence={confidence:.2f}\n\n" + f"{_debug_state_text(state)}" ) - 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, + + state = service.set_signal_duration( signal="BUY", seconds=15, confidence=confidence, - decision_status="READY", - ready=True, + force_ready=True, + ) + + await message.answer( + f"✅ [DEBUG] BUY READY confidence={confidence:.2f}\n\n" + f"{_debug_state_text(state)}" ) - 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, + + state = service.set_signal_duration( signal="SELL", seconds=seconds, confidence=confidence, - decision_status="CONFIRMING", - ready=False, + force_ready=False, + ) + + await message.answer( + f"✅ [DEBUG] SELL {seconds}s confidence={confidence:.2f}\n\n" + f"{_debug_state_text(state)}" ) - 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, + + state = service.set_signal_duration( signal="SELL", seconds=15, confidence=confidence, - decision_status="READY", - ready=True, + force_ready=True, + ) + + await message.answer( + f"✅ [DEBUG] SELL READY confidence={confidence:.2f}\n\n" + f"{_debug_state_text(state)}" ) - 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") + state, result = service.open_long() + await message.answer(_execution_result_text("OPEN LONG", state, result)) return if command == "short": - _set_debug_position(state=state, side="SHORT") - await _refresh_auto_screen() - await message.answer("✅ Debug Auto: active SHORT position") + state, result = service.open_short() + await message.answer(_execution_result_text("OPEN SHORT", state, result)) return await message.answer(f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}") @@ -226,72 +202,49 @@ async def debug_exec(message: Message) -> None: parts = (message.text or "").split() command = parts[1].lower() if len(parts) > 1 else "help" - service = AutoTradeService() - state = service.get_state() - engine = ExecutionEngine() + service = DebugTradeService() if command in {"help", "-h", "--help"}: await message.answer(_debug_help_text()) return if command == "state": - _sync_state_from_position(state) + state = service.get_state() + service.update_market() 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)) + state, result = service.open_long() + await message.answer(_execution_result_text("EXEC BUY / LONG", state, result)) 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)) + state, result = service.open_short() + await message.answer(_execution_result_text("EXEC SELL / SHORT", state, result)) 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)) + state, result = service.flip() + await message.answer(_execution_result_text("EXEC AUTO FLIP", state, result)) 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)) + state, result = service.close(reason="DEBUG_CLOSE") + await message.answer(_execution_result_text("EXEC CLOSE", state, result)) + return + + if command == "process": + state, result = service.process() + await message.answer(_execution_result_text("EXEC PROCESS", state, result)) + return + + if command == "update": + state = service.update_market() + await message.answer( + "✅ [DEBUG] Market update\n\n" + f"{_debug_state_text(state)}" + ) return await message.answer(f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}") @@ -303,285 +256,132 @@ async def debug_live(message: Message) -> None: await message.answer("Debug mode выключен.") return - parts = (message.text or "").split() - command = parts[1].lower() if len(parts) > 1 else "help" + await message.answer( + "⚠️ /debug_live отключён в рамках развязки debug runtime.\n\n" + "Теперь debug больше не вмешивается в обычную автоторговлю.\n" + "Используйте:\n\n" + "/debug_exec buy\n" + "/debug_exec sell\n" + "/debug_exec flip\n" + "/debug_exec close\n\n" + "Live-мониторинг для изолированного debug будет добавлен в следующем пакете " + "через отдельный DebugTradeRunner и отдельный Debug Auto экран." + ) - service = AutoTradeService() + +@router.message(F.text.startswith("/debug_signal")) +async def debug_signal(message: Message) -> None: + if not _debug_enabled(): + await message.answer("Debug mode выключен.") + return + + signal, confidence, repeat_count, error = _parse_debug_signal_args(message.text) + + if error is not None: + await message.answer(f"⛔️ {error}\n\n{_debug_help_text()}") + return + + service = DebugTradeService() + state = service.set_signal( + signal=signal, + confidence=confidence, + repeat_count=repeat_count, + reason=f"[DEBUG] LEGACY FORCE {signal} {confidence:.2f} ×{repeat_count}", + force_ready=signal in {"BUY", "SELL"} and repeat_count >= 2, + ) + + await message.answer( + "✅ [DEBUG] Legacy signal forced\n\n" + f"{_debug_state_text(state)}" + ) + + +@router.message(F.text == "/debug_ready") +async def debug_ready(message: Message) -> None: + if not _debug_enabled(): + await message.answer("Debug mode выключен.") + return + + service = DebugTradeService() + state = service.set_signal_duration( + signal="BUY", + seconds=15, + confidence=0.95, + force_ready=True, + ) + + await message.answer( + "✅ [DEBUG] Legacy READY created\n\n" + f"{_debug_state_text(state)}" + ) + + +@router.message(F.text == "/debug_state") +async def debug_state(message: Message) -> None: + if not _debug_enabled(): + await message.answer("Debug mode выключен.") + return + + service = DebugTradeService() state = service.get_state() - engine = ExecutionEngine() + service.update_market() - 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()}") + await message.answer(_debug_state_text(state)) -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 _debug_state_text(state: DebugTradeState) -> str: + position = state.position - -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) + duration = _signal_duration_text(state) + pnl = position.unrealized_pnl_usd 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" + "[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"Leverage: {state.leverage}\n" + f"Allocated: $ {_format_money_compact(state.allocated_balance_usd)}\n" + f"Realized PnL: {_format_signed_usd(state.realized_pnl_usd)}\n\n" + f"Signal\n" + f"Signal: {_signal_icon(state.last_signal)} {state.last_signal}\n" + f"Duration: {duration}\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"Reason: {state.last_signal_reason or '—'}\n\n" + f"Risk\n" + f"SL: {_format_percent(state.stop_loss_percent)}\n" + f"TP: {_format_percent(state.take_profit_percent)}\n" + f"ML: {_format_usd_or_off(state.max_loss_usd)}\n" + f"Max Reserved: {_format_percent(state.max_reserved_balance_percent)}\n" + f"Block: {state.execution_block_reason or '—'}\n" + f"Adjustment: {state.execution_size_adjustment_reason or '—'}\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}" + f"Side: {position.side}\n" + f"Entry: {_format_usd_or_dash(position.entry_price)}\n" + f"Size: {_format_crypto_size(position.size)}\n" + f"Leverage: {_format_leverage(position.leverage)}\n" + f"PnL: {_format_signed_usd(pnl)}\n" + f"Opened: {position.opened_at or '—'}\n" + f"Updated: {position.updated_at or '—'}\n\n" + "Runtime: isolated [DEBUG]" + ) + + +def _execution_result_text( + title: str, + state: DebugTradeState, + result: ExecutionDecision, +) -> str: + 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"{_debug_state_text(state)}" ) @@ -611,91 +411,113 @@ def _parse_debug_signal_args(raw_text: str | None) -> tuple[str, float, int, str return signal, confidence, repeat_count, None -@router.message(F.text.startswith("/debug_signal")) -async def debug_signal(message: Message) -> None: - if not _debug_enabled(): - await message.answer("Debug mode выключен.") - return - - signal, confidence, repeat_count, error = _parse_debug_signal_args(message.text) - - if error is not None: - await message.answer(f"⛔️ {error}\n\n{_debug_help_text()}") - return - - service = AutoTradeService() - state = service.debug_force_signal( - signal=signal, - confidence=confidence, - repeat_count=repeat_count, - reason=f"DEBUG FORCE {signal} {confidence:.2f} ×{repeat_count}", - ) - - if state.status == "OFF": - state.status = "RUNNING" - - _set_signal_started_at(state) - await _refresh_auto_screen() - - JournalService().log_ui_info( - event_type="debug_signal_forced", - message=f"Debug-сигнал принудительно установлен: {signal}.", - screen="debug", - action="debug_signal", - user_id=message.from_user.id if message.from_user else None, - chat_id=message.chat.id, - payload={ - "signal": state.last_signal, - "decision_status": state.decision_status, - "confidence": state.last_signal_confidence, - "repeat_count": state.last_signal_repeat_count, - }, - ) - - await message.answer( - "✅ Debug signal forced\n\n" - 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}" - ) +def _parse_int(parts: list[str], *, index: int, default: int) -> int: + try: + return int(parts[index]) + except (IndexError, TypeError, ValueError): + return default -@router.message(F.text == "/debug_ready") -async def debug_ready(message: Message) -> None: - if not _debug_enabled(): - await message.answer("Debug mode выключен.") - return - - service = AutoTradeService() - state = service.get_state() - - _clear_debug_position(state) - _set_signal_state( - state=state, - signal="BUY", - seconds=15, - confidence=0.95, - decision_status="READY", - ready=True, - ) - - await _refresh_auto_screen() - - await message.answer( - "✅ Debug READY создан\n\n" - f"Signal: {state.last_signal}\n" - f"Decision: {state.decision_status}\n" - f"Confidence: {state.last_signal_confidence:.2f}" - ) +def _parse_float(parts: list[str], *, index: int, default: float) -> float: + try: + return float(parts[index]) + except (IndexError, TypeError, ValueError): + return default -@router.message(F.text == "/debug_state") -async def debug_state(message: Message) -> None: - if not _debug_enabled(): - await message.answer("Debug mode выключен.") - return +def _signal_duration_text(state: DebugTradeState) -> str: + started_at = state.signal_started_at - state = AutoTradeService().get_state() - _sync_state_from_position(state) - await message.answer(_debug_state_text(state)) \ No newline at end of file + if started_at is not None: + total_seconds = max(0, int(__import__("time").monotonic() - float(started_at))) + else: + total_seconds = max(0, (state.last_signal_repeat_count or 0) * 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 _signal_icon(signal: str | None) -> str: + mapping = { + "BUY": "🟢", + "SELL": "🔴", + "HOLD": "🟡", + } + return mapping.get(signal or "", "⚪") + + +def _format_leverage(value: float | int | None) -> str: + if value is None: + return "x—" + + return f"x{float(value):g}" + + +def _format_crypto_size(value: float | int | None) -> str: + if value is None: + return "—" + + number = float(value) + return f"{number:.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_compact(value: float | int | None) -> str: + if value is None: + return "—" + + number = float(value) + + if abs(number - round(number)) < 1e-9: + return f"{number:,.0f}".replace(",", " ") + + return f"{number:,.2f}".replace(",", " ").rstrip("0").rstrip(".") + + +def _format_usd_or_dash(value: float | int | None) -> str: + if value is None: + return "—" + + return f"$ {_format_money_compact(value)}" + + +def _format_usd_or_off(value: float | int | None) -> str: + if value is None: + return "off" + + return f"$ {_format_money_compact(value)}" + + +def _format_signed_usd(value: float | int | None) -> str: + if value is None: + return "—" + + amount = float(value) + + if amount > 0: + return f"🟢 +$ {_format_money_compact(amount)}" + + if amount < 0: + return f"🔴 −$ {_format_money_compact(abs(amount))}" + + return "$ 0" \ No newline at end of file diff --git a/app/src/telegram/handlers/debug_auto/main.py b/app/src/telegram/handlers/debug_auto/main.py new file mode 100644 index 0000000..5659b3d --- /dev/null +++ b/app/src/telegram/handlers/debug_auto/main.py @@ -0,0 +1,161 @@ +# app/src/telegram/handlers/debug_auto/main.py + +from __future__ import annotations + +from aiogram import F, Router +from aiogram.exceptions import TelegramBadRequest +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message + +from src.telegram.handlers.debug_auto.ui import ( + build_debug_auto_text, + debug_auto_keyboard, +) +from src.trading.debug.runner import DebugTradeRunner +from src.trading.debug.service import DebugTradeService + + +router = Router(name="debug_auto") + + +async def render_debug_auto_screen( + target_message: Message, + *, + edit_mode: bool, +) -> None: + text = build_debug_auto_text() + + if edit_mode: + try: + await target_message.edit_text( + text, + reply_markup=debug_auto_keyboard(), + ) + except TelegramBadRequest as exc: + if "message is not modified" not in str(exc).lower(): + raise + + DebugTradeRunner.register_screen( + bot=target_message.bot, + chat_id=target_message.chat.id, + message_id=target_message.message_id, + render_text=build_debug_auto_text, + render_markup=debug_auto_keyboard, + ) + return + + sent_message = await target_message.answer( + text, + reply_markup=debug_auto_keyboard(), + ) + + DebugTradeRunner.register_screen( + bot=sent_message.bot, + chat_id=sent_message.chat.id, + message_id=sent_message.message_id, + render_text=build_debug_auto_text, + render_markup=debug_auto_keyboard, + ) + + +@router.message(F.text.in_({"🧪 Debug Auto", "Debug Auto", "/debug_auto_screen"})) +async def open_debug_auto(message: Message, state: FSMContext) -> None: + await state.clear() + + DebugTradeRunner.set_current_screen("debug_auto") + + await DebugTradeRunner.delete_registered_screen( + bot=message.bot, + chat_id=message.chat.id, + ) + + await render_debug_auto_screen(message, edit_mode=False) + + +@router.callback_query(F.data == "debug_auto:start") +async def debug_auto_start(callback: CallbackQuery) -> None: + service = DebugTradeService() + state = service.get_state() + state.status = "RUNNING" + + DebugTradeRunner.set_current_screen("debug_auto") + DebugTradeRunner.start() + + if callback.message is not None: + await render_debug_auto_screen(callback.message, edit_mode=True) + + await callback.answer("[DEBUG] Мониторинг запущен.") + + +@router.callback_query(F.data == "debug_auto:stop") +async def debug_auto_stop(callback: CallbackQuery) -> None: + DebugTradeRunner.stop() + DebugTradeService().stop() + + if callback.message is not None: + await render_debug_auto_screen(callback.message, edit_mode=True) + + await callback.answer("[DEBUG] Мониторинг остановлен.") + + +@router.callback_query(F.data == "debug_auto:long") +async def debug_auto_long(callback: CallbackQuery) -> None: + service = DebugTradeService() + _, result = service.open_long() + + DebugTradeRunner.set_current_screen("debug_auto") + DebugTradeRunner.start() + + if callback.message is not None: + await render_debug_auto_screen(callback.message, edit_mode=True) + + await callback.answer(result.reason, show_alert=False) + + +@router.callback_query(F.data == "debug_auto:short") +async def debug_auto_short(callback: CallbackQuery) -> None: + service = DebugTradeService() + _, result = service.open_short() + + DebugTradeRunner.set_current_screen("debug_auto") + DebugTradeRunner.start() + + if callback.message is not None: + await render_debug_auto_screen(callback.message, edit_mode=True) + + await callback.answer(result.reason, show_alert=False) + + +@router.callback_query(F.data == "debug_auto:flip") +async def debug_auto_flip(callback: CallbackQuery) -> None: + service = DebugTradeService() + _, result = service.flip() + + DebugTradeRunner.set_current_screen("debug_auto") + DebugTradeRunner.start() + + if callback.message is not None: + await render_debug_auto_screen(callback.message, edit_mode=True) + + await callback.answer(result.reason, show_alert=False) + + +@router.callback_query(F.data == "debug_auto:close") +async def debug_auto_close(callback: CallbackQuery) -> None: + service = DebugTradeService() + _, result = service.close(reason="DEBUG_SCREEN_CLOSE") + + if callback.message is not None: + await render_debug_auto_screen(callback.message, edit_mode=True) + + await callback.answer(result.reason, show_alert=False) + + +@router.callback_query(F.data == "debug_auto:reset") +async def debug_auto_reset(callback: CallbackQuery) -> None: + DebugTradeService().reset() + + if callback.message is not None: + await render_debug_auto_screen(callback.message, edit_mode=True) + + await callback.answer("[DEBUG] Runtime reset.") \ No newline at end of file diff --git a/app/src/telegram/handlers/debug_auto/ui.py b/app/src/telegram/handlers/debug_auto/ui.py new file mode 100644 index 0000000..069a349 --- /dev/null +++ b/app/src/telegram/handlers/debug_auto/ui.py @@ -0,0 +1,228 @@ +# app/src/telegram/handlers/debug_auto/ui.py + +from __future__ import annotations + +import time + +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from src.trading.debug.service import DebugTradeService + + +def debug_auto_keyboard() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + + builder.button(text="▶️ Start", callback_data="debug_auto:start") + builder.button(text="🛑 Stop", callback_data="debug_auto:stop") + builder.button(text="🟢 LONG", callback_data="debug_auto:long") + builder.button(text="🔴 SHORT", callback_data="debug_auto:short") + builder.button(text="🔁 Flip", callback_data="debug_auto:flip") + builder.button(text="❌ Close", callback_data="debug_auto:close") + builder.button(text="🔄 Reset", callback_data="debug_auto:reset") + + builder.adjust(2, 3, 2) + return builder.as_markup() + + +def build_debug_auto_text() -> str: + state = DebugTradeService().get_state() + position = state.position + + parts = [ + "🧪 [DEBUG] Автоторговля", + "", + f"Status · {state.status}", + f"Актив · {_asset_symbol(state.symbol)}", + f"Стратегия · {state.strategy or '—'}", + f"Баланс · $ {_format_money_compact(state.allocated_balance_usd)}", + f"Realized PnL · {_format_signed_usd(state.realized_pnl_usd)}", + "", + _signal_line(state), + ] + + if state.last_signal != "HOLD": + parts.append(f"Уверенность · {state.last_signal_confidence:.2f}") + + parts.extend( + [ + f"Decision · {state.decision_status}", + "", + "Risk", + f"SL · {_format_percent(state.stop_loss_percent)}", + f"TP · {_format_percent(state.take_profit_percent)}", + f"ML · {_format_usd_or_off(state.max_loss_usd)}", + f"Max Reserved · {_format_percent(state.max_reserved_balance_percent)}", + ] + ) + + if state.execution_block_reason or state.execution_size_adjustment_reason: + parts.extend( + [ + "", + f"Blocked · {state.execution_block_reason or '—'}", + f"Adjusted · {state.execution_size_adjustment_reason or '—'}", + ] + ) + + parts.append("") + + if position.side == "NONE": + parts.extend( + [ + "📭 Позиция не открыта", + "", + "Debug runtime изолирован от обычной автоторговли.", + ] + ) + return "\n".join(parts) + + side_icon = "🟢" if position.side == "LONG" else "🔴" + + notional = None + if position.entry_price is not None and position.size is not None: + notional = position.entry_price * position.size + + reserved = None + if notional is not None and position.leverage and position.leverage > 0: + reserved = notional / position.leverage + + parts.extend( + [ + f"{side_icon} {position.side} · {_asset_symbol(position.symbol)} · {_leverage_text(position.leverage)}", + "", + f"Entry · {_format_usd_or_dash(position.entry_price)}", + f"Size · {_format_crypto_size(position.size)}", + f"Notional · {_format_usd_or_dash(notional)}", + f"Reserved · {_format_usd_or_dash(reserved)}", + f"PnL · {_format_signed_usd(position.unrealized_pnl_usd)}", + f"Opened · {position.opened_at or '—'}", + f"Updated · {position.updated_at or '—'}", + "", + "Debug runtime изолирован от обычной автоторговли.", + ] + ) + + return "\n".join(parts) + + +def _signal_line(state) -> str: + signal = state.last_signal or "HOLD" + + if signal in {"BUY", "SELL"} and state.is_signal_ready: + return f"Сигнал {_signal_icon(signal)} {signal} · READY" + + return f"Сигнал {_signal_icon(signal)} {signal} · {_signal_duration_text(state)}" + + +def _signal_duration_text(state) -> str: + started_at = state.signal_started_at + + if started_at is not None: + total_seconds = max(0, int(time.monotonic() - float(started_at))) + else: + total_seconds = max(0, (state.last_signal_repeat_count or 0) * 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 _signal_icon(signal: str | None) -> str: + mapping = { + "BUY": "🟢", + "SELL": "🔴", + "HOLD": "🟡", + } + return mapping.get(signal or "", "⚪") + + +def _asset_symbol(symbol: str | None) -> str: + if not symbol: + return "—" + + base = symbol.split("_", 1)[0].upper() + + if "/" in base: + return base.split("/", 1)[0] + + for suffix in ("USDT", "USD", "EUR", "BTC"): + if base.endswith(suffix) and len(base) > len(suffix): + return base[: -len(suffix)] + + return base + + +def _leverage_text(value: float | int | None) -> str: + if value is None: + return "x—" + + return f"x{float(value):g}" + + +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_crypto_size(value: float | int | None) -> str: + if value is None: + return "—" + + return f"{float(value):.5f}".rstrip("0").rstrip(".") + + +def _format_money_compact(value: float | int | None) -> str: + if value is None: + return "—" + + number = float(value) + + if abs(number - round(number)) < 1e-9: + return f"{number:,.0f}".replace(",", " ") + + return f"{number:,.2f}".replace(",", " ").rstrip("0").rstrip(".") + + +def _format_usd_or_dash(value: float | int | None) -> str: + if value is None: + return "—" + + return f"$ {_format_money_compact(value)}" + + +def _format_usd_or_off(value: float | int | None) -> str: + if value is None: + return "off" + + return f"$ {_format_money_compact(value)}" + + +def _format_signed_usd(value: float | int | None) -> str: + if value is None: + return "—" + + amount = float(value) + + if amount > 0: + return f"🟢 +$ {_format_money_compact(amount)}" + + if amount < 0: + return f"🔴 −$ {_format_money_compact(abs(amount))}" + + return "$ 0" \ No newline at end of file diff --git a/app/src/telegram/routers.py b/app/src/telegram/routers.py index 0acc8d9..f248933 100644 --- a/app/src/telegram/routers.py +++ b/app/src/telegram/routers.py @@ -3,6 +3,8 @@ from aiogram import Dispatcher from src.telegram.handlers.auto import router as auto_router +from src.telegram.handlers.debug import router as debug_router +from src.telegram.handlers.debug_auto.main import router as debug_auto_router from src.telegram.handlers.home import router as home_router from src.telegram.handlers.journal import router as journal_router from src.telegram.handlers.market import router as market_router @@ -12,7 +14,6 @@ from src.telegram.handlers.start import router as start_router from src.telegram.handlers.system import router as system_router from src.telegram.handlers.trade.main import router as trade_main_router from src.telegram.handlers.trade.new_order import router as trade_new_order_router -from src.telegram.handlers.debug import router as debug_router def setup_routers(dispatcher: Dispatcher) -> None: @@ -25,5 +26,6 @@ def setup_routers(dispatcher: Dispatcher) -> None: dispatcher.include_router(trade_new_order_router) dispatcher.include_router(auto_router) dispatcher.include_router(journal_router) + dispatcher.include_router(debug_auto_router) dispatcher.include_router(debug_router) dispatcher.include_router(system_router) \ No newline at end of file diff --git a/app/src/trading/debug/__init__.py b/app/src/trading/debug/__init__.py new file mode 100644 index 0000000..6c43ea2 --- /dev/null +++ b/app/src/trading/debug/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations \ No newline at end of file diff --git a/app/src/trading/debug/execution.py b/app/src/trading/debug/execution.py new file mode 100644 index 0000000..9b46229 --- /dev/null +++ b/app/src/trading/debug/execution.py @@ -0,0 +1,443 @@ +# app/src/trading/debug/execution.py + +from __future__ import annotations + +import math +from datetime import datetime + +from src.integrations.exchange.service import ExchangeService +from src.trading.debug.state import DebugPositionState, DebugTradeState +from src.trading.execution.models import ExecutionDecision + + +class DebugExecutionEngine: + _size_precision = 5 + + def process(self, state: DebugTradeState) -> ExecutionDecision: + if state.status != "RUNNING": + return ExecutionDecision( + "NONE", + False, + "[DEBUG] Execution доступен только в режиме RUNNING.", + ) + + self.update_unrealized_pnl(state) + + risk_decision = self.risk_close_decision(state) + if risk_decision is not None: + return risk_decision + + if state.decision_status != "READY" or not state.is_signal_ready: + return ExecutionDecision( + "NONE", + False, + "[DEBUG] Сигнал ещё не готов к execution.", + ) + + if self._should_flip_position(state): + return self.flip_position(state) + + if state.last_signal == "BUY": + return self.open_position_if_empty(state=state, side="LONG") + + if state.last_signal == "SELL": + return self.open_position_if_empty(state=state, side="SHORT") + + return ExecutionDecision("NONE", False, "[DEBUG] Нет торгового действия.") + + def open_position_if_empty( + self, + *, + state: DebugTradeState, + side: str, + ) -> ExecutionDecision: + if state.position.side != "NONE": + return ExecutionDecision("NONE", False, "[DEBUG] Позиция уже открыта.") + + try: + entry_price = self._entry_price_for_side(state.symbol, side) + except Exception as exc: + return ExecutionDecision( + "NONE", + False, + f"[DEBUG] Не удалось получить цену входа: {exc}", + ) + + size = self.calculate_position_size(state, entry_price=entry_price) + + if size <= 0: + return ExecutionDecision( + "NONE", + False, + "[DEBUG] Позиция не открыта: невозможно рассчитать size.", + ) + + size = self.adjust_size_by_margin_limit( + state=state, + entry_price=entry_price, + size=size, + ) + size = self._round_size(size) + + if size <= 0: + return ExecutionDecision( + "NONE", + False, + "[DEBUG] Позиция не открыта: итоговый size равен 0.", + ) + + now = self._now_time() + + state.position = DebugPositionState( + 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, + ) + + return ExecutionDecision( + f"DEBUG_OPEN_{side}", + True, + f"[DEBUG] Paper позиция открыта: {side}.", + ) + + def flip_position(self, state: DebugTradeState) -> ExecutionDecision: + position = state.position + + if position.side == "NONE": + return ExecutionDecision("NONE", False, "[DEBUG] Нет позиции для flip.") + + new_side = self._target_side_from_signal(state.last_signal) + if new_side is None: + return ExecutionDecision("NONE", False, "[DEBUG] Нет направления для flip.") + + try: + 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"[DEBUG] Ошибка цены для flip: {exc}") + + pnl = self.calculate_pnl(state, exit_price) + new_size = self.calculate_position_size(state, entry_price=new_entry_price) + + if new_size <= 0: + return ExecutionDecision( + "NONE", + False, + "[DEBUG] Flip отменён: невозможно рассчитать new size.", + ) + + new_size = self.adjust_size_by_margin_limit( + state=state, + entry_price=new_entry_price, + size=new_size, + ) + new_size = self._round_size(new_size) + + if new_size <= 0: + return ExecutionDecision( + "NONE", + False, + "[DEBUG] Flip отменён: итоговый new size равен 0.", + ) + + state.realized_pnl_usd += pnl + + now = self._now_time() + old_side = position.side + + state.position = DebugPositionState( + side=new_side, + symbol=state.symbol, + entry_price=new_entry_price, + size=new_size, + leverage=state.leverage, + unrealized_pnl_usd=0.0, + opened_at=now, + updated_at=now, + ) + + return ExecutionDecision( + f"DEBUG_FLIP_{old_side}_TO_{new_side}", + True, + f"[DEBUG] Flip выполнен: {old_side} → {new_side}.", + ) + + def close_position( + self, + state: DebugTradeState, + *, + forced_reason: str | None = None, + ) -> ExecutionDecision: + position = state.position + + if position.side == "NONE": + return ExecutionDecision( + "NONE", + False, + "[DEBUG] Нет открытой позиции для закрытия.", + ) + + try: + exit_price = self._exit_price_for_side( + position.symbol or state.symbol, + position.side, + ) + except Exception as exc: + return ExecutionDecision("NONE", False, f"[DEBUG] Ошибка цены закрытия: {exc}") + + pnl = self.calculate_pnl(state, exit_price) + state.realized_pnl_usd += pnl + + old_side = position.side + state.position = DebugPositionState() + + action = f"DEBUG_CLOSE_{forced_reason}" if forced_reason else "DEBUG_CLOSE" + + return ExecutionDecision( + action, + True, + f"[DEBUG] Позиция закрыта: {old_side}. PnL: {pnl:.4f}", + ) + + def risk_close_decision(self, state: DebugTradeState) -> ExecutionDecision | None: + position = state.position + + if position.side == "NONE": + return None + + try: + current_price = self._exit_price_for_side( + position.symbol or state.symbol, + position.side, + ) + except Exception: + return None + + price_move_percent = self.calculate_price_move_percent(state, current_price) + unrealized_pnl = self.calculate_pnl(state, current_price) + + if self._is_max_loss_hit(state, unrealized_pnl): + return self.close_position(state, forced_reason="MAX_LOSS") + + if self._is_stop_loss_hit(state, price_move_percent): + return self.close_position(state, forced_reason="STOP_LOSS") + + if self._is_take_profit_hit(state, price_move_percent): + return self.close_position(state, forced_reason="TAKE_PROFIT") + + return None + + def update_unrealized_pnl(self, state: DebugTradeState) -> None: + position = state.position + + if position.side == "NONE": + position.unrealized_pnl_usd = None + return + + try: + current_price = self._exit_price_for_side( + position.symbol or state.symbol, + position.side, + ) + except Exception: + return + + position.unrealized_pnl_usd = self.calculate_pnl(state, current_price) + position.updated_at = self._now_time() + + def calculate_position_size( + self, + state: DebugTradeState, + *, + entry_price: float | None = None, + ) -> float: + if state.risk_percent is None or state.risk_percent <= 0: + return 0.0 + + if state.stop_loss_percent is None or state.stop_loss_percent <= 0: + return 0.0 + + price = entry_price + if price is None: + price = self._signal_entry_price(state) + + if price <= 0: + return 0.0 + + target_risk_usd = state.allocated_balance_usd * (state.risk_percent / 100) + stop_loss_distance_usd = price * (state.stop_loss_percent / 100) + + if stop_loss_distance_usd <= 0: + return 0.0 + + return self._round_size(target_risk_usd / stop_loss_distance_usd) + + def adjust_size_by_margin_limit( + self, + *, + state: DebugTradeState, + entry_price: float, + size: float, + ) -> float: + state.execution_block_reason = None + state.execution_size_adjustment_reason = None + + max_percent = state.max_reserved_balance_percent + if max_percent is None or max_percent <= 0: + return self._round_size(size) + + leverage = state.leverage or 1.0 + + if leverage <= 0 or entry_price <= 0: + state.execution_block_reason = "[DEBUG] Invalid leverage or entry price." + return 0.0 + + max_reserved_usd = state.allocated_balance_usd * (max_percent / 100) + max_notional_usd = max_reserved_usd * leverage + max_size = max_notional_usd / entry_price + + if size <= max_size: + return self._round_size(size) + + state.execution_size_adjustment_reason = "MARGIN_LIMIT" + return self._round_size(max_size) + + def calculate_price_move_percent( + self, + state: DebugTradeState, + current_price: float, + ) -> float: + position = state.position + entry = position.entry_price or 0.0 + + if entry <= 0: + return 0.0 + + if position.side == "LONG": + return round(((current_price - entry) / entry) * 100, 4) + + if position.side == "SHORT": + return round(((entry - current_price) / entry) * 100, 4) + + return 0.0 + + def calculate_pnl(self, state: DebugTradeState, current_price: float) -> float: + position = state.position + + entry = position.entry_price or 0.0 + size = position.size or 0.0 + + if position.side == "LONG": + return round((current_price - entry) * size, 4) + + if position.side == "SHORT": + return round((entry - current_price) * size, 4) + + return 0.0 + + def _should_flip_position(self, state: DebugTradeState) -> bool: + if state.position.side == "LONG" and state.last_signal == "SELL": + return True + + if state.position.side == "SHORT" and state.last_signal == "BUY": + return True + + return False + + def _target_side_from_signal(self, signal: str | None) -> str | None: + if signal == "BUY": + return "LONG" + + if signal == "SELL": + return "SHORT" + + return None + + def _is_stop_loss_hit(self, state: DebugTradeState, 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: DebugTradeState, 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: DebugTradeState, unrealized_pnl: float) -> bool: + if state.max_loss_usd is None: + return False + + return unrealized_pnl <= -abs(state.max_loss_usd) + + def _signal_entry_price(self, state: DebugTradeState) -> 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_fresh_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_fresh_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_fresh_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 + + def _now_time(self) -> str: + return datetime.now().strftime("%H:%M:%S") \ No newline at end of file diff --git a/app/src/trading/debug/runner.py b/app/src/trading/debug/runner.py new file mode 100644 index 0000000..b3ef341 --- /dev/null +++ b/app/src/trading/debug/runner.py @@ -0,0 +1,170 @@ +# app/src/trading/debug/runner.py + +from __future__ import annotations + +import asyncio +import time +from typing import Callable + +from aiogram import Bot +from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter + +from src.trading.debug.service import DebugTradeService + + +class DebugTradeRunner: + _task: asyncio.Task | None = None + + _bot: Bot | None = None + _chat_id: int | None = None + _message_id: int | None = None + _render_text: Callable[[], str] | None = None + _render_markup: Callable[[], object] | None = None + + _current_screen: str | None = None + + _interval_seconds = 5 + _last_text: str | None = None + _last_refresh_at: float = 0.0 + _retry_after_until: float = 0.0 + + @classmethod + def register_screen( + cls, + *, + bot: Bot, + chat_id: int, + message_id: int, + render_text: Callable[[], str], + render_markup: Callable[[], object], + ) -> None: + cls._bot = bot + cls._chat_id = chat_id + cls._message_id = message_id + cls._render_text = render_text + cls._render_markup = render_markup + cls._last_text = None + + @classmethod + async def delete_registered_screen( + cls, + *, + bot: Bot, + chat_id: int, + ) -> None: + if cls._chat_id is None or cls._message_id is None: + return + + if cls._chat_id != chat_id: + return + + try: + await bot.delete_message( + chat_id=cls._chat_id, + message_id=cls._message_id, + ) + except Exception: + pass + + cls._message_id = None + cls._render_text = None + cls._render_markup = None + cls._last_text = None + + @classmethod + def set_current_screen(cls, screen: str) -> None: + cls._current_screen = screen + + @classmethod + def start(cls) -> None: + state = DebugTradeService().get_state() + state.status = "RUNNING" + + if cls._task is not None and not cls._task.done(): + return + + cls._task = asyncio.create_task(cls._worker()) + + @classmethod + def stop(cls) -> None: + if cls._task is None: + return + + cls._task.cancel() + cls._task = None + + @classmethod + async def _worker(cls) -> None: + service = DebugTradeService() + + while True: + state = service.get_state() + + if state.status == "OFF": + cls._task = None + break + + service.process() + await cls.refresh_screen(force=False) + + await asyncio.sleep(cls._interval_seconds) + + @classmethod + async def refresh_screen(cls, *, force: bool = False) -> None: + if cls._current_screen != "debug_auto": + return + + now = time.monotonic() + + if now < cls._retry_after_until: + return + + if not force and now - cls._last_refresh_at < cls._interval_seconds: + return + + if not all( + [ + cls._bot, + cls._chat_id, + cls._message_id, + cls._render_text, + cls._render_markup, + ] + ): + return + + text = cls._render_text() + + if text == cls._last_text: + return + + try: + await cls._bot.edit_message_text( + chat_id=cls._chat_id, + message_id=cls._message_id, + text=text, + reply_markup=cls._render_markup(), + ) + cls._last_text = text + cls._last_refresh_at = now + + except TelegramRetryAfter as exc: + cls._retry_after_until = time.monotonic() + exc.retry_after + 5 + + except TelegramBadRequest as exc: + error_text = str(exc).lower() + + if "message is not modified" in error_text: + cls._last_text = text + cls._last_refresh_at = now + return + + if "message to edit not found" in error_text: + cls._message_id = None + cls._render_text = None + cls._render_markup = None + cls._last_text = None + return + + except Exception: + pass \ No newline at end of file diff --git a/app/src/trading/debug/service.py b/app/src/trading/debug/service.py new file mode 100644 index 0000000..771427e --- /dev/null +++ b/app/src/trading/debug/service.py @@ -0,0 +1,219 @@ +# app/src/trading/debug/service.py + +from __future__ import annotations + +import time +from datetime import datetime + +from src.core.config import load_settings +from src.trading.debug.execution import DebugExecutionEngine +from src.trading.debug.state import DebugPositionState, DebugTradeState +from src.trading.execution.models import ExecutionDecision + + +class DebugTradeService: + _state = DebugTradeState() + + _confirm_repeats = 2 + _ready_confidence = 0.3 + + def get_state(self) -> DebugTradeState: + if not self._state.symbol: + self._state.symbol = load_settings().default_symbol + + return self._state + + def reset(self) -> DebugTradeState: + state = self.get_state() + + state.status = "RUNNING" + state.last_signal = "HOLD" + state.last_signal_confidence = 0.0 + state.last_signal_repeat_count = 1 + state.last_signal_reason = "[DEBUG] RESET HOLD" + state.signal_started_at = time.monotonic() + + state.decision_status = "WAITING" + state.decision_reason = "[DEBUG] Reset." + state.is_signal_confirmed = False + state.is_signal_ready = False + + state.execution_block_reason = None + state.execution_size_adjustment_reason = None + + state.position = DebugPositionState() + + return state + + def stop(self) -> DebugTradeState: + state = self.get_state() + state.status = "OFF" + return state + + def set_signal( + self, + *, + signal: str, + confidence: float = 0.0, + repeat_count: int = 1, + reason: str | None = None, + force_ready: bool = False, + ) -> DebugTradeState: + state = self.get_state() + + normalized_signal = signal.strip().upper() + if normalized_signal not in {"BUY", "SELL", "HOLD"}: + normalized_signal = "HOLD" + + previous_signal = state.last_signal + + state.status = "RUNNING" + state.last_signal = normalized_signal + state.last_signal_confidence = max(0.0, min(1.0, confidence)) + state.last_signal_repeat_count = max(1, int(repeat_count)) + state.last_signal_reason = reason or f"[DEBUG] SIGNAL {normalized_signal}" + + if previous_signal != normalized_signal or state.signal_started_at is None: + state.signal_started_at = time.monotonic() + + self._update_decision_state(state, force_ready=force_ready) + + return state + + def set_signal_duration( + self, + *, + signal: str, + seconds: int, + confidence: float = 0.0, + force_ready: bool = False, + ) -> DebugTradeState: + repeat_count = max(1, int(max(0, seconds) / 5)) + + state = self.set_signal( + signal=signal, + confidence=confidence, + repeat_count=repeat_count, + reason=f"[DEBUG] {signal.upper()} {seconds}s", + force_ready=force_ready, + ) + + state.signal_started_at = time.monotonic() - max(0, seconds) + + return state + + def open_long(self) -> tuple[DebugTradeState, ExecutionDecision]: + state = self.set_signal( + signal="BUY", + confidence=0.95, + repeat_count=3, + reason="[DEBUG] OPEN LONG", + force_ready=True, + ) + + result = DebugExecutionEngine().process(state) + return state, result + + def open_short(self) -> tuple[DebugTradeState, ExecutionDecision]: + state = self.set_signal( + signal="SELL", + confidence=0.95, + repeat_count=3, + reason="[DEBUG] OPEN SHORT", + force_ready=True, + ) + + result = DebugExecutionEngine().process(state) + return state, result + + def flip(self) -> tuple[DebugTradeState, ExecutionDecision]: + state = self.get_state() + + if state.position.side == "LONG": + target_signal = "SELL" + elif state.position.side == "SHORT": + target_signal = "BUY" + else: + return state, ExecutionDecision( + "NONE", + False, + "[DEBUG] Flip невозможен: нет открытой позиции.", + ) + + state = self.set_signal( + signal=target_signal, + confidence=0.95, + repeat_count=3, + reason="[DEBUG] AUTO FLIP", + force_ready=True, + ) + + result = DebugExecutionEngine().process(state) + return state, result + + def close(self, *, reason: str = "DEBUG_CLOSE") -> tuple[DebugTradeState, ExecutionDecision]: + state = self.get_state() + result = DebugExecutionEngine().close_position(state, forced_reason=reason) + return state, result + + def update_market(self) -> DebugTradeState: + state = self.get_state() + + state.last_check_at = datetime.now().strftime("%H:%M:%S") + + if state.status != "RUNNING": + return state + + DebugExecutionEngine().update_unrealized_pnl(state) + return state + + def process(self) -> tuple[DebugTradeState, ExecutionDecision]: + state = self.get_state() + + state.last_check_at = datetime.now().strftime("%H:%M:%S") + + result = DebugExecutionEngine().process(state) + return state, result + + def _update_decision_state( + self, + state: DebugTradeState, + *, + force_ready: bool = False, + ) -> None: + state.is_signal_confirmed = False + state.is_signal_ready = False + + if state.last_signal == "HOLD": + state.decision_status = "WAITING" + state.decision_reason = "[DEBUG] Нет торгового направления." + return + + if force_ready: + state.is_signal_confirmed = True + state.is_signal_ready = True + state.decision_status = "READY" + state.decision_reason = "[DEBUG] Signal forced READY." + return + + if state.last_signal_repeat_count < self._confirm_repeats: + state.decision_status = "CONFIRMING" + state.decision_reason = ( + f"[DEBUG] Сигнал {state.last_signal} подтверждается: " + f"{state.last_signal_repeat_count}/{self._confirm_repeats}." + ) + return + + state.is_signal_confirmed = True + + if state.last_signal_confidence < self._ready_confidence: + state.decision_status = "BLOCKED" + state.decision_reason = ( + f"[DEBUG] Confidence низкая: " + f"{state.last_signal_confidence:.2f} < {self._ready_confidence:.2f}." + ) + return + + state.is_signal_ready = True + state.decision_status = "READY" + state.decision_reason = "[DEBUG] Signal ready." \ No newline at end of file diff --git a/app/src/trading/debug/state.py b/app/src/trading/debug/state.py new file mode 100644 index 0000000..d83594f --- /dev/null +++ b/app/src/trading/debug/state.py @@ -0,0 +1,57 @@ +# app/src/trading/debug/state.py + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(slots=True) +class DebugPositionState: + side: str = "NONE" + symbol: str = "" + + entry_price: float | None = None + size: float | None = None + leverage: float | None = None + + unrealized_pnl_usd: float | None = None + + opened_at: str | None = None + updated_at: str | None = None + + +@dataclass(slots=True) +class DebugTradeState: + status: str = "OFF" + + strategy: str | None = "TREND" + symbol: str = "BTC/USD_LEVERAGE" + + allocated_balance_usd: float = 1000.0 + realized_pnl_usd: float = 0.0 + + risk_percent: float | None = 1.0 + leverage: float | None = 2.0 + + stop_loss_percent: float | None = 1.0 + take_profit_percent: float | None = None + max_loss_usd: float | None = None + max_reserved_balance_percent: float | None = 50.0 + + last_signal: str | None = "HOLD" + last_signal_confidence: float = 0.0 + last_signal_repeat_count: int = 0 + last_signal_reason: str | None = None + signal_started_at: float | None = None + + decision_status: str = "WAITING" + decision_reason: str | None = None + is_signal_confirmed: bool = False + is_signal_ready: bool = False + + execution_block_reason: str | None = None + execution_size_adjustment_reason: str | None = None + + position: DebugPositionState = field(default_factory=DebugPositionState) + + last_check_at: str | None = None \ No newline at end of file diff --git a/docs/roadmap/master-roadmap.md b/docs/roadmap/master-roadmap.md index 614a349..fb47506 100644 --- a/docs/roadmap/master-roadmap.md +++ b/docs/roadmap/master-roadmap.md @@ -266,6 +266,30 @@ - integration testing flow for execution alerts - preparation for isolated debug runtime architecture +#### 07.4.3.15 — Isolated Debug Runtime & Debug Auto Screen ✅ + +- isolated DebugTradeState +- isolated DebugPositionState +- isolated DebugTradeService +- isolated DebugExecutionEngine +- isolated DebugTradeRunner +- separate `/debug_auto_screen` +- separate debug auto UI +- debug commands no longer mutate AutoTradeService +- debug execution no longer mutates ExecutionEngine._position +- debug runner no longer uses AutoTradeRunner +- `/debug_live` disabled as production-runtime injector +- legacy debug commands redirected to isolated debug runtime +- debug LONG / SHORT / FLIP / CLOSE sandbox flow +- debug Start / Stop / Reset controls +- debug PnL live refresh +- debug margin / reserved rendering +- debug bid / ask execution pricing +- fresh REST snapshot support for debug execution +- debug_auto router added +- ordinary 🤖 Автоторговля screen remains unchanged by debug commands +- preparation for production execution pricing layer + ### 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 124ae3e..82d07c1 100644 --- a/docs/roadmap/stage-07-auto-trading-roadmap.md +++ b/docs/roadmap/stage-07-auto-trading-roadmap.md @@ -253,6 +253,29 @@ - integration testing flow for execution alerts - preparation for isolated debug runtime architecture +#### 07.4.3.15 — Isolated Debug Runtime & Debug Auto Screen ✅ +- isolated DebugTradeState +- isolated DebugPositionState +- isolated DebugTradeService +- isolated DebugExecutionEngine +- isolated DebugTradeRunner +- separate `/debug_auto_screen` +- separate debug auto UI +- debug commands no longer mutate AutoTradeService +- debug execution no longer mutates ExecutionEngine._position +- debug runner no longer uses AutoTradeRunner +- `/debug_live` disabled as production-runtime injector +- legacy debug commands redirected to isolated debug runtime +- debug LONG / SHORT / FLIP / CLOSE sandbox flow +- debug Start / Stop / Reset controls +- debug PnL live refresh +- debug margin / reserved rendering +- debug bid / ask execution pricing +- fresh REST snapshot support for debug execution +- debug_auto router added +- ordinary 🤖 Автоторговля screen remains unchanged by debug commands +- preparation for production execution pricing layer + --- ### 07.4.4 diff --git a/docs/stages/stage-07_4_3_15-isolated_debug_runtime.md b/docs/stages/stage-07_4_3_15-isolated_debug_runtime.md new file mode 100644 index 0000000..f1c0877 --- /dev/null +++ b/docs/stages/stage-07_4_3_15-isolated_debug_runtime.md @@ -0,0 +1,176 @@ +# 07.4.3.15 — Isolated Debug Runtime & Debug Auto Screen + +## Цель + +Изолировать debug-режим от обычной автоторговли и добавить отдельный экран: + +```text +/debug_auto_screen +``` + +Теперь debug-команды больше не должны изменять runtime обычной автоторговли. + +--- + +## Что было раньше + +Ранее команды: + +```text +/debug_exec +/debug_live +/debug_auto +``` + +работали напрямую с обычными компонентами автоторговли: + +```text +AutoTradeService +ExecutionEngine +AutoTradeRunner +Auto UI +``` + +Это было удобно для integration testing, но могло загрязнять реальное состояние автоторговли. + +--- + +## Что реализовано + +### 1. Отдельный debug state + +Добавлен отдельный debug state: + +```text +app/src/trading/debug/state.py +``` + +Основные структуры: + +```text +DebugTradeState +DebugPositionState +``` + +--- + +### 2. Отдельный debug execution engine + +Добавлен: + +```text +app/src/trading/debug/execution.py +``` + +Важно: + +```text +DebugExecutionEngine не трогает ExecutionEngine._position +``` + +--- + +### 3. Отдельный debug service + +Добавлен: + +```text +app/src/trading/debug/service.py +``` + +Важно: + +```text +DebugTradeService не трогает AutoTradeService +``` + +--- + +### 4. Переделаны debug-команды + +Файл: + +```text +app/src/telegram/handlers/debug.py +``` + +Теперь команды работают через изолированный debug runtime. + +--- + +### 5. Отключён старый /debug_live + +Команда: + +```text +/debug_live +``` + +больше не вмешивается в обычную автоторговлю. + +--- + +### 6. Отдельный debug runner + +Добавлен: + +```text +app/src/trading/debug/runner.py +``` + +Он обновляет только debug screen: + +```text +🧪 [DEBUG] Автоторговля +``` + +--- + +### 7. Отдельный debug auto screen + +Добавлены файлы: + +```text +app/src/telegram/handlers/debug_auto/main.py +app/src/telegram/handlers/debug_auto/ui.py +``` + +Открытие экрана: + +```text +/debug_auto_screen +``` + +--- + +### 8. Fresh REST snapshot для debug execution + +Для debug execution добавлен fresh REST snapshot: + +```text +ExchangeService.get_fresh_market_snapshot() +``` + +--- + +## Архитектурный результат + +Теперь debug runtime отделён от обычной автоторговли. + +Было: + +```text +debug commands → AutoTradeService / ExecutionEngine / AutoTradeRunner +``` + +Стало: + +```text +debug commands → DebugTradeService / DebugExecutionEngine / DebugTradeRunner +``` + +--- + +## Следующий этап + +07.4.3.16 — Production Execution Pricing Layer