deploy: 2026-03-20 07:49
This commit is contained in:
144
strategy/exit_rules.py
Normal file
144
strategy/exit_rules.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user