Stage 07.4.3.15 — Isolated debug runtime and debug auto screen

This commit is contained in:
2026-05-09 09:17:34 +03:00
parent df76490783
commit 71cf206e32
13 changed files with 1875 additions and 492 deletions

View File

@@ -312,6 +312,63 @@ class ExchangeService:
"updated_at": self._format_exchange_time(close_time), "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]: def get_balance_summary(self) -> list[BalanceSummary]:
if not self.settings.exchange_enabled: if not self.settings.exchange_enabled:
return mock_balance_summary() return mock_balance_summary()

View File

@@ -3,19 +3,15 @@
from __future__ import annotations from __future__ import annotations
import math import math
import time
from datetime import datetime
from aiogram import F, Router from aiogram import F, Router
from aiogram.types import Message from aiogram.types import Message
from src.core.config import load_settings from src.core.config import load_settings
from src.integrations.exchange.service import ExchangeService from src.trading.debug.execution import DebugExecutionEngine
from src.trading.auto.runner import AutoTradeRunner from src.trading.debug.service import DebugTradeService
from src.trading.auto.service import AutoTradeService from src.trading.debug.state import DebugTradeState
from src.trading.execution.engine import ExecutionEngine from src.trading.execution.models import ExecutionDecision
from src.trading.journal.service import JournalService
from src.trading.position.state import PositionState
router = Router(name="debug") router = Router(name="debug")
@@ -28,7 +24,8 @@ def _debug_enabled() -> bool:
def _debug_help_text() -> str: def _debug_help_text() -> str:
return ( return (
"<b>🧪 Debug commands</b>\n\n" "<b>🧪 Debug commands</b>\n\n"
"<b>Auto UI states:</b>\n" "<b>Isolated Debug Runtime:</b>\n"
"/debug_auto reset\n"
"/debug_auto off\n" "/debug_auto off\n"
"/debug_auto hold 335\n" "/debug_auto hold 335\n"
"/debug_auto buy 12 0.74\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 sell_ready 0.91\n"
"/debug_auto long\n" "/debug_auto long\n"
"/debug_auto short\n" "/debug_auto short\n"
"/debug_auto reset\n"
"/debug_auto state\n\n" "/debug_auto state\n\n"
"<b>Paper execution:</b>\n" "<b>Isolated Debug Execution:</b>\n"
"/debug_exec buy — открыть LONG\n" "/debug_exec buy — открыть DEBUG LONG\n"
"/debug_exec sell — открыть SHORT\n" "/debug_exec sell — открыть DEBUG SHORT\n"
"/debug_exec flip — перевернуть текущую позицию\n" "/debug_exec flip — перевернуть DEBUG позицию\n"
"/debug_exec flip_buy — перевернуть в LONG\n" "/debug_exec close — закрыть DEBUG позицию\n"
"/debug_exec flip_sell — перевернуть в SHORT\n" "/debug_exec process — один цикл DEBUG execution\n"
"/debug_exec close — закрыть позицию\n" "/debug_exec update — обновить DEBUG PnL\n"
"/debug_exec state — состояние позиции\n\n" "/debug_exec state — состояние DEBUG runtime\n\n"
"<b>Live paper test:</b>\n" "<b>Legacy aliases:</b>\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"
"<b>Legacy:</b>\n"
"/debug_signal BUY 0.95 3\n" "/debug_signal BUY 0.95 3\n"
"/debug_signal SELL 0.70 2\n" "/debug_signal SELL 0.70 2\n"
"/debug_signal HOLD 0.00 1\n" "/debug_signal HOLD 0.00 1\n"
"/debug_ready\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() parts = (message.text or "").split()
command = parts[1].lower() if len(parts) > 1 else "help" command = parts[1].lower() if len(parts) > 1 else "help"
service = AutoTradeService() service = DebugTradeService()
state = service.get_state()
if command in {"help", "-h", "--help"}: if command in {"help", "-h", "--help"}:
await message.answer(_debug_help_text()) await message.answer(_debug_help_text())
return return
if command == "off": if command == "reset":
_clear_debug_position(state) state = service.reset()
state.status = "OFF" await message.answer(
state.decision_status = "WAITING" "✅ [DEBUG] Runtime reset\n\n"
state.last_signal = "HOLD" f"{_debug_state_text(state)}"
state.last_signal_confidence = 0.0 )
state.last_signal_repeat_count = 1
state.is_signal_confirmed = False
state.is_signal_ready = False
_set_signal_started_at(state)
await _refresh_auto_screen()
await message.answer("✅ Debug Auto: OFF")
return return
if command == "reset": if command == "off":
_clear_debug_position(state) state = service.stop()
state.status = "RUNNING" await message.answer(
state.decision_status = "WAITING" "✅ [DEBUG] Runtime stopped\n\n"
state.decision_reason = None f"{_debug_state_text(state)}"
state.last_signal = "HOLD" )
state.last_signal_reason = "DEBUG RESET HOLD"
state.last_signal_confidence = 0.0
state.last_signal_repeat_count = 1
state.is_signal_confirmed = False
state.is_signal_ready = False
state.execution_block_reason = None
state.execution_size_adjustment_reason = None
_set_signal_started_at(state)
await _refresh_auto_screen()
await message.answer("✅ Debug Auto: reset to RUNNING HOLD")
return return
if command == "state": if command == "state":
_sync_state_from_position(state) state = service.get_state()
service.update_market()
await message.answer(_debug_state_text(state)) await message.answer(_debug_state_text(state))
return return
if command == "hold": if command == "hold":
seconds = _parse_int(parts, index=2, default=335) seconds = _parse_int(parts, index=2, default=335)
_clear_debug_position(state) state = service.set_signal_duration(
_set_signal_state(
state=state,
signal="HOLD", signal="HOLD",
seconds=seconds, seconds=seconds,
confidence=0.0, confidence=0.0,
decision_status="WAITING", force_ready=False,
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 return
if command == "buy": if command == "buy":
seconds = _parse_int(parts, index=2, default=12) seconds = _parse_int(parts, index=2, default=12)
confidence = _parse_float(parts, index=3, default=0.74) confidence = _parse_float(parts, index=3, default=0.74)
_clear_debug_position(state)
_set_signal_state( state = service.set_signal_duration(
state=state,
signal="BUY", signal="BUY",
seconds=seconds, seconds=seconds,
confidence=confidence, confidence=confidence,
decision_status="CONFIRMING", force_ready=False,
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 return
if command == "buy_ready": if command == "buy_ready":
confidence = _parse_float(parts, index=2, default=0.88) confidence = _parse_float(parts, index=2, default=0.88)
_clear_debug_position(state)
_set_signal_state( state = service.set_signal_duration(
state=state,
signal="BUY", signal="BUY",
seconds=15, seconds=15,
confidence=confidence, confidence=confidence,
decision_status="READY", force_ready=True,
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 return
if command == "sell": if command == "sell":
seconds = _parse_int(parts, index=2, default=9) seconds = _parse_int(parts, index=2, default=9)
confidence = _parse_float(parts, index=3, default=0.71) confidence = _parse_float(parts, index=3, default=0.71)
_clear_debug_position(state)
_set_signal_state( state = service.set_signal_duration(
state=state,
signal="SELL", signal="SELL",
seconds=seconds, seconds=seconds,
confidence=confidence, confidence=confidence,
decision_status="CONFIRMING", force_ready=False,
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 return
if command == "sell_ready": if command == "sell_ready":
confidence = _parse_float(parts, index=2, default=0.91) confidence = _parse_float(parts, index=2, default=0.91)
_clear_debug_position(state)
_set_signal_state( state = service.set_signal_duration(
state=state,
signal="SELL", signal="SELL",
seconds=15, seconds=15,
confidence=confidence, confidence=confidence,
decision_status="READY", force_ready=True,
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 return
if command == "long": if command == "long":
_set_debug_position(state=state, side="LONG") state, result = service.open_long()
await _refresh_auto_screen() await message.answer(_execution_result_text("OPEN LONG", state, result))
await message.answer("✅ Debug Auto: active LONG position")
return return
if command == "short": if command == "short":
_set_debug_position(state=state, side="SHORT") state, result = service.open_short()
await _refresh_auto_screen() await message.answer(_execution_result_text("OPEN SHORT", state, result))
await message.answer("✅ Debug Auto: active SHORT position")
return return
await message.answer(f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}") 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() parts = (message.text or "").split()
command = parts[1].lower() if len(parts) > 1 else "help" command = parts[1].lower() if len(parts) > 1 else "help"
service = AutoTradeService() service = DebugTradeService()
state = service.get_state()
engine = ExecutionEngine()
if command in {"help", "-h", "--help"}: if command in {"help", "-h", "--help"}:
await message.answer(_debug_help_text()) await message.answer(_debug_help_text())
return return
if command == "state": if command == "state":
_sync_state_from_position(state) state = service.get_state()
service.update_market()
await message.answer(_debug_state_text(state)) await message.answer(_debug_state_text(state))
return return
if command == "buy": if command == "buy":
_prepare_ready_signal(state=state, signal="BUY", confidence=0.95) state, result = service.open_long()
result = engine.process(state) await message.answer(_execution_result_text("EXEC BUY / LONG", state, result))
await _after_debug_execution()
await message.answer(_execution_result_text("BUY execution", result, state))
return return
if command == "sell": if command == "sell":
_prepare_ready_signal(state=state, signal="SELL", confidence=0.95) state, result = service.open_short()
result = engine.process(state) await message.answer(_execution_result_text("EXEC SELL / SHORT", state, result))
await _after_debug_execution()
await message.answer(_execution_result_text("SELL execution", result, state))
return return
if command == "flip": if command == "flip":
position = engine.get_position() state, result = service.flip()
current_side = position.side or state.position_side or "NONE" await message.answer(_execution_result_text("EXEC AUTO FLIP", state, result))
if current_side == "LONG":
target_signal = "SELL"
elif current_side == "SHORT":
target_signal = "BUY"
else:
await message.answer(
"⛔️ Flip невозможен: нет открытой позиции.\n\n"
"Сначала выполните /debug_exec buy или /debug_exec sell."
)
return
_prepare_ready_signal(state=state, signal=target_signal, confidence=0.95)
result = engine.process(state)
await _after_debug_execution()
await message.answer(_execution_result_text("AUTO FLIP execution", result, state))
return
if command == "flip_buy":
_prepare_ready_signal(state=state, signal="BUY", confidence=0.95)
result = engine.process(state)
await _after_debug_execution()
await message.answer(_execution_result_text("FLIP to LONG execution", result, state))
return
if command == "flip_sell":
_prepare_ready_signal(state=state, signal="SELL", confidence=0.95)
result = engine.process(state)
await _after_debug_execution()
await message.answer(_execution_result_text("FLIP to SHORT execution", result, state))
return return
if command == "close": if command == "close":
result = engine._close_position(state, forced_reason="DEBUG_CLOSE") state, result = service.close(reason="DEBUG_CLOSE")
await _after_debug_execution() await message.answer(_execution_result_text("EXEC CLOSE", state, result))
await message.answer(_execution_result_text("CLOSE execution", result, state)) 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 return
await message.answer(f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}") 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 выключен.") await message.answer("Debug mode выключен.")
return return
parts = (message.text or "").split() await message.answer(
command = parts[1].lower() if len(parts) > 1 else "help" "⚠️ /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() state = service.get_state()
engine = ExecutionEngine() service.update_market()
if command in {"help", "-h", "--help"}: await message.answer(_debug_state_text(state))
await message.answer(_debug_help_text())
return
if command == "buy":
_prepare_ready_signal(state=state, signal="BUY", confidence=0.95)
result = engine.process(state)
await _start_live_monitoring()
await message.answer(_execution_result_text("LIVE BUY execution", result, state))
return
if command == "sell":
_prepare_ready_signal(state=state, signal="SELL", confidence=0.95)
result = engine.process(state)
await _start_live_monitoring()
await message.answer(_execution_result_text("LIVE SELL execution", result, state))
return
if command == "flip":
position = engine.get_position()
current_side = position.side or state.position_side or "NONE"
if current_side == "LONG":
target_signal = "SELL"
elif current_side == "SHORT":
target_signal = "BUY"
else:
await message.answer(
"⛔️ Live flip невозможен: нет открытой позиции.\n\n"
"Сначала выполните /debug_live buy или /debug_live sell."
)
return
_prepare_ready_signal(state=state, signal=target_signal, confidence=0.95)
result = engine.process(state)
await _start_live_monitoring()
await message.answer(_execution_result_text("LIVE AUTO FLIP execution", result, state))
return
if command == "close":
result = engine._close_position(state, forced_reason="DEBUG_LIVE_CLOSE")
await _after_debug_execution()
await message.answer(_execution_result_text("LIVE CLOSE execution", result, state))
return
if command == "stop":
AutoTradeRunner.stop()
await _refresh_auto_screen()
await message.answer("✅ Debug live stopped. Позиция не закрыта.")
return
if command == "state":
_sync_state_from_position(state)
await message.answer(_debug_state_text(state))
return
await message.answer(f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}")
def _prepare_ready_signal(*, state, signal: str, confidence: float) -> None: def _debug_state_text(state: DebugTradeState) -> str:
state.status = "RUNNING" position = state.position
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)
duration = _signal_duration_text(state)
def _set_signal_state( pnl = position.unrealized_pnl_usd
*,
state,
signal: str,
seconds: int,
confidence: float,
decision_status: str,
ready: bool,
) -> None:
state.status = "RUNNING"
state.last_signal = signal
state.last_signal_confidence = max(0.0, min(1.0, confidence))
state.last_signal_repeat_count = _seconds_to_repeats(seconds)
state.last_signal_reason = f"DEBUG {signal} {seconds}s"
state.decision_status = decision_status
state.decision_reason = f"DEBUG {decision_status}"
state.is_signal_confirmed = ready
state.is_signal_ready = ready
state.execution_block_reason = None
state.execution_size_adjustment_reason = None
_set_signal_started_at(state, seconds_ago=seconds)
def _set_signal_started_at(state, *, seconds_ago: int = 0) -> None:
if hasattr(state, "signal_started_at"):
state.signal_started_at = time.monotonic() - max(0, seconds_ago)
def _set_debug_position(*, state, side: str) -> None:
state.status = "RUNNING"
state.last_signal = "BUY" if side == "LONG" else "SELL"
state.last_signal_confidence = 0.90
state.last_signal_repeat_count = 3
state.decision_status = "READY"
state.is_signal_confirmed = True
state.is_signal_ready = True
_set_signal_started_at(state, seconds_ago=15)
entry_price = _debug_entry_price(state.symbol, side)
size = _debug_size_for_notional(entry_price, notional=1000.0)
now = datetime.now().strftime("%H:%M:%S")
position = PositionState(
side=side,
symbol=state.symbol,
entry_price=entry_price,
size=size,
leverage=state.leverage,
unrealized_pnl_usd=0.0,
opened_at=now,
updated_at=now,
)
ExecutionEngine._position = position
_sync_state_from_position(state)
def _clear_debug_position(state) -> None:
ExecutionEngine._position = PositionState()
state.position_side = "NONE"
state.entry_price = None
state.position_size = None
state.unrealized_pnl_usd = None
def _sync_state_from_position(state) -> None:
position = ExecutionEngine().get_position()
state.position_side = position.side
state.entry_price = position.entry_price
state.position_size = position.size
state.unrealized_pnl_usd = position.unrealized_pnl_usd
def _debug_entry_price(symbol: str, side: str) -> float:
try:
snapshot = ExchangeService().get_market_snapshot(symbol)
if side == "LONG":
return float(snapshot.get("ask_price") or snapshot.get("last_price"))
if side == "SHORT":
return float(snapshot.get("bid_price") or snapshot.get("last_price"))
return float(snapshot.get("last_price"))
except Exception:
return 100000.0
def _debug_size_for_notional(entry_price: float, *, notional: float) -> float:
if entry_price <= 0:
return 0.0
value = notional / entry_price
factor = 10**5
return math.floor(value * factor) / factor
def _seconds_to_repeats(seconds: int) -> int:
return max(1, math.ceil(max(0, seconds) / 5))
def _parse_int(parts: list[str], *, index: int, default: int) -> int:
try:
return int(parts[index])
except (IndexError, TypeError, ValueError):
return default
def _parse_float(parts: list[str], *, index: int, default: float) -> float:
try:
return float(parts[index])
except (IndexError, TypeError, ValueError):
return default
async def _refresh_auto_screen() -> None:
AutoTradeRunner.set_current_screen("auto")
AutoTradeRunner._last_text = None
await AutoTradeRunner._refresh_screen(force=True)
async def _start_live_monitoring() -> None:
state = AutoTradeService().get_state()
state.status = "RUNNING"
_sync_state_from_position(state)
AutoTradeRunner.set_current_screen("auto")
AutoTradeRunner._last_text = None
await AutoTradeRunner.process_last_event_now()
await _refresh_auto_screen()
AutoTradeRunner.start()
async def _after_debug_execution() -> None:
state = AutoTradeService().get_state()
_sync_state_from_position(state)
AutoTradeRunner.set_current_screen("auto")
AutoTradeRunner._last_text = None
await AutoTradeRunner.process_last_event_now()
await _refresh_auto_screen()
def _execution_result_text(title: str, result, state) -> str:
_sync_state_from_position(state)
return ( return (
f"✅ Debug {title}\n\n" "<b>[DEBUG] Auto State</b>\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 (
"<b>Debug Auto State</b>\n\n"
f"Status: {state.status}\n" f"Status: {state.status}\n"
f"Symbol: {state.symbol}\n" f"Symbol: {state.symbol}\n"
f"Strategy: {state.strategy}\n" f"Strategy: {state.strategy}\n"
f"Risk: {state.risk_percent}\n" f"Risk: {state.risk_percent}\n"
f"Leverage: {state.leverage}\n\n" f"Leverage: {state.leverage}\n"
f"Signal: {state.last_signal}\n" f"Allocated: $ {_format_money_compact(state.allocated_balance_usd)}\n"
f"Realized PnL: {_format_signed_usd(state.realized_pnl_usd)}\n\n"
f"<b>Signal</b>\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"Repeats: {state.last_signal_repeat_count}\n"
f"Confidence: {state.last_signal_confidence:.2f}\n" f"Confidence: {state.last_signal_confidence:.2f}\n"
f"Decision: {state.decision_status}\n" f"Decision: {state.decision_status}\n"
f"Ready: {state.is_signal_ready}\n" f"Ready: {state.is_signal_ready}\n"
f"Signal started at: {getattr(state, 'signal_started_at', None)}\n\n" f"Reason: {state.last_signal_reason or ''}\n\n"
f"<b>Runner</b>\n" f"<b>Risk</b>\n"
f"Screen: {AutoTradeRunner._current_screen}\n" f"SL: {_format_percent(state.stop_loss_percent)}\n"
f"Chat ID: {AutoTradeRunner._chat_id}\n" f"TP: {_format_percent(state.take_profit_percent)}\n"
f"Message ID: {AutoTradeRunner._message_id}\n" f"ML: {_format_usd_or_off(state.max_loss_usd)}\n"
f"Has bot: {AutoTradeRunner._bot is not None}\n" f"Max Reserved: {_format_percent(state.max_reserved_balance_percent)}\n"
f"Has render_text: {AutoTradeRunner._render_text is not None}\n" f"Block: {state.execution_block_reason or ''}\n"
f"Task running: {runner_task_running}\n\n" f"Adjustment: {state.execution_size_adjustment_reason or ''}\n\n"
f"<b>Position</b>\n" f"<b>Position</b>\n"
f"Side: {state.position_side}\n" f"Side: {position.side}\n"
f"Entry: {state.entry_price}\n" f"Entry: {_format_usd_or_dash(position.entry_price)}\n"
f"Size: {state.position_size}\n" f"Size: {_format_crypto_size(position.size)}\n"
f"PnL: {state.unrealized_pnl_usd}" 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 return signal, confidence, repeat_count, None
@router.message(F.text.startswith("/debug_signal")) def _parse_int(parts: list[str], *, index: int, default: int) -> int:
async def debug_signal(message: Message) -> None: try:
if not _debug_enabled(): return int(parts[index])
await message.answer("Debug mode выключен.") except (IndexError, TypeError, ValueError):
return return default
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}"
)
@router.message(F.text == "/debug_ready") def _parse_float(parts: list[str], *, index: int, default: float) -> float:
async def debug_ready(message: Message) -> None: try:
if not _debug_enabled(): return float(parts[index])
await message.answer("Debug mode выключен.") except (IndexError, TypeError, ValueError):
return return default
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}"
)
@router.message(F.text == "/debug_state") def _signal_duration_text(state: DebugTradeState) -> str:
async def debug_state(message: Message) -> None: started_at = state.signal_started_at
if not _debug_enabled():
await message.answer("Debug mode выключен.")
return
state = AutoTradeService().get_state() if started_at is not None:
_sync_state_from_position(state) total_seconds = max(0, int(__import__("time").monotonic() - float(started_at)))
await message.answer(_debug_state_text(state)) 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"

View File

@@ -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.")

View File

@@ -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 = [
"🧪 <b>[DEBUG] Автоторговля</b>",
"",
f"<b>Status</b> · {state.status}",
f"<b>Актив</b> · {_asset_symbol(state.symbol)}",
f"<b>Стратегия</b> · {state.strategy or ''}",
f"<b>Баланс</b> · $ {_format_money_compact(state.allocated_balance_usd)}",
f"<b>Realized PnL</b> · {_format_signed_usd(state.realized_pnl_usd)}",
"",
_signal_line(state),
]
if state.last_signal != "HOLD":
parts.append(f"<b>Уверенность</b> · {state.last_signal_confidence:.2f}")
parts.extend(
[
f"<b>Decision</b> · {state.decision_status}",
"",
"<b>Risk</b>",
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(
[
"📭 <b>Позиция не открыта</b>",
"",
"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} <b>{position.side}</b> · {_asset_symbol(position.symbol)} · {_leverage_text(position.leverage)}",
"",
f"<b>Entry</b> · {_format_usd_or_dash(position.entry_price)}",
f"<b>Size</b> · {_format_crypto_size(position.size)}",
f"<b>Notional</b> · {_format_usd_or_dash(notional)}",
f"<b>Reserved</b> · {_format_usd_or_dash(reserved)}",
f"<b>PnL</b> · {_format_signed_usd(position.unrealized_pnl_usd)}",
f"<b>Opened</b> · {position.opened_at or ''}",
f"<b>Updated</b> · {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"<b>Сигнал</b> {_signal_icon(signal)} {signal} · READY"
return f"<b>Сигнал</b> {_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"

View File

@@ -3,6 +3,8 @@
from aiogram import Dispatcher from aiogram import Dispatcher
from src.telegram.handlers.auto import router as auto_router 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.home import router as home_router
from src.telegram.handlers.journal import router as journal_router from src.telegram.handlers.journal import router as journal_router
from src.telegram.handlers.market import router as market_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.system import router as system_router
from src.telegram.handlers.trade.main import router as trade_main_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.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: 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(trade_new_order_router)
dispatcher.include_router(auto_router) dispatcher.include_router(auto_router)
dispatcher.include_router(journal_router) dispatcher.include_router(journal_router)
dispatcher.include_router(debug_auto_router)
dispatcher.include_router(debug_router) dispatcher.include_router(debug_router)
dispatcher.include_router(system_router) dispatcher.include_router(system_router)

View File

@@ -0,0 +1 @@
from __future__ import annotations

View File

@@ -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")

View File

@@ -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

View File

@@ -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."

View File

@@ -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

View File

@@ -266,6 +266,30 @@
- integration testing flow for execution alerts - integration testing flow for execution alerts
- preparation for isolated debug runtime architecture - 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 ### 07.4.4
⏳ Grid Strategy ⏳ Grid Strategy

View File

@@ -253,6 +253,29 @@
- integration testing flow for execution alerts - integration testing flow for execution alerts
- preparation for isolated debug runtime architecture - 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 ### 07.4.4

View File

@@ -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