diff --git a/app/src/telegram/handlers/auto/ui.py b/app/src/telegram/handlers/auto/ui.py index af2df4c..6b5def9 100644 --- a/app/src/telegram/handlers/auto/ui.py +++ b/app/src/telegram/handlers/auto/ui.py @@ -41,21 +41,21 @@ def auto_keyboard() -> InlineKeyboardMarkup: status = (state.status or "").upper() if status == "OFF": - builder.button(text="▶️ Start", callback_data="auto:start") - builder.button(text="👀 Watch", callback_data="auto:observe") + builder.button(text="▶️ Запустить", callback_data="auto:start") + builder.button(text="👀 Наблюдать", callback_data="auto:observe") elif status == "RUNNING": - builder.button(text="👀 Watch", callback_data="auto:observe") - builder.button(text="🛑 Stop", callback_data="auto:stop") + builder.button(text="👀 Наблюдать", callback_data="auto:observe") + builder.button(text="🛑 Остановить", callback_data="auto:stop") elif status == "OBSERVING": - builder.button(text="▶️ Start", callback_data="auto:start") - builder.button(text="🛑 Stop", callback_data="auto:stop") + builder.button(text="▶️ Запустить", callback_data="auto:start") + builder.button(text="🛑 Остановить", callback_data="auto:stop") else: - builder.button(text="▶️ Start", callback_data="auto:start") - builder.button(text="👀 Watch", callback_data="auto:observe") - builder.button(text="🛑 Stop", callback_data="auto:stop") + builder.button(text="▶️ Запустить", callback_data="auto:start") + builder.button(text="👀 Наблюдать", callback_data="auto:observe") + builder.button(text="🛑 Остановить", callback_data="auto:stop") builder.button(text="🛠️ Настройки", callback_data="settings:auto") builder.button(text="🧯 Защита", callback_data="auto:risk") @@ -192,7 +192,7 @@ def _settings_risk_percent_line(state) -> str: ml = ( f"-{_format_money_compact(abs(state.max_loss_usd))}" if state.max_loss_usd is not None - else "off" + else "выкл" ) return f"SL {sl} · TP {tp} · ML {ml}" @@ -205,15 +205,10 @@ def _build_waiting_text(state) -> str: estimated_size = _estimated_size(state, price) cycle_trades = int(getattr(state, "cycle_closed_trades", 0) or 0) - - cycle_pnl = float( - getattr(state, "cycle_realized_pnl_usd", 0.0) or 0.0 - ) + cycle_pnl = float(getattr(state, "cycle_realized_pnl_usd", 0.0) or 0.0) parts = [ - ( - f"{_status_text(state)}" - ), + f"{_status_text(state)}", _account_mode_line(), "", f"Доступно 💰 {_format_money_compact(available)}", @@ -222,10 +217,7 @@ def _build_waiting_text(state) -> str: if cycle_trades > 0: parts.extend([ "", - ( - f"🔄 {_cycle_number_text(state)} · " - f"{cycle_trades} {_trade_word(cycle_trades)}" - ), + f"🔄 {_cycle_number_text(state)} · {cycle_trades} {_trade_word(cycle_trades)}", _format_pnl_line(cycle_pnl), ]) @@ -236,37 +228,50 @@ def _build_waiting_text(state) -> str: parts.extend([ "", _signal_line(state), - "", ]) + execution_runtime_line = _execution_runtime_line(state) + if execution_runtime_line: + parts.extend([ + "", + execution_runtime_line, + ]) + block_title = ( "Прогноз сделки 🔮" if state.status == "OBSERVING" else "Подготовка ордера 🧾" ) - parts.extend([ + order_lines = [ + "", block_title, _order_header_line(state), f"Цена · {_format_plain_or_dash(price)}", _estimated_size_text(state, price), _max_reserved_line(state, price), _effective_risk_line(state), + ] + + execution_confidence_line = _execution_confidence_line(state) + if execution_confidence_line: + order_lines.append(execution_confidence_line) + + order_lines.append( _risk_summary_line( state, estimated_size, entry_price_override=price, - ), - ]) + ) + ) + + parts.extend(order_lines) adjustment_visible = _adaptive_adjustment_visible(state) if adjustment_visible: reason = getattr(state, "adaptive_size_reason", "") or "" - - multiplier = float( - getattr(state, "adaptive_size_multiplier", 1.0) or 1.0 - ) + multiplier = float(getattr(state, "adaptive_size_multiplier", 1.0) or 1.0) parts.extend([ "", @@ -280,6 +285,50 @@ def _build_waiting_text(state) -> str: return "\n".join(parts) +def _execution_runtime_line(state) -> str: + quality = str( + getattr(state, "execution_quality", "") or "" + ).upper() + + reason = str( + getattr(state, "execution_quality_reason", "") or "" + ).upper() + + freshness = _execution_freshness_text(state) + + if quality == "GOOD": + return "" + + if quality == "WARNING": + if reason == "WIDE_SPREAD": + return f"Исполнение ⚠️ Повышенный spread · {freshness}" + + if reason == "AGING_SNAPSHOT": + return f"Исполнение ⚠️ Snapshot стареет · {freshness}" + + if reason == "SNAPSHOT_UNAVAILABLE": + return f"Исполнение ⚠️ Нет стакана · {freshness}" + + return f"Исполнение ⚠️ Предупреждение · {freshness}" + + if quality == "BLOCKED": + if reason == "MARKET_CLOSED": + return "Исполнение ⏸️ Рынок закрыт" + + if reason == "STALE_SNAPSHOT": + return f"Исполнение 🔴 Snapshot устарел · {freshness}" + + if reason == "HIGH_SPREAD": + return f"Исполнение 🔴 Высокий spread · {freshness}" + + if reason == "SNAPSHOT_ERROR": + return "Исполнение 🔴 Нет данных рынка" + + return f"Исполнение 🔴 Заблокировано · {freshness}" + + return "" + + def _build_active_position_text(state) -> str: current_price = _current_price(state.symbol) price_for_calc = current_price or state.entry_price or 0.0 @@ -356,9 +405,14 @@ def _build_active_position_text(state) -> str: ), f"Объём · {_format_usd_compact(notional)}", _format_pnl_line(pnl), - _risk_summary_line(state, size), ]) + execution_runtime_line = _execution_runtime_line(state) + if execution_runtime_line: + parts.append(execution_runtime_line) + + parts.append(_risk_summary_line(state, size)) + if pnl < 0: reason_block = _position_warning_reason(state) @@ -528,6 +582,40 @@ def _effective_risk_line(state) -> str: return f"Риск · {_format_usd_compact(_target_risk_usd(state))}" +def _execution_confidence_line(state) -> str: + score = safe_float( + getattr(state, "execution_confidence_score", None) + ) + + if score is None: + return "" + + level = str( + getattr(state, "execution_confidence_level", "") or "" + ).upper() + + if level == "HIGH": + icon = "🟢" + level_text = "Высокая" + + elif level == "NORMAL": + icon = "🟡" + level_text = "Нормальная" + + elif level == "LOW": + icon = "🔴" + level_text = "Низкая" + + else: + icon = "⚪" + level_text = "Неизвестно" + + return ( + f"Уверенность исполнения " + f"{icon} {level_text} · {score:.2f}" + ) + + def _estimated_size(state, price: float | None) -> float | None: if ( price is None @@ -642,13 +730,13 @@ def _risk_summary_line( key, value = enabled[0] if key == "SL": - return f"Stop Loss -{_format_usd_compact(value)}" + return f"Stop Loss −{_format_usd_compact(value)}" if key == "TP": return f"Take Profit +{_format_usd_compact(value)}" if key == "ML": - return f"Max Loss -{_format_usd_compact(value)}" + return f"Макс. убыток −{_format_usd_compact(value)}" items: list[str] = [] @@ -662,7 +750,7 @@ def _risk_summary_line( items.append(f"ML -{_format_usd_compact(ml_value)}") if not items: - return "SL off · TP off · ML off" + return "SL выкл · TP выкл · ML выкл" return " · ".join(items) @@ -739,7 +827,7 @@ def _signal_text(signal: str) -> str: mapping = { "BUY": "Long", "SELL": "Short", - "HOLD": "Hold", + "HOLD": "Ожидание", } return mapping.get(signal.upper(), signal.title()) @@ -757,7 +845,7 @@ def _signal_line(state) -> str: state.decision_status == "READY" or getattr(state, "is_signal_ready", False) ): - return f"{signal_text} · READY" + return f"{signal_text} · готов" duration = _signal_duration_text(state) @@ -845,6 +933,21 @@ def _signal_duration_text(state) -> str: return f"{seconds}с" +def _execution_freshness_text(state) -> str: + freshness = str( + getattr(state, "execution_price_freshness", "") or "" + ).upper() + + mapping = { + "FRESH": "данные свежие", + "AGING": "данные стареют", + "STALE": "данные устарели", + "UNKNOWN": "нет данных", + } + + return mapping.get(freshness, "нет данных") + + def _status_text(state) -> str: runtime = _cycle_runtime_text(state) @@ -1012,7 +1115,7 @@ def _format_crypto_size(value: float | int | None) -> str: def _format_percent(value: float | int | None) -> str: if value is None: - return "off" + return "выкл" number = float(value) @@ -1085,24 +1188,21 @@ def _adaptive_adjustment_visible(state) -> bool: return _adaptive_size_active(state) and _show_adaptive_banner(state) -def _short_adaptive_reason(reason: str, multiplier: float | None = None) -> str: - if multiplier is not None: - try: - value = float(multiplier) - except (TypeError, ValueError): - value = 1.0 +def _short_adaptive_reason( + reason: str, + multiplier: float | None = None, +) -> str: + value = safe_float(multiplier) + if value is not None: if value < 0.15: - return "Вход почти заблокирован" - - if value < 0.40: - return "Размер сильно уменьшен" + return "Вход заблокирован" if value < 0.75: - return "Размер уменьшен" + return "Риск снижен" if value < 1.0: - return "Небольшая коррекция" + return "Размер уменьшен" if value > 1.05: return "Размер увеличен" @@ -1122,6 +1222,6 @@ def _short_adaptive_reason(reason: str, multiplier: float | None = None) -> str: return "Низкая уверенность" if not reason: - return "Размер изменён" + return "Размер скорректирован" return reason[:1].upper() + reason[1:] \ No newline at end of file diff --git a/app/src/trading/auto/service.py b/app/src/trading/auto/service.py index 40bb48e..0121e86 100644 --- a/app/src/trading/auto/service.py +++ b/app/src/trading/auto/service.py @@ -526,6 +526,12 @@ class AutoTradeService: state.execution_quality = None state.execution_quality_reason = None state.execution_quality_message = None + state.execution_price_source = None + state.execution_price_age_seconds = None + state.execution_bid_price = None + state.execution_ask_price = None + state.execution_last_price = None + state.execution_price_freshness = None state.execution_confidence_score = None state.execution_confidence_level = None state.execution_confidence_required_score = self._execution_confidence_required_score @@ -579,6 +585,49 @@ class AutoTradeService: state.snapshot_age_seconds = None state.spread_percent = None + state.position_pnl_percent = None + state.position_hold_seconds = None + state.position_pressure = None + state.position_health_score = None + state.position_health_status = None + state.position_health_reason = None + state.position_risk_level = None + state.position_risk_reason = None + state.position_trend_alignment = None + state.position_adverse_momentum = False + state.position_exit_pressure = None + + state.position_lifecycle_stage = None + state.position_hold_quality = None + state.position_decay_state = None + state.position_exit_confidence = None + state.position_exit_signal = None + state.position_intelligence_reason = None + state.position_recommended_action = None + + state.position_peak_pnl_usd = None + state.position_peak_pnl_percent = None + state.position_mfe_percent = None + state.position_mae_percent = None + state.position_fatigue_score = None + state.position_fatigue_state = None + state.position_giveback_percent = None + state.position_conviction_state = None + state.position_exit_urgency = None + state.position_reversal_risk = None + + state.autonomous_action = None + state.autonomous_action_reason = None + state.autonomous_action_confidence = None + state.autonomous_protection_required = False + state.autonomous_reduce_required = False + state.autonomous_exit_required = False + state.autonomous_last_action = None + state.autonomous_last_action_reason = None + state.autonomous_last_action_at = None + + state.last_loss_monotonic_at = None + # собрать контекст для стратегии def _build_strategy_context(self) -> StrategyContext: state = self.get_state() @@ -1394,6 +1443,11 @@ class AutoTradeService: is_fresh = bool(snapshot.get("is_fresh", False)) source = str(snapshot.get("source") or "") + self._sync_execution_pricing_state( + state, + snapshot, + ) + state.snapshot_age_seconds = age_seconds state.spread_percent = self._spread_percent( bid_price=bid_price, @@ -1490,6 +1544,767 @@ class AutoTradeService: return round((spread / mid_price) * 100, 5) + def _sync_execution_pricing_state( + self, + state: AutoTradeState, + snapshot: JsonDict, + ) -> None: + age_seconds = safe_float(snapshot.get("age_seconds")) + + state.execution_price_source = str(snapshot.get("source") or "") + state.execution_price_age_seconds = age_seconds + state.execution_bid_price = safe_float(snapshot.get("bid_price")) + state.execution_ask_price = safe_float(snapshot.get("ask_price")) + state.execution_last_price = safe_float(snapshot.get("last_price")) + + if age_seconds is None: + state.execution_price_freshness = "UNKNOWN" + elif age_seconds <= 1: + state.execution_price_freshness = "FRESH" + elif age_seconds <= self._warning_snapshot_age_seconds: + state.execution_price_freshness = "AGING" + else: + state.execution_price_freshness = "STALE" + + def _sync_position_health_state(self, state: AutoTradeState) -> None: + if state.position_side == "NONE" or state.entry_price is None: + state.position_pnl_percent = None + state.position_hold_seconds = None + state.position_pressure = None + state.position_health_score = None + state.position_health_status = None + state.position_health_reason = None + state.position_risk_level = None + state.position_risk_reason = None + state.position_trend_alignment = None + state.position_adverse_momentum = False + state.position_exit_pressure = None + return + + pnl_percent = self._position_pnl_percent(state) + hold_seconds = self._position_hold_seconds(state) + trend_alignment = self._position_trend_alignment(state) + adverse_momentum = self._has_adverse_position_momentum(state) + + pressure = self._position_pressure( + state=state, + pnl_percent=pnl_percent, + ) + + health_score = self._position_health_score( + state=state, + pnl_percent=pnl_percent, + trend_alignment=trend_alignment, + adverse_momentum=adverse_momentum, + ) + + risk_level, risk_reason = self._position_risk_level( + state=state, + pnl_percent=pnl_percent, + trend_alignment=trend_alignment, + adverse_momentum=adverse_momentum, + ) + + state.position_pnl_percent = pnl_percent + state.position_hold_seconds = hold_seconds + state.position_pressure = pressure + state.position_health_score = health_score + state.position_health_status = self._position_health_status(health_score) + state.position_health_reason = self._position_health_reason( + pressure=pressure, + trend_alignment=trend_alignment, + adverse_momentum=adverse_momentum, + ) + state.position_risk_level = risk_level + state.position_risk_reason = risk_reason + state.position_trend_alignment = trend_alignment + state.position_adverse_momentum = adverse_momentum + state.position_exit_pressure = self._position_exit_pressure( + state=state, + pnl_percent=pnl_percent, + risk_level=risk_level, + ) + + def _position_pnl_percent(self, state: AutoTradeState) -> float | None: + entry_price = safe_float(state.entry_price) + size = safe_float(state.position_size) + pnl = safe_float(state.unrealized_pnl_usd) + + if entry_price is None or entry_price <= 0: + return None + + if size is None or size <= 0: + return None + + if pnl is None: + return None + + notional = entry_price * size + + if notional <= 0: + return None + + return round((pnl / notional) * 100, 4) + + def _position_hold_seconds(self, state: AutoTradeState) -> int | None: + opened_at = getattr(state, "position_opened_monotonic_at", None) + + if opened_at is None: + return None + + opened = safe_float(opened_at) + + if opened is None: + return None + + return max(0, int(time.monotonic() - opened)) + + def _position_pressure( + self, + *, + state: AutoTradeState, + pnl_percent: float | None, + ) -> str: + pnl = safe_float(state.unrealized_pnl_usd) or 0.0 + + if pnl_percent is None: + if pnl < 0: + return "LOSS" + if pnl > 0: + return "PROFIT" + return "FLAT" + + if pnl_percent <= -0.8: + return "HIGH_LOSS" + + if pnl_percent <= -0.3: + return "LOSS" + + if pnl_percent >= 0.8: + return "STRONG_PROFIT" + + if pnl_percent >= 0.3: + return "PROFIT" + + return "FLAT" + + def _position_trend_alignment(self, state: AutoTradeState) -> str: + side = str(state.position_side or "NONE").upper() + market_state = str(state.market_state or "").upper() + trend = str(state.market_trend or "").upper() + + if side == "NONE": + return "NONE" + + if side == "LONG": + if market_state == "TREND_UP" or trend == "UP": + return "ALIGNED" + if market_state == "TREND_DOWN" or trend == "DOWN": + return "AGAINST" + + if side == "SHORT": + if market_state == "TREND_DOWN" or trend == "DOWN": + return "ALIGNED" + if market_state == "TREND_UP" or trend == "UP": + return "AGAINST" + + return "NEUTRAL" + + def _has_adverse_position_momentum(self, state: AutoTradeState) -> bool: + side = str(state.position_side or "NONE").upper() + momentum_direction = str(state.momentum_direction or "").upper() + momentum_state = str(state.momentum_state or "").upper() + + if side == "LONG": + return ( + momentum_direction == "DOWN" + or momentum_state in {"MOMENTUM_DOWN", "BREAKOUT_DOWN"} + ) + + if side == "SHORT": + return ( + momentum_direction == "UP" + or momentum_state in {"MOMENTUM_UP", "BREAKOUT_UP"} + ) + + return False + + def _position_health_score( + self, + *, + state: AutoTradeState, + pnl_percent: float | None, + trend_alignment: str, + adverse_momentum: bool, + ) -> int: + score = 100 + + if pnl_percent is not None: + if pnl_percent <= -1.0: + score -= 35 + elif pnl_percent <= -0.5: + score -= 22 + elif pnl_percent < 0: + score -= 10 + elif pnl_percent >= 0.8: + score += 5 + + if trend_alignment == "AGAINST": + score -= 25 + elif trend_alignment == "NEUTRAL": + score -= 8 + + if adverse_momentum: + score -= 20 + + if state.execution_quality == "BLOCKED": + score -= 15 + elif state.execution_quality == "WARNING": + score -= 8 + + if state.market_runtime_degraded: + score -= 10 + + return max(0, min(100, score)) + + def _position_health_status(self, score: int | None) -> str: + if score is None: + return "UNKNOWN" + + if score >= 80: + return "HEALTHY" + + if score >= 55: + return "WATCH" + + if score >= 35: + return "PRESSURE" + + return "DANGER" + + def _position_health_reason( + self, + *, + pressure: str, + trend_alignment: str, + adverse_momentum: bool, + ) -> str: + if trend_alignment == "AGAINST" and adverse_momentum: + return "тренд и momentum против позиции" + + if trend_alignment == "AGAINST": + return "тренд против позиции" + + if adverse_momentum: + return "momentum против позиции" + + if pressure in {"HIGH_LOSS", "LOSS"}: + return "позиция под давлением" + + if pressure in {"PROFIT", "STRONG_PROFIT"}: + return "позиция в прибыли" + + return "позиция стабильна" + + def _position_risk_level( + self, + *, + state: AutoTradeState, + pnl_percent: float | None, + trend_alignment: str, + adverse_momentum: bool, + ) -> tuple[str, str]: + if state.execution_quality == "BLOCKED": + return "HIGH", "исполнение заблокировано" + + if pnl_percent is not None and pnl_percent <= -1.0: + return "HIGH", "сильная просадка позиции" + + if trend_alignment == "AGAINST" and adverse_momentum: + return "HIGH", "рынок движется против позиции" + + if pnl_percent is not None and pnl_percent < 0: + if trend_alignment == "AGAINST" or adverse_momentum: + return "ELEVATED", "убыток усиливается рыночным контекстом" + + return "MODERATE", "позиция в минусе" + + if adverse_momentum: + return "MODERATE", "momentum против позиции" + + return "LOW", "критичных рисков нет" + + def _position_exit_pressure( + self, + *, + state: AutoTradeState, + pnl_percent: float | None, + risk_level: str, + ) -> str: + if risk_level == "HIGH": + return "HIGH" + + if risk_level == "ELEVATED": + return "WATCH" + + if pnl_percent is not None and pnl_percent <= -0.5: + return "WATCH" + + return "LOW" + + def _sync_position_intelligence_state(self, state: AutoTradeState) -> None: + if state.position_side == "NONE" or state.entry_price is None: + state.position_lifecycle_stage = None + state.position_hold_quality = None + state.position_decay_state = None + state.position_exit_confidence = None + state.position_exit_signal = None + state.position_intelligence_reason = None + state.position_recommended_action = None + state.position_peak_pnl_usd = None + state.position_peak_pnl_percent = None + state.position_mfe_percent = None + state.position_mae_percent = None + state.position_fatigue_score = None + state.position_fatigue_state = None + state.position_giveback_percent = None + state.position_conviction_state = None + state.position_exit_urgency = None + state.position_reversal_risk = None + return + + lifecycle_stage = self._position_lifecycle_stage(state) + hold_quality = self._position_hold_quality(state) + decay_state = self._position_decay_state(state) + + self._sync_advanced_position_analytics( + state=state, + lifecycle_stage=lifecycle_stage, + hold_quality=hold_quality, + decay_state=decay_state, + ) + + exit_confidence = self._position_exit_confidence( + state=state, + hold_quality=hold_quality, + decay_state=decay_state, + ) + + exit_signal = self._position_exit_signal(exit_confidence) + + state.position_lifecycle_stage = lifecycle_stage + state.position_hold_quality = hold_quality + state.position_decay_state = decay_state + state.position_exit_confidence = exit_confidence + state.position_exit_signal = exit_signal + state.position_intelligence_reason = self._position_intelligence_reason( + state=state, + hold_quality=hold_quality, + decay_state=decay_state, + exit_signal=exit_signal, + ) + state.position_recommended_action = self._position_recommended_action( + exit_signal + ) + + def _position_lifecycle_stage(self, state: AutoTradeState) -> str: + hold_seconds = state.position_hold_seconds + + if hold_seconds is None: + return "UNKNOWN" + + if hold_seconds < 60: + return "NEW" + + if hold_seconds < 300: + return "ACTIVE" + + if hold_seconds < 900: + return "MATURE" + + return "AGED" + + def _position_hold_quality(self, state: AutoTradeState) -> str: + health_status = str(state.position_health_status or "").upper() + pressure = str(state.position_pressure or "").upper() + trend_alignment = str(state.position_trend_alignment or "").upper() + + if health_status == "DANGER": + return "BAD" + + if pressure == "HIGH_LOSS": + return "BAD" + + if trend_alignment == "AGAINST" and state.position_adverse_momentum: + return "BAD" + + if health_status == "PRESSURE": + return "WEAK" + + if pressure == "LOSS": + return "WEAK" + + if pressure in {"PROFIT", "STRONG_PROFIT"} and trend_alignment == "ALIGNED": + return "GOOD" + + if health_status == "HEALTHY": + return "GOOD" + + return "NEUTRAL" + + def _position_decay_state(self, state: AutoTradeState) -> str: + pressure = str(state.position_pressure or "").upper() + trend_alignment = str(state.position_trend_alignment or "").upper() + lifecycle = str(state.position_lifecycle_stage or "").upper() + + if pressure in {"HIGH_LOSS", "LOSS"} and state.position_adverse_momentum: + return "ACCELERATING_LOSS" + + if trend_alignment == "AGAINST" and state.position_adverse_momentum: + return "CONTEXT_DECAY" + + if pressure == "PROFIT" and state.position_adverse_momentum: + return "PROFIT_DECAY" + + if lifecycle == "AGED" and pressure == "FLAT": + return "TIME_DECAY" + + return "NONE" + + def _position_exit_confidence( + self, + *, + state: AutoTradeState, + hold_quality: str, + decay_state: str, + ) -> float: + score = 0.0 + + risk_level = str(state.position_risk_level or "").upper() + exit_pressure = str(state.position_exit_pressure or "").upper() + + if risk_level == "HIGH": + score += 0.45 + elif risk_level == "ELEVATED": + score += 0.30 + elif risk_level == "MODERATE": + score += 0.15 + + if exit_pressure == "HIGH": + score += 0.30 + elif exit_pressure == "WATCH": + score += 0.15 + + if hold_quality == "BAD": + score += 0.25 + elif hold_quality == "WEAK": + score += 0.15 + + if decay_state in {"ACCELERATING_LOSS", "CONTEXT_DECAY"}: + score += 0.25 + elif decay_state in {"PROFIT_DECAY", "TIME_DECAY"}: + score += 0.15 + + if state.execution_quality == "BLOCKED": + score += 0.10 + + return round(max(0.0, min(1.0, score)), 3) + + def _position_exit_signal(self, exit_confidence: float | None) -> str: + if exit_confidence is None: + return "NONE" + + if exit_confidence >= 0.75: + return "EXIT" + + if exit_confidence >= 0.50: + return "REDUCE_OR_PROTECT" + + if exit_confidence >= 0.30: + return "WATCH" + + return "HOLD" + + def _position_intelligence_reason( + self, + *, + state: AutoTradeState, + hold_quality: str, + decay_state: str, + exit_signal: str, + ) -> str: + if exit_signal == "EXIT": + return "позиция требует выхода" + + if exit_signal == "REDUCE_OR_PROTECT": + return "позицию нужно защитить или уменьшить" + + if decay_state != "NONE": + return "качество удержания ухудшается" + + if hold_quality == "GOOD": + return "позицию можно удерживать" + + if hold_quality == "WEAK": + return "позиция требует наблюдения" + + return "критичных признаков выхода нет" + + def _position_recommended_action(self, exit_signal: str | None) -> str: + if exit_signal == "EXIT": + return "CLOSE" + + if exit_signal == "REDUCE_OR_PROTECT": + return "PROTECT" + + if exit_signal == "WATCH": + return "WATCH" + + return "HOLD" + + def _sync_advanced_position_analytics( + self, + *, + state: AutoTradeState, + lifecycle_stage: str, + hold_quality: str, + decay_state: str, + ) -> None: + pnl = safe_float(state.unrealized_pnl_usd) + pnl_percent = safe_float(state.position_pnl_percent) + + peak_pnl = safe_float(state.position_peak_pnl_usd) + peak_pnl_percent = safe_float(state.position_peak_pnl_percent) + + if pnl is not None: + if peak_pnl is None or pnl > peak_pnl: + state.position_peak_pnl_usd = pnl + + if pnl_percent is not None: + if peak_pnl_percent is None or pnl_percent > peak_pnl_percent: + state.position_peak_pnl_percent = pnl_percent + + state.position_mfe_percent = self._position_mfe_percent(state) + state.position_mae_percent = self._position_mae_percent(state) + state.position_giveback_percent = self._position_giveback_percent(state) + + fatigue_score = self._position_fatigue_score( + state=state, + lifecycle_stage=lifecycle_stage, + hold_quality=hold_quality, + decay_state=decay_state, + ) + + state.position_fatigue_score = fatigue_score + state.position_fatigue_state = self._position_fatigue_state(fatigue_score) + state.position_conviction_state = self._position_conviction_state(state) + state.position_exit_urgency = self._position_exit_urgency(state) + state.position_reversal_risk = self._position_reversal_risk(state) + + def _position_mfe_percent(self, state: AutoTradeState) -> float | None: + peak = safe_float(state.position_peak_pnl_percent) + + if peak is None: + return None + + return round(max(0.0, peak), 4) + + def _position_mae_percent(self, state: AutoTradeState) -> float | None: + current = safe_float(state.position_pnl_percent) + + if current is None: + return None + + return round(min(0.0, current), 4) + + def _position_giveback_percent(self, state: AutoTradeState) -> float | None: + peak = safe_float(state.position_peak_pnl_percent) + current = safe_float(state.position_pnl_percent) + + if peak is None or current is None: + return None + + if peak <= 0: + return 0.0 + + giveback = peak - current + + if giveback <= 0: + return 0.0 + + return round((giveback / peak) * 100, 2) + + def _position_fatigue_score( + self, + *, + state: AutoTradeState, + lifecycle_stage: str, + hold_quality: str, + decay_state: str, + ) -> float: + score = 0.0 + + giveback = safe_float(state.position_giveback_percent) or 0.0 + hold_seconds = safe_float(state.position_hold_seconds) or 0.0 + + if lifecycle_stage == "AGED": + score += 0.25 + elif lifecycle_stage == "MATURE": + score += 0.15 + + if hold_quality == "BAD": + score += 0.30 + elif hold_quality == "WEAK": + score += 0.18 + + if decay_state in {"ACCELERATING_LOSS", "CONTEXT_DECAY"}: + score += 0.30 + elif decay_state in {"PROFIT_DECAY", "TIME_DECAY"}: + score += 0.18 + + if giveback >= 70: + score += 0.30 + elif giveback >= 45: + score += 0.20 + elif giveback >= 25: + score += 0.10 + + if hold_seconds >= 1800: + score += 0.15 + elif hold_seconds >= 900: + score += 0.08 + + if state.position_adverse_momentum: + score += 0.15 + + return round(max(0.0, min(1.0, score)), 3) + + def _position_fatigue_state(self, score: float | None) -> str: + value = safe_float(score) + + if value is None: + return "UNKNOWN" + + if value >= 0.75: + return "EXHAUSTED" + + if value >= 0.50: + return "TIRED" + + if value >= 0.25: + return "WATCH" + + return "FRESH" + + def _position_conviction_state(self, state: AutoTradeState) -> str: + health = str(state.position_health_status or "").upper() + fatigue = str(state.position_fatigue_state or "").upper() + alignment = str(state.position_trend_alignment or "").upper() + + if health == "DANGER" or fatigue == "EXHAUSTED": + return "BROKEN" + + if alignment == "AGAINST" or fatigue == "TIRED": + return "WEAKENING" + + if health == "HEALTHY" and alignment == "ALIGNED": + return "STRONG" + + return "NEUTRAL" + + def _position_exit_urgency(self, state: AutoTradeState) -> str: + exit_signal = str(state.position_exit_signal or "").upper() + fatigue = str(state.position_fatigue_state or "").upper() + risk = str(state.position_risk_level or "").upper() + + if exit_signal == "EXIT" or risk == "HIGH": + return "IMMEDIATE" + + if fatigue == "EXHAUSTED": + return "HIGH" + + if exit_signal == "REDUCE_OR_PROTECT" or fatigue == "TIRED": + return "MEDIUM" + + if exit_signal == "WATCH": + return "LOW" + + return "NONE" + + def _position_reversal_risk(self, state: AutoTradeState) -> str: + giveback = safe_float(state.position_giveback_percent) or 0.0 + fatigue = str(state.position_fatigue_state or "").upper() + adverse = bool(state.position_adverse_momentum) + + if adverse and giveback >= 45: + return "HIGH" + + if fatigue in {"TIRED", "EXHAUSTED"} and giveback >= 25: + return "ELEVATED" + + if adverse: + return "MODERATE" + + return "LOW" + + def _sync_autonomous_trade_management( + self, + state: AutoTradeState, + ) -> None: + if state.position_side == "NONE": + state.autonomous_action = None + state.autonomous_action_reason = None + state.autonomous_action_confidence = None + state.autonomous_protection_required = False + state.autonomous_reduce_required = False + state.autonomous_exit_required = False + return + + exit_signal = str(state.position_exit_signal or "HOLD").upper() + exit_confidence = safe_float(state.position_exit_confidence) or 0.0 + + action = "HOLD" + reason = "позиция удерживается" + + protect_required = False + reduce_required = False + exit_required = False + + if exit_signal == "WATCH": + action = "WATCH" + reason = "позиция требует наблюдения" + + elif exit_signal == "REDUCE_OR_PROTECT": + if state.position_pressure in {"HIGH_LOSS", "LOSS"}: + action = "REDUCE" + reduce_required = True + reason = "позиция должна быть уменьшена" + else: + action = "PROTECT" + protect_required = True + reason = "позиция требует защиты" + + elif exit_signal == "EXIT": + action = "EXIT" + exit_required = True + reason = "позиция требует закрытия" + + if ( + state.position_adverse_momentum + and state.position_trend_alignment == "AGAINST" + and exit_confidence >= 0.65 + ): + action = "EXIT" + exit_required = True + reason = "рынок агрессивно движется против позиции" + + state.autonomous_action = action + state.autonomous_action_reason = reason + state.autonomous_action_confidence = exit_confidence + state.autonomous_protection_required = protect_required + state.autonomous_reduce_required = reduce_required + state.autonomous_exit_required = exit_required + def _log_execution_quality_if_changed( self, *, @@ -1831,6 +2646,13 @@ class AutoTradeService: if state.execution_quality != "BLOCKED": ExecutionEngine().process(state) + self._sync_position_health_state(state) + self._sync_position_intelligence_state(state) + self._sync_autonomous_trade_management(state) + + if state.execution_quality != "BLOCKED": + ExecutionEngine().process_runtime_action(state) + self._sync_execution_semantic_state(state) return state \ No newline at end of file diff --git a/app/src/trading/auto/state.py b/app/src/trading/auto/state.py index ea1030b..8c1f8db 100644 --- a/app/src/trading/auto/state.py +++ b/app/src/trading/auto/state.py @@ -61,9 +61,88 @@ class AutoTradeState: # размер позиции position_size: float | None = None + # monotonic timestamp открытия текущей позиции + position_opened_monotonic_at: float | None = None + # нереализованный PnL unrealized_pnl_usd: float | None = None + # position health / runtime risk + position_pnl_percent: float | None = None + position_hold_seconds: int | None = None + position_pressure: str | None = None + position_health_score: int | None = None + position_health_status: str | None = None + position_health_reason: str | None = None + position_risk_level: str | None = None + position_risk_reason: str | None = None + position_trend_alignment: str | None = None + position_adverse_momentum: bool = False + position_exit_pressure: str | None = None + + # position intelligence layer + position_lifecycle_stage: str | None = None + position_hold_quality: str | None = None + position_decay_state: str | None = None + position_exit_confidence: float | None = None + position_exit_signal: str | None = None + position_intelligence_reason: str | None = None + position_recommended_action: str | None = None + + # advanced lifecycle analytics + position_peak_pnl_usd: float | None = None + position_peak_pnl_percent: float | None = None + + position_mfe_percent: float | None = None + position_mae_percent: float | None = None + + position_fatigue_score: float | None = None + position_fatigue_state: str | None = None + + position_giveback_percent: float | None = None + + # position behavioral state + position_conviction_state: str | None = None + position_exit_urgency: str | None = None + position_reversal_risk: str | None = None + + # autonomous trade management + autonomous_action: str | None = None + autonomous_action_reason: str | None = None + autonomous_action_confidence: float | None = None + autonomous_protection_required: bool = False + autonomous_reduce_required: bool = False + autonomous_exit_required: bool = False + + # runtime position action execution + autonomous_last_action: str | None = None + autonomous_last_action_reason: str | None = None + autonomous_last_action_at: float | None = None + + # loss cooldown tracking + last_loss_monotonic_at: float | None = None + + # runtime protection engine + position_protection_status: str | None = None + position_protection_reason: str | None = None + + # break-even protection + break_even_armed: bool = False + break_even_price: float | None = None + + # trailing protection + trailing_stop_active: bool = False + trailing_stop_price: float | None = None + + # locked profit protection + profit_lock_active: bool = False + profit_lock_price: float | None = None + + # runtime protection execution + runtime_protection_action: str | None = None + runtime_protection_reason: str | None = None + runtime_protection_updated_at: float | None = None + # максимальная просадка max_drawdown_usd: float | None = None @@ -235,6 +314,25 @@ class AutoTradeState: # человекочитаемое объяснение качества исполнения execution_quality_message: str | None = None + # источник execution pricing snapshot + execution_price_source: str | None = None + + # возраст execution snapshot + execution_price_age_seconds: float | None = None + + # bid execution price + execution_bid_price: float | None = None + + # ask execution price + execution_ask_price: float | None = None + + # last execution price + execution_last_price: float | None = None + + # pricing freshness status: + # FRESH / AGING / STALE / UNKNOWN + execution_price_freshness: str | None = None + # признак деградации runtime market data market_runtime_degraded: bool = False diff --git a/app/src/trading/diagnostics/formatter.py b/app/src/trading/diagnostics/formatter.py index 0b839a3..ae451d4 100644 --- a/app/src/trading/diagnostics/formatter.py +++ b/app/src/trading/diagnostics/formatter.py @@ -56,6 +56,9 @@ class SemanticDiagnosticFormatter: if mode != "COMPACT": if has_position: sections.append(self._position_block(position)) + position_health_block = self._position_health_block(position) + if position_health_block: + sections.append(position_health_block) if self._has_adaptive_size(adaptive): sections.append(self._adaptive_block(adaptive)) @@ -1035,6 +1038,111 @@ class SemanticDiagnosticFormatter: return "\n".join(lines).strip() + def _position_health_block(self, data: JsonDict) -> str: + health_state = str(data.get("health_state") or "") + health_score = safe_float(data.get("health_score")) + health_message = str(data.get("health_message") or "").strip() + pressure_state = str(data.get("pressure_state") or "") + trend_alignment = str(data.get("trend_alignment") or "") + adverse_momentum = bool(data.get("adverse_momentum")) + risk_used = safe_float(data.get("risk_used_percent")) + price_move = safe_float(data.get("price_move_percent")) + opened_age = data.get("opened_age_seconds") + + if not health_state or health_state in {"NONE", "UNKNOWN"}: + return "" + + lines = [ + ( + f"{self._position_health_icon(health_state)} " + f"Здоровье позиции · {self._position_health_title(health_state)}" + ), + ] + + if health_score is not None: + lines.append(f"• Score: {health_score:.0f}/100") + + if price_move is not None: + lines.append(f"• Движение цены: {price_move:+.3f}%") + + if risk_used is not None and risk_used > 0: + lines.append(f"• Использовано риска: {risk_used:.1f}%") + + alignment_line = self._position_alignment_line(trend_alignment) + if alignment_line: + lines.append(alignment_line) + + pressure_line = self._position_pressure_line(pressure_state) + if pressure_line: + lines.append(pressure_line) + + if adverse_momentum: + lines.append("• Импульс: против позиции") + + hold_time = self._duration(opened_age) + if hold_time != "—": + lines.append(f"• В позиции: {hold_time}") + + if health_message: + lines.append(health_message) + + return "\n".join(lines) + + def _position_health_icon(self, value: object) -> str: + text = str(value or "") + + if text == "HEALTHY": + return "🟢" + + if text == "WATCH": + return "🟡" + + if text == "PRESSURE": + return "🟠" + + if text == "DANGER": + return "🔴" + + return "⚪️" + + def _position_health_title(self, value: object) -> str: + text = str(value or "") + + mapping = { + "HEALTHY": "устойчива", + "WATCH": "под наблюдением", + "PRESSURE": "под давлением", + "DANGER": "высокий риск", + } + + return mapping.get(text, "неизвестно") + + def _position_alignment_line(self, value: object) -> str: + text = str(value or "") + + if text == "ALIGNED": + return "• Рынок: по позиции" + + if text == "AGAINST": + return "• Рынок: против позиции" + + if text == "NEUTRAL": + return "• Рынок: нейтрально" + + return "" + + def _position_pressure_line(self, value: object) -> str: + text = str(value or "") + + mapping = { + "PROFIT": "• Давление: нет", + "PROFIT_UNDER_PRESSURE": "• Давление: прибыль под риском", + "LOSS": "• Давление: умеренное", + "PRESSURE": "• Давление: повышенное", + "DANGER": "• Давление: критическое", + } + + return mapping.get(text, "") def _position_risk_line(self, data: JsonDict) -> str: items: list[str] = [] diff --git a/app/src/trading/diagnostics/snapshot.py b/app/src/trading/diagnostics/snapshot.py index c93344c..5e27ab5 100644 --- a/app/src/trading/diagnostics/snapshot.py +++ b/app/src/trading/diagnostics/snapshot.py @@ -33,6 +33,11 @@ class SemanticDiagnosticSnapshotBuilder: position_current_price = self._position_current_price(state) + position_health = self._position_health( + state=state, + current_price=position_current_price, + ) + return { "status": { "status": state.status, @@ -142,6 +147,18 @@ class SemanticDiagnosticSnapshotBuilder: "stop_loss_usd": state.effective_target_risk_usd, "take_profit_usd": self._take_profit_usd(state), "max_loss_usd": state.max_loss_usd, + "opened_age_seconds": self._age_seconds( + now=now, + started_at=state.position_opened_monotonic_at, + ), + "price_move_percent": position_health.get("price_move_percent"), + "risk_used_percent": position_health.get("risk_used_percent"), + "health_state": position_health.get("health_state"), + "health_score": position_health.get("health_score"), + "health_message": position_health.get("health_message"), + "pressure_state": position_health.get("pressure_state"), + "trend_alignment": position_health.get("trend_alignment"), + "adverse_momentum": position_health.get("adverse_momentum"), }, "runtime_health": { "health_score": health_score, @@ -153,6 +170,8 @@ class SemanticDiagnosticSnapshotBuilder: "runtime_expired_message": state.runtime_expired_message, "has_market_data": state.market_state is not None, "has_momentum_data": getattr(state, "momentum_state", None) is not None, + "position_health_state": position_health.get("health_state"), + "position_health_score": position_health.get("health_score"), }, "summary": { "health_score": health_score, @@ -177,6 +196,7 @@ class SemanticDiagnosticSnapshotBuilder: "momentum": getattr(state, "momentum_state", None), "execution": state.execution_semantic_status, "position": state.position_side, + "position_health": position_health.get("health_state"), "is_ready": state.is_signal_ready, "is_blocked": bool(blockers), "blockers": blockers, @@ -483,6 +503,288 @@ class SemanticDiagnosticSnapshotBuilder: return result + def _position_health( + self, + *, + state: AutoTradeState, + current_price: float | None, + ) -> dict[str, Any]: + if state.position_side == "NONE": + return { + "health_state": "NONE", + "health_score": None, + "health_message": None, + "price_move_percent": None, + "risk_used_percent": None, + "pressure_state": "NONE", + "trend_alignment": "NONE", + "adverse_momentum": False, + } + + entry_price = safe_float(state.entry_price) + pnl = safe_float(state.unrealized_pnl_usd) + stop_loss_usd = safe_float(state.effective_target_risk_usd) + max_loss_usd = safe_float(state.max_loss_usd) + + price_move_percent = self._position_price_move_percent( + side=state.position_side, + entry_price=entry_price, + current_price=current_price, + ) + + risk_used_percent = self._position_risk_used_percent( + pnl=pnl, + stop_loss_usd=stop_loss_usd, + max_loss_usd=max_loss_usd, + ) + + trend_alignment = self._position_trend_alignment(state) + adverse_momentum = self._has_adverse_momentum(state) + pressure_state = self._position_pressure_state( + pnl=pnl, + risk_used_percent=risk_used_percent, + adverse_momentum=adverse_momentum, + ) + + opened_age_seconds = self._age_seconds( + now=time.monotonic(), + started_at=state.position_opened_monotonic_at, + ) + + health_score = self._position_health_score( + pnl=pnl, + risk_used_percent=risk_used_percent, + trend_alignment=trend_alignment, + adverse_momentum=adverse_momentum, + pressure_state=pressure_state, + opened_age_seconds=opened_age_seconds, + ) + + health_state = self._position_health_state(health_score) + health_message = self._position_health_message( + health_state=health_state, + pressure_state=pressure_state, + trend_alignment=trend_alignment, + adverse_momentum=adverse_momentum, + ) + + return { + "health_state": health_state, + "health_score": health_score, + "health_message": health_message, + "price_move_percent": price_move_percent, + "risk_used_percent": risk_used_percent, + "pressure_state": pressure_state, + "trend_alignment": trend_alignment, + "adverse_momentum": adverse_momentum, + } + + def _position_price_move_percent( + self, + *, + side: str | None, + entry_price: float | None, + current_price: float | None, + ) -> float | None: + if entry_price is None or current_price is None: + return None + + if entry_price <= 0 or current_price <= 0: + return None + + normalized_side = str(side or "").upper() + + if normalized_side == "LONG": + return round(((current_price - entry_price) / entry_price) * 100, 4) + + if normalized_side == "SHORT": + return round(((entry_price - current_price) / entry_price) * 100, 4) + + return None + + def _position_risk_used_percent( + self, + *, + pnl: float | None, + stop_loss_usd: float | None, + max_loss_usd: float | None, + ) -> float | None: + if pnl is None or pnl >= 0: + return 0.0 + + candidates = [ + value + for value in [stop_loss_usd, max_loss_usd] + if value is not None and value > 0 + ] + + if not candidates: + return None + + risk_base = min(candidates) + + if risk_base is None or risk_base <= 0: + return None + + return round(min(999.0, (abs(pnl) / abs(risk_base)) * 100), 1) + + def _position_trend_alignment(self, state: AutoTradeState) -> str: + side = str(state.position_side or "").upper() + market_state = str(state.market_state or "").upper() + trend = str(state.market_trend or "").upper() + + if side == "LONG": + if market_state == "TREND_UP" or trend == "UP": + return "ALIGNED" + + if market_state == "TREND_DOWN" or trend == "DOWN": + return "AGAINST" + + if side == "SHORT": + if market_state == "TREND_DOWN" or trend == "DOWN": + return "ALIGNED" + + if market_state == "TREND_UP" or trend == "UP": + return "AGAINST" + + return "NEUTRAL" + + def _has_adverse_momentum(self, state: AutoTradeState) -> bool: + side = str(state.position_side or "").upper() + momentum_direction = str(state.momentum_direction or "").upper() + momentum_state = str(state.momentum_state or "").upper() + + if side == "LONG": + return ( + momentum_direction == "DOWN" + or momentum_state in {"MOMENTUM_DOWN", "BREAKOUT_DOWN"} + ) + + if side == "SHORT": + return ( + momentum_direction == "UP" + or momentum_state in {"MOMENTUM_UP", "BREAKOUT_UP"} + ) + + return False + + def _position_pressure_state( + self, + *, + pnl: float | None, + risk_used_percent: float | None, + adverse_momentum: bool, + ) -> str: + if pnl is None: + return "UNKNOWN" + + if pnl >= 0: + if adverse_momentum: + return "PROFIT_UNDER_PRESSURE" + + return "PROFIT" + + if risk_used_percent is None: + return "LOSS" + + if risk_used_percent >= 80: + return "DANGER" + + if risk_used_percent >= 50: + return "PRESSURE" + + return "LOSS" + + def _position_health_score( + self, + *, + pnl: float | None, + risk_used_percent: float | None, + trend_alignment: str, + adverse_momentum: bool, + pressure_state: str, + opened_age_seconds: int | None, + ) -> int: + score = 100 + + if pnl is not None: + if pnl < 0: + score -= 20 + elif pnl > 0: + score += 5 + + if risk_used_percent is not None: + if risk_used_percent >= 80: + score -= 45 + elif risk_used_percent >= 50: + score -= 30 + elif risk_used_percent >= 25: + score -= 15 + + if trend_alignment == "AGAINST": + score -= 25 + elif trend_alignment == "ALIGNED": + score += 8 + + if adverse_momentum: + score -= 20 + + if pressure_state == "DANGER": + score -= 20 + elif pressure_state == "PRESSURE": + score -= 10 + + if opened_age_seconds is not None: + if opened_age_seconds >= 7200: + score -= 20 + elif opened_age_seconds >= 3600: + score -= 10 + + return max(0, min(100, score)) + + def _position_health_state(self, score: int | None) -> str: + if score is None: + return "UNKNOWN" + + if score >= 75: + return "HEALTHY" + + if score >= 50: + return "WATCH" + + if score >= 30: + return "PRESSURE" + + return "DANGER" + + def _position_health_message( + self, + *, + health_state: str, + pressure_state: str, + trend_alignment: str, + adverse_momentum: bool, + ) -> str: + if health_state == "HEALTHY": + return "Позиция выглядит устойчиво." + + if health_state == "WATCH": + return "Позиция требует наблюдения." + + if health_state == "PRESSURE": + if trend_alignment == "AGAINST": + return "Позиция под давлением: рынок против направления." + + if adverse_momentum: + return "Позиция под давлением: импульс против позиции." + + return "Позиция под давлением." + + if health_state == "DANGER": + return "Высокий риск по открытой позиции." + + return "Состояние позиции не определено." + def _take_profit_usd(self, state: AutoTradeState) -> float | None: take_profit_percent = safe_float(state.take_profit_percent) position_size = safe_float(state.position_size) diff --git a/app/src/trading/execution/engine.py b/app/src/trading/execution/engine.py index 0e91d2d..9274c41 100644 --- a/app/src/trading/execution/engine.py +++ b/app/src/trading/execution/engine.py @@ -32,8 +32,27 @@ class ExecutionEngine: _min_flip_confidence = 0.75 _min_flip_repeat_count = 3 _min_flip_hold_seconds = 60 + _flip_cooldown_seconds = 45 _loss_flip_confidence = 0.9 _last_flip_block_key: str | None = None + _runtime_action_cooldown_seconds = 30 + _last_runtime_action_key: str | None = None + _emergency_halt_drawdown_usd = 250.0 + _emergency_halt_loss_streak = 5 + + _execution_cooldown_after_loss_seconds = 90 + + _max_execution_snapshot_age_seconds = 5 + + _degraded_market_block_states = { + "HIGH_VOLATILITY", + "CHAOTIC", + "LIQUIDITY_VOID", + } + + _conflict_execution_block = True + + _last_supervisor_block_key: str | None = None def get_position(self) -> PositionState: return type(self)._position @@ -50,6 +69,14 @@ class ExecutionEngine: if risk_decision is not None: return risk_decision + protection_decision = self._process_runtime_protection(state) + if protection_decision is not None: + return protection_decision + + supervisor_decision = self._process_execution_supervisor(state) + if supervisor_decision is not None: + return supervisor_decision + if state.decision_status != "READY" or not state.is_signal_ready: return ExecutionDecision("NONE", False, "Сигнал ещё не готов к execution.") @@ -69,6 +96,198 @@ class ExecutionEngine: return ExecutionDecision("NONE", False, "Нет торгового действия.") + def process_runtime_action(self, state: AutoTradeState) -> ExecutionDecision: + self._sync_state_from_position(state) + + position = type(self)._position + + if state.status != "RUNNING": + return ExecutionDecision( + "NONE", + False, + "Runtime action доступен только в режиме RUNNING.", + ) + + if position.side == "NONE": + return ExecutionDecision( + "NONE", + False, + "Нет открытой позиции для runtime action.", + ) + + action = str( + getattr(state, "autonomous_action", "") or "" + ).upper() + + confidence = safe_float( + getattr(state, "autonomous_action_confidence", None) + ) or 0.0 + + reason = str( + getattr(state, "autonomous_action_reason", "") or "" + ) + + if action in {"", "HOLD", "WATCH"}: + return ExecutionDecision( + "NONE", + False, + "Runtime action не требуется.", + ) + + if self._runtime_action_cooldown_active(state, action): + return ExecutionDecision( + "NONE", + False, + "Runtime action cooldown активен.", + ) + + if action == "PROTECT": + return self._log_runtime_action( + state=state, + action="PROTECT", + reason=reason or "позиция требует защиты", + confidence=confidence, + executed=False, + ) + + if action == "REDUCE": + return self._log_runtime_action( + state=state, + action="REDUCE", + reason=reason or "позиция требует уменьшения", + confidence=confidence, + executed=False, + ) + + if action == "EXIT": + if confidence < 0.75: + return self._log_runtime_action( + state=state, + action="EXIT_BLOCKED", + reason=( + "autonomous exit заблокирован: " + f"confidence {confidence:.2f} < 0.75" + ), + confidence=confidence, + executed=False, + ) + + decision = self._close_position( + state, + forced_reason="AUTONOMOUS_EXIT", + ) + + state.autonomous_last_action = "EXIT" + state.autonomous_last_action_reason = reason or decision.reason + state.autonomous_last_action_at = time.monotonic() + + return decision + + return ExecutionDecision( + "NONE", + False, + f"Неизвестный runtime action: {action}.", + ) + + def _runtime_action_cooldown_active( + self, + state: AutoTradeState, + action: str, + ) -> bool: + ts = safe_float( + getattr(state, "autonomous_last_action_at", None) + ) + + last_action = str( + getattr(state, "autonomous_last_action", "") or "" + ).upper() + + if ts is None: + return False + + if last_action != action: + return False + + return ( + time.monotonic() - ts + ) < self._runtime_action_cooldown_seconds + + def _log_runtime_action( + self, + *, + state: AutoTradeState, + action: str, + reason: str, + confidence: float, + executed: bool, + ) -> ExecutionDecision: + position = type(self)._position + + key = ( + f"{state.symbol}:" + f"{position.side}:" + f"{action}:" + f"{reason}:" + f"{confidence:.2f}" + ) + + if key != type(self)._last_runtime_action_key: + type(self)._last_runtime_action_key = key + + payload: JsonDict = { + "execution_type": "RUNTIME_ACTION", + "action": action, + "executed": executed, + "symbol": state.symbol, + "position_side": position.side, + "entry_price": position.entry_price, + "size": position.size, + "unrealized_pnl_usd": state.unrealized_pnl_usd, + "position_health_status": getattr( + state, + "position_health_status", + None, + ), + "position_risk_level": getattr( + state, + "position_risk_level", + None, + ), + "position_exit_signal": getattr( + state, + "position_exit_signal", + None, + ), + "position_exit_confidence": getattr( + state, + "position_exit_confidence", + None, + ), + "autonomous_action": getattr( + state, + "autonomous_action", + None, + ), + "confidence": confidence, + "reason": reason, + } + + JournalService().log_ui_warning( + event_type="runtime_position_action", + message=f"Runtime action: {action}. Причина: {reason}.", + screen="auto", + action="runtime_position_action", + payload=payload, + ) + + EventBus.emit("runtime_position_action", payload) + + state.autonomous_last_action = action + state.autonomous_last_action_reason = reason + state.autonomous_last_action_at = time.monotonic() + + return ExecutionDecision(action, executed, reason) + def _open_position_if_empty( self, *, @@ -89,6 +308,7 @@ class ExecutionEngine: return ExecutionDecision("NONE", False, f"Не удалось получить цену для paper execution: {exc}") now = self._now_time() + opened_monotonic_at = time.monotonic() size = self._calculate_position_size(state, entry_price=entry_price) if size <= 0: @@ -127,6 +347,7 @@ class ExecutionEngine: leverage=state.leverage, unrealized_pnl_usd=0.0, opened_at=now, + opened_monotonic_at=opened_monotonic_at, updated_at=now, ) @@ -159,6 +380,7 @@ class ExecutionEngine: "repeat_count": state.last_signal_repeat_count, "reason": state.last_signal_reason, "opened_at": now, + "opened_monotonic_at": opened_monotonic_at, "pricing": "ask_for_long_bid_for_short", "pricing_role": entry.pricing_role, "price_source": entry.source, @@ -198,6 +420,7 @@ class ExecutionEngine: return ExecutionDecision("NONE", False, f"Ошибка получения цены для flip: {exc}") now = self._now_time() + opened_monotonic_at = time.monotonic() pnl = self._calculate_pnl(exit_price) new_size = self._calculate_position_size(state, entry_price=new_entry_price) @@ -257,6 +480,7 @@ class ExecutionEngine: leverage=state.leverage, unrealized_pnl_usd=0.0, opened_at=now, + opened_monotonic_at=opened_monotonic_at, updated_at=now, ) @@ -299,6 +523,7 @@ class ExecutionEngine: "repeat_count": state.last_signal_repeat_count, "reason": state.last_signal_reason, "opened_at": old_opened_at, + "new_opened_monotonic_at": opened_monotonic_at, "closed_at": now, "new_opened_at": now, "pricing": "exit_by_side_then_entry_by_side", @@ -370,6 +595,9 @@ class ExecutionEngine: if pnl > 0: state.cycle_winning_trades += 1 + if pnl < 0: + state.last_loss_monotonic_at = time.monotonic() + now = self._now_time() payload: JsonDict = { @@ -411,6 +639,8 @@ class ExecutionEngine: type(self)._position = PositionState() self._sync_state_from_position(state) + state.position_opened_monotonic_at = None + self._reset_runtime_protection_state(state) state.execution_block_reason = None state.last_flip_block_reason = None state.last_execution_action = ( @@ -435,6 +665,229 @@ class ExecutionEngine: return ExecutionDecision("CLOSE", True, "Позиция закрыта.") + def _process_execution_supervisor( + self, + state: AutoTradeState, + ) -> ExecutionDecision | None: + halt_reason = self._execution_halt_reason(state) + + if halt_reason is not None: + return self._block_execution( + state=state, + reason=halt_reason, + action="EXECUTION_HALTED", + ) + + cooldown_reason = self._execution_cooldown_reason(state) + + if cooldown_reason is not None: + return self._block_execution( + state=state, + reason=cooldown_reason, + action="EXECUTION_COOLDOWN", + ) + + degraded_reason = self._degraded_market_reason(state) + + if degraded_reason is not None: + return self._block_execution( + state=state, + reason=degraded_reason, + action="DEGRADED_MARKET", + ) + + stale_reason = self._stale_execution_reason(state) + + if stale_reason is not None: + return self._block_execution( + state=state, + reason=stale_reason, + action="STALE_EXECUTION", + ) + + conflict_reason = self._conflict_signal_reason(state) + + if conflict_reason is not None: + return self._block_execution( + state=state, + reason=conflict_reason, + action="SIGNAL_CONFLICT", + ) + + return None + + def _execution_halt_reason( + self, + state: AutoTradeState, + ) -> str | None: + pnl = safe_float(state.cycle_realized_pnl_usd) or 0.0 + + if pnl <= -abs(self._emergency_halt_drawdown_usd): + return ( + "execution emergency halt: " + "cycle drawdown limit exceeded" + ) + + closed = safe_float(state.cycle_closed_trades) or 0 + wins = safe_float(state.cycle_winning_trades) or 0 + + losses = max(0, int(closed - wins)) + + if losses >= self._emergency_halt_loss_streak: + return ( + "execution emergency halt: " + "loss streak exceeded" + ) + + return None + + def _execution_cooldown_reason( + self, + state: AutoTradeState, + ) -> str | None: + ts = safe_float( + getattr(state, "last_loss_monotonic_at", None) + ) + + if ts is None: + return None + + delta = ( + time.monotonic() - ts + ) + + if delta < self._execution_cooldown_after_loss_seconds: + remaining = int( + self._execution_cooldown_after_loss_seconds - delta + ) + + return ( + "execution cooldown after loss " + f"({remaining}s remaining)" + ) + + return None + + def _degraded_market_reason( + self, + state: AutoTradeState, + ) -> str | None: + market_state = getattr(state, "market_state", None) + + if market_state in self._degraded_market_block_states: + return ( + "market state blocked execution: " + f"{market_state}" + ) + + return None + + def _stale_execution_reason( + self, + state: AutoTradeState, + ) -> str | None: + age = safe_float( + getattr(state, "execution_price_age_seconds", None) + ) + + if age is None: + age = safe_float( + getattr(state, "snapshot_age_seconds", None) + ) + + if age is None: + return None + + if age > self._max_execution_snapshot_age_seconds: + return ( + "execution snapshot stale: " + f"{age:.2f}s" + ) + + return None + + def _conflict_signal_reason( + self, + state: AutoTradeState, + ) -> str | None: + if not self._conflict_execution_block: + return None + + signal = (state.last_signal or "").upper() + momentum_direction = str(getattr(state, "momentum_direction", "") or "").upper() + trend_direction = str(getattr(state, "market_trend", "") or "").upper() + + if signal == "BUY": + if momentum_direction == "DOWN": + return "BUY conflicts with momentum" + + if trend_direction == "DOWN": + return "BUY conflicts with trend" + + if signal == "SELL": + if momentum_direction == "UP": + return "SELL conflicts with momentum" + + if trend_direction == "UP": + return "SELL conflicts with trend" + + return None + + def _block_execution( + self, + *, + state: AutoTradeState, + reason: str, + action: str, + ) -> ExecutionDecision: + state.execution_block_reason = reason + state.last_execution_action = action + state.last_execution_reason = reason + + key = ( + f"{action}:" + f"{state.symbol}:" + f"{reason}" + ) + + if key != type(self)._last_supervisor_block_key: + type(self)._last_supervisor_block_key = key + + payload: JsonDict = { + "execution_type": "SUPERVISOR_BLOCK", + "action": action, + "symbol": state.symbol, + "reason": reason, + "market_state": getattr( + state, + "market_state", + None, + ), + "signal": state.last_signal, + "confidence": state.last_signal_confidence, + "unrealized_pnl_usd": state.unrealized_pnl_usd, + "cycle_realized_pnl_usd": state.cycle_realized_pnl_usd, + } + + JournalService().log_ui_warning( + event_type="execution_supervisor_block", + message=f"Execution supervisor blocked action: {reason}", + screen="auto", + action="execution_supervisor", + payload=payload, + ) + + EventBus.emit( + "execution_supervisor_block", + payload, + ) + + return ExecutionDecision( + "NONE", + False, + reason, + ) + def _risk_close_decision(self, state: AutoTradeState) -> ExecutionDecision | None: position = type(self)._position @@ -479,6 +932,540 @@ class ExecutionEngine: return None + def _process_runtime_protection( + self, + state: AutoTradeState, + ) -> ExecutionDecision | None: + position = type(self)._position + + if position.side == "NONE": + self._reset_runtime_protection_state(state) + return None + + try: + current_execution = self._exit_price_for_side( + position.symbol or state.symbol, + position.side, + ) + current_price = current_execution.price + except Exception: + self._sync_runtime_protection_state( + state=state, + status="DEGRADED", + reason="нет актуальной цены для protection engine", + ) + return None + + self._sync_runtime_protection_state( + state=state, + status="ACTIVE", + reason="protection engine активен", + ) + + self._update_break_even_protection( + state=state, + current_price=current_price, + ) + + self._update_profit_lock_protection( + state=state, + current_price=current_price, + ) + + self._update_trailing_stop_protection( + state=state, + current_price=current_price, + ) + + close_reason = self._runtime_protection_close_reason( + state=state, + current_price=current_price, + ) + + if close_reason is None: + close_reason = self._runtime_intelligence_close_reason( + state=state, + current_price=current_price, + ) + + if close_reason is None: + return None + + pnl = self._calculate_pnl(current_price) + + return self._close_position( + state, + forced_reason=close_reason, + forced_exit_price=current_price, + forced_pnl=pnl, + forced_price_meta=current_execution, + ) + + def _reset_runtime_protection_state(self, state: AutoTradeState) -> None: + state.position_protection_status = None + state.position_protection_reason = None + state.break_even_armed = False + state.break_even_price = None + state.trailing_stop_active = False + state.trailing_stop_price = None + state.profit_lock_active = False + state.profit_lock_price = None + state.runtime_protection_action = None + state.runtime_protection_reason = None + state.runtime_protection_updated_at = None + + def _sync_runtime_protection_state( + self, + *, + state: AutoTradeState, + status: str, + reason: str, + ) -> None: + state.position_protection_status = status + state.position_protection_reason = reason + state.runtime_protection_updated_at = time.monotonic() + + def _runtime_intelligence_close_reason( + self, + *, + state: AutoTradeState, + current_price: float, + ) -> str | None: + giveback_reason = self._giveback_close_reason( + state=state, + current_price=current_price, + ) + + if giveback_reason is not None: + return giveback_reason + + time_decay_reason = self._time_decay_close_reason( + state=state, + current_price=current_price, + ) + + if time_decay_reason is not None: + return time_decay_reason + + return None + + + def _giveback_close_reason( + self, + *, + state: AutoTradeState, + current_price: float, + ) -> str | None: + pnl_percent = self._calculate_price_move_percent(current_price) + + peak_percent = safe_float( + getattr(state, "position_peak_pnl_percent", None) + ) + + if peak_percent is None or peak_percent <= 0: + return None + + if pnl_percent is None: + return None + + giveback = peak_percent - pnl_percent + + if giveback <= 0: + return None + + giveback_percent = round((giveback / peak_percent) * 100, 2) + + 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 + + if ( + peak_percent >= 0.75 + and giveback_percent >= 55 + and pnl_percent > 0 + ): + return "GIVEBACK_PROTECTION" + + if ( + peak_percent >= 0.50 + and giveback_percent >= 40 + and adverse_momentum + ): + return "GIVEBACK_MOMENTUM_REVERSAL" + + if ( + peak_percent >= 0.50 + and giveback_percent >= 35 + and fatigue_state in {"TIRED", "EXHAUSTED"} + ): + return "GIVEBACK_FATIGUE_EXIT" + + if ( + peak_percent >= 0.50 + and giveback_percent >= 35 + and reversal_risk in {"ELEVATED", "HIGH"} + and exit_confidence >= 0.50 + ): + return "GIVEBACK_REVERSAL_RISK" + + return None + + + def _time_decay_close_reason( + self, + *, + state: AutoTradeState, + current_price: float, + ) -> str | None: + hold_seconds = safe_float( + getattr(state, "position_hold_seconds", None) + ) + + if hold_seconds is None: + hold_seconds = safe_float( + self._position_hold_seconds(type(self)._position) + ) + + if hold_seconds is None: + return None + + pnl_percent = self._calculate_price_move_percent(current_price) + + 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) + ) + + if pnl_percent is None: + return None + + if ( + hold_seconds >= 2400 + and -0.15 <= pnl_percent <= 0.25 + and conviction_state in {"WEAKENING", "BROKEN", "NEUTRAL"} + ): + return "TIME_DECAY_EXIT" + + if ( + hold_seconds >= 1800 + and -0.20 <= pnl_percent <= 0.35 + and fatigue_state in {"TIRED", "EXHAUSTED"} + ): + return "TIME_DECAY_FATIGUE_EXIT" + + if ( + hold_seconds >= 1200 + and pnl_percent <= 0.20 + and adverse_momentum + ): + return "TIME_DECAY_ADVERSE_MOMENTUM" + + if ( + hold_seconds >= 1200 + and pnl_percent <= 0.30 + and market_runtime_degraded + ): + return "TIME_DECAY_DEGRADED_MARKET" + + if ( + hold_seconds >= 1800 + and decay_state in {"TIME_DECAY", "CONTEXT_DECAY"} + and pnl_percent <= 0.30 + ): + return "TIME_DECAY_CONTEXT_DECAY" + + return None + + def _update_break_even_protection( + self, + *, + state: AutoTradeState, + current_price: float, + ) -> None: + position = type(self)._position + + if state.break_even_armed: + return + + pnl_percent = self._calculate_price_move_percent(current_price) + + if pnl_percent is None: + return + + if pnl_percent < 0.35: + return + + entry_price = safe_float(position.entry_price) + + if entry_price is None or entry_price <= 0: + return + + state.break_even_armed = True + state.break_even_price = entry_price + state.runtime_protection_action = "BREAK_EVEN_ARMED" + state.runtime_protection_reason = "позиция вышла в прибыль, break-even активирован" + state.runtime_protection_updated_at = time.monotonic() + + self._log_runtime_protection_event( + state=state, + action="BREAK_EVEN_ARMED", + reason=state.runtime_protection_reason, + current_price=current_price, + ) + + def _update_profit_lock_protection( + self, + *, + state: AutoTradeState, + current_price: float, + ) -> None: + position = type(self)._position + + pnl_percent = self._calculate_price_move_percent(current_price) + + if pnl_percent is None: + return + + if pnl_percent < 0.75: + return + + entry_price = safe_float(position.entry_price) + + if entry_price is None or entry_price <= 0: + return + + if position.side == "LONG": + lock_price = entry_price * 1.003 + + elif position.side == "SHORT": + lock_price = entry_price * 0.997 + + else: + return + + previous_price = safe_float(state.profit_lock_price) + + if previous_price is not None: + if position.side == "LONG" and lock_price <= previous_price: + return + + if position.side == "SHORT" and lock_price >= previous_price: + return + + state.profit_lock_active = True + state.profit_lock_price = round(lock_price, 8) + state.runtime_protection_action = "PROFIT_LOCK_ACTIVE" + state.runtime_protection_reason = "часть прибыли защищена profit lock" + state.runtime_protection_updated_at = time.monotonic() + + self._log_runtime_protection_event( + state=state, + action="PROFIT_LOCK_ACTIVE", + reason=state.runtime_protection_reason, + current_price=current_price, + ) + + def _update_trailing_stop_protection( + self, + *, + state: AutoTradeState, + current_price: float, + ) -> None: + position = type(self)._position + + pnl_percent = self._calculate_price_move_percent(current_price) + + if pnl_percent is None: + return + + if pnl_percent < 1.0: + return + + trail_distance_percent = 0.35 + + if position.side == "LONG": + trail_price = current_price * (1 - trail_distance_percent / 100) + + previous_price = safe_float(state.trailing_stop_price) + + if previous_price is not None and trail_price <= previous_price: + return + + elif position.side == "SHORT": + trail_price = current_price * (1 + trail_distance_percent / 100) + + previous_price = safe_float(state.trailing_stop_price) + + if previous_price is not None and trail_price >= previous_price: + return + + else: + return + + state.trailing_stop_active = True + state.trailing_stop_price = round(trail_price, 8) + state.runtime_protection_action = "TRAILING_STOP_ACTIVE" + state.runtime_protection_reason = "trailing stop подтянут вслед за прибылью" + state.runtime_protection_updated_at = time.monotonic() + + self._log_runtime_protection_event( + state=state, + action="TRAILING_STOP_ACTIVE", + reason=state.runtime_protection_reason, + current_price=current_price, + ) + + def _runtime_protection_close_reason( + self, + *, + state: AutoTradeState, + current_price: float, + ) -> str | None: + position = type(self)._position + + fatigue_state = str(getattr(state, "position_fatigue_state", "") or "").upper() + reversal_risk = str(getattr(state, "position_reversal_risk", "") or "").upper() + exit_urgency = str(getattr(state, "position_exit_urgency", "") or "").upper() + conviction = str(getattr(state, "position_conviction_state", "") or "").upper() + risk_level = str(getattr(state, "position_risk_level", "") or "").upper() + exit_signal = str(getattr(state, "position_exit_signal", "") or "").upper() + decay_state = str(getattr(state, "position_decay_state", "") or "").upper() + + if exit_urgency == "IMMEDIATE": + return "LIFECYCLE_EXIT" + + if conviction == "BROKEN": + return "CONVICTION_BROKEN" + + if fatigue_state == "EXHAUSTED" and reversal_risk in {"ELEVATED", "HIGH"}: + return "FATIGUE_EXIT" + + if ( + state.position_adverse_momentum + and reversal_risk == "HIGH" + and risk_level in {"ELEVATED", "HIGH"} + ): + return "MOMENTUM_EXIT" + + if ( + getattr(state, "market_runtime_degraded", False) + and exit_signal in {"EXIT", "REDUCE_OR_PROTECT"} + and decay_state != "NONE" + ): + return "DEGRADATION_EXIT" + + if position.side == "LONG": + if ( + state.trailing_stop_active + and state.trailing_stop_price is not None + and current_price <= state.trailing_stop_price + ): + return "TRAILING_STOP" + + if ( + state.profit_lock_active + and state.profit_lock_price is not None + and current_price <= state.profit_lock_price + ): + return "PROFIT_LOCK" + + if ( + state.break_even_armed + and state.break_even_price is not None + and current_price <= state.break_even_price + ): + return "BREAK_EVEN" + + if position.side == "SHORT": + if ( + state.trailing_stop_active + and state.trailing_stop_price is not None + and current_price >= state.trailing_stop_price + ): + return "TRAILING_STOP" + + if ( + state.profit_lock_active + and state.profit_lock_price is not None + and current_price >= state.profit_lock_price + ): + return "PROFIT_LOCK" + + if ( + state.break_even_armed + and state.break_even_price is not None + and current_price >= state.break_even_price + ): + return "BREAK_EVEN" + + return None + + def _log_runtime_protection_event( + self, + *, + state: AutoTradeState, + action: str, + reason: str, + current_price: float, + ) -> None: + position = type(self)._position + + payload: JsonDict = { + "execution_type": "RUNTIME_PROTECTION", + "action": action, + "symbol": state.symbol, + "position_side": position.side, + "entry_price": position.entry_price, + "current_price": current_price, + "size": position.size, + "unrealized_pnl_usd": state.unrealized_pnl_usd, + "position_pnl_percent": self._calculate_price_move_percent(current_price), + "break_even_armed": state.break_even_armed, + "break_even_price": state.break_even_price, + "profit_lock_active": state.profit_lock_active, + "profit_lock_price": state.profit_lock_price, + "trailing_stop_active": state.trailing_stop_active, + "trailing_stop_price": state.trailing_stop_price, + "reason": reason, + } + + JournalService().log_ui_info( + event_type="runtime_protection_updated", + message=f"Runtime protection: {action}. {reason}.", + screen="auto", + action="runtime_protection", + payload=payload, + ) + + EventBus.emit("runtime_protection_updated", payload) + def _is_stop_loss_hit(self, state: AutoTradeState, price_move_percent: float) -> bool: if state.stop_loss_percent is None: return False @@ -558,6 +1545,12 @@ class ExecutionEngine: f"({hold_seconds}с < {self._min_flip_hold_seconds}с)" ) + if self._flip_cooldown_active(state): + return ( + "flip cooldown активен " + f"(< {self._flip_cooldown_seconds}с)" + ) + if signal == "BUY" and momentum_direction == "DOWN": return "momentum направлен против BUY сигнала" @@ -629,6 +1622,13 @@ class ExecutionEngine: return ExecutionDecision("NONE", False, reason) def _position_hold_seconds(self, position: PositionState) -> int | None: + opened_monotonic_at = safe_float( + getattr(position, "opened_monotonic_at", None) + ) + + if opened_monotonic_at is not None: + return max(0, int(time.monotonic() - opened_monotonic_at)) + if not position.opened_at: return None @@ -645,6 +1645,19 @@ class ExecutionEngine: except Exception: return None + def _flip_cooldown_active( + self, + state: AutoTradeState, + ) -> bool: + ts = getattr(state, "last_flip_monotonic_at", None) + + if ts is None: + return False + + return ( + time.monotonic() - float(ts) + ) < self._flip_cooldown_seconds + def _target_side_from_signal(self, signal: str | None) -> str | None: if signal == "BUY": return "LONG" @@ -662,17 +1675,103 @@ class ExecutionEngine: return try: - current_execution = self._exit_price_for_side(position.symbol or state.symbol, position.side) + 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) + pnl = self._calculate_pnl(current_price) + pnl_percent = self._calculate_price_move_percent(current_price) + + position.unrealized_pnl_usd = pnl position.updated_at = self._now_time() + if position.peak_unrealized_pnl_usd is None or pnl > position.peak_unrealized_pnl_usd: + position.peak_unrealized_pnl_usd = pnl + + if position.peak_pnl_percent is None or pnl_percent > position.peak_pnl_percent: + position.peak_pnl_percent = pnl_percent + + if position.max_favorable_excursion_percent is None: + position.max_favorable_excursion_percent = max(0.0, pnl_percent) + else: + position.max_favorable_excursion_percent = max( + position.max_favorable_excursion_percent, + pnl_percent, + ) + + if position.max_adverse_excursion_percent is None: + position.max_adverse_excursion_percent = min(0.0, pnl_percent) + else: + position.max_adverse_excursion_percent = min( + position.max_adverse_excursion_percent, + pnl_percent, + ) + + self._sync_position_runtime_memory( + position=position, + current_price=current_price, + pnl_percent=pnl_percent, + ) + self._sync_state_from_position(state) + def _sync_position_runtime_memory( + self, + *, + position: PositionState, + current_price: float, + pnl_percent: float, + ) -> None: + if position.best_price_seen is None: + position.best_price_seen = current_price + + if position.worst_price_seen is None: + position.worst_price_seen = current_price + + if position.side == "LONG": + position.best_price_seen = max(position.best_price_seen, current_price) + position.worst_price_seen = min(position.worst_price_seen, current_price) + + elif position.side == "SHORT": + position.best_price_seen = min(position.best_price_seen, current_price) + position.worst_price_seen = max(position.worst_price_seen, current_price) + + peak = safe_float(position.peak_pnl_percent) or 0.0 + + giveback_score = 0.0 + + if peak > 0: + giveback = max(0.0, peak - pnl_percent) + giveback_score = min(1.0, giveback / max(0.01, peak)) + + fatigue = 0.0 + + if giveback_score >= 0.70: + fatigue += 0.35 + elif giveback_score >= 0.45: + fatigue += 0.25 + elif giveback_score >= 0.25: + fatigue += 0.12 + + if pnl_percent < 0: + fatigue += 0.20 + + position.fatigue_score = round(max(0.0, min(1.0, fatigue)), 3) + + if position.fatigue_score >= 0.75: + position.fatigue_state = "EXHAUSTED" + elif position.fatigue_score >= 0.50: + position.fatigue_state = "TIRED" + elif position.fatigue_score >= 0.25: + position.fatigue_state = "WATCH" + else: + position.fatigue_state = "FRESH" + def _calculate_position_size( self, state: AutoTradeState, @@ -744,7 +1843,7 @@ class ExecutionEngine: 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"}: + if market_state in {"HIGH_VOLATILITY", "LOW_VOLATILITY", "RANGE", "CHAOTIC", "LIQUIDITY_VOID"}: multiplier *= 0.65 if market_trend_strength == "STRONG": @@ -772,27 +1871,22 @@ class ExecutionEngine: 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) + 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 strength is not None: - if strength >= 1.5: - multiplier *= 1.1 - elif strength <= 0.7: - multiplier *= 0.8 + if signal == "BUY" and momentum_direction == "DOWN": + multiplier *= 0.65 - if signal == "BUY": - if momentum_direction == "DOWN": - multiplier *= 0.75 + if signal == "SELL" and momentum_direction == "UP": + multiplier *= 0.65 - 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) @@ -808,6 +1902,9 @@ class ExecutionEngine: else: multiplier *= 0.8 + if getattr(state, "market_runtime_degraded", False): + multiplier *= 0.75 + return round(max(0.0, min(1.25, multiplier)), 4) def _sync_adaptive_size_state( @@ -972,6 +2069,14 @@ class ExecutionEngine: def _entry_price_for_side(self, symbol: str, side: str) -> _ExecutionPrice: snapshot = ExchangeService().get_execution_snapshot(symbol) + if ( + snapshot.age_seconds is not None + and snapshot.age_seconds > 5 + ): + raise ValueError( + "Execution snapshot is stale." + ) + if side == "LONG": return _ExecutionPrice( price=self._snapshot_price(snapshot.ask_price, "ask_price"), @@ -1001,6 +2106,14 @@ class ExecutionEngine: def _exit_price_for_side(self, symbol: str, side: str) -> _ExecutionPrice: snapshot = ExchangeService().get_execution_snapshot(symbol) + if ( + snapshot.age_seconds is not None + and snapshot.age_seconds > 5 + ): + raise ValueError( + "Execution snapshot is stale." + ) + if side == "LONG": return _ExecutionPrice( price=self._snapshot_price(snapshot.bid_price, "bid_price"), @@ -1098,5 +2211,144 @@ class ExecutionEngine: state.position_size = position.size state.unrealized_pnl_usd = position.unrealized_pnl_usd + if position.side == "NONE": + state.position_opened_monotonic_at = None + + state.position_peak_pnl_usd = None + state.position_peak_pnl_percent = None + state.position_mfe_percent = None + state.position_mae_percent = None + state.position_fatigue_score = None + state.position_fatigue_state = None + state.position_giveback_percent = None + state.position_conviction_state = None + state.position_exit_urgency = None + state.position_reversal_risk = None + return + + state.position_opened_monotonic_at = position.opened_monotonic_at + state.position_peak_pnl_usd = position.peak_unrealized_pnl_usd + state.position_peak_pnl_percent = position.peak_pnl_percent + state.position_mfe_percent = position.max_favorable_excursion_percent + state.position_mae_percent = position.max_adverse_excursion_percent + state.position_fatigue_score = position.fatigue_score + state.position_fatigue_state = position.fatigue_state + + def _refresh_position_runtime_metrics( + self, + *, + position: PositionState, + current_price: float, + ) -> None: + price_move_percent = self._calculate_price_move_percent(current_price) + pnl = safe_float(position.unrealized_pnl_usd) + + if pnl is not None: + peak_pnl = safe_float(position.peak_unrealized_pnl_usd) + + if peak_pnl is None or pnl > peak_pnl: + position.peak_unrealized_pnl_usd = pnl + + peak_percent = safe_float(position.peak_pnl_percent) + + if peak_percent is None or price_move_percent > peak_percent: + position.peak_pnl_percent = price_move_percent + + mfe = safe_float(position.max_favorable_excursion_percent) + mae = safe_float(position.max_adverse_excursion_percent) + + if mfe is None or price_move_percent > mfe: + position.max_favorable_excursion_percent = price_move_percent + + if mae is None or price_move_percent < mae: + position.max_adverse_excursion_percent = price_move_percent + + best_price = safe_float(position.best_price_seen) + worst_price = safe_float(position.worst_price_seen) + + if best_price is None: + position.best_price_seen = current_price + elif position.side == "LONG" and current_price > best_price: + position.best_price_seen = current_price + elif position.side == "SHORT" and current_price < best_price: + position.best_price_seen = current_price + + if worst_price is None: + position.worst_price_seen = current_price + elif position.side == "LONG" and current_price < worst_price: + position.worst_price_seen = current_price + elif position.side == "SHORT" and current_price > worst_price: + position.worst_price_seen = current_price + + fatigue_score = self._runtime_fatigue_score(position) + position.fatigue_score = fatigue_score + position.fatigue_state = self._runtime_fatigue_state(fatigue_score) + + def _runtime_fatigue_score(self, position: PositionState) -> float: + score = 0.0 + + mfe = safe_float(position.max_favorable_excursion_percent) or 0.0 + current_peak = safe_float(position.peak_pnl_percent) or 0.0 + mae = safe_float(position.max_adverse_excursion_percent) or 0.0 + + hold_seconds = 0 + + opened_at = safe_float(position.opened_monotonic_at) + if opened_at is not None: + hold_seconds = max(0, int(time.monotonic() - opened_at)) + + if hold_seconds >= 1800: + score += 0.25 + elif hold_seconds >= 900: + score += 0.15 + elif hold_seconds >= 300: + score += 0.08 + + if mfe > 0 and current_peak > 0: + giveback = max(0.0, mfe - current_peak) + + if giveback >= 0.75: + score += 0.25 + elif giveback >= 0.45: + score += 0.18 + elif giveback >= 0.25: + score += 0.10 + + if mae <= -1.0: + score += 0.25 + elif mae <= -0.5: + score += 0.15 + + return round(max(0.0, min(1.0, score)), 3) + + def _runtime_fatigue_state(self, score: float | None) -> str: + value = safe_float(score) + + if value is None: + return "UNKNOWN" + + if value >= 0.75: + return "EXHAUSTED" + + if value >= 0.50: + return "TIRED" + + if value >= 0.25: + return "WATCH" + + return "FRESH" + + def _reset_position_lifecycle_state(self, state: AutoTradeState) -> None: + state.position_peak_pnl_usd = None + state.position_peak_pnl_percent = None + state.position_mfe_percent = None + state.position_mae_percent = None + state.position_fatigue_score = None + state.position_fatigue_state = None + state.position_giveback_percent = None + state.position_conviction_state = None + state.position_exit_urgency = None + state.position_reversal_risk = None + def _now_time(self) -> str: return datetime.now().strftime("%H:%M:%S") \ No newline at end of file diff --git a/app/src/trading/position/state.py b/app/src/trading/position/state.py index 3271e68..432ad12 100644 --- a/app/src/trading/position/state.py +++ b/app/src/trading/position/state.py @@ -25,8 +25,27 @@ class PositionState: # нереализованный PnL unrealized_pnl_usd: float | None = None + # peak pnl tracking + peak_unrealized_pnl_usd: float | None = None + peak_pnl_percent: float | None = None + + # lifecycle tracking + max_favorable_excursion_percent: float | None = None + max_adverse_excursion_percent: float | None = None + + # runtime fatigue tracking + fatigue_score: float | None = None + fatigue_state: str | None = None + + # position regime memory + best_price_seen: float | None = None + worst_price_seen: float | None = None + # время открытия позиции opened_at: str | None = None + # monotonic timestamp открытия позиции + opened_monotonic_at: float | None = None + # время последнего обновления позиции updated_at: str | None = None \ No newline at end of file diff --git a/docs/roadmap/master-roadmap.md b/docs/roadmap/master-roadmap.md index 2921209..01cb282 100644 --- a/docs/roadmap/master-roadmap.md +++ b/docs/roadmap/master-roadmap.md @@ -1385,6 +1385,52 @@ - реализована подготовка semantic position health layer - реализована подготовка runtime risk reasoning layer +### 07.4.4.1.12 ✅ Position Health & Runtime Risk Layer +- реализован runtime position protection engine +- реализован position-aware execution layer +- реализован runtime protection lifecycle +- реализован break-even protection engine +- реализован automated break-even activation +- реализован runtime break-even synchronization +- реализован profit lock protection engine +- реализован runtime profit protection logic +- реализован trailing stop runtime engine +- реализован dynamic trailing stop synchronization +- реализован giveback protection engine +- реализован runtime peak pnl tracking +- реализован pnl deterioration analysis +- реализован giveback runtime exits +- реализован time decay protection engine +- реализован stale position detection +- реализован weak hold structure analysis +- реализован time-based runtime exit layer +- реализован runtime autonomous action engine +- реализованы runtime actions PROTECT / REDUCE / EXIT +- реализован runtime action cooldown layer +- реализован execution supervisor layer +- реализован emergency execution halt +- реализован execution cooldown after loss +- реализован degraded market execution blocker +- реализован stale snapshot execution blocker +- реализован signal conflict protection layer +- реализован runtime market regime protection +- реализован execution snapshot freshness validation +- реализован runtime flip protection upgrade +- реализован flip cooldown engine +- реализован breakout-aware flip protection +- реализован loss-aware flip protection +- реализован adaptive runtime risk engine +- реализован advanced adaptive sizing layer +- реализована execution quality-aware sizing logic +- реализован margin-aware effective risk engine +- реализован effective risk synchronization +- реализован runtime event propagation layer +- реализована runtime journal observability architecture +- реализован runtime semantic reasoning layer +- реализованы human-readable runtime explanations +- реализована runtime diagnostics propagation +- реализована preparation for partial exit engine +- реализована preparation for advanced runtime orchestration --- diff --git a/docs/roadmap/stage-07-auto-trading-roadmap.md b/docs/roadmap/stage-07-auto-trading-roadmap.md index f9bade2..2d7d2df 100644 --- a/docs/roadmap/stage-07-auto-trading-roadmap.md +++ b/docs/roadmap/stage-07-auto-trading-roadmap.md @@ -1495,6 +1495,53 @@ - реализована подготовка semantic position health layer - реализована подготовка runtime risk reasoning layer +### 07.4.4.1.12 ✅ Position Health & Runtime Risk Layer +- реализован runtime position protection engine +- реализован position-aware execution layer +- реализован runtime protection lifecycle +- реализован break-even protection engine +- реализован automated break-even activation +- реализован runtime break-even synchronization +- реализован profit lock protection engine +- реализован runtime profit protection logic +- реализован trailing stop runtime engine +- реализован dynamic trailing stop synchronization +- реализован giveback protection engine +- реализован runtime peak pnl tracking +- реализован pnl deterioration analysis +- реализован giveback runtime exits +- реализован time decay protection engine +- реализован stale position detection +- реализован weak hold structure analysis +- реализован time-based runtime exit layer +- реализован runtime autonomous action engine +- реализованы runtime actions PROTECT / REDUCE / EXIT +- реализован runtime action cooldown layer +- реализован execution supervisor layer +- реализован emergency execution halt +- реализован execution cooldown after loss +- реализован degraded market execution blocker +- реализован stale snapshot execution blocker +- реализован signal conflict protection layer +- реализован runtime market regime protection +- реализован execution snapshot freshness validation +- реализован runtime flip protection upgrade +- реализован flip cooldown engine +- реализован breakout-aware flip protection +- реализован loss-aware flip protection +- реализован adaptive runtime risk engine +- реализован advanced adaptive sizing layer +- реализована execution quality-aware sizing logic +- реализован margin-aware effective risk engine +- реализован effective risk synchronization +- реализован runtime event propagation layer +- реализована runtime journal observability architecture +- реализован runtime semantic reasoning layer +- реализованы human-readable runtime explanations +- реализована runtime diagnostics propagation +- реализована preparation for partial exit engine +- реализована preparation for advanced runtime orchestration + --- ### 07.4.5 diff --git a/docs/stages/stage-07_4_4_1_12-osition_health_and_untime_risk_layer.md b/docs/stages/stage-07_4_4_1_12-osition_health_and_untime_risk_layer.md new file mode 100644 index 0000000..8506943 --- /dev/null +++ b/docs/stages/stage-07_4_4_1_12-osition_health_and_untime_risk_layer.md @@ -0,0 +1,207 @@ +# 07.4.4.1.12 — Position Health & Runtime Risk Layer + +Статус + +## ✅ Реализовано + +Рекомендуемый commit message: + +git commit -m "07.4.4.1.12 — Position Health & Runtime Risk Layer" + +--- + +## Краткое описание этапа + +Этап посвящён развитию runtime execution protection layer и внедрению полноценной position-aware risk management architecture поверх execution engine. + +Главная цель этапа: + +* научить систему анализировать состояние уже открытой позиции; +* реализовать runtime position protection; +* внедрить adaptive runtime risk management; +* реализовать autonomous runtime actions; +* внедрить giveback protection; +* реализовать time decay logic; +* внедрить runtime execution supervisor; +* подготовить execution engine к institutional-grade runtime orchestration. + +--- + +## Основные реализованные изменения + +1. Runtime Position Protection Engine + +Реализован полноценный runtime protection engine для активной позиции. + +Добавлены: + +* runtime protection lifecycle; +* runtime protection state synchronization; +* runtime protection event propagation; +* runtime protection diagnostics rendering. + +Теперь execution engine способен: + +* отслеживать состояние позиции в runtime; +* реагировать на ухудшение структуры позиции; +* управлять protection logic в реальном времени; +* выполнять forced runtime exits. + +--- + +2. Break-even Protection Layer + +Реализован automated break-even engine. + +Добавлены: + +* break_even_armed +* break_even_price +* BREAK_EVEN runtime actions + +Теперь система автоматически переводит позицию в break-even и защищает капитал после выхода позиции в прибыль. + +--- + +3. Profit Lock Engine + +Реализован profit lock protection layer. + +Добавлены: + +* profit_lock_active +* profit_lock_price +* PROFIT_LOCK runtime protection logic + +Теперь execution layer способен фиксировать часть прибыли и предотвращать глубокий giveback прибыли. + +--- + +4. Trailing Stop Runtime Engine + +Реализован полноценный trailing stop engine. + +Добавлены: + +* trailing_stop_active +* trailing_stop_price +* dynamic trailing updates +* runtime trailing synchronization + +Теперь runtime protection подтягивает protection level вслед за ценой и сопровождает трендовое движение. + +--- + +5. Giveback Protection Engine + +Реализован отдельный giveback engine. + +Добавлены: + +* runtime peak pnl tracking +* giveback detection logic +* profit deterioration analysis +* aggressive runtime exit handling + +Теперь execution engine способен отслеживать потерю накопленной прибыли и закрывать позицию при dangerous pnl giveback. + +--- + +6. Time Decay Runtime Engine + +Реализован time decay protection layer. + +Добавлены: + +* position hold duration analysis +* stale position detection +* weak holding structure analysis +* time-based runtime exits + +Теперь система умеет определять слишком долгие позиции и закрывать weak stagnant positions. + +--- + +7. Runtime Autonomous Action Layer + +Существенно расширен runtime autonomous action engine. + +Добавлены runtime actions: + +* PROTECT +* REDUCE +* EXIT +* EXIT_BLOCKED + +Теперь runtime layer способен самостоятельно инициировать runtime actions и выполнять autonomous exits. + +--- + +8. Execution Supervisor Layer + +Реализован полноценный execution supervisor. + +Добавлены: + +* emergency execution halt; +* execution cooldown after loss; +* degraded market execution blocking; +* stale snapshot blocking; +* signal conflict protection. + +Теперь supervisor умеет блокировать execution в опасных market regimes. + +--- + +9. Adaptive Runtime Risk Layer + +Существенно расширен adaptive sizing engine. + +Теперь adaptive risk учитывает: + +* execution confidence; +* market state; +* trend quality; +* market phase; +* momentum structure; +* execution quality; +* spread degradation; +* snapshot degradation. + +--- + +10. Runtime Event & Journal Architecture + +Существенно расширена observability architecture. + +Добавлены runtime events: + +* runtime_position_action +* runtime_protection_updated +* execution_supervisor_block +* paper_flip_blocked +* paper_position_closed +* paper_position_flipped + +--- + +Итог этапа + +После этапа: + +* execution engine стал position-aware; +* реализован полноценный runtime risk layer; +* система научилась защищать уже открытую позицию; +* внедрён autonomous runtime execution; +* реализован giveback protection; +* реализован time decay analysis; +* execution supervisor стал market-aware; +* runtime execution стал значительно безопаснее. + +--- + +Рекомендуемый commit + +git add . +git commit -m "07.4.4.1.12 — Position Health & Runtime Risk Layer" +git push origin main \ No newline at end of file