Coverage for src/jquantstats/exceptions.py: 100%
86 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-06 04:52 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-06 04:52 +0000
1"""Domain-specific exception types for the jquantstats package.
3This module defines a hierarchy of exceptions that provide meaningful context
4when data-validation errors occur within the package.
6All exceptions inherit from `JQuantStatsError` so callers can catch the
7entire family with a single ``except JQuantStatsError`` clause if they prefer.
9Examples:
10 >>> raise MissingDateColumnError("prices") # doctest: +ELLIPSIS
11 Traceback (most recent call last):
12 ...
13 jquantstats.exceptions.MissingDateColumnError: ...
14"""
16from __future__ import annotations
18from typing import Any
21class JQuantStatsError(Exception):
22 """Base class for all JQuantStats domain errors."""
25class MissingDateColumnError(JQuantStatsError, ValueError):
26 """Raised when a required date column is absent from a DataFrame.
28 Args:
29 frame_name: Descriptive name of the frame missing the column (e.g. ``"prices"``).
30 column: Name of the date column that was looked up (e.g. the
31 ``date_col`` argument). When omitted, the default ``'date'``
32 column is assumed.
33 available: Column names actually present in the frame, included in
34 the error message to help diagnose the mismatch.
36 Examples:
37 >>> raise MissingDateColumnError("prices") # doctest: +ELLIPSIS
38 Traceback (most recent call last):
39 ...
40 jquantstats.exceptions.MissingDateColumnError: ...
41 """
43 def __init__(self, frame_name: str, column: str | None = None, available: list[str] | None = None) -> None:
44 """Initialize with the frame name and, optionally, the missing column and available columns."""
45 available = [] if available is None else list(available)
46 if column is None:
47 msg = f"DataFrame '{frame_name}' is missing the required 'date' column."
48 else:
49 cols = ", ".join(f"'{c}'" for c in available) if available else ""
50 msg = (
51 f"DataFrame '{frame_name}' has no column '{column}' to use as the date column"
52 + (f"; available columns: {cols}" if cols else "")
53 + ". Pass date_col=<name of an existing column>."
54 )
55 super().__init__(msg)
56 self.frame_name = frame_name
57 self.column = column
58 self.available = available
61class InvalidCashPositionTypeError(JQuantStatsError, TypeError):
62 """Raised when ``cashposition`` is not a `polars.DataFrame`.
64 Args:
65 actual_type: The ``type.__name__`` of the value that was supplied.
67 Examples:
68 >>> raise InvalidCashPositionTypeError("dict")
69 Traceback (most recent call last):
70 ...
71 jquantstats.exceptions.InvalidCashPositionTypeError: cashposition must be pl.DataFrame, got dict.
72 """
74 def __init__(self, actual_type: str) -> None:
75 """Initialize with the offending type name."""
76 super().__init__(f"cashposition must be pl.DataFrame, got {actual_type}.")
77 self.actual_type = actual_type
80class InvalidPricesTypeError(JQuantStatsError, TypeError):
81 """Raised when ``prices`` is not a `polars.DataFrame`.
83 Args:
84 actual_type: The ``type.__name__`` of the value that was supplied.
86 Examples:
87 >>> raise InvalidPricesTypeError("list")
88 Traceback (most recent call last):
89 ...
90 jquantstats.exceptions.InvalidPricesTypeError: prices must be pl.DataFrame, got list.
91 """
93 def __init__(self, actual_type: str) -> None:
94 """Initialize with the offending type name."""
95 super().__init__(f"prices must be pl.DataFrame, got {actual_type}.")
96 self.actual_type = actual_type
99class NonPositiveAumError(JQuantStatsError, ValueError):
100 """Raised when ``aum`` is not strictly positive.
102 Args:
103 aum: The non-positive value that was supplied.
105 Examples:
106 >>> raise NonPositiveAumError(0.0)
107 Traceback (most recent call last):
108 ...
109 jquantstats.exceptions.NonPositiveAumError: aum must be strictly positive, got 0.0.
110 """
112 def __init__(self, aum: float) -> None:
113 """Initialize with the offending aum value."""
114 super().__init__(f"aum must be strictly positive, got {aum}.")
115 self.aum = aum
118class RowCountMismatchError(JQuantStatsError, ValueError):
119 """Raised when ``prices`` and ``cashposition`` have different numbers of rows.
121 Args:
122 prices_rows: Number of rows in the prices DataFrame.
123 cashposition_rows: Number of rows in the cashposition DataFrame.
125 Examples:
126 >>> raise RowCountMismatchError(10, 9) # doctest: +ELLIPSIS
127 Traceback (most recent call last):
128 ...
129 jquantstats.exceptions.RowCountMismatchError: ...
130 """
132 def __init__(self, prices_rows: int, cashposition_rows: int) -> None:
133 """Initialize with the row counts of the two mismatched DataFrames."""
134 super().__init__(
135 f"cashposition and prices must have the same number of rows, "
136 f"got cashposition={cashposition_rows} and prices={prices_rows}."
137 )
138 self.prices_rows = prices_rows
139 self.cashposition_rows = cashposition_rows
142class IntegerIndexBoundError(JQuantStatsError, TypeError):
143 """Raised when a row-index bound is not an integer.
145 Args:
146 param: Name of the offending parameter (e.g. ``"start"`` or ``"end"``).
147 actual_type: The ``type.__name__`` of the value that was supplied.
149 Examples:
150 >>> raise IntegerIndexBoundError("start", "str")
151 Traceback (most recent call last):
152 ...
153 jquantstats.exceptions.IntegerIndexBoundError: start must be an integer, got str.
154 """
156 def __init__(self, param: str, actual_type: str) -> None:
157 """Initialize with the parameter name and the offending type."""
158 super().__init__(f"{param} must be an integer, got {actual_type}.")
159 self.param = param
160 self.actual_type = actual_type
163class PositionExprColumnError(JQuantStatsError, ValueError):
164 """Raised when a position expression creates columns that do not exist in prices.
166 Position expressions (``cash_position``, ``position``, ``risk_position``)
167 are evaluated against the prices frame and must overwrite existing asset
168 columns. An expression that creates a *new* column (e.g. via ``.alias``)
169 leaves the original asset columns untouched, which would silently treat
170 raw prices as positions.
172 Args:
173 param: Name of the offending parameter (e.g. ``"cash_position"``).
174 extra: Column names created by the expression that are absent from prices.
176 Examples:
177 >>> raise PositionExprColumnError("cash_position", ["A2"]) # doctest: +ELLIPSIS
178 Traceback (most recent call last):
179 ...
180 jquantstats.exceptions.PositionExprColumnError: ...
181 """
183 def __init__(self, param: str, extra: list[str]) -> None:
184 """Initialize with the parameter name and the unexpected columns it created."""
185 cols = ", ".join(f"'{c}'" for c in extra)
186 super().__init__(
187 f"{param} expression created new column(s) {cols} that do not exist in prices. "
188 f"Expressions must overwrite existing asset columns (e.g. pl.col('A') * 2); "
189 f"asset columns the expression does not overwrite keep their raw price values."
190 )
191 self.param = param
192 self.extra = list(extra)
195class NoAssetColumnsError(JQuantStatsError, ValueError):
196 """Raised when a DataFrame contains no numeric asset columns to aggregate.
198 Args:
199 frame_name: Descriptive name of the frame without asset columns (e.g. ``"profits"``).
201 Examples:
202 >>> raise NoAssetColumnsError("profits") # doctest: +ELLIPSIS
203 Traceback (most recent call last):
204 ...
205 jquantstats.exceptions.NoAssetColumnsError: ...
206 """
208 def __init__(self, frame_name: str) -> None:
209 """Initialize with the name of the frame lacking asset columns."""
210 super().__init__(
211 f"DataFrame '{frame_name}' contains no numeric asset columns; "
212 f"at least one numeric column besides 'date' is required."
213 )
214 self.frame_name = frame_name
217class NegativeCostBpsError(JQuantStatsError, ValueError):
218 """Raised when a trading cost in basis points is negative.
220 Args:
221 cost_bps: The negative cost value that was supplied.
223 Examples:
224 >>> raise NegativeCostBpsError(-1.0)
225 Traceback (most recent call last):
226 ...
227 jquantstats.exceptions.NegativeCostBpsError: cost_bps must be non-negative, got -1.0.
228 """
230 def __init__(self, cost_bps: float) -> None:
231 """Initialize with the offending cost value."""
232 super().__init__(f"cost_bps must be non-negative, got {cost_bps}.")
233 self.cost_bps = cost_bps
236class NegativeAnnualFeeError(JQuantStatsError, ValueError):
237 """Raised when an annual management fee is negative.
239 Args:
240 annual_fee: The negative fee value that was supplied.
242 Examples:
243 >>> raise NegativeAnnualFeeError(-0.01)
244 Traceback (most recent call last):
245 ...
246 jquantstats.exceptions.NegativeAnnualFeeError: annual_fee must be non-negative, got -0.01.
247 """
249 def __init__(self, annual_fee: float) -> None:
250 """Initialize with the offending fee value."""
251 super().__init__(f"annual_fee must be non-negative, got {annual_fee}.")
252 self.annual_fee = annual_fee
255class InvalidMaxBpsError(JQuantStatsError, ValueError):
256 """Raised when ``max_bps`` is not a positive integer.
258 Args:
259 max_bps: The invalid value that was supplied.
261 Examples:
262 >>> raise InvalidMaxBpsError(0)
263 Traceback (most recent call last):
264 ...
265 jquantstats.exceptions.InvalidMaxBpsError: max_bps must be a positive integer, got 0.
266 """
268 def __init__(self, max_bps: Any) -> None:
269 """Initialize with the offending value."""
270 super().__init__(f"max_bps must be a positive integer, got {max_bps!r}.")
271 self.max_bps = max_bps
274class UncleanSeriesError(JQuantStatsError, ValueError):
275 """Raised when a derived series contains null or non-finite values.
277 Args:
278 name: Name of the offending series (may be empty when unknown).
279 reason: Either ``"null"`` or ``"non-finite"``.
281 Examples:
282 >>> raise UncleanSeriesError("profit", "null") # doctest: +ELLIPSIS
283 Traceback (most recent call last):
284 ...
285 jquantstats.exceptions.UncleanSeriesError: ...
286 """
288 def __init__(self, name: str, reason: str) -> None:
289 """Initialize with the series name and the kind of dirty value found."""
290 label = f"series '{name}'" if name else "series"
291 super().__init__(
292 f"{label} contains {reason} values; inputs must produce a clean, finite series. "
293 f"Check prices and positions for gaps or zero/negative prices."
294 )
295 self.name = name
296 self.reason = reason
299class MuSchemaError(JQuantStatsError, ValueError):
300 """Raised when a ``mu`` (expected-returns) frame doesn't match the portfolio's assets.
302 Args:
303 missing: Portfolio asset columns absent from the mu frame.
305 Examples:
306 >>> raise MuSchemaError(["AAPL"]) # doctest: +ELLIPSIS
307 Traceback (most recent call last):
308 ...
309 jquantstats.exceptions.MuSchemaError: ...
310 """
312 def __init__(self, missing: list[str]) -> None:
313 """Initialize with the asset columns missing from the mu frame."""
314 cols = ", ".join(f"'{c}'" for c in missing)
315 super().__init__(f"mu is missing expected-return columns for portfolio asset(s): {cols}.")
316 self.missing = missing
319class NullsInReturnsError(JQuantStatsError, ValueError):
320 """Raised when null values are detected in returns (or benchmark) data.
322 Polars propagates ``null`` through calculations whereas pandas silently
323 drops ``NaN``. Leaving nulls in place will cause most statistics to
324 return ``null`` instead of a numeric result.
326 Use the ``null_strategy`` parameter on `from_returns`
327 or `from_prices` to handle nulls automatically, or
328 clean the data before construction.
330 Args:
331 frame_name: Descriptive name of the frame that contains nulls
332 (e.g. ``"returns"`` or ``"benchmark"``).
333 columns: Names of the columns that contain at least one null.
335 Examples:
336 >>> raise NullsInReturnsError("returns", ["Asset1", "Asset2"])
337 Traceback (most recent call last):
338 ...
339 jquantstats.exceptions.NullsInReturnsError: ...
340 """
342 def __init__(self, frame_name: str, columns: list[str]) -> None:
343 """Initialize with the frame name and the columns that contain nulls."""
344 cols_str = ", ".join(f"'{c}'" for c in columns)
345 super().__init__(
346 f"DataFrame '{frame_name}' contains null values in column(s): {cols_str}. "
347 f"Pass null_strategy='drop' or null_strategy='forward_fill' to handle nulls "
348 f"automatically, or clean the data before construction."
349 )
350 self.frame_name = frame_name
351 self.columns = columns
354class NoBenchmarkError(JQuantStatsError, AttributeError):
355 """Raised when a benchmark-dependent statistic is requested without a benchmark.
357 Subclasses `AttributeError` so existing callers that catch
358 ``AttributeError`` for the no-benchmark path keep working unchanged.
360 Examples:
361 >>> raise NoBenchmarkError()
362 Traceback (most recent call last):
363 ...
364 jquantstats.exceptions.NoBenchmarkError: No benchmark data available
365 """
367 def __init__(self) -> None:
368 """Initialize with the fixed no-benchmark message."""
369 super().__init__("No benchmark data available")
372class NonPositiveWindowError(JQuantStatsError, ValueError):
373 """Raised when a rolling-window size is not a positive integer.
375 Args:
376 param: Name of the offending parameter (e.g. ``"window"`` or
377 ``"rolling_period"``).
379 Examples:
380 >>> raise NonPositiveWindowError("window")
381 Traceback (most recent call last):
382 ...
383 jquantstats.exceptions.NonPositiveWindowError: window must be a positive integer
384 """
386 def __init__(self, param: str) -> None:
387 """Initialize with the name of the offending window parameter."""
388 super().__init__(f"{param} must be a positive integer")
389 self.param = param
392class NonPositivePeriodsPerYearError(JQuantStatsError, ValueError):
393 """Raised when ``periods_per_year`` is not strictly positive.
395 Examples:
396 >>> raise NonPositivePeriodsPerYearError()
397 Traceback (most recent call last):
398 ...
399 jquantstats.exceptions.NonPositivePeriodsPerYearError: periods_per_year must be positive
400 """
402 def __init__(self) -> None:
403 """Initialize with the fixed non-positive periods-per-year message."""
404 super().__init__("periods_per_year must be positive")
407class BenchmarkAlignmentWarning(UserWarning):
408 """Emitted when aligning returns and benchmark drops rows from either side.
410 Returns and benchmark are aligned on their common dates with an inner
411 join. Rows whose date appears in only one of the two frames are
412 silently discarded by that join; this warning surfaces how many rows
413 were lost so a partially overlapping benchmark cannot truncate the
414 analysis unnoticed.
416 Suppress it once the overlap is understood::
418 import warnings
419 from jquantstats.exceptions import BenchmarkAlignmentWarning
421 warnings.filterwarnings("ignore", category=BenchmarkAlignmentWarning)
422 """