Coverage for src/jquantstats/exceptions.py: 100%
125 statements
« prev ^ index » next coverage.py v7.17.1, created at 2026-11-03 15:52 +0000
« prev ^ index » next coverage.py v7.17.1, created at 2026-11-03 15: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.
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 a frame aligned with it 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 frame that should match it.
165 frame_name: Name of that frame in the message; ``"cashposition"`` by
166 default, ``"weights"`` or ``"realized_costs"`` where those are checked.
168 Examples:
169 >>> raise RowCountMismatchError(10, 9) # doctest: +ELLIPSIS
170 Traceback (most recent call last):
171 ...
172 jquantstats.exceptions.RowCountMismatchError: ...
173 """
175 def __init__(self, prices_rows: int, cashposition_rows: int, frame_name: str = "cashposition") -> None:
176 """Initialize with the row counts of the two mismatched DataFrames."""
177 super().__init__(
178 f"{frame_name} and prices must have the same number of rows, "
179 f"got {frame_name}={cashposition_rows} and prices={prices_rows}."
180 )
181 self.prices_rows = prices_rows
182 self.cashposition_rows = cashposition_rows
185class IntegerIndexBoundError(JQuantStatsError, TypeError):
186 """Raised when a row-index bound is not an integer.
188 Args:
189 param: Name of the offending parameter (e.g. ``"start"`` or ``"end"``).
190 actual_type: The ``type.__name__`` of the value that was supplied.
192 Examples:
193 >>> raise IntegerIndexBoundError("start", "str")
194 Traceback (most recent call last):
195 ...
196 jquantstats.exceptions.IntegerIndexBoundError: start must be an integer, got str.
197 """
199 def __init__(self, param: str, actual_type: str) -> None:
200 """Initialize with the parameter name and the offending type."""
201 super().__init__(f"{param} must be an integer, got {actual_type}.")
202 self.param = param
203 self.actual_type = actual_type
206class InvalidTruncateBoundError(JQuantStatsError, ValueError):
207 """Raised when a truncation bound is neither a row index nor a usable date.
209 Covers a value of an unsupported type (a float, a bool) and a string that
210 is not ISO-8601, both on an object that *has* a temporal index. An
211 integer-indexed object raises `IntegerIndexBoundError` instead, since there
212 the only legal bound is a row index.
214 Args:
215 param: Name of the offending parameter (e.g. ``"start"`` or ``"end"``).
216 value: The value that was supplied.
218 Examples:
219 >>> raise InvalidTruncateBoundError("start", "last tuesday")
220 Traceback (most recent call last):
221 ...
222 jquantstats.exceptions.InvalidTruncateBoundError: start must be an int row index, a date/datetime, \
223or an ISO-8601 string; got 'last tuesday'.
224 """
226 def __init__(self, param: str, value: Any) -> None:
227 """Initialize with the parameter name and the offending value."""
228 super().__init__(f"{param} must be an int row index, a date/datetime, or an ISO-8601 string; got {value!r}.")
229 self.param = param
230 self.value = value
233class MixedTruncateBoundsError(JQuantStatsError, TypeError):
234 """Raised when ``start`` and ``end`` mix a row index with a date.
236 The two describe different axes, so honouring one and ignoring the other
237 would silently truncate to a range the caller never asked for.
239 Args:
240 row_param: Name of the parameter given as a row index.
241 date_param: Name of the parameter given as a date.
243 Examples:
244 >>> raise MixedTruncateBoundsError("start", "end")
245 Traceback (most recent call last):
246 ...
247 jquantstats.exceptions.MixedTruncateBoundsError: start and end must both be row indices or both be \
248dates; got start as a row index and end as a date.
249 """
251 def __init__(self, row_param: str, date_param: str) -> None:
252 """Initialize with the row-index and date parameter names."""
253 super().__init__(
254 f"start and end must both be row indices or both be dates; "
255 f"got {row_param} as a row index and {date_param} as a date."
256 )
257 self.row_param = row_param
258 self.date_param = date_param
261class PositionExprColumnError(JQuantStatsError, ValueError):
262 """Raised when a position expression creates columns that do not exist in prices.
264 Position expressions (``cash_position``, ``position``, ``risk_position``)
265 are evaluated against the prices frame and must overwrite existing asset
266 columns. An expression that creates a *new* column (e.g. via ``.alias``)
267 leaves the original asset columns untouched, which would silently treat
268 raw prices as positions.
270 Args:
271 param: Name of the offending parameter (e.g. ``"cash_position"``).
272 extra: Column names created by the expression that are absent from prices.
274 Examples:
275 >>> raise PositionExprColumnError("cash_position", ["A2"]) # doctest: +ELLIPSIS
276 Traceback (most recent call last):
277 ...
278 jquantstats.exceptions.PositionExprColumnError: ...
279 """
281 def __init__(self, param: str, extra: list[str]) -> None:
282 """Initialize with the parameter name and the unexpected columns it created."""
283 cols = ", ".join(f"'{c}'" for c in extra)
284 super().__init__(
285 f"{param} expression created new column(s) {cols} that do not exist in prices. "
286 f"Expressions must overwrite existing asset columns (e.g. pl.col('A') * 2); "
287 f"asset columns the expression does not overwrite keep their raw price values."
288 )
289 self.param = param
290 self.extra = list(extra)
293class WeightsColumnError(JQuantStatsError, ValueError):
294 """Raised when a target-weights frame names assets that are not in prices.
296 `Portfolio.from_weights` aligns weights to prices by column name. A weight
297 column with no matching price column cannot be traded, and dropping it
298 silently would leave its weight in cash without the caller noticing.
300 Args:
301 unknown: Weight column names that have no matching price column.
303 Examples:
304 >>> raise WeightsColumnError(["C"]) # doctest: +ELLIPSIS
305 Traceback (most recent call last):
306 ...
307 jquantstats.exceptions.WeightsColumnError: ...
308 """
310 def __init__(self, unknown: list[str]) -> None:
311 """Initialize with the weight columns that have no price column."""
312 cols = ", ".join(f"'{c}'" for c in unknown)
313 super().__init__(f"weights contain column(s) {cols} that do not exist in prices.")
314 self.unknown = list(unknown)
317class RebalancePriceError(JQuantStatsError, ValueError):
318 """Raised when a held or targeted asset has no usable price on a rebalancing row.
320 A rebalancing trades every asset that is held or targeted at that row's
321 price. Without a finite, positive price the trade cannot be valued, so the
322 simulation stops rather than guess.
324 Args:
325 row: 0-based row index of the rebalancing.
326 assets: Assets that are held or targeted but have no usable price.
328 Examples:
329 >>> raise RebalancePriceError(3, ["A"]) # doctest: +ELLIPSIS
330 Traceback (most recent call last):
331 ...
332 jquantstats.exceptions.RebalancePriceError: ...
333 """
335 def __init__(self, row: int, assets: list[str]) -> None:
336 """Initialize with the rebalancing row and the unpriced assets."""
337 cols = ", ".join(f"'{c}'" for c in assets)
338 super().__init__(
339 f"cannot rebalance at row {row}: asset(s) {cols} are held or targeted "
340 f"but have no finite, positive price on that row."
341 )
342 self.row = row
343 self.assets = list(assets)
346class NoAssetColumnsError(JQuantStatsError, ValueError):
347 """Raised when a DataFrame contains no numeric asset columns to aggregate.
349 Args:
350 frame_name: Descriptive name of the frame without asset columns (e.g. ``"profits"``).
352 Examples:
353 >>> raise NoAssetColumnsError("profits") # doctest: +ELLIPSIS
354 Traceback (most recent call last):
355 ...
356 jquantstats.exceptions.NoAssetColumnsError: ...
357 """
359 def __init__(self, frame_name: str) -> None:
360 """Initialize with the name of the frame lacking asset columns."""
361 super().__init__(
362 f"DataFrame '{frame_name}' contains no numeric asset columns; "
363 f"at least one numeric column besides 'date' is required."
364 )
365 self.frame_name = frame_name
368class NegativeCostBpsError(JQuantStatsError, ValueError):
369 """Raised when a trading cost in basis points is negative.
371 Args:
372 cost_bps: The negative cost value that was supplied.
374 Examples:
375 >>> raise NegativeCostBpsError(-1.0)
376 Traceback (most recent call last):
377 ...
378 jquantstats.exceptions.NegativeCostBpsError: cost_bps must be non-negative, got -1.0.
379 """
381 def __init__(self, cost_bps: float) -> None:
382 """Initialize with the offending cost value."""
383 super().__init__(f"cost_bps must be non-negative, got {cost_bps}.")
384 self.cost_bps = cost_bps
387class NegativeAnnualFeeError(JQuantStatsError, ValueError):
388 """Raised when an annual management fee is negative.
390 Args:
391 annual_fee: The negative fee value that was supplied.
393 Examples:
394 >>> raise NegativeAnnualFeeError(-0.01)
395 Traceback (most recent call last):
396 ...
397 jquantstats.exceptions.NegativeAnnualFeeError: annual_fee must be non-negative, got -0.01.
398 """
400 def __init__(self, annual_fee: float) -> None:
401 """Initialize with the offending fee value."""
402 super().__init__(f"annual_fee must be non-negative, got {annual_fee}.")
403 self.annual_fee = annual_fee
406class InvalidMaxBpsError(JQuantStatsError, ValueError):
407 """Raised when ``max_bps`` is not a positive integer.
409 Args:
410 max_bps: The invalid value that was supplied.
412 Examples:
413 >>> raise InvalidMaxBpsError(0)
414 Traceback (most recent call last):
415 ...
416 jquantstats.exceptions.InvalidMaxBpsError: max_bps must be a positive integer, got 0.
417 """
419 def __init__(self, max_bps: Any) -> None:
420 """Initialize with the offending value."""
421 super().__init__(f"max_bps must be a positive integer, got {max_bps!r}.")
422 self.max_bps = max_bps
425class UncleanSeriesError(JQuantStatsError, ValueError):
426 """Raised when a derived series contains null or non-finite values.
428 Args:
429 name: Name of the offending series (may be empty when unknown).
430 reason: Either ``"null"`` or ``"non-finite"``.
432 Examples:
433 >>> raise UncleanSeriesError("profit", "null") # doctest: +ELLIPSIS
434 Traceback (most recent call last):
435 ...
436 jquantstats.exceptions.UncleanSeriesError: ...
437 """
439 def __init__(self, name: str, reason: str) -> None:
440 """Initialize with the series name and the kind of dirty value found."""
441 label = f"series '{name}'" if name else "series"
442 super().__init__(
443 f"{label} contains {reason} values; inputs must produce a clean, finite series. "
444 f"Check prices and positions for gaps or zero/negative prices."
445 )
446 self.name = name
447 self.reason = reason
450class MissingReturnsColumnError(JQuantStatsError, ValueError):
451 """Raised when a frame handed to the returns bridge has no ``'returns'`` column.
453 `jquantstats.portfolio.Portfolio.as_data` reads the return series from the
454 ``'returns'`` column and ignores every other column. A frame without one
455 carries no return series at all, so it is rejected rather than silently
456 analysed on whatever columns happen to be present.
458 Args:
459 available: Column names actually present in the frame, included in the
460 error message to help diagnose the mismatch.
462 Examples:
463 >>> raise MissingReturnsColumnError(["date", "profit"]) # doctest: +ELLIPSIS
464 Traceback (most recent call last):
465 ...
466 jquantstats.exceptions.MissingReturnsColumnError: ...
467 """
469 def __init__(self, available: list[str] | None = None) -> None:
470 """Initialize with the column names present in the offending frame."""
471 available = [] if available is None else list(available)
472 cols = ", ".join(f"'{c}'" for c in available) if available else ""
473 super().__init__(
474 "DataFrame has no 'returns' column to bridge into Data"
475 + (f"; available columns: {cols}" if cols else "")
476 + ". Pass a frame produced by Portfolio.returns, cost_adjusted_returns "
477 "or deduct_management_fee, or rename your return column to 'returns'."
478 )
479 self.available = available
482class MuSchemaError(JQuantStatsError, ValueError):
483 """Raised when a ``mu`` (expected-returns) frame doesn't match the portfolio's assets.
485 Args:
486 missing: Portfolio asset columns absent from the mu frame.
488 Examples:
489 >>> raise MuSchemaError(["AAPL"]) # doctest: +ELLIPSIS
490 Traceback (most recent call last):
491 ...
492 jquantstats.exceptions.MuSchemaError: ...
493 """
495 def __init__(self, missing: list[str]) -> None:
496 """Initialize with the asset columns missing from the mu frame."""
497 cols = ", ".join(f"'{c}'" for c in missing)
498 super().__init__(f"mu is missing expected-return columns for portfolio asset(s): {cols}.")
499 self.missing = missing
502class NullsInReturnsError(JQuantStatsError, ValueError):
503 """Raised when null values are detected in returns (or benchmark) data.
505 Polars propagates ``null`` through calculations whereas pandas silently
506 drops ``NaN``. Leaving nulls in place will cause most statistics to
507 return ``null`` instead of a numeric result.
509 Use the ``null_strategy`` parameter on `from_returns`
510 or `from_prices` to handle nulls automatically, or
511 clean the data before construction.
513 Args:
514 frame_name: Descriptive name of the frame that contains nulls
515 (e.g. ``"returns"`` or ``"benchmark"``).
516 columns: Names of the columns that contain at least one null.
518 Examples:
519 >>> raise NullsInReturnsError("returns", ["Asset1", "Asset2"])
520 Traceback (most recent call last):
521 ...
522 jquantstats.exceptions.NullsInReturnsError: ...
523 """
525 def __init__(self, frame_name: str, columns: list[str]) -> None:
526 """Initialize with the frame name and the columns that contain nulls."""
527 cols_str = ", ".join(f"'{c}'" for c in columns)
528 super().__init__(
529 f"DataFrame '{frame_name}' contains null values in column(s): {cols_str}. "
530 f"Pass null_strategy='drop' or null_strategy='forward_fill' to handle nulls "
531 f"automatically, or clean the data before construction."
532 )
533 self.frame_name = frame_name
534 self.columns = columns
537class NoBenchmarkError(JQuantStatsError, AttributeError):
538 """Raised when a benchmark-dependent statistic is requested without a benchmark.
540 Subclasses `AttributeError` so existing callers that catch
541 ``AttributeError`` for the no-benchmark path keep working unchanged.
543 Examples:
544 >>> raise NoBenchmarkError()
545 Traceback (most recent call last):
546 ...
547 jquantstats.exceptions.NoBenchmarkError: No benchmark data available
548 """
550 def __init__(self) -> None:
551 """Initialize with the fixed no-benchmark message."""
552 super().__init__("No benchmark data available")
555class NonPositiveWindowError(JQuantStatsError, ValueError):
556 """Raised when a rolling-window size is not a positive integer.
558 Args:
559 param: Name of the offending parameter (e.g. ``"window"`` or
560 ``"rolling_period"``).
562 Examples:
563 >>> raise NonPositiveWindowError("window")
564 Traceback (most recent call last):
565 ...
566 jquantstats.exceptions.NonPositiveWindowError: window must be a positive integer
567 """
569 def __init__(self, param: str) -> None:
570 """Initialize with the name of the offending window parameter."""
571 super().__init__(f"{param} must be a positive integer")
572 self.param = param
575class NonPositivePeriodsPerYearError(JQuantStatsError, ValueError):
576 """Raised when ``periods_per_year`` is not strictly positive.
578 Examples:
579 >>> raise NonPositivePeriodsPerYearError()
580 Traceback (most recent call last):
581 ...
582 jquantstats.exceptions.NonPositivePeriodsPerYearError: periods_per_year must be positive
583 """
585 def __init__(self) -> None:
586 """Initialize with the fixed non-positive periods-per-year message."""
587 super().__init__("periods_per_year must be positive")
590class BenchmarkAlignmentWarning(UserWarning):
591 """Emitted when aligning returns and benchmark drops rows from either side.
593 Returns and benchmark are aligned on their common dates with an inner
594 join. Rows whose date appears in only one of the two frames are
595 silently discarded by that join; this warning surfaces how many rows
596 were lost so a partially overlapping benchmark cannot truncate the
597 analysis unnoticed.
599 Suppress it once the overlap is understood::
601 import warnings
602 from jquantstats.exceptions import BenchmarkAlignmentWarning
604 warnings.filterwarnings("ignore", category=BenchmarkAlignmentWarning)
605 """
608class UnknownPlotBackendError(JQuantStatsError, ValueError):
609 """Raised when an unrecognised plotting backend is selected.
611 Args:
612 backend: The rejected backend name.
613 supported: The backend names that are accepted, in display order.
615 Examples:
616 >>> raise UnknownPlotBackendError("ggplot", ["matplotlib", "plotly"])
617 Traceback (most recent call last):
618 ...
619 jquantstats.exceptions.UnknownPlotBackendError: unknown plot backend 'ggplot'; ...
620 """
622 def __init__(self, backend: str, supported: list[str]) -> None:
623 """Initialize with the rejected backend and the accepted alternatives."""
624 expected = ", ".join(repr(name) for name in supported)
625 super().__init__(f"unknown plot backend {backend!r}; expected one of {expected}")
626 self.backend = backend
627 self.supported = supported
630class MissingBackendError(JQuantStatsError, ImportError):
631 """Raised when a plotting backend is selected but its library is not installed.
633 Subclasses `ImportError` as well as `JQuantStatsError` so that callers
634 already guarding optional rendering with ``except ImportError`` keep
635 working unchanged.
637 Args:
638 backend: The backend that could not be loaded.
639 extra: The packaging extra that installs it.
641 Examples:
642 >>> raise MissingBackendError("matplotlib", "mpl")
643 Traceback (most recent call last):
644 ...
645 jquantstats.exceptions.MissingBackendError: the 'matplotlib' plot backend requires matplotlib...
646 """
648 def __init__(self, backend: str, extra: str) -> None:
649 """Initialize with the unavailable backend and the extra that provides it."""
650 super().__init__(
651 f"the {backend!r} plot backend requires {backend}, which is not installed. "
652 f"Install it with: pip install 'jquantstats[{extra}]'"
653 )
654 self.backend = backend
655 self.extra = extra