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