# app/src/trading/execution/position_exit_decision.py from __future__ import annotations import time from typing import ClassVar, Protocol from src.core.numbers import safe_float from src.trading.auto.state import AutoTradeState from src.trading.execution.position_metrics import PositionMetrics, build_position_metrics from src.trading.position.state import PositionState from src.trading.execution.constants import get_position_exit_thresholds class _ExecutionPositionExitDecisionProtocol(Protocol): _position: ClassVar[PositionState] class ExecutionPositionExitDecisionMixin(_ExecutionPositionExitDecisionProtocol): """ Execution-слой принятия решения о runtime-закрытии позиции. Важно: - этот файл НЕ рассчитывает PnL, движение цены и время удержания сам; - все числовые метрики позиции берутся из position_metrics.py; - здесь остаётся только логика принятия решения: закрывать позицию или нет. """ def _runtime_intelligence_close_reason( self, *, state: AutoTradeState, current_price: float, ) -> str | None: metrics = build_position_metrics( type(self)._position, current_price=current_price, ) # Защита от раннего выхода на обычной волне/откате. # Если позиция открыта недавно и просадка ещё в рамках нормальной # волатильности актива, intelligence-close не закрывает сделку. if self._is_normal_pullback_wave(state=state, metrics=metrics): return None giveback_reason = self._giveback_close_reason( state=state, metrics=metrics, ) if giveback_reason is not None: self._sync_intelligence_exit_state( state=state, reason=giveback_reason, algorithm="GIVEBACK", ) return giveback_reason time_decay_reason = self._time_decay_close_reason( state=state, metrics=metrics, ) if time_decay_reason is not None: self._sync_intelligence_exit_state( state=state, reason=time_decay_reason, algorithm="TIME_DECAY", ) return time_decay_reason return None def _sync_intelligence_exit_state( self, *, state: AutoTradeState, reason: str, algorithm: str, ) -> None: # В AutoTradeState сейчас нет отдельного поля position_exit_algorithm. # Поэтому алгоритм пишем в position_intelligence_reason — это поле уже есть # в state и попадёт дальше в диагностику / журнал закрытия. state.position_intelligence_reason = algorithm state.runtime_protection_action = "INTELLIGENCE_EXIT" state.runtime_protection_reason = reason state.runtime_protection_updated_at = time.monotonic() def _giveback_close_reason( self, *, state: AutoTradeState, metrics: PositionMetrics, ) -> str | None: price_move_percent = metrics.price_move_percent peak_percent = safe_float( getattr(state, "position_peak_pnl_percent", None) ) if peak_percent is None or peak_percent <= 0: return None giveback = peak_percent - price_move_percent if giveback <= 0: return None giveback_percent = round((giveback / peak_percent) * 100, 2) # Сохраняем рассчитанный giveback в state, # чтобы журнал закрытия видел именно то значение, # на основании которого принято решение. state.position_giveback_percent = giveback_percent fatigue_state = str( getattr(state, "position_fatigue_state", "") or "" ).upper() reversal_risk = str( getattr(state, "position_reversal_risk", "") or "" ).upper() adverse_momentum = bool( getattr(state, "position_adverse_momentum", False) ) exit_confidence = safe_float( getattr(state, "position_exit_confidence", None) ) or 0.0 thresholds = self._exit_thresholds(state) market_quality = str( getattr(state, "market_trend_quality", "") or "" ).upper() stall_state = str( getattr(state, "position_stall_state", "") or "" ).upper() # В CLEAN рынке даём прибыли больше пространства. # В NOISY рынке фиксируем быстрее, потому что откаты чаще съедают прибыль. if market_quality == "NOISY": min_peak = thresholds["noisy_giveback_min_peak"] giveback_limit = thresholds["noisy_giveback_percent"] else: min_peak = thresholds["clean_giveback_min_peak"] giveback_limit = thresholds["clean_giveback_percent"] if ( peak_percent >= min_peak and giveback_percent >= giveback_limit and price_move_percent > 0.10 ): return ( "NOISY_GIVEBACK_EXIT" if market_quality == "NOISY" else "CLEAN_GIVEBACK_EXIT" ) if ( stall_state in {"NOISY_STALLED", "ADVERSE_STALLED"} and peak_percent >= min_peak and giveback_percent >= max(25, giveback_limit - 10) and price_move_percent > 0 ): return "STALL_GIVEBACK_EXIT" if ( peak_percent >= 1.50 and giveback_percent >= 50 and price_move_percent > 0.25 ): return "GIVEBACK_PROFIT_LOCK" if ( peak_percent >= 1.20 and giveback_percent >= 60 and price_move_percent > 0.15 ): return "GIVEBACK_PROTECTION" if ( peak_percent >= 1.00 and giveback_percent >= 50 and adverse_momentum ): return "GIVEBACK_MOMENTUM_REVERSAL" if ( peak_percent >= 1.00 and giveback_percent >= 45 and fatigue_state in {"TIRED", "EXHAUSTED"} ): return "GIVEBACK_FATIGUE_EXIT" if ( peak_percent >= 1.00 and giveback_percent >= 45 and reversal_risk in {"ELEVATED", "HIGH"} and exit_confidence >= 0.60 ): return "GIVEBACK_REVERSAL_RISK" return None def _time_decay_close_reason( self, *, state: AutoTradeState, metrics: PositionMetrics, ) -> str | None: hold_seconds = metrics.hold_seconds if hold_seconds is None: return None price_move_percent = metrics.price_move_percent thresholds = self._exit_thresholds(state) # Hard-loss — отдельный аварийный intelligence-exit. # Если движение цены уже глубже допустимого порога, # не ждём fatigue / time-decay / adverse momentum. if price_move_percent <= thresholds["hard_loss"]: return "HARD_LOSS_EXIT" fatigue_state = str( getattr(state, "position_fatigue_state", "") or "" ).upper() conviction_state = str( getattr(state, "position_conviction_state", "") or "" ).upper() decay_state = str( getattr(state, "position_decay_state", "") or "" ).upper() adverse_momentum = bool( getattr(state, "position_adverse_momentum", False) ) market_runtime_degraded = bool( getattr(state, "market_runtime_degraded", False) ) net_pnl_usd = safe_float(getattr(metrics, "net_pnl_usd", None)) or 0.0 risk_level = str( getattr(state, "position_risk_level", "") or "" ).upper() # Time-decay не должен закрывать позицию просто потому, # что она долго стоит около нуля. # Разрешаем time-decay закрытие только если: # - сделка уже покрыла RT-комиссию и net PnL положительный; # - или есть реальное ухудшение: adverse momentum / HIGH risk / BROKEN conviction. real_deterioration = ( adverse_momentum or risk_level == "HIGH" or conviction_state == "BROKEN" ) market_quality = str( getattr(state, "market_trend_quality", "") or "" ).upper() peak_percent = safe_float( getattr(state, "position_peak_pnl_percent", None) ) or 0.0 giveback_percent = safe_float( getattr(state, "position_giveback_percent", None) ) or 0.0 # Специальный быстрый выход для NOISY рынка. # В шумном рынке не ждём классический time-decay 1500-2100 секунд: # если позиция после минимального времени уже в минусе # или быстро отдаёт прибыль, закрываем раньше. if market_quality == "NOISY" and hold_seconds >= thresholds["noisy_min_hold"]: if ( price_move_percent <= thresholds["noisy_loss_exit"] and adverse_momentum ): return "NOISY_ADVERSE_EXIT" if ( peak_percent > 0 and giveback_percent >= thresholds["noisy_profit_giveback"] and price_move_percent > 0 ): return "NOISY_PROFIT_GIVEBACK_EXIT" if net_pnl_usd <= 0 and not real_deterioration: return None # Нейтральную позицию по ETH/BTC/LTC/XRP держим дольше. # Например для ETH: если движение внутри ±0.40%, # не закрываем её по time-decay раньше neutral_min_hold. if ( hold_seconds < thresholds["neutral_min_hold"] and abs(price_move_percent) <= thresholds["neutral_band"] and not real_deterioration ): return None if ( hold_seconds >= thresholds["neutral_min_hold"] and -thresholds["neutral_band"] <= price_move_percent <= thresholds["neutral_band"] and conviction_state in {"WEAKENING", "BROKEN", "NEUTRAL"} ): return "TIME_DECAY_EXIT" if ( hold_seconds >= thresholds["min_hold"] and -thresholds["neutral_band"] <= price_move_percent <= thresholds["neutral_band"] and fatigue_state in {"TIRED", "EXHAUSTED"} ): return "TIME_DECAY_FATIGUE_EXIT" if ( hold_seconds >= thresholds["min_hold"] and price_move_percent <= thresholds["normal_pullback"] and adverse_momentum ): return "TIME_DECAY_ADVERSE_MOMENTUM" if ( hold_seconds >= thresholds["min_hold"] and price_move_percent <= thresholds["normal_pullback"] and market_runtime_degraded ): return "TIME_DECAY_DEGRADED_MARKET" if ( hold_seconds >= thresholds["neutral_min_hold"] and decay_state in {"TIME_DECAY", "CONTEXT_DECAY"} and price_move_percent <= thresholds["neutral_band"] ): return "TIME_DECAY_CONTEXT_DECAY" return None def _exit_thresholds(self, state: AutoTradeState) -> dict[str, float]: return get_position_exit_thresholds( getattr(state, "symbol", None) ) def _is_normal_pullback_wave( self, *, state: AutoTradeState, metrics: PositionMetrics, ) -> bool: thresholds = self._exit_thresholds(state) hold_seconds = safe_float(metrics.hold_seconds) price_move_percent = safe_float(metrics.price_move_percent) if hold_seconds is None or price_move_percent is None: return False # Если убыток уже глубже hard_loss — это не обычный откат. if price_move_percent <= thresholds["hard_loss"]: return False if hold_seconds >= thresholds["min_hold"]: return False if price_move_percent < thresholds["normal_pullback"]: return False adverse_momentum = bool( getattr(state, "position_adverse_momentum", False) ) risk_level = str( getattr(state, "position_risk_level", "") or "" ).upper() conviction_state = str( getattr(state, "position_conviction_state", "") or "" ).upper() # Если есть реальное ухудшение, это уже не обычный откат. # Так мы не блокируем быстрый выход в NOISY рынке, # когда momentum/риск явно против позиции. if adverse_momentum or risk_level == "HIGH" or conviction_state == "BROKEN": return False market_phase = str(getattr(state, "market_phase", "") or "").upper() market_quality = str(getattr(state, "market_trend_quality", "") or "").upper() market_structure = str(getattr(state, "market_structure", "") or "").upper() trend_alignment = str(getattr(state, "position_trend_alignment", "") or "").upper() # Обычный откат/шум/флэт после входа не должен сразу закрывать сделку. if market_phase in {"PULLBACK", "RANGE", "SQUEEZE"}: return True if market_quality == "NOISY" and trend_alignment != "AGAINST": return True if market_structure in {"HH_HL", "LH_LL", "MIXED"} and trend_alignment != "AGAINST": return True return False