Add detailed flip diagnostics

This commit is contained in:
2026-07-03 12:29:42 +03:00
parent 4d58fcf2bf
commit 8a07d24bd7

View File

@@ -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}:"
@@ -174,7 +178,7 @@ 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}.",