Coverage for src/basanos/exceptions.py: 100%

48 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-23 05:58 +0000

1"""Domain-specific exception types for the Basanos package. 

2 

3This module defines a hierarchy of exceptions that provide meaningful context 

4when data-validation errors occur within the library. 

5 

6All exceptions inherit from `BasanosError` so callers can catch the 

7entire family with a single ``except BasanosError`` clause if they prefer. 

8 

9Linear-algebra exceptions (``DimensionMismatchError``, ``NonSquareMatrixError``, 

10``SingularMatrixError``) extend their ``cvx.linalg`` counterparts and are 

11importable directly from this module. 

12""" 

13 

14from __future__ import annotations 

15 

16from cvx.linalg import DimensionMismatchError as _DimensionMismatchError 

17from cvx.linalg import NonSquareMatrixError as _NonSquareMatrixError 

18from cvx.linalg import SingularMatrixError as _SingularMatrixError 

19 

20 

21class BasanosError(Exception): 

22 """Base class for all Basanos domain errors.""" 

23 

24 

25class DimensionMismatchError(_DimensionMismatchError): 

26 """Raised when matrix or vector dimensions are incompatible.""" 

27 

28 

29class NonSquareMatrixError(_NonSquareMatrixError): 

30 """Raised when a square matrix is required but not provided.""" 

31 

32 

33class SingularMatrixError(_SingularMatrixError): 

34 """Raised when a matrix is singular and cannot be solved.""" 

35 

36 

37class InsufficientDataError(BasanosError, ValueError): 

38 """Raised when there are too few finite entries to perform a computation. 

39 

40 Examples: 

41 >>> raise InsufficientDataError("All diagonal entries are non-finite.") 

42 Traceback (most recent call last): 

43 ... 

44 basanos.exceptions.InsufficientDataError: All diagonal entries are non-finite. 

45 """ 

46 

47 def __init__(self, detail: str = "") -> None: 

48 """Initialize with an optional detail message that overrides the default.""" 

49 msg = "Insufficient finite data to complete the computation." 

50 if detail: 

51 msg = detail 

52 super().__init__(msg) 

53 

54 

55class MissingDateColumnError(BasanosError, ValueError): 

56 """Raised when a required ``'date'`` column is absent from a DataFrame. 

57 

58 Args: 

59 frame_name: Descriptive name of the frame missing the column (e.g. ``"prices"``). 

60 

61 Examples: 

62 >>> raise MissingDateColumnError("prices") 

63 Traceback (most recent call last): 

64 ... 

65 basanos.exceptions.MissingDateColumnError: DataFrame 'prices' is missing the required 'date' column. 

66 """ 

67 

68 def __init__(self, frame_name: str) -> None: 

69 """Initialize with the name of the frame that is missing the column.""" 

70 super().__init__(f"DataFrame '{frame_name}' is missing the required 'date' column.") 

71 self.frame_name = frame_name 

72 

73 

74class ShapeMismatchError(BasanosError, ValueError): 

75 """Raised when two DataFrames have incompatible shapes. 

76 

77 Args: 

78 prices_shape: Shape of the prices DataFrame. 

79 mu_shape: Shape of the mu DataFrame. 

80 

81 Examples: 

82 >>> raise ShapeMismatchError((10, 3), (9, 3)) 

83 Traceback (most recent call last): 

84 ... 

85 basanos.exceptions.ShapeMismatchError: 'prices' and 'mu' must have the same shape, got (10, 3) vs (9, 3). 

86 """ 

87 

88 def __init__(self, prices_shape: tuple[int, int], mu_shape: tuple[int, int]) -> None: 

89 """Initialize with the shapes of the two mismatched DataFrames.""" 

90 super().__init__(f"'prices' and 'mu' must have the same shape, got {prices_shape} vs {mu_shape}.") 

91 self.prices_shape = prices_shape 

92 self.mu_shape = mu_shape 

93 

94 

95class ColumnMismatchError(BasanosError, ValueError): 

96 """Raised when two DataFrames have different column sets. 

97 

98 Args: 

99 prices_columns: Columns of the prices DataFrame. 

100 mu_columns: Columns of the mu DataFrame. 

101 

102 Examples: 

103 >>> raise ColumnMismatchError(["A", "B"], ["A", "C"]) # doctest: +ELLIPSIS 

104 Traceback (most recent call last): 

105 ... 

106 basanos.exceptions.ColumnMismatchError: 'prices' and 'mu' must have identical columns... 

107 """ 

108 

109 def __init__(self, prices_columns: list[str], mu_columns: list[str]) -> None: 

110 """Initialize with the column lists of the two mismatched DataFrames.""" 

111 super().__init__( 

112 f"'prices' and 'mu' must have identical columns; got {sorted(prices_columns)} vs {sorted(mu_columns)}." 

113 ) 

114 self.prices_columns = prices_columns 

115 self.mu_columns = mu_columns 

116 

117 

118class NonPositivePricesError(BasanosError, ValueError): 

119 """Raised when an asset column contains zero or negative prices. 

120 

121 Log-return computation requires strictly positive prices. 

122 

123 Args: 

124 asset: Name of the asset with the offending values. 

125 

126 Examples: 

127 >>> raise NonPositivePricesError("A") # doctest: +ELLIPSIS 

128 Traceback (most recent call last): 

129 ... 

130 basanos.exceptions.NonPositivePricesError: Asset 'A' contains non-positive... 

131 """ 

132 

133 def __init__(self, asset: str) -> None: 

134 """Initialize with the name of the asset that contains non-positive prices.""" 

135 super().__init__(f"Asset '{asset}' contains non-positive prices; strictly positive values are required.") 

136 self.asset = asset 

137 

138 

139class ExcessiveNullsError(BasanosError, ValueError): 

140 """Raised when an asset column contains too many null values. 

141 

142 Args: 

143 asset: Name of the offending asset column. 

144 null_fraction: Observed fraction of null values (0.0 to 1.0). 

145 max_fraction: Maximum allowed fraction of null values. 

146 

147 Examples: 

148 >>> raise ExcessiveNullsError("A", 1.0, 0.9) # doctest: +ELLIPSIS 

149 Traceback (most recent call last): 

150 ... 

151 basanos.exceptions.ExcessiveNullsError: Asset 'A' has 100% null values,... 

152 """ 

153 

154 def __init__(self, asset: str, null_fraction: float, max_fraction: float) -> None: 

155 """Initialize with the asset name and the observed/maximum null fractions.""" 

156 super().__init__( 

157 f"Asset '{asset}' has {null_fraction:.0%} null values, " 

158 f"exceeding the maximum allowed fraction of {max_fraction:.0%}." 

159 ) 

160 self.asset = asset 

161 self.null_fraction = null_fraction 

162 self.max_fraction = max_fraction 

163 

164 

165class MonotonicPricesError(BasanosError, ValueError): 

166 """Raised when an asset's price series is strictly monotonic. 

167 

168 A monotonic series (all non-decreasing or all non-increasing) has no 

169 variance in its return sign, indicating malformed or synthetic data. 

170 

171 Args: 

172 asset: Name of the offending asset column. 

173 

174 Examples: 

175 >>> raise MonotonicPricesError("A") # doctest: +ELLIPSIS 

176 Traceback (most recent call last): 

177 ... 

178 basanos.exceptions.MonotonicPricesError: Asset 'A' has monotonic prices... 

179 """ 

180 

181 def __init__(self, asset: str) -> None: 

182 """Initialize with the name of the asset that has monotonic prices.""" 

183 super().__init__( 

184 f"Asset '{asset}' has monotonic prices " 

185 "(all non-decreasing or all non-increasing), indicating malformed or synthetic data." 

186 ) 

187 self.asset = asset 

188 

189 

190class FactorModelError(BasanosError, ValueError): 

191 """Raised when `FactorModel` arguments fail validation. 

192 

193 Covers shape mismatches between factor loadings, factor covariance, and 

194 idiosyncratic variance arrays, non-positive idiosyncratic variances, 

195 invalid return matrix dimensionality, and out-of-range factor counts. 

196 

197 Examples: 

198 >>> raise FactorModelError("factor_loadings must be 2-D, got ndim=1.") 

199 Traceback (most recent call last): 

200 ... 

201 basanos.exceptions.FactorModelError: factor_loadings must be 2-D, got ndim=1. 

202 """ 

203 

204 

205class StreamStateCorruptError(BasanosError, ValueError): 

206 """Raised when a saved stream archive is missing required state keys. 

207 

208 This is raised by `load` when the ``.npz`` archive 

209 does not contain one or more keys that the current `_StreamState` 

210 schema requires. The error message lists the missing keys so callers 

211 can diagnose schema mismatches immediately rather than seeing a bare 

212 ``KeyError`` with no context. 

213 

214 Args: 

215 missing: Collection of key names absent from the archive. 

216 

217 Examples: 

218 >>> raise StreamStateCorruptError({"vola_s_x", "corr_ret_buf"}) # doctest: +ELLIPSIS 

219 Traceback (most recent call last): 

220 ... 

221 basanos.exceptions.StreamStateCorruptError: Stream archive is missing required keys: ... 

222 """ 

223 

224 def __init__(self, missing: set[str] | frozenset[str]) -> None: 

225 """Initialize with the set of missing archive keys.""" 

226 keys = ", ".join(sorted(missing)) 

227 super().__init__( 

228 f"Stream archive is missing required keys: {keys}. " 

229 "Re-generate the archive via BasanosStream.from_warmup() and save()." 

230 ) 

231 self.missing = frozenset(missing)