From 8a07d24bd76b1beea7ae529baf3a45c9ef125bc0 Mon Sep 17 00:00:00 2001 From: Sergey Date: Fri, 3 Jul 2026 12:29:42 +0300 Subject: [PATCH] Add detailed flip diagnostics --- app/src/trading/execution/flip.py | 102 +++++++++++++++++++++++------- 1 file changed, 79 insertions(+), 23 deletions(-) diff --git a/app/src/trading/execution/flip.py b/app/src/trading/execution/flip.py index 08fcc85..6f4dbb0 100644 --- a/app/src/trading/execution/flip.py +++ b/app/src/trading/execution/flip.py @@ -113,7 +113,9 @@ class _ExecutionFlipProtocol(Protocol): class ExecutionFlipMixin(_ExecutionFlipProtocol): # ---------- Diagnostics ---------- - # записать диагностическое событие по пути flip без изменения торговой логики + # Записать диагностическое событие по пути flip без изменения торговой логики. + # Эти события нужны, чтобы понять, где именно разворот был остановлен: + # READY, supervisor, flip guard, price, sizing или успешное исполнение. def _log_flip_diagnostic( self, *, @@ -125,6 +127,8 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): confidence = safe_float(state.last_signal_confidence) or 0.0 repeat_count = int(safe_float(state.last_signal_repeat_count) or 0) + # Дедупликация защищает журнал от спама на каждом тике одного и того же + # состояния. Если причина/стадия изменилась — событие будет записано. key = ( f"{stage}:" f"{state.symbol}:" @@ -172,9 +176,9 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): ) EventBus.emit("paper_flip_diagnostic", payload) - + # ---------- Payload builders ---------- - # собрать payload отказа flip без изменения состояния + # Собрать payload отказа flip без изменения состояния позиции. def _build_flip_rejected_payload( self, *, @@ -197,7 +201,7 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): "updated_at": position.updated_at, } - # собрать payload блокировки flip без изменения состояния + # Собрать payload блокировки flip guard'ами без изменения состояния позиции. def _build_flip_blocked_payload( self, *, @@ -211,21 +215,20 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): "execution_type": EXECUTION_TYPE_FLIP_BLOCKED, "symbol": state.symbol, "position_side": position.side, - **build_signal_payload( state, confidence=confidence, reason=reason, ), **build_execution_quality_payload(state), - "unrealized_pnl_usd": state.unrealized_pnl_usd, **build_market_context_payload(state), "opened_at": position.opened_at, "updated_at": position.updated_at, } - # собрать payload выполненного flip без изменения состояния + # Собрать payload выполненного flip. + # Здесь фиксируем и закрытую старую позицию, и параметры новой позиции. def _build_flip_executed_payload( self, *, @@ -311,6 +314,7 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): # ---------- Runtime protection ---------- **build_runtime_protection_payload(state), + # ---------- Pricing diagnostics ---------- "pricing": PRICING_FLIP_MODE, "exit_pricing_role": exit_execution.pricing_role, "exit_price_source": exit_execution.source, @@ -323,7 +327,9 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): } # ---------- Journal helpers ---------- - # записать отказ flip execution в журнал + # Записать отказ flip execution в журнал. + # Reject отличается от block: reject происходит уже внутри попытки исполнения, + # например из-за отсутствия цены или невозможности рассчитать size. def _log_flip_rejected( self, *, @@ -344,17 +350,26 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): ) # ---------- Decision helpers ---------- - # записать отказ flip и вернуть стандартное решение без исполнения + # Записать отказ flip и вернуть стандартное решение без исполнения. + # diagnostic_stage указывает, на каком техническом этапе flip был отклонён. def _reject_flip( self, *, state: AutoTradeState, reason: str, + diagnostic_stage: str | None = None, ) -> ExecutionDecision: + if diagnostic_stage is not None: + self._log_flip_diagnostic( + state=state, + stage=diagnostic_stage, + reason=reason, + ) + self._log_flip_rejected(state=state, reason=reason) return ExecutionDecision(EXECUTION_ACTION_NONE, False, reason) - # записать блокировку flip в state, journal и event bus + # Записать блокировку flip guard'ами в state, journal и event bus. def _block_flip( self, state: AutoTradeState, @@ -363,6 +378,14 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): position = type(self)._position confidence = safe_float(state.last_signal_confidence) or 0.0 + # Диагностика отдельно показывает, что flip дошёл до flip.py, + # но был остановлен именно flip-specific guard'ами. + self._log_flip_diagnostic( + state=state, + stage="FLIP_BLOCKED_BY_FLIP_GUARD", + reason=reason, + ) + state.execution_block_reason = reason state.last_flip_block_reason = reason state.last_execution_action = EXECUTION_ACTION_FLIP_BLOCKED @@ -398,7 +421,8 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): return ExecutionDecision(EXECUTION_ACTION_NONE, False, reason) # ---------- Flip checks ---------- - # проверить, нужен ли flip позиции по текущему сигналу + # Проверить, нужен ли flip позиции по текущему сигналу. + # Здесь только факт противоположного сигнала, без оценки качества рынка. def _should_flip_position(self, state: AutoTradeState) -> bool: position = type(self)._position signal = str(state.last_signal or "").upper() @@ -414,7 +438,8 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): return False - # определить причину блокировки flip, если flip сейчас опасен + # Определить причину блокировки flip, если flip сейчас опасен. + # Важно: пока торговую логику не меняем, только делаем её наблюдаемой. def _flip_block_reason(self, state: AutoTradeState) -> str | None: position = type(self)._position @@ -423,6 +448,9 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): execution_confidence = safe_float(state.execution_confidence_score) repeat_count = int(safe_float(state.last_signal_repeat_count) or 0) unrealized_pnl = safe_float(state.unrealized_pnl_usd) or 0.0 + + # hold_seconds считаем через position metrics. + # current_price пока берём entry_price, чтобы не менять текущую механику. metrics = build_position_metrics( position, current_price=position.entry_price, @@ -535,7 +563,7 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): return None - # проверить, активен ли cooldown после последнего flip + # Проверить, активен ли cooldown после последнего flip. def _flip_cooldown_active(self, state: AutoTradeState) -> bool: ts = safe_float(getattr(state, "last_flip_monotonic_at", None)) @@ -544,7 +572,7 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): return (time.monotonic() - ts) < self._flip_cooldown_seconds - # определить сторону позиции по сигналу BUY / SELL + # Определить сторону новой позиции по сигналу BUY / SELL. def _target_side_from_signal(self, signal: str | None) -> str | None: normalized_signal = str(signal or "").upper() @@ -557,22 +585,33 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): return None # ---------- Execution ---------- - # закрыть текущую позицию и открыть новую в противоположную сторону + # Закрыть текущую позицию и открыть новую в противоположную сторону. def _flip_position(self, state: AutoTradeState) -> ExecutionDecision: position = type(self)._position if position.side == POSITION_SIDE_NONE: self._sync_state_from_position(state) reason = "Нет позиции для flip." - return self._reject_flip(state=state, reason=reason) + return self._reject_flip( + state=state, + reason=reason, + diagnostic_stage="FLIP_REJECTED_NO_POSITION", + ) new_side = self._target_side_from_signal(state.last_signal) if new_side is None: reason = "Нет направления для flip." - return self._reject_flip(state=state, reason=reason) + return self._reject_flip( + state=state, + reason=reason, + diagnostic_stage="FLIP_REJECTED_NO_DIRECTION", + ) try: + # Для flip нужны две цены: + # 1) exit price — закрытие старой позиции; + # 2) entry price — открытие новой позиции. exit_execution = self._exit_price_for_side( position.symbol or state.symbol, position.side, @@ -586,7 +625,11 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): except Exception as exc: reason = f"Ошибка получения цены для flip: {exc}" - return self._reject_flip(state=state, reason=reason) + return self._reject_flip( + state=state, + reason=reason, + diagnostic_stage="FLIP_REJECTED_BY_PRICE", + ) now = self._now_time() opened_monotonic_at = time.monotonic() @@ -606,7 +649,11 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): if new_size <= 0: reason = "Flip отменён: невозможно рассчитать adaptive size." - return self._reject_flip(state=state, reason=reason) + return self._reject_flip( + state=state, + reason=reason, + diagnostic_stage="FLIP_REJECTED_BY_SIZING", + ) new_size = self._adjust_size_by_margin_limit( state=state, @@ -624,7 +671,11 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): if new_size <= 0: reason = "Flip отменён: итоговый size равен 0." - return self._reject_flip(state=state, reason=reason) + return self._reject_flip( + state=state, + reason=reason, + diagnostic_stage="FLIP_REJECTED_BY_SIZING", + ) state.realized_pnl_usd += pnl state.cycle_realized_pnl_usd += pnl @@ -635,16 +686,14 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): if pnl > 0: state.cycle_winning_trades += 1 - # прибыльный flip закрывает серию убытков + # Прибыльный flip закрывает серию убытков и выключает loss cooldown. state.cycle_consecutive_losses = 0 - state.loss_cooldown_active = False state.loss_cooldown_reason = None elif pnl < 0: state.cycle_losing_trades += 1 state.cycle_consecutive_losses += 1 - state.last_loss_monotonic_at = time.monotonic() if state.cycle_consecutive_losses >= EXECUTION_MAX_CONSECUTIVE_LOSSES: @@ -737,6 +786,13 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): entry_execution=entry_execution, ) + # Отдельная диагностика успешного прохождения всего flip-пайплайна. + self._log_flip_diagnostic( + state=state, + stage="FLIP_EXECUTED", + reason=f"{old_side} -> {new_side}", + ) + JournalService().log_ui_info( event_type="position_flipped", message=f"Направление позиции изменено: {old_side} → {new_side}.",