Stage 07.4.4.1.14 — Execution refactoring and runtime semantics
This commit is contained in:
408
app/src/trading/execution/position_exit_decision.py
Normal file
408
app/src/trading/execution/position_exit_decision.py
Normal file
@@ -0,0 +1,408 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user