"""Exit engine — position-level exit management. Evaluates stop-loss hits, take-profit targets, and trailing ATR-based stops for open positions. Called once per evaluation tick *before* the signal pipelines run so that exit signals take priority over new entry signals. Priority order (first match wins per position): 1. stop_loss hit → EXIT_FULL, reason ``"stop_hit"`` 2. target_2 hit → EXIT_FULL, reason ``"target_2_hit"`` 3. trailing stop → EXIT_FULL, reason ``"trailing_stop_hit"`` 4. target_1 hit → EXIT_HALF, reason ``"target_1_hit"`` Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7 """ from __future__ import annotations import logging from services.signal_engine.config import ExitConfig from services.signal_engine.models import ( ExitSignal, ExitType, OpenPositionState, ) logger = logging.getLogger(__name__) def evaluate_exits( positions: list[OpenPositionState], current_prices: dict[str, float], config: ExitConfig, ) -> list[ExitSignal]: """Evaluate exit conditions for all open positions. For each position the current price is looked up in *current_prices* (keyed by ticker). If the ticker is absent the position's own ``current_price`` field is used as a fallback. Checks are applied in priority order — only the **first** matching condition per position emits an ``ExitSignal``. Parameters ---------- positions: Snapshots of open positions to evaluate. current_prices: Latest prices keyed by ticker symbol. config: Exit engine configuration (trailing stop ATR multiplier, etc.). Returns ------- list[ExitSignal] One signal per position that triggered an exit condition. Positions with no exit condition produce no signal. """ signals: list[ExitSignal] = [] for pos in positions: price = current_prices.get(pos.ticker, pos.current_price) signal = _evaluate_single_position(pos, price, config) if signal is not None: signals.append(signal) return signals # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _evaluate_single_position( pos: OpenPositionState, price: float, config: ExitConfig, ) -> ExitSignal | None: """Check exit conditions for a single position in priority order. Priority: stop_loss > target_2 > trailing_stop > target_1. """ # 1. Stop-loss hit (highest priority) if price <= pos.stop_loss: return ExitSignal( position_id=pos.position_id, ticker=pos.ticker, exit_type=ExitType.EXIT_FULL, reason="stop_hit", price=price, ) # 2. Target 2 hit → full exit if price >= pos.target_2: return ExitSignal( position_id=pos.position_id, ticker=pos.ticker, exit_type=ExitType.EXIT_FULL, reason="target_2_hit", price=price, ) # 3. Trailing stop (only active after partial exit) if pos.partial_exit_done: trailing_stop = _compute_trailing_stop(pos, price, config) if price <= trailing_stop: return ExitSignal( position_id=pos.position_id, ticker=pos.ticker, exit_type=ExitType.EXIT_FULL, reason="trailing_stop_hit", price=price, ) # 4. Target 1 hit → partial exit (only if not already done) if not pos.partial_exit_done and price >= pos.target_1: return ExitSignal( position_id=pos.position_id, ticker=pos.ticker, exit_type=ExitType.EXIT_HALF, reason="target_1_hit", price=price, ) return None def _compute_trailing_stop( pos: OpenPositionState, price: float, config: ExitConfig, ) -> float: """Compute the effective trailing stop level. The trailing stop is ``price - ATR * multiplier``, but it only ratchets **upward** — if the position already has a higher trailing stop recorded, that value is kept. When ATR is unavailable (``None``), the existing ``trailing_stop`` on the position is returned as-is. If neither is set, returns 0.0 (effectively no trailing stop). """ existing = pos.trailing_stop if pos.trailing_stop is not None else 0.0 if pos.atr is None: return existing new_level = price - pos.atr * config.trailing_stop_atr_multiplier # Ratchet upward only return max(existing, new_level)