Coverage for src/finbot/risk/models.py: 98%
77 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 17:12 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 17:12 +0000
1import math
2from dataclasses import dataclass
5@dataclass(frozen=True)
6class RiskLimits:
7 max_position_pct_nav: float = 1.0
8 max_gross_exposure: float = 1.0
9 max_symbol_exposure: float = 1.0
10 max_open_positions: int = 100
11 minimum_cash_reserve: float = 0.0
12 max_order_value: float = float("inf")
14 def __post_init__(self) -> None:
15 self._validate_fraction("max_position_pct_nav", self.max_position_pct_nav)
16 self._validate_positive("max_gross_exposure", self.max_gross_exposure)
17 self._validate_fraction("max_symbol_exposure", self.max_symbol_exposure)
19 if not isinstance(self.max_open_positions, int):
20 raise ValueError("max_open_positions must be an integer.")
21 if self.max_open_positions <= 0:
22 raise ValueError("max_open_positions must be greater than zero.")
24 if not math.isfinite(self.minimum_cash_reserve):
25 raise ValueError("minimum_cash_reserve must be finite.")
26 if self.minimum_cash_reserve < 0:
27 raise ValueError("minimum_cash_reserve cannot be negative.")
29 if math.isnan(self.max_order_value) or self.max_order_value <= 0:
30 raise ValueError("max_order_value must be greater than zero.")
32 @staticmethod
33 def _validate_fraction(name: str, value: float) -> None:
34 if not math.isfinite(value):
35 raise ValueError(f"{name} must be finite.")
37 if not 0 < value <= 1.0:
38 raise ValueError(f"{name} must be greater than zero and at most 1.0.")
40 @staticmethod
41 def _validate_positive(name: str, value: float) -> None:
42 if not math.isfinite(value):
43 raise ValueError(f"{name} must be finite.")
45 if value <= 0:
46 raise ValueError(f"{name} must be greater than zero.")
49@dataclass(frozen=True)
50class RiskDecision:
51 accepted: bool
52 order_value_budget: float | None = None
53 reason: str | None = None
55 def __post_init__(self) -> None:
56 if self.accepted:
57 if ( 57 ↛ 62line 57 didn't jump to line 62 because the condition on line 57 was never true
58 self.order_value_budget is None
59 or not math.isfinite(self.order_value_budget)
60 or self.order_value_budget <= 0
61 ):
62 raise ValueError(
63 "Accepted risk decisions require a positive finite order value budget.",
64 )
66 if self.reason is not None:
67 raise ValueError("Accepted risk decisions cannot include a rejection reason.")
68 else:
69 if self.reason is None or not self.reason.strip():
70 raise ValueError("Rejected risk decisions require a clear reason.")
72 @classmethod
73 def accept(cls, order_value_budget: float) -> "RiskDecision":
74 return cls(accepted=True, order_value_budget=order_value_budget)
76 @classmethod
77 def reject(cls, reason: str) -> "RiskDecision":
78 return cls(accepted=False, reason=reason)
81@dataclass(frozen=True)
82class SizingDecision:
83 quantity: float | None
84 reason: str | None = None
86 def __post_init__(self) -> None:
87 if self.quantity is None:
88 if self.reason is None or not self.reason.strip():
89 raise ValueError("Rejected sizing decisions require a clear reason.")
90 return
92 if not math.isfinite(self.quantity) or self.quantity <= 0:
93 raise ValueError("Accepted sizing decisions require a positive finite quantity.")
95 if self.reason is not None:
96 raise ValueError("Accepted sizing decisions cannot include a rejection reason.")
98 @property
99 def accepted(self) -> bool:
100 return self.quantity is not None
102 @classmethod
103 def accept(cls, quantity: float) -> "SizingDecision":
104 return cls(quantity=quantity)
106 @classmethod
107 def reject(cls, reason: str) -> "SizingDecision":
108 return cls(quantity=None, reason=reason)