from __future__ import annotations from datetime import UTC, datetime from app.config import Settings class RiskManager: """Tracks per-session and per-day limits and can halt trading. A halt is sticky until ``reset`` is called (which happens on engine start). All limits are optional; a value of ``0`` disables that limit. """ def __init__( self, *, max_daily_loss: float = 0.0, max_drawdown_pct: float = 0.0, max_position_pct: float = 1.0, max_trades_per_day: int = 0, ) -> None: self.max_daily_loss = max_daily_loss self.max_drawdown_pct = max_drawdown_pct self.max_position_pct = max_position_pct self.max_trades_per_day = max_trades_per_day self.reset(0.0) @classmethod def from_settings(cls, settings: Settings, starting_equity: float) -> RiskManager: manager = cls( max_daily_loss=settings.max_daily_loss, max_drawdown_pct=settings.max_drawdown_pct, max_position_pct=settings.max_position_pct, max_trades_per_day=settings.max_trades_per_day, ) manager.reset(starting_equity) return manager def reset(self, starting_equity: float) -> None: self.starting_equity = starting_equity self.peak_equity = starting_equity self.last_equity = starting_equity self.day_start_equity = starting_equity self.day = datetime.now(UTC).date() self.trades_today = 0 self.halted = False self.halt_reason: str | None = None @property def day_pnl(self) -> float: return self.last_equity - self.day_start_equity @property def drawdown_pct(self) -> float: if self.peak_equity <= 0: return 0.0 return max(0.0, (self.peak_equity - self.last_equity) / self.peak_equity * 100) def _roll_day(self, equity: float) -> None: today = datetime.now(UTC).date() if today != self.day: self.day = today self.day_start_equity = equity self.trades_today = 0 def update(self, equity: float) -> str | None: """Update with the latest equity; return a halt reason if a limit tripped. Detection only — the caller decides when to actually halt (so it can flatten first). Use ``halt()`` to make it sticky. """ self.last_equity = equity self._roll_day(equity) self.peak_equity = max(self.peak_equity, equity) if self.halted: return self.halt_reason if self.max_daily_loss > 0 and self.day_pnl <= -self.max_daily_loss: return f"daily loss limit hit (P&L {self.day_pnl:.2f})" if self.max_drawdown_pct > 0 and self.drawdown_pct >= self.max_drawdown_pct: return f"max drawdown hit ({self.drawdown_pct:.2f}%)" return None def halt(self, reason: str) -> str: self.halted = True self.halt_reason = reason return reason def can_enter(self) -> bool: return self.entry_block_reason() is None def entry_block_reason(self) -> str | None: if self.halted: return self.halt_reason if self.max_trades_per_day > 0 and self.trades_today >= self.max_trades_per_day: return f"max trades per day reached ({self.max_trades_per_day})" return None def record_entry(self) -> None: self.trades_today += 1 def clamp_size(self, size: float) -> float: return max(0.0, min(size, self.max_position_pct))