from __future__ import annotations import logging from dataclasses import dataclass from enum import Enum from config import Config from portfolio import Position log = logging.getLogger(__name__) class ExitReason(Enum): HARD_STOP = "hard_stop" # Price dropped too far from entry TRAILING_STOP = "trailing_stop" # Price dropped too far from peak TAKE_PROFIT = "take_profit" # Price hit the profit target TIME_LIMIT = "time_limit" # Position held too long @dataclass class ExitSignal: reason: ExitReason current_price: float pnl_pct: float class RiskManager: def __init__(self, config: Config): self.config = config def check(self, position: Position, current_price: float) -> ExitSignal | None: """ Evaluate a position against all exit rules. Returns an ExitSignal if any rule triggers, otherwise None. Also updates the position's peak price. Rules are checked in priority order: 1. Hard stop — worst-case absolute floor 2. Trailing stop — peak-relative stop, protects accumulated gains 3. Take profit — locks in the profit target 4. Time limit — exits stagnant positions to free capital """ position.update_peak(current_price) pnl_pct = position.pnl_pct(current_price) drop_from_peak = position.drop_from_peak_pct(current_price) hours_held = position.hours_held() # 1. Hard stop-loss if pnl_pct <= -self.config.hard_stop_pct: log.warning( "%s HARD STOP: pnl=%.2f%% (limit=%.2f%%)", position.pair, pnl_pct, -self.config.hard_stop_pct, ) return ExitSignal(ExitReason.HARD_STOP, current_price, pnl_pct) # 2. Trailing stop if drop_from_peak >= self.config.trailing_stop_pct: log.warning( "%s TRAILING STOP: dropped %.2f%% from peak $%.6f (current $%.6f) pnl=%.2f%%", position.pair, drop_from_peak, position.peak_price, current_price, pnl_pct, ) return ExitSignal(ExitReason.TRAILING_STOP, current_price, pnl_pct) # 3. Take profit if pnl_pct >= self.config.take_profit_pct: log.info( "%s TAKE PROFIT: pnl=%.2f%% (target=%.2f%%)", position.pair, pnl_pct, self.config.take_profit_pct, ) return ExitSignal(ExitReason.TAKE_PROFIT, current_price, pnl_pct) # 4. Time limit if hours_held >= self.config.max_hold_hours: log.info( "%s TIME LIMIT: held %.1fh / %dh pnl=%.2f%%", position.pair, hours_held, self.config.max_hold_hours, pnl_pct, ) return ExitSignal(ExitReason.TIME_LIMIT, current_price, pnl_pct) log.debug( "%s HOLD: pnl=%.2f%% peak_drop=%.2f%% held=%.1fh peak=$%.6f", position.pair, pnl_pct, drop_from_peak, hours_held, position.peak_price, ) return None