# app/src/trading/execution/engine.py from __future__ import annotations import time import math from dataclasses import dataclass from datetime import datetime from src.core.event_bus import EventBus from src.integrations.exchange.service import ExchangeService from src.trading.auto.state import AutoTradeState from src.trading.execution.models import ExecutionDecision from src.trading.journal.service import JournalService from src.trading.position.state import PositionState from src.core.numbers import safe_float from src.core.types import JsonDict, NumericLike @dataclass(slots=True) class _ExecutionPrice: price: float source: str age_seconds: float | None updated_at: str pricing_role: str class ExecutionEngine: _position = PositionState() _size_precision = 5 _min_flip_confidence = 0.75 _min_flip_repeat_count = 3 _min_flip_hold_seconds = 60 _loss_flip_confidence = 0.9 _last_flip_block_key: str | None = None def get_position(self) -> PositionState: return type(self)._position def process(self, state: AutoTradeState) -> ExecutionDecision: self._sync_state_from_position(state) if state.status != "RUNNING": return ExecutionDecision("NONE", False, "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, "Сигнал ещё не готов к execution.") if self._should_flip_position(state): flip_block_reason = self._flip_block_reason(state) if flip_block_reason is not None: return self._block_flip(state, flip_block_reason) return self._flip_position(state) if state.last_signal == "BUY": return self._open_position_if_empty(state=state, side="LONG", action="OPEN_LONG") if state.last_signal == "SELL": return self._open_position_if_empty(state=state, side="SHORT", action="OPEN_SHORT") return ExecutionDecision("NONE", False, "Нет торгового действия.") def _open_position_if_empty( self, *, state: AutoTradeState, side: str, action: str, ) -> ExecutionDecision: position = type(self)._position if position.side != "NONE": self._sync_state_from_position(state) return ExecutionDecision("NONE", False, "Позиция уже открыта.") try: entry = self._entry_price_for_side(state.symbol, side) entry_price = entry.price except Exception as exc: return ExecutionDecision("NONE", False, f"Не удалось получить цену для paper execution: {exc}") now = self._now_time() size = self._calculate_position_size(state, entry_price=entry_price) if size <= 0: return ExecutionDecision( "NONE", False, "Позиция не открыта: невозможно рассчитать adaptive size.", ) size = self._adjust_size_by_margin_limit( state=state, entry_price=entry_price, size=size, ) self._sync_effective_risk_after_margin_limit( state, base_size=state.adaptive_size_base or 0.0, final_size=size, ) size = self._round_size(size) if size <= 0: return ExecutionDecision( "NONE", False, "Позиция не открыта: итоговый size равен 0.", ) type(self)._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, ) self._sync_state_from_position(state) state.execution_block_reason = None state.last_flip_block_reason = None state.last_execution_action = action state.last_execution_reason = f"Позиция {side} открыта." payload: JsonDict = { "execution_type": "ENTRY", "action": action, "symbol": state.symbol, "side": side, "entry_price": entry_price, "size": size, "leverage": state.leverage, "signal": state.last_signal, "confidence": state.last_signal_confidence, "execution_confidence_score": state.execution_confidence_score, "execution_confidence_level": state.execution_confidence_level, "execution_confidence_reason": state.execution_confidence_reason, "adaptive_size_multiplier": state.adaptive_size_multiplier, "adaptive_size_reason": state.adaptive_size_reason, "adaptive_size_factors": state.adaptive_size_factors, "effective_risk_percent": state.effective_risk_percent, "effective_target_risk_usd": state.effective_target_risk_usd, "adaptive_size_base": state.adaptive_size_base, "adaptive_size_final": state.adaptive_size_final, "repeat_count": state.last_signal_repeat_count, "reason": state.last_signal_reason, "opened_at": now, "pricing": "ask_for_long_bid_for_short", "pricing_role": entry.pricing_role, "price_source": entry.source, "price_age_seconds": entry.age_seconds, "price_updated_at": entry.updated_at, } JournalService().log_ui_info( event_type="position_opened", message=f"Позиция {side} открыта: {state.symbol}.", screen="auto", action="paper_execution", payload=payload, ) EventBus.emit("paper_position_opened", payload) return ExecutionDecision(action, True, f"Позиция {side} открыта.") def _flip_position(self, state: AutoTradeState) -> ExecutionDecision: position = type(self)._position if position.side == "NONE": self._sync_state_from_position(state) return ExecutionDecision("NONE", False, "Нет позиции для flip.") new_side = self._target_side_from_signal(state.last_signal) if new_side is None: return ExecutionDecision("NONE", False, "Нет направления для flip.") try: exit_execution = self._exit_price_for_side(position.symbol or state.symbol, position.side) entry_execution = self._entry_price_for_side(state.symbol, new_side) exit_price = exit_execution.price new_entry_price = entry_execution.price except Exception as exc: return ExecutionDecision("NONE", False, f"Ошибка получения цены для flip: {exc}") now = self._now_time() pnl = self._calculate_pnl(exit_price) new_size = self._calculate_position_size(state, entry_price=new_entry_price) if new_size <= 0: return ExecutionDecision( "NONE", False, "Flip отменён: невозможно рассчитать adaptive size.", ) new_size = self._adjust_size_by_margin_limit( state=state, entry_price=new_entry_price, size=new_size, ) self._sync_effective_risk_after_margin_limit( state, base_size=state.adaptive_size_base or 0.0, final_size=new_size, ) new_size = self._round_size(new_size) if new_size <= 0: return ExecutionDecision( "NONE", False, "Flip отменён: итоговый size равен 0.", ) state.realized_pnl_usd += pnl state.cycle_realized_pnl_usd += pnl state.cycle_closed_trades += 1 if pnl > 0: state.cycle_winning_trades += 1 old_side = position.side old_entry_price = position.entry_price old_size = position.size old_leverage = position.leverage old_opened_at = position.opened_at state.last_flip_old_side = old_side state.last_flip_new_side = new_side state.last_flip_pnl_usd = pnl state.last_flip_reason = state.last_signal_reason state.last_flip_monotonic_at = time.monotonic() type(self)._position = PositionState( 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, ) self._sync_state_from_position(state) state.execution_block_reason = None state.last_flip_block_reason = None state.last_execution_action = f"FLIP_{old_side}_TO_{new_side}" state.last_execution_reason = "Направление позиции изменено." state.last_flip_at = now type(self)._last_flip_block_key = None payload: JsonDict = { "execution_type": "FLIP", "action": f"FLIP_{old_side}_TO_{new_side}", "symbol": state.symbol, "old_side": old_side, "new_side": new_side, "side": new_side, "entry_price": old_entry_price, "exit_price": exit_price, "new_entry_price": new_entry_price, "old_size": old_size, "new_size": new_size, "size": new_size, "old_leverage": old_leverage, "leverage": state.leverage, "pnl": pnl, "signal": state.last_signal, "confidence": state.last_signal_confidence, "execution_confidence_score": state.execution_confidence_score, "execution_confidence_level": state.execution_confidence_level, "execution_confidence_reason": state.execution_confidence_reason, "adaptive_size_multiplier": state.adaptive_size_multiplier, "adaptive_size_reason": state.adaptive_size_reason, "adaptive_size_factors": state.adaptive_size_factors, "effective_risk_percent": state.effective_risk_percent, "effective_target_risk_usd": state.effective_target_risk_usd, "adaptive_size_base": state.adaptive_size_base, "adaptive_size_final": state.adaptive_size_final, "repeat_count": state.last_signal_repeat_count, "reason": state.last_signal_reason, "opened_at": old_opened_at, "closed_at": now, "new_opened_at": now, "pricing": "exit_by_side_then_entry_by_side", "exit_pricing_role": exit_execution.pricing_role, "exit_price_source": exit_execution.source, "exit_price_age_seconds": exit_execution.age_seconds, "exit_price_updated_at": exit_execution.updated_at, "entry_pricing_role": entry_execution.pricing_role, "entry_price_source": entry_execution.source, "entry_price_age_seconds": entry_execution.age_seconds, "entry_price_updated_at": entry_execution.updated_at, } JournalService().log_ui_info( event_type="position_flipped", message=f"Направление позиции изменено: {old_side} → {new_side}.", screen="auto", action="paper_execution", payload=payload, ) EventBus.emit("paper_position_flipped", payload) return ExecutionDecision( f"FLIP_{old_side}_TO_{new_side}", True, f"Направление позиции изменено: {old_side} → {new_side}.", ) def _close_position( self, state: AutoTradeState, *, forced_reason: str | None = None, forced_exit_price: NumericLike | None = None, forced_pnl: NumericLike | None = None, forced_price_meta: _ExecutionPrice | None = None, ) -> ExecutionDecision: position = type(self)._position if position.side == "NONE": self._sync_state_from_position(state) return ExecutionDecision("NONE", False, "Нет открытой позиции для закрытия.") if forced_exit_price is not None: exit_price = safe_float(forced_exit_price) or 0.0 exit_execution = forced_price_meta else: try: exit_execution = self._exit_price_for_side(position.symbol or state.symbol, position.side) exit_price = exit_execution.price except Exception as exc: return ExecutionDecision("NONE", False, f"Ошибка получения цены для закрытия: {exc}") pnl = ( safe_float(forced_pnl) if forced_pnl is not None else self._calculate_pnl(exit_price) ) if pnl is None: pnl = 0.0 state.realized_pnl_usd += pnl state.cycle_realized_pnl_usd += pnl state.cycle_closed_trades += 1 if pnl > 0: state.cycle_winning_trades += 1 now = self._now_time() payload: JsonDict = { "execution_type": "EXIT", "action": "CLOSE", "symbol": state.symbol, "side": position.side, "entry_price": position.entry_price, "exit_price": exit_price, "size": position.size, "leverage": position.leverage, "pnl": pnl, "signal": state.last_signal, "confidence": state.last_signal_confidence, "repeat_count": state.last_signal_repeat_count, "reason": state.last_signal_reason, "risk_reason": forced_reason, "is_forced": forced_reason is not None, "opened_at": position.opened_at, "closed_at": now, "pricing": "bid_for_long_exit_ask_for_short_exit", "pricing_role": exit_execution.pricing_role if exit_execution else None, "price_source": exit_execution.source if exit_execution else None, "price_age_seconds": exit_execution.age_seconds if exit_execution else None, "price_updated_at": exit_execution.updated_at if exit_execution else None, } close_reason = forced_reason or "MANUAL" JournalService().log_ui_info( event_type="position_closed", message=f"Позиция {position.side} закрыта: {close_reason}.", screen="auto", action="paper_execution", payload=payload, ) EventBus.emit("paper_position_closed", payload) type(self)._position = PositionState() self._sync_state_from_position(state) state.execution_block_reason = None state.last_flip_block_reason = None state.last_execution_action = ( f"FORCE_CLOSE_{forced_reason}" if forced_reason is not None else "CLOSE" ) state.last_execution_reason = ( f"Позиция закрыта по правилу защиты: {forced_reason}." if forced_reason is not None else "Позиция закрыта." ) type(self)._last_flip_block_key = None if forced_reason is not None: return ExecutionDecision( f"FORCE_CLOSE_{forced_reason}", True, f"Позиция закрыта по правилу защиты: {forced_reason}.", ) return ExecutionDecision("CLOSE", True, "Позиция закрыта.") def _risk_close_decision(self, state: AutoTradeState) -> ExecutionDecision | None: position = type(self)._position if position.side == "NONE": return None try: current_execution = self._exit_price_for_side(position.symbol or state.symbol, position.side) current_price = current_execution.price except Exception: return None price_move_percent = self._calculate_price_move_percent(current_price) unrealized_pnl = self._calculate_pnl(current_price) if self._is_max_loss_hit(state, unrealized_pnl): return self._close_position( state, forced_reason="MAX_LOSS", forced_exit_price=current_price, forced_pnl=unrealized_pnl, forced_price_meta=current_execution, ) if self._is_stop_loss_hit(state, price_move_percent): return self._close_position( state, forced_reason="STOP_LOSS", forced_exit_price=current_price, forced_pnl=unrealized_pnl, forced_price_meta=current_execution, ) if self._is_take_profit_hit(state, price_move_percent): return self._close_position( state, forced_reason="TAKE_PROFIT", forced_exit_price=current_price, forced_pnl=unrealized_pnl, forced_price_meta=current_execution, ) return None def _is_stop_loss_hit(self, state: AutoTradeState, 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: AutoTradeState, 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: AutoTradeState, unrealized_pnl: float) -> bool: if state.max_loss_usd is None: return False return unrealized_pnl <= -abs(state.max_loss_usd) def _calculate_price_move_percent( self, current_price: NumericLike | None, ) -> float: position = type(self)._position price = safe_float(current_price) or 0.0 entry = safe_float(position.entry_price) or 0.0 if entry <= 0: return 0.0 if position.side == "LONG": return round(((price - entry) / entry) * 100, 4) if position.side == "SHORT": return round(((entry - price) / entry) * 100, 4) return 0.0 def _should_flip_position(self, state: AutoTradeState) -> bool: position = type(self)._position if position.side == "NONE": return False if position.side == "LONG" and state.last_signal == "SELL": return True if position.side == "SHORT" and state.last_signal == "BUY": return True return False def _flip_block_reason(self, state: AutoTradeState) -> str | None: position = type(self)._position confidence = safe_float(state.last_signal_confidence) or 0.0 repeat_count = int(safe_float(state.last_signal_repeat_count) or 0) unrealized_pnl = safe_float(state.unrealized_pnl_usd) or 0.0 hold_seconds = self._position_hold_seconds(position) momentum_direction = getattr(state, "momentum_direction", None) momentum_state = getattr(state, "momentum_state", None) signal = (state.last_signal or "").upper() if confidence < self._min_flip_confidence: return ( "уверенность сигнала ниже порога " f"({confidence:.2f} < {self._min_flip_confidence:.2f})" ) if repeat_count < self._min_flip_repeat_count: return ( "сигнал ещё не подтверждён нужным количеством повторов " f"({repeat_count} < {self._min_flip_repeat_count})" ) if hold_seconds is not None and hold_seconds < self._min_flip_hold_seconds: return ( "позиция открыта слишком недавно " f"({hold_seconds}с < {self._min_flip_hold_seconds}с)" ) if signal == "BUY" and momentum_direction == "DOWN": return "momentum направлен против BUY сигнала" if signal == "SELL" and momentum_direction == "UP": return "momentum направлен против SELL сигнала" if momentum_state in {"BREAKOUT_UP", "BREAKOUT_DOWN"}: if confidence < 0.85: return ( "flip заблокирован во время breakout impulse " f"({confidence:.2f} < 0.85)" ) if unrealized_pnl < 0 and confidence < self._loss_flip_confidence: return ( "позиция сейчас в минусе, а сигнал недостаточно сильный " f"({confidence:.2f} < {self._loss_flip_confidence:.2f})" ) return None def _block_flip( self, state: AutoTradeState, reason: str, ) -> ExecutionDecision: position = type(self)._position confidence = safe_float(state.last_signal_confidence) or 0.0 state.execution_block_reason = reason state.last_flip_block_reason = reason state.last_execution_action = "FLIP_BLOCKED" state.last_execution_reason = reason block_key = ( f"{position.side}:" f"{state.last_signal}:" f"{state.last_signal_repeat_count}:" f"{confidence:.2f}:" f"{reason}" ) if block_key != type(self)._last_flip_block_key: type(self)._last_flip_block_key = block_key payload: JsonDict = { "execution_type": "FLIP_BLOCKED", "symbol": state.symbol, "position_side": position.side, "signal": state.last_signal, "confidence": confidence, "repeat_count": state.last_signal_repeat_count, "reason": reason, "unrealized_pnl_usd": state.unrealized_pnl_usd, "opened_at": position.opened_at, "updated_at": position.updated_at, } JournalService().log_ui_warning( event_type="position_flip_blocked", message=f"Смена направления позиции заблокирована: {reason}.", screen="auto", action="paper_execution", payload=payload, ) EventBus.emit("paper_flip_blocked", payload) return ExecutionDecision("NONE", False, reason) def _position_hold_seconds(self, position: PositionState) -> int | None: if not position.opened_at: return None try: opened_at = datetime.strptime(position.opened_at, "%H:%M:%S") now = datetime.strptime(self._now_time(), "%H:%M:%S") seconds = int((now - opened_at).total_seconds()) if seconds < 0: seconds += 24 * 60 * 60 return seconds except Exception: return None def _target_side_from_signal(self, signal: str | None) -> str | None: if signal == "BUY": return "LONG" if signal == "SELL": return "SHORT" return None def _update_unrealized_pnl(self, state: AutoTradeState) -> None: position = type(self)._position if position.side == "NONE": self._sync_state_from_position(state) return try: current_execution = self._exit_price_for_side(position.symbol or state.symbol, position.side) current_price = current_execution.price except Exception: self._sync_state_from_position(state) return position.unrealized_pnl_usd = self._calculate_pnl(current_price) position.updated_at = self._now_time() self._sync_state_from_position(state) def _calculate_position_size( self, state: AutoTradeState, *, entry_price: float | None = None, ) -> float: if state.risk_percent is None or state.risk_percent <= 0: self._sync_adaptive_size_state(state, base_size=0.0, final_size=0.0, multiplier=0.0) return 0.0 if state.stop_loss_percent is None or state.stop_loss_percent <= 0: self._sync_adaptive_size_state(state, base_size=0.0, final_size=0.0, multiplier=0.0) return 0.0 price = entry_price if price is None: try: price = self._signal_entry_price(state).price except Exception: self._sync_adaptive_size_state(state, base_size=0.0, final_size=0.0, multiplier=0.0) return 0.0 if price <= 0: self._sync_adaptive_size_state(state, base_size=0.0, final_size=0.0, multiplier=0.0) return 0.0 balance_usd = state.allocated_balance_usd target_risk_usd = balance_usd * (state.risk_percent / 100) stop_loss_distance_usd = price * (state.stop_loss_percent / 100) if stop_loss_distance_usd <= 0: self._sync_adaptive_size_state(state, base_size=0.0, final_size=0.0, multiplier=0.0) return 0.0 base_size = target_risk_usd / stop_loss_distance_usd multiplier = self._adaptive_size_multiplier(state) final_size = base_size * multiplier self._sync_adaptive_size_state( state, base_size=base_size, final_size=final_size, multiplier=multiplier, ) return self._round_size(final_size) def _adaptive_size_multiplier(self, state: AutoTradeState) -> float: multiplier = 1.0 execution_confidence_score = getattr(state, "execution_confidence_score", None) score_raw = safe_float(execution_confidence_score) if score_raw is not None: score = max(0.0, min(1.0, score_raw)) if score < 0.55: multiplier *= 0.0 elif score < 0.65: multiplier *= 0.65 elif score < 0.75: multiplier *= 0.85 elif score >= 0.85: multiplier *= 1.15 market_state = getattr(state, "market_state", None) market_trend_strength = getattr(state, "market_trend_strength", None) market_trend_quality = getattr(state, "market_trend_quality", None) market_phase = getattr(state, "market_phase", None) if market_state in {"HIGH_VOLATILITY", "LOW_VOLATILITY", "RANGE"}: multiplier *= 0.65 if market_trend_strength == "STRONG": multiplier *= 1.1 elif market_trend_strength == "WEAK": multiplier *= 0.75 if market_trend_quality == "CLEAN": multiplier *= 1.05 elif market_trend_quality == "NOISY": multiplier *= 0.75 if market_phase == "IMPULSE": multiplier *= 1.1 elif market_phase == "PULLBACK": multiplier *= 0.8 elif market_phase in {"RANGE", "SQUEEZE"}: multiplier *= 0.7 momentum_state = getattr(state, "momentum_state", None) momentum_direction = getattr(state, "momentum_direction", None) momentum_strength = getattr(state, "momentum_strength", None) signal = (state.last_signal or "").upper() if momentum_state in {"BREAKOUT_UP", "BREAKOUT_DOWN"}: multiplier *= 1.15 elif momentum_state in {"MOMENTUM_UP", "MOMENTUM_DOWN"}: multiplier *= 1.05 if momentum_strength is not None: strength = safe_float(momentum_strength) if strength is not None: if strength >= 1.5: multiplier *= 1.1 elif strength <= 0.7: multiplier *= 0.8 if signal == "BUY": if momentum_direction == "DOWN": multiplier *= 0.75 if signal == "SELL": if momentum_direction == "UP": multiplier *= 0.75 execution_quality = getattr(state, "execution_quality", None) execution_quality_reason = getattr(state, "execution_quality_reason", None) if execution_quality == "BLOCKED": multiplier *= 0.0 elif execution_quality == "WARNING": if execution_quality_reason == "WIDE_SPREAD": multiplier *= 0.75 elif execution_quality_reason == "AGING_SNAPSHOT": multiplier *= 0.8 elif execution_quality_reason == "SNAPSHOT_UNAVAILABLE": multiplier *= 0.7 else: multiplier *= 0.8 return round(max(0.0, min(1.25, multiplier)), 4) def _sync_adaptive_size_state( self, state: AutoTradeState, *, base_size: float, final_size: float, multiplier: float, ) -> None: reason = self._adaptive_size_reason(multiplier) state.adaptive_size_base = self._round_size(base_size) state.adaptive_size_final = self._round_size(final_size) state.adaptive_size_multiplier = multiplier if multiplier != 1: state.adaptive_size_changed_at = time.monotonic() base_risk_percent = safe_float(state.risk_percent) or 0.0 state.effective_risk_percent = round( base_risk_percent * multiplier, 4, ) state.effective_target_risk_usd = round( state.allocated_balance_usd * (state.effective_risk_percent / 100), 4, ) state.adaptive_size_reason = reason state.adaptive_size_factors = { "execution_confidence_score": getattr(state, "execution_confidence_score", None), "execution_confidence_level": getattr(state, "execution_confidence_level", None), "market_state": getattr(state, "market_state", None), "market_trend_strength": getattr(state, "market_trend_strength", None), "market_trend_quality": getattr(state, "market_trend_quality", None), "market_phase": getattr(state, "market_phase", None), "momentum_state": getattr(state, "momentum_state", None), "momentum_direction": getattr(state, "momentum_direction", None), "momentum_strength": getattr(state, "momentum_strength", None), "execution_quality": getattr(state, "execution_quality", None), "execution_quality_reason": getattr(state, "execution_quality_reason", None), "spread_percent": getattr(state, "spread_percent", None), "base_size": self._round_size(base_size), "final_size": self._round_size(final_size), "multiplier": multiplier, } if multiplier <= 0: state.execution_size_adjustment_reason = "ADAPTIVE_SIZE_ZERO" elif multiplier < 1: state.execution_size_adjustment_reason = "ADAPTIVE_SIZE_REDUCED" elif multiplier > 1: state.execution_size_adjustment_reason = "ADAPTIVE_SIZE_INCREASED" else: state.execution_size_adjustment_reason = None def _sync_effective_risk_after_margin_limit( self, state: AutoTradeState, *, base_size: float, final_size: float, ) -> None: adaptive_final = safe_float(state.adaptive_size_final) or 0.0 if adaptive_final <= 0: state.effective_risk_percent = 0.0 state.effective_target_risk_usd = 0.0 return margin_ratio = max( 0.0, min(1.0, final_size / adaptive_final), ) current_effective_risk = safe_float(state.effective_risk_percent) or 0.0 state.effective_risk_percent = round( current_effective_risk * margin_ratio, 4, ) state.effective_target_risk_usd = round( state.allocated_balance_usd * (state.effective_risk_percent / 100), 4, ) def _adaptive_size_reason(self, multiplier: float) -> str: if multiplier <= 0: return "adaptive size заблокировал вход" if multiplier < 0.75: return "размер позиции сильно уменьшен по risk/runtime факторам" if multiplier < 1: return "размер позиции умеренно уменьшен по risk/runtime факторам" if multiplier > 1: return "размер позиции увеличен при сильном execution context" return "размер позиции без adaptive корректировки" def _adjust_size_by_margin_limit( self, *, state: AutoTradeState, entry_price: float, size: float, ) -> float: 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 = "Invalid leverage or entry price." return 0.0 balance_usd = state.allocated_balance_usd max_reserved_usd = 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" limited_size = self._round_size(max_size) adaptive_final = safe_float(state.adaptive_size_final) or 0.0 if adaptive_final > 0: effective_multiplier = limited_size / adaptive_final if effective_multiplier < 0.5: state.adaptive_size_reason = ( "размер позиции сильно ограничен margin limit" ) else: state.adaptive_size_reason = ( "размер позиции ограничен margin limit" ) return limited_size def _signal_entry_price(self, state: AutoTradeState) -> _ExecutionPrice: 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) -> _ExecutionPrice: snapshot = ExchangeService().get_execution_snapshot(symbol) if side == "LONG": return _ExecutionPrice( price=self._snapshot_price(snapshot.ask_price, "ask_price"), source=snapshot.source, age_seconds=snapshot.age_seconds, updated_at=snapshot.updated_at, pricing_role="LONG_ENTRY_ASK", ) if side == "SHORT": return _ExecutionPrice( price=self._snapshot_price(snapshot.bid_price, "bid_price"), source=snapshot.source, age_seconds=snapshot.age_seconds, updated_at=snapshot.updated_at, pricing_role="SHORT_ENTRY_BID", ) return _ExecutionPrice( price=self._snapshot_price(snapshot.last_price, "last_price"), source=snapshot.source, age_seconds=snapshot.age_seconds, updated_at=snapshot.updated_at, pricing_role="ENTRY_LAST", ) def _exit_price_for_side(self, symbol: str, side: str) -> _ExecutionPrice: snapshot = ExchangeService().get_execution_snapshot(symbol) if side == "LONG": return _ExecutionPrice( price=self._snapshot_price(snapshot.bid_price, "bid_price"), source=snapshot.source, age_seconds=snapshot.age_seconds, updated_at=snapshot.updated_at, pricing_role="LONG_EXIT_BID", ) if side == "SHORT": return _ExecutionPrice( price=self._snapshot_price(snapshot.ask_price, "ask_price"), source=snapshot.source, age_seconds=snapshot.age_seconds, updated_at=snapshot.updated_at, pricing_role="SHORT_EXIT_ASK", ) return _ExecutionPrice( price=self._snapshot_price(snapshot.last_price, "last_price"), source=snapshot.source, age_seconds=snapshot.age_seconds, updated_at=snapshot.updated_at, pricing_role="EXIT_LAST", ) def _market_last_price(self, symbol: str) -> _ExecutionPrice: snapshot = ExchangeService().get_execution_snapshot(symbol) return _ExecutionPrice( price=self._snapshot_price(snapshot.last_price, "last_price"), source=snapshot.source, age_seconds=snapshot.age_seconds, updated_at=snapshot.updated_at, pricing_role="MARKET_LAST", ) def _snapshot_price( self, raw_price: NumericLike | None, name: str, ) -> float: if raw_price is None: raise ValueError( f"Execution snapshot price '{name}' is missing." ) price = safe_float(raw_price) if price is None: raise ValueError( f"Execution snapshot price '{name}' is invalid." ) if price <= 0: raise ValueError( f"Execution snapshot price '{name}' is invalid: {price}" ) return price def _round_size(self, size: NumericLike | None) -> float: value = safe_float(size) if value is None: return 0.0 factor = 10 ** self._size_precision return math.floor(value * factor) / factor def _calculate_pnl( self, current_price: NumericLike | None, ) -> float: position = type(self)._position price = safe_float(current_price) or 0.0 entry = safe_float(position.entry_price) or 0.0 size = safe_float(position.size) or 0.0 if position.side == "LONG": return round((price - entry) * size, 4) if position.side == "SHORT": return round((entry - price) * size, 4) return 0.0 def _sync_state_from_position(self, state: AutoTradeState) -> None: position = type(self)._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 _now_time(self) -> str: return datetime.now().strftime("%H:%M:%S")