Files
dzentra_bot/app/src/trading/execution/position_exit_decision.py

408 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# app/src/trading/execution/position_exit_decision.py
from __future__ import annotations
import time
from typing import ClassVar, Protocol
from src.core.numbers import safe_float
from src.trading.auto.state import AutoTradeState
from src.trading.execution.position_metrics import PositionMetrics, build_position_metrics
from src.trading.position.state import PositionState
from src.trading.execution.constants import get_position_exit_thresholds
class _ExecutionPositionExitDecisionProtocol(Protocol):
_position: ClassVar[PositionState]
class ExecutionPositionExitDecisionMixin(_ExecutionPositionExitDecisionProtocol):
"""
Execution-слой принятия решения о runtime-закрытии позиции.
Важно:
- этот файл НЕ рассчитывает PnL, движение цены и время удержания сам;
- все числовые метрики позиции берутся из position_metrics.py;
- здесь остаётся только логика принятия решения: закрывать позицию или нет.
"""
def _runtime_intelligence_close_reason(
self,
*,
state: AutoTradeState,
current_price: float,
) -> str | None:
metrics = build_position_metrics(
type(self)._position,
current_price=current_price,
)
# Защита от раннего выхода на обычной волне/откате.
# Если позиция открыта недавно и просадка ещё в рамках нормальной
# волатильности актива, intelligence-close не закрывает сделку.
if self._is_normal_pullback_wave(state=state, metrics=metrics):
return None
giveback_reason = self._giveback_close_reason(
state=state,
metrics=metrics,
)
if giveback_reason is not None:
self._sync_intelligence_exit_state(
state=state,
reason=giveback_reason,
algorithm="GIVEBACK",
)
return giveback_reason
time_decay_reason = self._time_decay_close_reason(
state=state,
metrics=metrics,
)
if time_decay_reason is not None:
self._sync_intelligence_exit_state(
state=state,
reason=time_decay_reason,
algorithm="TIME_DECAY",
)
return time_decay_reason
return None
def _sync_intelligence_exit_state(
self,
*,
state: AutoTradeState,
reason: str,
algorithm: str,
) -> None:
# В AutoTradeState сейчас нет отдельного поля position_exit_algorithm.
# Поэтому алгоритм пишем в position_intelligence_reason — это поле уже есть
# в state и попадёт дальше в диагностику / журнал закрытия.
state.position_intelligence_reason = algorithm
state.runtime_protection_action = "INTELLIGENCE_EXIT"
state.runtime_protection_reason = reason
state.runtime_protection_updated_at = time.monotonic()
def _giveback_close_reason(
self,
*,
state: AutoTradeState,
metrics: PositionMetrics,
) -> str | None:
price_move_percent = metrics.price_move_percent
peak_percent = safe_float(
getattr(state, "position_peak_pnl_percent", None)
)
if peak_percent is None or peak_percent <= 0:
return None
giveback = peak_percent - price_move_percent
if giveback <= 0:
return None
giveback_percent = round((giveback / peak_percent) * 100, 2)
# Сохраняем рассчитанный giveback в state,
# чтобы журнал закрытия видел именно то значение,
# на основании которого принято решение.
state.position_giveback_percent = giveback_percent
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
thresholds = self._exit_thresholds(state)
market_quality = str(
getattr(state, "market_trend_quality", "") or ""
).upper()
stall_state = str(
getattr(state, "position_stall_state", "") or ""
).upper()
# В CLEAN рынке даём прибыли больше пространства.
# В NOISY рынке фиксируем быстрее, потому что откаты чаще съедают прибыль.
if market_quality == "NOISY":
min_peak = thresholds["noisy_giveback_min_peak"]
giveback_limit = thresholds["noisy_giveback_percent"]
else:
min_peak = thresholds["clean_giveback_min_peak"]
giveback_limit = thresholds["clean_giveback_percent"]
if (
peak_percent >= min_peak
and giveback_percent >= giveback_limit
and price_move_percent > 0.10
):
return (
"NOISY_GIVEBACK_EXIT"
if market_quality == "NOISY"
else "CLEAN_GIVEBACK_EXIT"
)
if (
stall_state in {"NOISY_STALLED", "ADVERSE_STALLED"}
and peak_percent >= min_peak
and giveback_percent >= max(25, giveback_limit - 10)
and price_move_percent > 0
):
return "STALL_GIVEBACK_EXIT"
if (
peak_percent >= 1.50
and giveback_percent >= 50
and price_move_percent > 0.25
):
return "GIVEBACK_PROFIT_LOCK"
if (
peak_percent >= 1.20
and giveback_percent >= 60
and price_move_percent > 0.15
):
return "GIVEBACK_PROTECTION"
if (
peak_percent >= 1.00
and giveback_percent >= 50
and adverse_momentum
):
return "GIVEBACK_MOMENTUM_REVERSAL"
if (
peak_percent >= 1.00
and giveback_percent >= 45
and fatigue_state in {"TIRED", "EXHAUSTED"}
):
return "GIVEBACK_FATIGUE_EXIT"
if (
peak_percent >= 1.00
and giveback_percent >= 45
and reversal_risk in {"ELEVATED", "HIGH"}
and exit_confidence >= 0.60
):
return "GIVEBACK_REVERSAL_RISK"
return None
def _time_decay_close_reason(
self,
*,
state: AutoTradeState,
metrics: PositionMetrics,
) -> str | None:
hold_seconds = metrics.hold_seconds
if hold_seconds is None:
return None
price_move_percent = metrics.price_move_percent
thresholds = self._exit_thresholds(state)
# Hard-loss — отдельный аварийный intelligence-exit.
# Если движение цены уже глубже допустимого порога,
# не ждём fatigue / time-decay / adverse momentum.
if price_move_percent <= thresholds["hard_loss"]:
return "HARD_LOSS_EXIT"
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)
)
net_pnl_usd = safe_float(getattr(metrics, "net_pnl_usd", None)) or 0.0
risk_level = str(
getattr(state, "position_risk_level", "") or ""
).upper()
# Time-decay не должен закрывать позицию просто потому,
# что она долго стоит около нуля.
# Разрешаем time-decay закрытие только если:
# - сделка уже покрыла RT-комиссию и net PnL положительный;
# - или есть реальное ухудшение: adverse momentum / HIGH risk / BROKEN conviction.
real_deterioration = (
adverse_momentum
or risk_level == "HIGH"
or conviction_state == "BROKEN"
)
market_quality = str(
getattr(state, "market_trend_quality", "") or ""
).upper()
peak_percent = safe_float(
getattr(state, "position_peak_pnl_percent", None)
) or 0.0
giveback_percent = safe_float(
getattr(state, "position_giveback_percent", None)
) or 0.0
# Специальный быстрый выход для NOISY рынка.
# В шумном рынке не ждём классический time-decay 1500-2100 секунд:
# если позиция после минимального времени уже в минусе
# или быстро отдаёт прибыль, закрываем раньше.
if market_quality == "NOISY" and hold_seconds >= thresholds["noisy_min_hold"]:
if (
price_move_percent <= thresholds["noisy_loss_exit"]
and adverse_momentum
):
return "NOISY_ADVERSE_EXIT"
if (
peak_percent > 0
and giveback_percent >= thresholds["noisy_profit_giveback"]
and price_move_percent > 0
):
return "NOISY_PROFIT_GIVEBACK_EXIT"
if net_pnl_usd <= 0 and not real_deterioration:
return None
# Нейтральную позицию по ETH/BTC/LTC/XRP держим дольше.
# Например для ETH: если движение внутри ±0.40%,
# не закрываем её по time-decay раньше neutral_min_hold.
if (
hold_seconds < thresholds["neutral_min_hold"]
and abs(price_move_percent) <= thresholds["neutral_band"]
and not real_deterioration
):
return None
if (
hold_seconds >= thresholds["neutral_min_hold"]
and -thresholds["neutral_band"] <= price_move_percent <= thresholds["neutral_band"]
and conviction_state in {"WEAKENING", "BROKEN", "NEUTRAL"}
):
return "TIME_DECAY_EXIT"
if (
hold_seconds >= thresholds["min_hold"]
and -thresholds["neutral_band"] <= price_move_percent <= thresholds["neutral_band"]
and fatigue_state in {"TIRED", "EXHAUSTED"}
):
return "TIME_DECAY_FATIGUE_EXIT"
if (
hold_seconds >= thresholds["min_hold"]
and price_move_percent <= thresholds["normal_pullback"]
and adverse_momentum
):
return "TIME_DECAY_ADVERSE_MOMENTUM"
if (
hold_seconds >= thresholds["min_hold"]
and price_move_percent <= thresholds["normal_pullback"]
and market_runtime_degraded
):
return "TIME_DECAY_DEGRADED_MARKET"
if (
hold_seconds >= thresholds["neutral_min_hold"]
and decay_state in {"TIME_DECAY", "CONTEXT_DECAY"}
and price_move_percent <= thresholds["neutral_band"]
):
return "TIME_DECAY_CONTEXT_DECAY"
return None
def _exit_thresholds(self, state: AutoTradeState) -> dict[str, float]:
return get_position_exit_thresholds(
getattr(state, "symbol", None)
)
def _is_normal_pullback_wave(
self,
*,
state: AutoTradeState,
metrics: PositionMetrics,
) -> bool:
thresholds = self._exit_thresholds(state)
hold_seconds = safe_float(metrics.hold_seconds)
price_move_percent = safe_float(metrics.price_move_percent)
if hold_seconds is None or price_move_percent is None:
return False
# Если убыток уже глубже hard_loss — это не обычный откат.
if price_move_percent <= thresholds["hard_loss"]:
return False
if hold_seconds >= thresholds["min_hold"]:
return False
if price_move_percent < thresholds["normal_pullback"]:
return False
adverse_momentum = bool(
getattr(state, "position_adverse_momentum", False)
)
risk_level = str(
getattr(state, "position_risk_level", "") or ""
).upper()
conviction_state = str(
getattr(state, "position_conviction_state", "") or ""
).upper()
# Если есть реальное ухудшение, это уже не обычный откат.
# Так мы не блокируем быстрый выход в NOISY рынке,
# когда momentum/риск явно против позиции.
if adverse_momentum or risk_level == "HIGH" or conviction_state == "BROKEN":
return False
market_phase = str(getattr(state, "market_phase", "") or "").upper()
market_quality = str(getattr(state, "market_trend_quality", "") or "").upper()
market_structure = str(getattr(state, "market_structure", "") or "").upper()
trend_alignment = str(getattr(state, "position_trend_alignment", "") or "").upper()
# Обычный откат/шум/флэт после входа не должен сразу закрывать сделку.
if market_phase in {"PULLBACK", "RANGE", "SQUEEZE"}:
return True
if market_quality == "NOISY" and trend_alignment != "AGAINST":
return True
if market_structure in {"HH_HL", "LH_LL", "MIXED"} and trend_alignment != "AGAINST":
return True
return False