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

75 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-19 05:23 +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 IntegerIndexBoundError(BasanosError, TypeError): 

215 """Raised when a row-index bound is not an integer. 

216 

217 Args: 

218 param: Name of the offending parameter (e.g. ``"start"`` or ``"end"``). 

219 actual_type: The ``type.__name__`` of the value that was supplied. 

220 

221 Examples: 

222 >>> raise IntegerIndexBoundError("start", "str") 

223 Traceback (most recent call last): 

224 ... 

225 basanos.exceptions.IntegerIndexBoundError: start must be an integer, got str. 

226 """ 

227 

228 def __init__(self, param: str, actual_type: str) -> None: 

229 """Initialize with the parameter name and the offending type.""" 

230 super().__init__(f"{param} must be an integer, got {actual_type}.") 

231 self.param = param 

232 self.actual_type = actual_type 

233 

234 

235class InvalidPricesTypeError(BasanosError, TypeError): 

236 """Raised when ``prices`` is not a :class:`polars.DataFrame`. 

237 

238 Args: 

239 actual_type: The ``type.__name__`` of the value that was supplied. 

240 

241 Examples: 

242 >>> raise InvalidPricesTypeError("list") 

243 Traceback (most recent call last): 

244 ... 

245 basanos.exceptions.InvalidPricesTypeError: prices must be pl.DataFrame, got list. 

246 """ 

247 

248 def __init__(self, actual_type: str) -> None: 

249 """Initialize with the offending type name.""" 

250 super().__init__(f"prices must be pl.DataFrame, got {actual_type}.") 

251 self.actual_type = actual_type 

252 

253 

254class InvalidCashPositionTypeError(BasanosError, TypeError): 

255 """Raised when ``cashposition`` is not a :class:`polars.DataFrame`. 

256 

257 Args: 

258 actual_type: The ``type.__name__`` of the value that was supplied. 

259 

260 Examples: 

261 >>> raise InvalidCashPositionTypeError("dict") 

262 Traceback (most recent call last): 

263 ... 

264 basanos.exceptions.InvalidCashPositionTypeError: cashposition must be pl.DataFrame, got dict. 

265 """ 

266 

267 def __init__(self, actual_type: str) -> None: 

268 """Initialize with the offending type name.""" 

269 super().__init__(f"cashposition must be pl.DataFrame, got {actual_type}.") 

270 self.actual_type = actual_type 

271 

272 

273class RowCountMismatchError(BasanosError, ValueError): 

274 """Raised when ``prices`` and ``cashposition`` have different numbers of rows. 

275 

276 Args: 

277 prices_rows: Number of rows in the prices DataFrame. 

278 cashposition_rows: Number of rows in the cashposition DataFrame. 

279 

280 Examples: 

281 >>> raise RowCountMismatchError(10, 9) # doctest: +ELLIPSIS 

282 Traceback (most recent call last): 

283 ... 

284 basanos.exceptions.RowCountMismatchError: cashposition and prices must have the same number of rows... 

285 """ 

286 

287 def __init__(self, prices_rows: int, cashposition_rows: int) -> None: 

288 """Initialize with the row counts of the two mismatched DataFrames.""" 

289 super().__init__( 

290 f"cashposition and prices must have the same number of rows, " 

291 f"got cashposition={cashposition_rows} and prices={prices_rows}." 

292 ) 

293 self.prices_rows = prices_rows 

294 self.cashposition_rows = cashposition_rows 

295 

296 

297class NonPositiveAumError(BasanosError, ValueError): 

298 """Raised when ``aum`` is not strictly positive. 

299 

300 Args: 

301 aum: The non-positive value that was supplied. 

302 

303 Examples: 

304 >>> raise NonPositiveAumError(0.0) 

305 Traceback (most recent call last): 

306 ... 

307 basanos.exceptions.NonPositiveAumError: aum must be strictly positive, got 0.0. 

308 """ 

309 

310 def __init__(self, aum: float) -> None: 

311 """Initialize with the offending aum value.""" 

312 super().__init__(f"aum must be strictly positive, got {aum}.") 

313 self.aum = aum 

314 

315 

316class IllConditionedMatrixWarning(UserWarning): 

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

318 

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

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

321 

322 Examples: 

323 >>> import warnings 

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

325 ... warnings.simplefilter("always") 

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

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

328 """ 

329 

330 

331class MonotonicPricesError(BasanosError, ValueError): 

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

333 

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

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

336 

337 Args: 

338 asset: Name of the offending asset column. 

339 

340 Examples: 

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

342 Traceback (most recent call last): 

343 ... 

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

345 """ 

346 

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

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

349 super().__init__( 

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

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

352 ) 

353 self.asset = asset