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
« 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.
3This module defines a hierarchy of exceptions that provide meaningful context
4when linear-algebra or data-validation errors occur within the library.
6All exceptions inherit from :class:`BasanosError` so callers can catch the
7entire family with a single ``except BasanosError`` clause if they prefer.
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"""
16from __future__ import annotations
19class BasanosError(Exception):
20 """Base class for all Basanos domain errors."""
23class NonSquareMatrixError(BasanosError, ValueError):
24 """Raised when a matrix is required to be square but is not.
26 Args:
27 rows: Number of rows in the offending matrix.
28 cols: Number of columns in the offending matrix.
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 """
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
44class DimensionMismatchError(BasanosError, ValueError):
45 """Raised when vector and matrix dimensions are incompatible.
47 Args:
48 vector_size: Length of the offending vector.
49 matrix_size: Expected dimension inferred from the matrix.
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 """
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
65class SingularMatrixError(BasanosError, ValueError):
66 """Raised when a matrix is (numerically) singular and cannot be inverted.
68 This wraps :class:`numpy.linalg.LinAlgError` to provide domain-specific
69 context.
71 Examples:
72 >>> raise SingularMatrixError()
73 Traceback (most recent call last):
74 ...
75 basanos.exceptions.SingularMatrixError: Matrix is singular and cannot be solved.
76 """
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)
86class InsufficientDataError(BasanosError, ValueError):
87 """Raised when there are too few finite entries to perform a computation.
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 """
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)
104class MissingDateColumnError(BasanosError, ValueError):
105 """Raised when a required ``'date'`` column is absent from a DataFrame.
107 Args:
108 frame_name: Descriptive name of the frame missing the column (e.g. ``"prices"``).
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 """
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
123class ShapeMismatchError(BasanosError, ValueError):
124 """Raised when two DataFrames have incompatible shapes.
126 Args:
127 prices_shape: Shape of the prices DataFrame.
128 mu_shape: Shape of the mu DataFrame.
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 """
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
144class ColumnMismatchError(BasanosError, ValueError):
145 """Raised when two DataFrames have different column sets.
147 Args:
148 prices_columns: Columns of the prices DataFrame.
149 mu_columns: Columns of the mu DataFrame.
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 """
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
167class NonPositivePricesError(BasanosError, ValueError):
168 """Raised when an asset column contains zero or negative prices.
170 Log-return computation requires strictly positive prices.
172 Args:
173 asset: Name of the asset with the offending values.
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 """
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
188class ExcessiveNullsError(BasanosError, ValueError):
189 """Raised when an asset column contains too many null values.
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.
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 """
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
214class IntegerIndexBoundError(BasanosError, TypeError):
215 """Raised when a row-index bound is not an integer.
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.
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 """
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
235class InvalidPricesTypeError(BasanosError, TypeError):
236 """Raised when ``prices`` is not a :class:`polars.DataFrame`.
238 Args:
239 actual_type: The ``type.__name__`` of the value that was supplied.
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 """
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
254class InvalidCashPositionTypeError(BasanosError, TypeError):
255 """Raised when ``cashposition`` is not a :class:`polars.DataFrame`.
257 Args:
258 actual_type: The ``type.__name__`` of the value that was supplied.
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 """
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
273class RowCountMismatchError(BasanosError, ValueError):
274 """Raised when ``prices`` and ``cashposition`` have different numbers of rows.
276 Args:
277 prices_rows: Number of rows in the prices DataFrame.
278 cashposition_rows: Number of rows in the cashposition DataFrame.
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 """
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
297class NonPositiveAumError(BasanosError, ValueError):
298 """Raised when ``aum`` is not strictly positive.
300 Args:
301 aum: The non-positive value that was supplied.
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 """
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
316class IllConditionedMatrixWarning(UserWarning):
317 """Issued when a matrix has a condition number that exceeds a configured threshold.
319 A high condition number indicates the matrix is nearly singular, and
320 linear-algebra operations on it may produce numerically unreliable results.
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 """
331class MonotonicPricesError(BasanosError, ValueError):
332 """Raised when an asset's price series is strictly monotonic.
334 A monotonic series (all non-decreasing or all non-increasing) has no
335 variance in its return sign, indicating malformed or synthetic data.
337 Args:
338 asset: Name of the offending asset column.
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 """
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