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

59 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-02 17:47 +0000

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

2 

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

4when linear-algebra or data-validation errors occur within the library. 

5 

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

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

8 

9Examples: 

10 >>> raise NonSquareMatrixError(3, 2) 

11 Traceback (most recent call last): 

12 ... 

13 basanos.exceptions.NonSquareMatrixError: Matrix must be square, got shape (3, 2). 

14""" 

15 

16from __future__ import annotations 

17 

18 

19class BasanosError(Exception): 

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

21 

22 

23class NonSquareMatrixError(BasanosError, ValueError): 

24 """Raised when a matrix is required to be square but is not. 

25 

26 Args: 

27 rows: Number of rows in the offending matrix. 

28 cols: Number of columns in the offending matrix. 

29 

30 Examples: 

31 >>> raise NonSquareMatrixError(3, 2) 

32 Traceback (most recent call last): 

33 ... 

34 basanos.exceptions.NonSquareMatrixError: Matrix must be square, got shape (3, 2). 

35 """ 

36 

37 def __init__(self, rows: int, cols: int) -> None: 

38 """Initialize with the offending matrix shape.""" 

39 super().__init__(f"Matrix must be square, got shape ({rows}, {cols}).") 

40 self.rows = rows 

41 self.cols = cols 

42 

43 

44class DimensionMismatchError(BasanosError, ValueError): 

45 """Raised when vector and matrix dimensions are incompatible. 

46 

47 Args: 

48 vector_size: Length of the offending vector. 

49 matrix_size: Expected dimension inferred from the matrix. 

50 

51 Examples: 

52 >>> raise DimensionMismatchError(3, 2) 

53 Traceback (most recent call last): 

54 ... 

55 basanos.exceptions.DimensionMismatchError: Vector length 3 does not match matrix dimension 2. 

56 """ 

57 

58 def __init__(self, vector_size: int, matrix_size: int) -> None: 

59 """Initialize with the offending vector and matrix sizes.""" 

60 super().__init__(f"Vector length {vector_size} does not match matrix dimension {matrix_size}.") 

61 self.vector_size = vector_size 

62 self.matrix_size = matrix_size 

63 

64 

65class SingularMatrixError(BasanosError, ValueError): 

66 """Raised when a matrix is (numerically) singular and cannot be inverted. 

67 

68 This wraps :class:`numpy.linalg.LinAlgError` to provide domain-specific 

69 context. 

70 

71 Examples: 

72 >>> raise SingularMatrixError() 

73 Traceback (most recent call last): 

74 ... 

75 basanos.exceptions.SingularMatrixError: Matrix is singular and cannot be solved. 

76 """ 

77 

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

79 """Initialize with an optional extra detail string.""" 

80 msg = "Matrix is singular and cannot be solved." 

81 if detail: 

82 msg = f"{msg} {detail}" 

83 super().__init__(msg) 

84 

85 

86class InsufficientDataError(BasanosError, ValueError): 

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

88 

89 Examples: 

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

91 Traceback (most recent call last): 

92 ... 

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

94 """ 

95 

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

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

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

99 if detail: 

100 msg = detail 

101 super().__init__(msg) 

102 

103 

104class MissingDateColumnError(BasanosError, ValueError): 

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

106 

107 Args: 

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

109 

110 Examples: 

111 >>> raise MissingDateColumnError("prices") 

112 Traceback (most recent call last): 

113 ... 

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

115 """ 

116 

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

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

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

120 self.frame_name = frame_name 

121 

122 

123class ShapeMismatchError(BasanosError, ValueError): 

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

125 

126 Args: 

127 prices_shape: Shape of the prices DataFrame. 

128 mu_shape: Shape of the mu DataFrame. 

129 

130 Examples: 

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

132 Traceback (most recent call last): 

133 ... 

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

135 """ 

136 

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

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

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

140 self.prices_shape = prices_shape 

141 self.mu_shape = mu_shape 

142 

143 

144class ColumnMismatchError(BasanosError, ValueError): 

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

146 

147 Args: 

148 prices_columns: Columns of the prices DataFrame. 

149 mu_columns: Columns of the mu DataFrame. 

150 

151 Examples: 

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

153 Traceback (most recent call last): 

154 ... 

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

156 """ 

157 

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

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

160 super().__init__( 

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

162 ) 

163 self.prices_columns = prices_columns 

164 self.mu_columns = mu_columns 

165 

166 

167class NonPositivePricesError(BasanosError, ValueError): 

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

169 

170 Log-return computation requires strictly positive prices. 

171 

172 Args: 

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

174 

175 Examples: 

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

177 Traceback (most recent call last): 

178 ... 

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

180 """ 

181 

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

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

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

185 self.asset = asset 

186 

187 

188class ExcessiveNullsError(BasanosError, ValueError): 

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

190 

191 Args: 

192 asset: Name of the offending asset column. 

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

194 max_fraction: Maximum allowed fraction of null values. 

195 

196 Examples: 

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

198 Traceback (most recent call last): 

199 ... 

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

201 """ 

202 

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

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

205 super().__init__( 

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

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

208 ) 

209 self.asset = asset 

210 self.null_fraction = null_fraction 

211 self.max_fraction = max_fraction 

212 

213 

214class IllConditionedMatrixWarning(UserWarning): 

215 """Issued when a matrix has a condition number that exceeds a configured threshold. 

216 

217 A high condition number indicates the matrix is nearly singular, and 

218 linear-algebra operations on it may produce numerically unreliable results. 

219 

220 Examples: 

221 >>> import warnings 

222 >>> with warnings.catch_warnings(record=True) as w: 

223 ... warnings.simplefilter("always") 

224 ... warnings.warn("condition number 1e13", IllConditionedMatrixWarning) 

225 ... assert len(w) == 1 

226 """ 

227 

228 

229class MonotonicPricesError(BasanosError, ValueError): 

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

231 

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

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

234 

235 Args: 

236 asset: Name of the offending asset column. 

237 

238 Examples: 

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

240 Traceback (most recent call last): 

241 ... 

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

243 """ 

244 

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

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

247 super().__init__( 

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

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

250 ) 

251 self.asset = asset 

252 

253 

254class FactorModelError(BasanosError, ValueError): 

255 """Raised when :class:`~basanos.math.FactorModel` arguments fail validation. 

256 

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

258 idiosyncratic variance arrays, non-positive idiosyncratic variances, 

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

260 

261 Examples: 

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

263 Traceback (most recent call last): 

264 ... 

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

266 """ 

267 

268 

269class StreamStateCorruptError(BasanosError, ValueError): 

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

271 

272 This is raised by :meth:`BasanosStream.load` when the ``.npz`` archive 

273 does not contain one or more keys that the current :class:`_StreamState` 

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

275 can diagnose schema mismatches immediately rather than seeing a bare 

276 ``KeyError`` with no context. 

277 

278 Args: 

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

280 

281 Examples: 

282 >>> raise StreamStateCorruptError({"vola_s_x", "corr_zi_x"}) # doctest: +ELLIPSIS 

283 Traceback (most recent call last): 

284 ... 

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

286 """ 

287 

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

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

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

291 super().__init__( 

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

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

294 ) 

295 self.missing = frozenset(missing)