85 lines
3.0 KiB
Python
85 lines
3.0 KiB
Python
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
|