Coverage for src/jquantstats/exceptions.py: 100%
114 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 04:11 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 04:11 +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.
8Most also inherit the builtin the condition would otherwise have raised --
9`MissingDateColumnError` is a `ValueError` too -- so naming a condition here
10never costs a caller the builtin contract.
12Argument validation is deliberately *not* in this hierarchy
13-----------------------------------------------------------
15A few dozen raises across ``src/`` are plain `TypeError`, `ValueError` or
16`AttributeError` rather than members of the taxonomy above, each carrying a
17``# noqa: TRY003``. That is a standing exemption, not drift, and this paragraph
18is the single place it is recorded.
20The split is by *who made the mistake*. The taxonomy describes conditions in
21the caller's **data** -- a frame with no date column, a benchmark that does not
22overlap the returns -- which a caller may reasonably want to catch as a family
23and recover from. The exempt raises describe a broken **call contract**: a
24window that is not a positive integer, a cost that is not finite, an ``n`` that
25is not an ``int``. Those signal a bug at the call site, and folding them into
26`JQuantStatsError` would mean ``except JQuantStatsError`` silently swallowed
27the caller's own programming errors alongside the data conditions it meant to
28handle.
30The suppression is needed because TRY003 wants the message moved inside the
31exception class, which for a one-off argument check would mean declaring a
32class per check. The repo already ignores ``EM`` repo-wide for the same reason
33("literal exception messages are fine at our scale", ``ruff.toml``); TRY003 is
34the same trade-off made one call at a time. The suppressions are not annotated
35individually because the longest affected line is already 117 of the 120
36columns ``ruff.toml`` allows, leaving no room for a pointer.
38The exemption's scope is enforced rather than merely described:
39``tests/test_jquantstats/test_exception_policy.py`` asserts that every
40``# noqa: TRY003`` in ``src/`` sits on a raise of one of those three builtins,
41so the suppression cannot quietly spread to the taxonomy or to any other rule.
43Examples:
44 >>> raise MissingDateColumnError("prices") # doctest: +ELLIPSIS
45 Traceback (most recent call last):
46 ...
47 jquantstats.exceptions.MissingDateColumnError: ...
48"""
50from __future__ import annotations
52from typing import Any
55class JQuantStatsError(Exception):
56 """Base class for all JQuantStats domain errors."""
59class MissingDateColumnError(JQuantStatsError, ValueError):
60 """Raised when a required date column is absent from a DataFrame.
62 Args:
63 frame_name: Descriptive name of the frame missing the column (e.g. ``"prices"``).
64 column: Name of the date column that was looked up (e.g. the
65 ``date_col`` argument). When omitted, the column was being
66 auto-detected rather than looked up by name.
67 available: Column names actually present in the frame, included in
68 the error message to help diagnose the mismatch. Supplying them
69 without a *column* selects the auto-detection wording — no
70 temporal column was found to use as the date axis.
72 Examples:
73 >>> raise MissingDateColumnError("prices") # doctest: +ELLIPSIS
74 Traceback (most recent call last):
75 ...
76 jquantstats.exceptions.MissingDateColumnError: ...
77 """
79 def __init__(self, frame_name: str, column: str | None = None, available: list[str] | None = None) -> None:
80 """Initialize with the frame name and, optionally, the missing column and available columns."""
81 available = [] if available is None else list(available)
82 cols = ", ".join(f"'{c}'" for c in available) if available else ""
83 if column is None and not cols:
84 msg = f"DataFrame '{frame_name}' is missing the required 'date' column."
85 else:
86 lookup = (
87 f"has no column '{column}' to use as the date column"
88 if column is not None
89 else "has no temporal column to use as the date column"
90 )
91 msg = (
92 f"DataFrame '{frame_name}' {lookup}"
93 + (f"; available columns: {cols}" if cols else "")
94 + ". Pass date_col=<name of an existing column>."
95 )
96 super().__init__(msg)
97 self.frame_name = frame_name
98 self.column = column
99 self.available = available
102class InvalidCashPositionTypeError(JQuantStatsError, TypeError):
103 """Raised when ``cashposition`` is not a `polars.DataFrame`.
105 Args:
106 actual_type: The ``type.__name__`` of the value that was supplied.
108 Examples:
109 >>> raise InvalidCashPositionTypeError("dict")
110 Traceback (most recent call last):
111 ...
112 jquantstats.exceptions.InvalidCashPositionTypeError: cashposition must be pl.DataFrame, got dict.
113 """
115 def __init__(self, actual_type: str) -> None:
116 """Initialize with the offending type name."""
117 super().__init__(f"cashposition must be pl.DataFrame, got {actual_type}.")
118 self.actual_type = actual_type
121class InvalidPricesTypeError(JQuantStatsError, TypeError):
122 """Raised when ``prices`` is not a `polars.DataFrame`.
124 Args:
125 actual_type: The ``type.__name__`` of the value that was supplied.
127 Examples:
128 >>> raise InvalidPricesTypeError("list")
129 Traceback (most recent call last):
130 ...
131 jquantstats.exceptions.InvalidPricesTypeError: prices must be pl.DataFrame, got list.
132 """
134 def __init__(self, actual_type: str) -> None:
135 """Initialize with the offending type name."""
136 super().__init__(f"prices must be pl.DataFrame, got {actual_type}.")
137 self.actual_type = actual_type
140class NonPositiveAumError(JQuantStatsError, ValueError):
141 """Raised when ``aum`` is not strictly positive.
143 Args:
144 aum: The non-positive value that was supplied.
146 Examples:
147 >>> raise NonPositiveAumError(0.0)
148 Traceback (most recent call last):
149 ...
150 jquantstats.exceptions.NonPositiveAumError: aum must be strictly positive, got 0.0.
151 """
153 def __init__(self, aum: float) -> None:
154 """Initialize with the offending aum value."""
155 super().__init__(f"aum must be strictly positive, got {aum}.")
156 self.aum = aum
159class RowCountMismatchError(JQuantStatsError, ValueError):
160 """Raised when ``prices`` and ``cashposition`` have different numbers of rows.
162 Args:
163 prices_rows: Number of rows in the prices DataFrame.
164 cashposition_rows: Number of rows in the cashposition DataFrame.
166 Examples:
167 >>> raise RowCountMismatchError(10, 9) # doctest: +ELLIPSIS
168 Traceback (most recent call last):
169 ...
170 jquantstats.exceptions.RowCountMismatchError: ...
171 """
173 def __init__(self, prices_rows: int, cashposition_rows: int) -> None:
174 """Initialize with the row counts of the two mismatched DataFrames."""
175 super().__init__(
176 f"cashposition and prices must have the same number of rows, "
177 f"got cashposition={cashposition_rows} and prices={prices_rows}."
178 )
179 self.prices_rows = prices_rows
180 self.cashposition_rows = cashposition_rows
183class IntegerIndexBoundError(JQuantStatsError, TypeError):
184 """Raised when a row-index bound is not an integer.
186 Args:
187 param: Name of the offending parameter (e.g. ``"start"`` or ``"end"``).
188 actual_type: The ``type.__name__`` of the value that was supplied.
190 Examples:
191 >>> raise IntegerIndexBoundError("start", "str")
192 Traceback (most recent call last):
193 ...
194 jquantstats.exceptions.IntegerIndexBoundError: start must be an integer, got str.
195 """
197 def __init__(self, param: str, actual_type: str) -> None:
198 """Initialize with the parameter name and the offending type."""
199 super().__init__(f"{param} must be an integer, got {actual_type}.")
200 self.param = param
201 self.actual_type = actual_type
204class InvalidTruncateBoundError(JQuantStatsError, ValueError):
205 """Raised when a truncation bound is neither a row index nor a usable date.
207 Covers a value of an unsupported type (a float, a bool) and a string that
208 is not ISO-8601, both on an object that *has* a temporal index. An
209 integer-indexed object raises `IntegerIndexBoundError` instead, since there
210 the only legal bound is a row index.
212 Args:
213 param: Name of the offending parameter (e.g. ``"start"`` or ``"end"``).
214 value: The value that was supplied.
216 Examples:
217 >>> raise InvalidTruncateBoundError("start", "last tuesday")
218 Traceback (most recent call last):
219 ...
220 jquantstats.exceptions.InvalidTruncateBoundError: start must be an int row index, a date/datetime, \
221or an ISO-8601 string; got 'last tuesday'.
222 """
224 def __init__(self, param: str, value: Any) -> None:
225 """Initialize with the parameter name and the offending value."""
226 super().__init__(f"{param} must be an int row index, a date/datetime, or an ISO-8601 string; got {value!r}.")
227 self.param = param
228 self.value = value
231class MixedTruncateBoundsError(JQuantStatsError, TypeError):
232 """Raised when ``start`` and ``end`` mix a row index with a date.
234 The two describe different axes, so honouring one and ignoring the other
235 would silently truncate to a range the caller never asked for.
237 Args:
238 row_param: Name of the parameter given as a row index.
239 date_param: Name of the parameter given as a date.
241 Examples:
242 >>> raise MixedTruncateBoundsError("start", "end")
243 Traceback (most recent call last):
244 ...
245 jquantstats.exceptions.MixedTruncateBoundsError: start and end must both be row indices or both be \
246dates; got start as a row index and end as a date.
247 """
249 def __init__(self, row_param: str, date_param: str) -> None:
250 """Initialize with the row-index and date parameter names."""
251 super().__init__(
252 f"start and end must both be row indices or both be dates; "
253 f"got {row_param} as a row index and {date_param} as a date."
254 )
255 self.row_param = row_param
256 self.date_param = date_param
259class PositionExprColumnError(JQuantStatsError, ValueError):
260 """Raised when a position expression creates columns that do not exist in prices.
262 Position expressions (``cash_position``, ``position``, ``risk_position``)
263 are evaluated against the prices frame and must overwrite existing asset
264 columns. An expression that creates a *new* column (e.g. via ``.alias``)
265 leaves the original asset columns untouched, which would silently treat
266 raw prices as positions.
268 Args:
269 param: Name of the offending parameter (e.g. ``"cash_position"``).
270 extra: Column names created by the expression that are absent from prices.
272 Examples:
273 >>> raise PositionExprColumnError("cash_position", ["A2"]) # doctest: +ELLIPSIS
274 Traceback (most recent call last):
275 ...
276 jquantstats.exceptions.PositionExprColumnError: ...
277 """
279 def __init__(self, param: str, extra: list[str]) -> None:
280 """Initialize with the parameter name and the unexpected columns it created."""
281 cols = ", ".join(f"'{c}'" for c in extra)
282 super().__init__(
283 f"{param} expression created new column(s) {cols} that do not exist in prices. "
284 f"Expressions must overwrite existing asset columns (e.g. pl.col('A') * 2); "
285 f"asset columns the expression does not overwrite keep their raw price values."
286 )
287 self.param = param
288 self.extra = list(extra)
291class NoAssetColumnsError(JQuantStatsError, ValueError):
292 """Raised when a DataFrame contains no numeric asset columns to aggregate.
294 Args:
295 frame_name: Descriptive name of the frame without asset columns (e.g. ``"profits"``).
297 Examples:
298 >>> raise NoAssetColumnsError("profits") # doctest: +ELLIPSIS
299 Traceback (most recent call last):
300 ...
301 jquantstats.exceptions.NoAssetColumnsError: ...
302 """
304 def __init__(self, frame_name: str) -> None:
305 """Initialize with the name of the frame lacking asset columns."""
306 super().__init__(
307 f"DataFrame '{frame_name}' contains no numeric asset columns; "
308 f"at least one numeric column besides 'date' is required."
309 )
310 self.frame_name = frame_name
313class NegativeCostBpsError(JQuantStatsError, ValueError):
314 """Raised when a trading cost in basis points is negative.
316 Args:
317 cost_bps: The negative cost value that was supplied.
319 Examples:
320 >>> raise NegativeCostBpsError(-1.0)
321 Traceback (most recent call last):
322 ...
323 jquantstats.exceptions.NegativeCostBpsError: cost_bps must be non-negative, got -1.0.
324 """
326 def __init__(self, cost_bps: float) -> None:
327 """Initialize with the offending cost value."""
328 super().__init__(f"cost_bps must be non-negative, got {cost_bps}.")
329 self.cost_bps = cost_bps
332class NegativeAnnualFeeError(JQuantStatsError, ValueError):
333 """Raised when an annual management fee is negative.
335 Args:
336 annual_fee: The negative fee value that was supplied.
338 Examples:
339 >>> raise NegativeAnnualFeeError(-0.01)
340 Traceback (most recent call last):
341 ...
342 jquantstats.exceptions.NegativeAnnualFeeError: annual_fee must be non-negative, got -0.01.
343 """
345 def __init__(self, annual_fee: float) -> None:
346 """Initialize with the offending fee value."""
347 super().__init__(f"annual_fee must be non-negative, got {annual_fee}.")
348 self.annual_fee = annual_fee
351class InvalidMaxBpsError(JQuantStatsError, ValueError):
352 """Raised when ``max_bps`` is not a positive integer.
354 Args:
355 max_bps: The invalid value that was supplied.
357 Examples:
358 >>> raise InvalidMaxBpsError(0)
359 Traceback (most recent call last):
360 ...
361 jquantstats.exceptions.InvalidMaxBpsError: max_bps must be a positive integer, got 0.
362 """
364 def __init__(self, max_bps: Any) -> None:
365 """Initialize with the offending value."""
366 super().__init__(f"max_bps must be a positive integer, got {max_bps!r}.")
367 self.max_bps = max_bps
370class UncleanSeriesError(JQuantStatsError, ValueError):
371 """Raised when a derived series contains null or non-finite values.
373 Args:
374 name: Name of the offending series (may be empty when unknown).
375 reason: Either ``"null"`` or ``"non-finite"``.
377 Examples:
378 >>> raise UncleanSeriesError("profit", "null") # doctest: +ELLIPSIS
379 Traceback (most recent call last):
380 ...
381 jquantstats.exceptions.UncleanSeriesError: ...
382 """
384 def __init__(self, name: str, reason: str) -> None:
385 """Initialize with the series name and the kind of dirty value found."""
386 label = f"series '{name}'" if name else "series"
387 super().__init__(
388 f"{label} contains {reason} values; inputs must produce a clean, finite series. "
389 f"Check prices and positions for gaps or zero/negative prices."
390 )
391 self.name = name
392 self.reason = reason
395class MissingReturnsColumnError(JQuantStatsError, ValueError):
396 """Raised when a frame handed to the returns bridge has no ``'returns'`` column.
398 `jquantstats.portfolio.Portfolio.as_data` reads the return series from the
399 ``'returns'`` column and ignores every other column. A frame without one
400 carries no return series at all, so it is rejected rather than silently
401 analysed on whatever columns happen to be present.
403 Args:
404 available: Column names actually present in the frame, included in the
405 error message to help diagnose the mismatch.
407 Examples:
408 >>> raise MissingReturnsColumnError(["date", "profit"]) # doctest: +ELLIPSIS
409 Traceback (most recent call last):
410 ...
411 jquantstats.exceptions.MissingReturnsColumnError: ...
412 """
414 def __init__(self, available: list[str] | None = None) -> None:
415 """Initialize with the column names present in the offending frame."""
416 available = [] if available is None else list(available)
417 cols = ", ".join(f"'{c}'" for c in available) if available else ""
418 super().__init__(
419 "DataFrame has no 'returns' column to bridge into Data"
420 + (f"; available columns: {cols}" if cols else "")
421 + ". Pass a frame produced by Portfolio.returns, cost_adjusted_returns "
422 "or deduct_management_fee, or rename your return column to 'returns'."
423 )
424 self.available = available
427class MuSchemaError(JQuantStatsError, ValueError):
428 """Raised when a ``mu`` (expected-returns) frame doesn't match the portfolio's assets.
430 Args:
431 missing: Portfolio asset columns absent from the mu frame.
433 Examples:
434 >>> raise MuSchemaError(["AAPL"]) # doctest: +ELLIPSIS
435 Traceback (most recent call last):
436 ...
437 jquantstats.exceptions.MuSchemaError: ...
438 """
440 def __init__(self, missing: list[str]) -> None:
441 """Initialize with the asset columns missing from the mu frame."""
442 cols = ", ".join(f"'{c}'" for c in missing)
443 super().__init__(f"mu is missing expected-return columns for portfolio asset(s): {cols}.")
444 self.missing = missing
447class NullsInReturnsError(JQuantStatsError, ValueError):
448 """Raised when null values are detected in returns (or benchmark) data.
450 Polars propagates ``null`` through calculations whereas pandas silently
451 drops ``NaN``. Leaving nulls in place will cause most statistics to
452 return ``null`` instead of a numeric result.
454 Use the ``null_strategy`` parameter on `from_returns`
455 or `from_prices` to handle nulls automatically, or
456 clean the data before construction.
458 Args:
459 frame_name: Descriptive name of the frame that contains nulls
460 (e.g. ``"returns"`` or ``"benchmark"``).
461 columns: Names of the columns that contain at least one null.
463 Examples:
464 >>> raise NullsInReturnsError("returns", ["Asset1", "Asset2"])
465 Traceback (most recent call last):
466 ...
467 jquantstats.exceptions.NullsInReturnsError: ...
468 """
470 def __init__(self, frame_name: str, columns: list[str]) -> None:
471 """Initialize with the frame name and the columns that contain nulls."""
472 cols_str = ", ".join(f"'{c}'" for c in columns)
473 super().__init__(
474 f"DataFrame '{frame_name}' contains null values in column(s): {cols_str}. "
475 f"Pass null_strategy='drop' or null_strategy='forward_fill' to handle nulls "
476 f"automatically, or clean the data before construction."
477 )
478 self.frame_name = frame_name
479 self.columns = columns
482class NoBenchmarkError(JQuantStatsError, AttributeError):
483 """Raised when a benchmark-dependent statistic is requested without a benchmark.
485 Subclasses `AttributeError` so existing callers that catch
486 ``AttributeError`` for the no-benchmark path keep working unchanged.
488 Examples:
489 >>> raise NoBenchmarkError()
490 Traceback (most recent call last):
491 ...
492 jquantstats.exceptions.NoBenchmarkError: No benchmark data available
493 """
495 def __init__(self) -> None:
496 """Initialize with the fixed no-benchmark message."""
497 super().__init__("No benchmark data available")
500class NonPositiveWindowError(JQuantStatsError, ValueError):
501 """Raised when a rolling-window size is not a positive integer.
503 Args:
504 param: Name of the offending parameter (e.g. ``"window"`` or
505 ``"rolling_period"``).
507 Examples:
508 >>> raise NonPositiveWindowError("window")
509 Traceback (most recent call last):
510 ...
511 jquantstats.exceptions.NonPositiveWindowError: window must be a positive integer
512 """
514 def __init__(self, param: str) -> None:
515 """Initialize with the name of the offending window parameter."""
516 super().__init__(f"{param} must be a positive integer")
517 self.param = param
520class NonPositivePeriodsPerYearError(JQuantStatsError, ValueError):
521 """Raised when ``periods_per_year`` is not strictly positive.
523 Examples:
524 >>> raise NonPositivePeriodsPerYearError()
525 Traceback (most recent call last):
526 ...
527 jquantstats.exceptions.NonPositivePeriodsPerYearError: periods_per_year must be positive
528 """
530 def __init__(self) -> None:
531 """Initialize with the fixed non-positive periods-per-year message."""
532 super().__init__("periods_per_year must be positive")
535class BenchmarkAlignmentWarning(UserWarning):
536 """Emitted when aligning returns and benchmark drops rows from either side.
538 Returns and benchmark are aligned on their common dates with an inner
539 join. Rows whose date appears in only one of the two frames are
540 silently discarded by that join; this warning surfaces how many rows
541 were lost so a partially overlapping benchmark cannot truncate the
542 analysis unnoticed.
544 Suppress it once the overlap is understood::
546 import warnings
547 from jquantstats.exceptions import BenchmarkAlignmentWarning
549 warnings.filterwarnings("ignore", category=BenchmarkAlignmentWarning)
550 """
553class UnknownPlotBackendError(JQuantStatsError, ValueError):
554 """Raised when an unrecognised plotting backend is selected.
556 Args:
557 backend: The rejected backend name.
558 supported: The backend names that are accepted, in display order.
560 Examples:
561 >>> raise UnknownPlotBackendError("ggplot", ["matplotlib", "plotly"])
562 Traceback (most recent call last):
563 ...
564 jquantstats.exceptions.UnknownPlotBackendError: unknown plot backend 'ggplot'; ...
565 """
567 def __init__(self, backend: str, supported: list[str]) -> None:
568 """Initialize with the rejected backend and the accepted alternatives."""
569 expected = ", ".join(repr(name) for name in supported)
570 super().__init__(f"unknown plot backend {backend!r}; expected one of {expected}")
571 self.backend = backend
572 self.supported = supported
575class MissingBackendError(JQuantStatsError, ImportError):
576 """Raised when a plotting backend is selected but its library is not installed.
578 Subclasses `ImportError` as well as `JQuantStatsError` so that callers
579 already guarding optional rendering with ``except ImportError`` keep
580 working unchanged.
582 Args:
583 backend: The backend that could not be loaded.
584 extra: The packaging extra that installs it.
586 Examples:
587 >>> raise MissingBackendError("matplotlib", "mpl")
588 Traceback (most recent call last):
589 ...
590 jquantstats.exceptions.MissingBackendError: the 'matplotlib' plot backend requires matplotlib...
591 """
593 def __init__(self, backend: str, extra: str) -> None:
594 """Initialize with the unavailable backend and the extra that provides it."""
595 super().__init__(
596 f"the {backend!r} plot backend requires {backend}, which is not installed. "
597 f"Install it with: pip install 'jquantstats[{extra}]'"
598 )
599 self.backend = backend
600 self.extra = extra