diff --git a/app/src/trading/execution/runtime_actions.py b/app/src/trading/execution/runtime_actions.py index 0c999ba..d6889b8 100644 --- a/app/src/trading/execution/runtime_actions.py +++ b/app/src/trading/execution/runtime_actions.py @@ -48,26 +48,31 @@ class _ExecutionRuntimeActionsProtocol(Protocol): def _sync_state_from_position( self, state: AutoTradeState, - ) -> None: ... + ) -> None: + ... def _close_position( self, state: AutoTradeState, *, forced_reason: str | None = None, - ) -> ExecutionDecision: ... + ) -> ExecutionDecision: + ... -class ExecutionRuntimeActionsMixin( - _ExecutionRuntimeActionsProtocol -): - # ----- Runtime autonomous actions subsystem. +class ExecutionRuntimeActionsMixin(_ExecutionRuntimeActionsProtocol): + # ----- Runtime autonomous actions subsystem. # Отвечает за: - # - runtime EXIT - # - runtime REDUCE - # - runtime PROTECT - # - cooldown runtime действий - # - runtime logging + # - autonomous EXIT; + # - autonomous PROTECT; + # - autonomous REDUCE; + # - cooldown runtime действий; + # - runtime logging. + # + # Важно: + # На текущем этапе PROTECT и REDUCE пока НЕ исполняют реальное действие. + # Они логируются как диагностические runtime-сигналы. + # Реальное закрытие позиции сейчас делает только AUTONOMOUS_ACTION_EXIT. _runtime_action_cooldown_seconds = RUNTIME_ACTION_COOLDOWN_SECONDS _last_runtime_action_key: str | None = None @@ -78,6 +83,10 @@ class ExecutionRuntimeActionsMixin( state: AutoTradeState, ) -> ExecutionDecision: # Главный runtime action processor. + # + # Этот метод вызывается после основного engine.process(). + # Если позиция открыта и autonomous_management выставил EXIT, + # здесь позиция может быть реально закрыта. self._sync_state_from_position(state) @@ -120,6 +129,9 @@ class ExecutionRuntimeActionsMixin( return ExecutionDecision(EXECUTION_ACTION_NONE, False, skip_reason) if action == AUTONOMOUS_ACTION_PROTECT: + # Пока PROTECT только логируется. + # Следующим этапом можно сделать его реальным действием: + # например, принудительно подтягивать break-even / profit-lock. return self._log_runtime_action( state=state, action=AUTONOMOUS_ACTION_PROTECT, @@ -129,6 +141,9 @@ class ExecutionRuntimeActionsMixin( ) if action == AUTONOMOUS_ACTION_REDUCE: + # Пока REDUCE только логируется. + # Если partial close не реализован, лучше позже перевести REDUCE + # в PROTECT или EXIT, чтобы не было иллюзии действия. return self._log_runtime_action( state=state, action=AUTONOMOUS_ACTION_REDUCE, @@ -138,24 +153,13 @@ class ExecutionRuntimeActionsMixin( ) if action == AUTONOMOUS_ACTION_EXIT: - if self._early_exit_guard_active(state): - hold_seconds = safe_float( - getattr(state, "position_hold_seconds", None) - ) or 0.0 - - thresholds = get_position_exit_thresholds( - getattr(state, "symbol", None) - ) - - min_hold = thresholds["min_hold"] + early_guard_reason = self._early_exit_guard_block_reason(state) + if early_guard_reason is not None: return self._log_runtime_action( state=state, action=AUTONOMOUS_ACTION_EXIT_BLOCKED, - reason=( - "early exit guard: позиция ещё слишком новая для закрытия " - f"({hold_seconds:.0f}s < {min_hold:.0f}s)" - ), + reason=early_guard_reason, confidence=confidence, executed=False, cooldown_action=None, @@ -207,6 +211,9 @@ class ExecutionRuntimeActionsMixin( action: str, ) -> bool: # Проверка cooldown runtime action. + # Cooldown нужен, чтобы один и тот же runtime action не спамил + # журнал и EventBus на каждом цикле. + ts = safe_float( getattr(state, "autonomous_last_action_at", None) ) @@ -225,6 +232,7 @@ class ExecutionRuntimeActionsMixin( time.monotonic() - ts ) < self._runtime_action_cooldown_seconds + # ----- PAYLOAD ----- def _build_runtime_action_payload( self, *, @@ -261,21 +269,24 @@ class ExecutionRuntimeActionsMixin( "unrealized_pnl_usd": state.unrealized_pnl_usd, "position_pnl_percent": state.position_pnl_percent, + # ---------- Health / intelligence ---------- **build_position_health_payload(state), "position_pressure": state.position_pressure, "position_exit_pressure": state.position_exit_pressure, **build_full_position_intelligence_payload(state), + # ---------- Autonomous ---------- **build_autonomous_payload(state), "autonomous_last_action": state.autonomous_last_action, "autonomous_last_action_reason": state.autonomous_last_action_reason, + # ---------- Protection / market / execution ---------- **build_runtime_protection_payload(state), **build_market_context_payload(state), **build_execution_quality_payload(state), } - + # ----- LOGGING ----- def _log_runtime_action( self, @@ -288,6 +299,11 @@ class ExecutionRuntimeActionsMixin( cooldown_action: str | None = None, ) -> ExecutionDecision: # Runtime action logging + deduplication. + # Даже если действие не исполняется, payload помогает понять: + # - почему runtime action появился; + # - почему он был заблокирован; + # - какие были position health / semantics / market context. + position = type(self)._position trade_id = position.trade_id or state.current_trade_id @@ -341,12 +357,23 @@ class ExecutionRuntimeActionsMixin( reason, ) - def _early_exit_guard_active(self, state: AutoTradeState) -> bool: + # ----- EARLY EXIT GUARD ----- + def _early_exit_guard_block_reason(self, state: AutoTradeState) -> str | None: + # Early exit guard защищает от слишком раннего закрытия позиции + # на обычном шуме/спреде/первой волне после входа. + # + # Но раньше он блокировал выход почти всегда до min_hold, + # пока убыток не доходил до hard_loss. + # + # Новая логика: + # - обычный ранний шум всё ещё блокируется; + # - реальное ухудшение позиции guard больше НЕ блокирует. + hold_seconds = safe_float(getattr(state, "position_hold_seconds", None)) pnl_percent = safe_float(getattr(state, "position_pnl_percent", None)) if hold_seconds is None or pnl_percent is None: - return False + return None thresholds = get_position_exit_thresholds( getattr(state, "symbol", None) @@ -356,10 +383,77 @@ class ExecutionRuntimeActionsMixin( hard_loss = thresholds["hard_loss"] if hold_seconds >= min_hold: - return False + return None # Если просадка уже критическая — guard не мешает защите. if pnl_percent <= hard_loss: - return False + return None - return True \ No newline at end of file + bypass_reason = self._early_exit_guard_bypass_reason(state) + + if bypass_reason is not None: + return None + + return ( + "early exit guard: позиция ещё слишком новая для закрытия " + f"({hold_seconds:.0f}s < {min_hold:.0f}s)" + ) + + def _early_exit_guard_bypass_reason(self, state: AutoTradeState) -> str | None: + # Причины, при которых ранний выход нужно разрешить. + # Это не делает выход автоматическим само по себе: + # action всё равно должен быть AUTONOMOUS_ACTION_EXIT, + # а confidence должен пройти RUNTIME_EXIT_CONFIDENCE_THRESHOLD. + + adverse_momentum = bool( + getattr(state, "position_adverse_momentum", False) + ) + + trend_alignment = str( + getattr(state, "position_trend_alignment", "") or "" + ).upper() + + risk_level = str( + getattr(state, "position_risk_level", "") or "" + ).upper() + + conviction_state = str( + getattr(state, "position_conviction_state", "") or "" + ).upper() + + stall_state = str( + getattr(state, "position_stall_state", "") or "" + ).upper() + + exit_urgency = str( + getattr(state, "position_exit_urgency", "") or "" + ).upper() + + decay_state = str( + getattr(state, "position_decay_state", "") or "" + ).upper() + + if risk_level in {"HIGH", "ELEVATED"}: + return f"early exit allowed: position risk is {risk_level}" + + if adverse_momentum and trend_alignment == "AGAINST": + return "early exit allowed: trend and momentum are against position" + + if conviction_state == "BROKEN": + return "early exit allowed: position conviction is broken" + + if stall_state == "ADVERSE_STALLED": + return "early exit allowed: position is adverse stalled" + + if exit_urgency in {"IMMEDIATE", "HIGH"}: + return f"early exit allowed: exit urgency is {exit_urgency}" + + if decay_state in {"ACCELERATING_LOSS", "CONTEXT_DECAY"}: + return f"early exit allowed: position decay is {decay_state}" + + return None + + # Старый публичный helper оставляем для совместимости, + # если где-то ещё в коде он вызывается напрямую. + def _early_exit_guard_active(self, state: AutoTradeState) -> bool: + return self._early_exit_guard_block_reason(state) is not None \ No newline at end of file