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

1import math 

2from dataclasses import dataclass 

3 

4 

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") 

13 

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) 

18 

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.") 

23 

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.") 

28 

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.") 

31 

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.") 

36 

37 if not 0 < value <= 1.0: 

38 raise ValueError(f"{name} must be greater than zero and at most 1.0.") 

39 

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.") 

44 

45 if value <= 0: 

46 raise ValueError(f"{name} must be greater than zero.") 

47 

48 

49@dataclass(frozen=True) 

50class RiskDecision: 

51 accepted: bool 

52 order_value_budget: float | None = None 

53 reason: str | None = None 

54 

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 ) 

65 

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.") 

71 

72 @classmethod 

73 def accept(cls, order_value_budget: float) -> "RiskDecision": 

74 return cls(accepted=True, order_value_budget=order_value_budget) 

75 

76 @classmethod 

77 def reject(cls, reason: str) -> "RiskDecision": 

78 return cls(accepted=False, reason=reason) 

79 

80 

81@dataclass(frozen=True) 

82class SizingDecision: 

83 quantity: float | None 

84 reason: str | None = None 

85 

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 

91 

92 if not math.isfinite(self.quantity) or self.quantity <= 0: 

93 raise ValueError("Accepted sizing decisions require a positive finite quantity.") 

94 

95 if self.reason is not None: 

96 raise ValueError("Accepted sizing decisions cannot include a rejection reason.") 

97 

98 @property 

99 def accepted(self) -> bool: 

100 return self.quantity is not None 

101 

102 @classmethod 

103 def accept(cls, quantity: float) -> "SizingDecision": 

104 return cls(quantity=quantity) 

105 

106 @classmethod 

107 def reject(cls, reason: str) -> "SizingDecision": 

108 return cls(quantity=None, reason=reason)