Stage 07.4.3.15 — Isolated debug runtime and debug auto screen
This commit is contained in:
1
app/src/trading/debug/__init__.py
Normal file
1
app/src/trading/debug/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
443
app/src/trading/debug/execution.py
Normal file
443
app/src/trading/debug/execution.py
Normal 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")
|
||||
170
app/src/trading/debug/runner.py
Normal file
170
app/src/trading/debug/runner.py
Normal 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
|
||||
219
app/src/trading/debug/service.py
Normal file
219
app/src/trading/debug/service.py
Normal 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."
|
||||
57
app/src/trading/debug/state.py
Normal file
57
app/src/trading/debug/state.py
Normal 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
|
||||
Reference in New Issue
Block a user