"""ICT exit / position management rules. Evaluates whether an open position should be closed based on TP, SL, CHOCH reversal, time expiry, or trailing stop. """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta from enum import Enum from typing import Optional from loguru import logger from config import settings from indicators.ict_engine import ICTSignals from indicators.multi_timeframe import TradeDirection class ExitReason(str, Enum): TAKE_PROFIT = "TP" STOP_LOSS = "SL" CHOCH = "CHOCH" TRAILING_STOP = "TRAILING" TIME_EXIT = "TIME" MANUAL = "MANUAL" @dataclass class ExitResult: """Result of exit rule evaluation.""" should_exit: bool reason: Optional[ExitReason] = None detail: str = "" class ExitRules: """Evaluate exit conditions for an open position. Conditions checked (in priority order): 1. Stop-loss hit 2. Take-profit hit 3. CHOCH in opposite direction 4. Trailing stop triggered 5. Time-based exit (too many candles without movement) """ def __init__( self, trailing_activation_pct: float | None = None, trailing_distance_pct: float | None = None, time_exit_candles: int = 48, ): self.trailing_activation_pct = ( trailing_activation_pct or getattr(settings, "TRAILING_STOP_ACTIVATION_PCT", 0.01) ) self.trailing_distance_pct = ( trailing_distance_pct or getattr(settings, "TRAILING_STOP_DISTANCE_PCT", 0.005) ) self.time_exit_candles = time_exit_candles def should_exit( self, direction: TradeDirection, entry_price: float, stop_loss: float, take_profit: float, current_price: float, signals: ICTSignals, opened_at: datetime | None = None, candles_since_entry: int = 0, trailing_stop: float | None = None, ) -> ExitResult: """Evaluate all exit conditions against current market state.""" # 1. Stop-Loss if direction == TradeDirection.LONG and current_price <= stop_loss: return ExitResult(True, ExitReason.STOP_LOSS, f"Price {current_price} <= SL {stop_loss}") if direction == TradeDirection.SHORT and current_price >= stop_loss: return ExitResult(True, ExitReason.STOP_LOSS, f"Price {current_price} >= SL {stop_loss}") # 2. Take-Profit if direction == TradeDirection.LONG and current_price >= take_profit: return ExitResult(True, ExitReason.TAKE_PROFIT, f"Price {current_price} >= TP {take_profit}") if direction == TradeDirection.SHORT and current_price <= take_profit: return ExitResult(True, ExitReason.TAKE_PROFIT, f"Price {current_price} <= TP {take_profit}") # 3. CHOCH in opposite direction choch = signals.latest_choch if choch is not None: if direction == TradeDirection.LONG and choch == -1: return ExitResult(True, ExitReason.CHOCH, "Bearish CHOCH while LONG") if direction == TradeDirection.SHORT and choch == 1: return ExitResult(True, ExitReason.CHOCH, "Bullish CHOCH while SHORT") # 4. Trailing stop if trailing_stop is not None: if direction == TradeDirection.LONG and current_price <= trailing_stop: return ExitResult(True, ExitReason.TRAILING_STOP, f"Trailing hit {trailing_stop}") if direction == TradeDirection.SHORT and current_price >= trailing_stop: return ExitResult(True, ExitReason.TRAILING_STOP, f"Trailing hit {trailing_stop}") # 5. Time-based exit if candles_since_entry >= self.time_exit_candles: return ExitResult(True, ExitReason.TIME_EXIT, f"Exceeded {self.time_exit_candles} candles") return ExitResult(False) def update_trailing_stop( self, direction: TradeDirection, entry_price: float, current_price: float, current_trailing: float | None, ) -> Optional[float]: """Update trailing stop if price has moved enough into profit. Returns the new trailing stop value, or None if not yet activated. """ if direction == TradeDirection.LONG: pnl_pct = (current_price - entry_price) / entry_price if pnl_pct < self.trailing_activation_pct: return current_trailing # not yet in profit enough new_trail = current_price * (1 - self.trailing_distance_pct) if current_trailing is None or new_trail > current_trailing: logger.debug("Trailing stop updated: {} -> {}", current_trailing, new_trail) return new_trail return current_trailing else: # SHORT pnl_pct = (entry_price - current_price) / entry_price if pnl_pct < self.trailing_activation_pct: return current_trailing new_trail = current_price * (1 + self.trailing_distance_pct) if current_trailing is None or new_trail < current_trailing: logger.debug("Trailing stop updated: {} -> {}", current_trailing, new_trail) return new_trail return current_trailing