408 lines
14 KiB
Python
408 lines
14 KiB
Python
# 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 |