Relax early autonomous exit guard

This commit is contained in:
2026-07-03 13:15:38 +03:00
parent 3dde8d3e87
commit 5f1f522fd7

View File

@@ -48,26 +48,31 @@ class _ExecutionRuntimeActionsProtocol(Protocol):
def _sync_state_from_position( def _sync_state_from_position(
self, self,
state: AutoTradeState, state: AutoTradeState,
) -> None: ... ) -> None:
...
def _close_position( def _close_position(
self, self,
state: AutoTradeState, state: AutoTradeState,
*, *,
forced_reason: str | None = None, forced_reason: str | None = None,
) -> ExecutionDecision: ... ) -> ExecutionDecision:
...
class ExecutionRuntimeActionsMixin( class ExecutionRuntimeActionsMixin(_ExecutionRuntimeActionsProtocol):
_ExecutionRuntimeActionsProtocol # ----- Runtime autonomous actions subsystem.
):
# ----- Runtime autonomous actions subsystem.
# Отвечает за: # Отвечает за:
# - runtime EXIT # - autonomous EXIT;
# - runtime REDUCE # - autonomous PROTECT;
# - runtime PROTECT # - autonomous REDUCE;
# - cooldown runtime действий # - cooldown runtime действий;
# - runtime logging # - runtime logging.
#
# Важно:
# На текущем этапе PROTECT и REDUCE пока НЕ исполняют реальное действие.
# Они логируются как диагностические runtime-сигналы.
# Реальное закрытие позиции сейчас делает только AUTONOMOUS_ACTION_EXIT.
_runtime_action_cooldown_seconds = RUNTIME_ACTION_COOLDOWN_SECONDS _runtime_action_cooldown_seconds = RUNTIME_ACTION_COOLDOWN_SECONDS
_last_runtime_action_key: str | None = None _last_runtime_action_key: str | None = None
@@ -78,6 +83,10 @@ class ExecutionRuntimeActionsMixin(
state: AutoTradeState, state: AutoTradeState,
) -> ExecutionDecision: ) -> ExecutionDecision:
# Главный runtime action processor. # Главный runtime action processor.
#
# Этот метод вызывается после основного engine.process().
# Если позиция открыта и autonomous_management выставил EXIT,
# здесь позиция может быть реально закрыта.
self._sync_state_from_position(state) self._sync_state_from_position(state)
@@ -120,6 +129,9 @@ class ExecutionRuntimeActionsMixin(
return ExecutionDecision(EXECUTION_ACTION_NONE, False, skip_reason) return ExecutionDecision(EXECUTION_ACTION_NONE, False, skip_reason)
if action == AUTONOMOUS_ACTION_PROTECT: if action == AUTONOMOUS_ACTION_PROTECT:
# Пока PROTECT только логируется.
# Следующим этапом можно сделать его реальным действием:
# например, принудительно подтягивать break-even / profit-lock.
return self._log_runtime_action( return self._log_runtime_action(
state=state, state=state,
action=AUTONOMOUS_ACTION_PROTECT, action=AUTONOMOUS_ACTION_PROTECT,
@@ -129,6 +141,9 @@ class ExecutionRuntimeActionsMixin(
) )
if action == AUTONOMOUS_ACTION_REDUCE: if action == AUTONOMOUS_ACTION_REDUCE:
# Пока REDUCE только логируется.
# Если partial close не реализован, лучше позже перевести REDUCE
# в PROTECT или EXIT, чтобы не было иллюзии действия.
return self._log_runtime_action( return self._log_runtime_action(
state=state, state=state,
action=AUTONOMOUS_ACTION_REDUCE, action=AUTONOMOUS_ACTION_REDUCE,
@@ -138,24 +153,13 @@ class ExecutionRuntimeActionsMixin(
) )
if action == AUTONOMOUS_ACTION_EXIT: if action == AUTONOMOUS_ACTION_EXIT:
if self._early_exit_guard_active(state): early_guard_reason = self._early_exit_guard_block_reason(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"]
if early_guard_reason is not None:
return self._log_runtime_action( return self._log_runtime_action(
state=state, state=state,
action=AUTONOMOUS_ACTION_EXIT_BLOCKED, action=AUTONOMOUS_ACTION_EXIT_BLOCKED,
reason=( reason=early_guard_reason,
"early exit guard: позиция ещё слишком новая для закрытия "
f"({hold_seconds:.0f}s < {min_hold:.0f}s)"
),
confidence=confidence, confidence=confidence,
executed=False, executed=False,
cooldown_action=None, cooldown_action=None,
@@ -207,6 +211,9 @@ class ExecutionRuntimeActionsMixin(
action: str, action: str,
) -> bool: ) -> bool:
# Проверка cooldown runtime action. # Проверка cooldown runtime action.
# Cooldown нужен, чтобы один и тот же runtime action не спамил
# журнал и EventBus на каждом цикле.
ts = safe_float( ts = safe_float(
getattr(state, "autonomous_last_action_at", None) getattr(state, "autonomous_last_action_at", None)
) )
@@ -225,6 +232,7 @@ class ExecutionRuntimeActionsMixin(
time.monotonic() - ts time.monotonic() - ts
) < self._runtime_action_cooldown_seconds ) < self._runtime_action_cooldown_seconds
# ----- PAYLOAD -----
def _build_runtime_action_payload( def _build_runtime_action_payload(
self, self,
*, *,
@@ -261,21 +269,24 @@ class ExecutionRuntimeActionsMixin(
"unrealized_pnl_usd": state.unrealized_pnl_usd, "unrealized_pnl_usd": state.unrealized_pnl_usd,
"position_pnl_percent": state.position_pnl_percent, "position_pnl_percent": state.position_pnl_percent,
# ---------- Health / intelligence ----------
**build_position_health_payload(state), **build_position_health_payload(state),
"position_pressure": state.position_pressure, "position_pressure": state.position_pressure,
"position_exit_pressure": state.position_exit_pressure, "position_exit_pressure": state.position_exit_pressure,
**build_full_position_intelligence_payload(state), **build_full_position_intelligence_payload(state),
# ---------- Autonomous ----------
**build_autonomous_payload(state), **build_autonomous_payload(state),
"autonomous_last_action": state.autonomous_last_action, "autonomous_last_action": state.autonomous_last_action,
"autonomous_last_action_reason": state.autonomous_last_action_reason, "autonomous_last_action_reason": state.autonomous_last_action_reason,
# ---------- Protection / market / execution ----------
**build_runtime_protection_payload(state), **build_runtime_protection_payload(state),
**build_market_context_payload(state), **build_market_context_payload(state),
**build_execution_quality_payload(state), **build_execution_quality_payload(state),
} }
# ----- LOGGING ----- # ----- LOGGING -----
def _log_runtime_action( def _log_runtime_action(
self, self,
@@ -288,6 +299,11 @@ class ExecutionRuntimeActionsMixin(
cooldown_action: str | None = None, cooldown_action: str | None = None,
) -> ExecutionDecision: ) -> ExecutionDecision:
# Runtime action logging + deduplication. # Runtime action logging + deduplication.
# Даже если действие не исполняется, payload помогает понять:
# - почему runtime action появился;
# - почему он был заблокирован;
# - какие были position health / semantics / market context.
position = type(self)._position position = type(self)._position
trade_id = position.trade_id or state.current_trade_id trade_id = position.trade_id or state.current_trade_id
@@ -341,12 +357,23 @@ class ExecutionRuntimeActionsMixin(
reason, 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)) hold_seconds = safe_float(getattr(state, "position_hold_seconds", None))
pnl_percent = safe_float(getattr(state, "position_pnl_percent", None)) pnl_percent = safe_float(getattr(state, "position_pnl_percent", None))
if hold_seconds is None or pnl_percent is None: if hold_seconds is None or pnl_percent is None:
return False return None
thresholds = get_position_exit_thresholds( thresholds = get_position_exit_thresholds(
getattr(state, "symbol", None) getattr(state, "symbol", None)
@@ -356,10 +383,77 @@ class ExecutionRuntimeActionsMixin(
hard_loss = thresholds["hard_loss"] hard_loss = thresholds["hard_loss"]
if hold_seconds >= min_hold: if hold_seconds >= min_hold:
return False return None
# Если просадка уже критическая — guard не мешает защите. # Если просадка уже критическая — guard не мешает защите.
if pnl_percent <= hard_loss: if pnl_percent <= hard_loss:
return False return None
return True 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