# app/src/trading/auto/position_health.py from __future__ import annotations from src.core.numbers import safe_float from src.core.types import NumericLike from src.trading.auto.state import AutoTradeState from src.trading.auto.state_reset import reset_position_health_state from src.trading.execution.constants import ( EXECUTION_QUALITY_BLOCKED, EXECUTION_QUALITY_WARNING, MARKET_VOLATILITY_HIGH_STATES, POSITION_CURRENT_INTERVAL_ADVERSE_MOVE_PERCENT, POSITION_CURRENT_INTERVAL_RISK_MOVE_PERCENT, POSITION_EXIT_PRESSURE_LOSS_PERCENT, POSITION_HEALTH_DANGER, POSITION_HEALTH_HEALTHY, POSITION_HEALTH_PNL_GOOD_PROFIT_PERCENT, POSITION_HEALTH_PNL_HARD_LOSS_PERCENT, POSITION_HEALTH_PNL_HIGH_PRESSURE_PERCENT, POSITION_HEALTH_PNL_PRESSURE_PERCENT, POSITION_HEALTH_PRESSURE, POSITION_HEALTH_UNKNOWN, POSITION_HEALTH_WATCH, POSITION_MOMENTUM_STRONG, POSITION_RISK_ELEVATED, POSITION_RISK_HIGH, POSITION_RISK_LOW, POSITION_RISK_MODERATE, POSITION_STOP_LOSS_RATIO_CRITICAL, POSITION_STOP_LOSS_RATIO_WARNING, POSITION_STOP_LOSS_RATIO_WATCH, get_position_health_thresholds, ) class AutoPositionHealthMixin: # синхронизировать runtime health/risk состояние открытой позиции def _sync_position_health_state(self, state: AutoTradeState) -> None: if state.position_side == "NONE" or state.entry_price is None: reset_position_health_state(state) return # PnL % и время удержания больше не считаем здесь. # Эти значения должны приходить из единого расчёта position_metrics.py # через execution/position_runtime.py. pnl_percent = safe_float(state.position_pnl_percent) hold_seconds = state.position_hold_seconds trend_alignment = self._position_trend_alignment(state) adverse_momentum = self._has_adverse_position_momentum(state) pressure = self._position_pressure( state=state, pnl_percent=pnl_percent, ) health_score = self._position_health_score( state=state, pnl_percent=pnl_percent, trend_alignment=trend_alignment, adverse_momentum=adverse_momentum, ) risk_level, risk_reason = self._position_risk_level( state=state, pnl_percent=pnl_percent, trend_alignment=trend_alignment, adverse_momentum=adverse_momentum, ) state.position_pnl_percent = pnl_percent state.position_hold_seconds = hold_seconds state.position_pressure = pressure state.position_health_score = health_score state.position_health_status = self._position_health_status(health_score) state.position_health_reason = self._position_health_reason( pressure=pressure, trend_alignment=trend_alignment, adverse_momentum=adverse_momentum, ) state.position_risk_level = risk_level state.position_risk_reason = risk_reason state.position_trend_alignment = trend_alignment state.position_adverse_momentum = adverse_momentum state.position_exit_pressure = self._position_exit_pressure( state=state, pnl_percent=pnl_percent, risk_level=risk_level, ) def _health_thresholds(self, state: AutoTradeState) -> dict[str, float]: return get_position_health_thresholds(state.symbol) # определить давление на позицию по PnL def _position_pressure( self, *, state: AutoTradeState, pnl_percent: NumericLike | None, ) -> str: pnl = safe_float(state.unrealized_pnl_usd) or 0.0 percent = safe_float(pnl_percent) if percent is None: if pnl < 0: return "LOSS" if pnl > 0: return "PROFIT" return "FLAT" thresholds = self._health_thresholds(state) if percent <= thresholds["high_loss"]: return "HIGH_LOSS" if percent <= thresholds["loss"]: return "LOSS" if percent >= thresholds["strong_profit"]: return "STRONG_PROFIT" if percent >= thresholds["profit"]: return "PROFIT" return "FLAT" # определить alignment позиции относительно тренда def _position_trend_alignment(self, state: AutoTradeState) -> str: side = str(state.position_side or "NONE").upper() market_state = str(state.market_state or "").upper() trend = str(state.market_trend or "").upper() htf_trend = str(getattr(state, "htf_trend", "") or "").upper() htf_alignment = str(getattr(state, "htf_alignment", "") or "").upper() if side == "NONE": return "NONE" # HTF AGAINST важнее локального тренда: # если старший таймфрейм против позиции, позиция считается рискованной. if htf_alignment == "AGAINST": return "AGAINST" if side == "LONG": if market_state == "TREND_UP" or trend == "UP": if htf_trend in {"DOWN"}: return "NEUTRAL" return "ALIGNED" if market_state == "TREND_DOWN" or trend == "DOWN": return "AGAINST" if side == "SHORT": if market_state == "TREND_DOWN" or trend == "DOWN": if htf_trend in {"UP"}: return "NEUTRAL" return "ALIGNED" if market_state == "TREND_UP" or trend == "UP": return "AGAINST" return "NEUTRAL" # проверить, направлен ли momentum против позиции def _has_adverse_position_momentum(self, state: AutoTradeState) -> bool: side = str(state.position_side or "NONE").upper() momentum_direction = str(state.momentum_direction or "").upper() momentum_state = str(state.momentum_state or "").upper() momentum_strength = safe_float(getattr(state, "momentum_strength", None)) or 0.0 current_interval_direction = str( getattr(state, "current_interval_direction", "") or "" ).upper() current_interval_change_percent = safe_float( getattr(state, "current_interval_change_percent", None) ) current_interval_move_abs = abs(current_interval_change_percent or 0.0) current_interval_against_long = ( current_interval_direction == "DOWN" and current_interval_move_abs >= POSITION_CURRENT_INTERVAL_ADVERSE_MOVE_PERCENT ) current_interval_against_short = ( current_interval_direction == "UP" and current_interval_move_abs >= POSITION_CURRENT_INTERVAL_ADVERSE_MOVE_PERCENT ) if side == "LONG": return ( momentum_state in {"MOMENTUM_DOWN", "BREAKOUT_DOWN"} or current_interval_against_long or ( momentum_direction == "DOWN" and momentum_strength >= POSITION_MOMENTUM_STRONG ) ) if side == "SHORT": return ( momentum_state in {"MOMENTUM_UP", "BREAKOUT_UP"} or current_interval_against_short or ( momentum_direction == "UP" and momentum_strength >= POSITION_MOMENTUM_STRONG ) ) return False # рассчитать health score позиции def _position_health_score( self, *, state: AutoTradeState, pnl_percent: NumericLike | None, trend_alignment: str, adverse_momentum: bool, ) -> int: score = 100 percent = safe_float(pnl_percent) stop_loss_percent = safe_float(getattr(state, "stop_loss_percent", None)) htf_alignment = str(getattr(state, "htf_alignment", "") or "").upper() market_structure = str(getattr(state, "market_structure", "") or "").upper() market_phase = str(getattr(state, "market_phase", "") or "").upper() trend_quality = str(getattr(state, "market_trend_quality", "") or "").upper() volatility = str(getattr(state, "market_volatility", "") or "").upper() if percent is not None: if percent <= POSITION_HEALTH_PNL_HARD_LOSS_PERCENT: score -= 40 elif percent <= POSITION_HEALTH_PNL_HIGH_PRESSURE_PERCENT: score -= 30 elif percent <= POSITION_HEALTH_PNL_PRESSURE_PERCENT: score -= 18 elif percent < 0: score -= 8 elif percent >= POSITION_HEALTH_PNL_GOOD_PROFIT_PERCENT: score += 5 # Если позиция прошла большую часть stop loss — ухудшаем score заранее. if stop_loss_percent is not None and stop_loss_percent > 0: loss_ratio = abs(percent) / stop_loss_percent if percent < 0 else 0.0 if loss_ratio >= POSITION_STOP_LOSS_RATIO_CRITICAL: score -= 25 elif loss_ratio >= POSITION_STOP_LOSS_RATIO_WARNING: score -= 15 if trend_alignment == "AGAINST": score -= 30 elif trend_alignment == "NEUTRAL": score -= 10 if adverse_momentum: score -= 25 if htf_alignment == "AGAINST": score -= 20 elif htf_alignment == "NEUTRAL": score -= 8 if market_structure == "MIXED": score -= 12 elif market_structure == "LH_LL" and state.position_side == "LONG": score -= 18 elif market_structure == "HH_HL" and state.position_side == "SHORT": score -= 18 if market_phase in {"RANGE", "SQUEEZE"}: score -= 10 elif market_phase == "PULLBACK" and trend_alignment != "ALIGNED": score -= 12 if trend_quality == "NOISY": score -= 12 if volatility in MARKET_VOLATILITY_HIGH_STATES: score -= 12 if state.execution_quality == EXECUTION_QUALITY_BLOCKED: score -= 15 elif state.execution_quality == EXECUTION_QUALITY_WARNING: score -= 8 if state.market_runtime_degraded: score -= 10 return max(0, min(100, score)) # классифицировать health status по score def _position_health_status(self, score: int | None) -> str: if score is None: return POSITION_HEALTH_UNKNOWN if score >= 80: return POSITION_HEALTH_HEALTHY if score >= 60: return POSITION_HEALTH_WATCH if score >= 40: return POSITION_HEALTH_PRESSURE return POSITION_HEALTH_DANGER # сформировать человекочитаемую причину health состояния def _position_health_reason( self, *, pressure: str, trend_alignment: str, adverse_momentum: bool, ) -> str: if trend_alignment == "AGAINST" and adverse_momentum: return "тренд и momentum против позиции" if trend_alignment == "AGAINST": return "тренд против позиции" if adverse_momentum: return "momentum против позиции" if pressure in {"HIGH_LOSS", "LOSS"}: return "позиция под давлением" if pressure in {"PROFIT", "STRONG_PROFIT"}: return "позиция в прибыли" return "позиция стабильна" # определить runtime risk level позиции def _position_risk_level( self, *, state: AutoTradeState, pnl_percent: NumericLike | None, trend_alignment: str, adverse_momentum: bool, ) -> tuple[str, str]: percent = safe_float(pnl_percent) stop_loss_percent = safe_float(getattr(state, "stop_loss_percent", None)) htf_alignment = str(getattr(state, "htf_alignment", "") or "").upper() market_structure = str(getattr(state, "market_structure", "") or "").upper() volatility = str(getattr(state, "market_volatility", "") or "").upper() current_interval_direction = str( getattr(state, "current_interval_direction", "") or "" ).upper() current_interval_change_percent = safe_float( getattr(state, "current_interval_change_percent", None) ) current_interval_move_abs = abs(current_interval_change_percent or 0.0) current_interval_against_position = ( ( state.position_side == "LONG" and current_interval_direction == "DOWN" ) or ( state.position_side == "SHORT" and current_interval_direction == "UP" ) ) if state.execution_quality == EXECUTION_QUALITY_BLOCKED: return POSITION_RISK_HIGH, "исполнение заблокировано" if percent is not None: if percent <= POSITION_HEALTH_PNL_HARD_LOSS_PERCENT: return POSITION_RISK_HIGH, "сильная просадка позиции" if stop_loss_percent is not None and stop_loss_percent > 0 and percent < 0: loss_ratio = abs(percent) / stop_loss_percent if loss_ratio >= POSITION_STOP_LOSS_RATIO_CRITICAL: return POSITION_RISK_HIGH, "позиция близко к stop loss" if loss_ratio >= POSITION_STOP_LOSS_RATIO_WARNING: return POSITION_RISK_ELEVATED, "позиция прошла больше половины stop loss" if trend_alignment == "AGAINST" and adverse_momentum: return POSITION_RISK_HIGH, "рынок движется против позиции" if htf_alignment == "AGAINST" and adverse_momentum: return POSITION_RISK_HIGH, "старший таймфрейм и momentum против позиции" if ( state.position_side == "LONG" and market_structure == "LH_LL" ): return POSITION_RISK_ELEVATED, "структура рынка против LONG" if ( state.position_side == "SHORT" and market_structure == "HH_HL" ): return POSITION_RISK_ELEVATED, "структура рынка против SHORT" if volatility in MARKET_VOLATILITY_HIGH_STATES and percent is not None and percent < 0: return POSITION_RISK_ELEVATED, "убыток в высокой волатильности" if percent is not None and percent < 0: if trend_alignment == "AGAINST" or adverse_momentum: return POSITION_RISK_ELEVATED, "убыток усиливается рыночным контекстом" return POSITION_RISK_MODERATE, "позиция в минусе" if current_interval_against_position and current_interval_move_abs >= POSITION_CURRENT_INTERVAL_RISK_MOVE_PERCENT: return POSITION_RISK_ELEVATED, "текущая 5м свеча против позиции" if current_interval_against_position and percent is not None and percent < 0: return POSITION_RISK_ELEVATED, "убыток усиливается текущей 5м свечой" if adverse_momentum: return POSITION_RISK_MODERATE, "momentum против позиции" if htf_alignment == "AGAINST": return POSITION_RISK_MODERATE, "старший таймфрейм против позиции" return POSITION_RISK_LOW, "критичных рисков нет" # определить давление на выход из позиции def _position_exit_pressure( self, *, state: AutoTradeState, pnl_percent: NumericLike | None, risk_level: str, ) -> str: percent = safe_float(pnl_percent) stop_loss_percent = safe_float(getattr(state, "stop_loss_percent", None)) if risk_level == POSITION_RISK_HIGH: return "HIGH" if risk_level in {POSITION_RISK_ELEVATED, POSITION_RISK_MODERATE}: return "WATCH" if percent is not None: if percent <= POSITION_EXIT_PRESSURE_LOSS_PERCENT: return "WATCH" if stop_loss_percent is not None and stop_loss_percent > 0 and percent < 0: loss_ratio = abs(percent) / stop_loss_percent if loss_ratio >= POSITION_STOP_LOSS_RATIO_WATCH: return "WATCH" return "LOW"