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