Stage 07.4.4.1.14 — Execution refactoring and runtime semantics

This commit is contained in:
2026-07-02 16:25:18 +03:00
parent d9e6392e28
commit e08c604ff5
72 changed files with 13626 additions and 4233 deletions

View 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