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
« 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.
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 IllConditionedMatrixWarning(UserWarning):
215 """Issued when a matrix has a condition number that exceeds a configured threshold.
217 A high condition number indicates the matrix is nearly singular, and
218 linear-algebra operations on it may produce numerically unreliable results.
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 """
229class MonotonicPricesError(BasanosError, ValueError):
230 """Raised when an asset's price series is strictly monotonic.
232 A monotonic series (all non-decreasing or all non-increasing) has no
233 variance in its return sign, indicating malformed or synthetic data.
235 Args:
236 asset: Name of the offending asset column.
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 """
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
254class FactorModelError(BasanosError, ValueError):
255 """Raised when :class:`~basanos.math.FactorModel` arguments fail validation.
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.
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 """
269class StreamStateCorruptError(BasanosError, ValueError):
270 """Raised when a saved stream archive is missing required state keys.
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.
278 Args:
279 missing: Collection of key names absent from the archive.
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 """
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)