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),
}
# получить свежий 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()

View File

@@ -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 (
"<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 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"
"<b>Paper execution:</b>\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"
"<b>Live paper test:</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"
"<b>Isolated Debug Execution:</b>\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"
"<b>Legacy aliases:</b>\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 (
"<b>Debug Auto State</b>\n\n"
"<b>[DEBUG] Auto State</b>\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"<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"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"<b>Runner</b>\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"<b>Risk</b>\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"<b>Position</b>\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))
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"

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

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