Files
crypto_news/strategy/exit_rules.py

145 lines
5.2 KiB
Python
Raw Normal View History

2026-03-20 07:49:42 +09:00
"""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