Coverage for src/jquantstats/exceptions.py: 100%
70 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-23 06:13 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-23 06:13 +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
19class JQuantStatsError(Exception):
20 """Base class for all JQuantStats domain errors."""
23class MissingDateColumnError(JQuantStatsError, ValueError):
24 """Raised when a required date column is absent from a DataFrame.
26 Args:
27 frame_name: Descriptive name of the frame missing the column (e.g. ``"prices"``).
28 column: Name of the date column that was looked up (e.g. the
29 ``date_col`` argument). When omitted, the default ``'date'``
30 column is assumed.
31 available: Column names actually present in the frame, included in
32 the error message to help diagnose the mismatch.
34 Examples:
35 >>> raise MissingDateColumnError("prices") # doctest: +ELLIPSIS
36 Traceback (most recent call last):
37 ...
38 jquantstats.exceptions.MissingDateColumnError: ...
39 """
41 def __init__(self, frame_name: str, column: str | None = None, available: list[str] | None = None) -> None:
42 """Initialize with the frame name and, optionally, the missing column and available columns."""
43 if column is None:
44 msg = f"DataFrame '{frame_name}' is missing the required 'date' column."
45 else:
46 cols = ", ".join(f"'{c}'" for c in available) if available else ""
47 msg = (
48 f"DataFrame '{frame_name}' has no column '{column}' to use as the date column"
49 + (f"; available columns: {cols}" if cols else "")
50 + ". Pass date_col=<name of an existing column>."
51 )
52 super().__init__(msg)
53 self.frame_name = frame_name
54 self.column = column
55 self.available = list(available) if available is not None else None
58class InvalidCashPositionTypeError(JQuantStatsError, TypeError):
59 """Raised when ``cashposition`` is not a `polars.DataFrame`.
61 Args:
62 actual_type: The ``type.__name__`` of the value that was supplied.
64 Examples:
65 >>> raise InvalidCashPositionTypeError("dict")
66 Traceback (most recent call last):
67 ...
68 jquantstats.exceptions.InvalidCashPositionTypeError: cashposition must be pl.DataFrame, got dict.
69 """
71 def __init__(self, actual_type: str) -> None:
72 """Initialize with the offending type name."""
73 super().__init__(f"cashposition must be pl.DataFrame, got {actual_type}.")
74 self.actual_type = actual_type
77class InvalidPricesTypeError(JQuantStatsError, TypeError):
78 """Raised when ``prices`` is not a `polars.DataFrame`.
80 Args:
81 actual_type: The ``type.__name__`` of the value that was supplied.
83 Examples:
84 >>> raise InvalidPricesTypeError("list")
85 Traceback (most recent call last):
86 ...
87 jquantstats.exceptions.InvalidPricesTypeError: prices must be pl.DataFrame, got list.
88 """
90 def __init__(self, actual_type: str) -> None:
91 """Initialize with the offending type name."""
92 super().__init__(f"prices must be pl.DataFrame, got {actual_type}.")
93 self.actual_type = actual_type
96class NonPositiveAumError(JQuantStatsError, ValueError):
97 """Raised when ``aum`` is not strictly positive.
99 Args:
100 aum: The non-positive value that was supplied.
102 Examples:
103 >>> raise NonPositiveAumError(0.0)
104 Traceback (most recent call last):
105 ...
106 jquantstats.exceptions.NonPositiveAumError: aum must be strictly positive, got 0.0.
107 """
109 def __init__(self, aum: float) -> None:
110 """Initialize with the offending aum value."""
111 super().__init__(f"aum must be strictly positive, got {aum}.")
112 self.aum = aum
115class RowCountMismatchError(JQuantStatsError, ValueError):
116 """Raised when ``prices`` and ``cashposition`` have different numbers of rows.
118 Args:
119 prices_rows: Number of rows in the prices DataFrame.
120 cashposition_rows: Number of rows in the cashposition DataFrame.
122 Examples:
123 >>> raise RowCountMismatchError(10, 9) # doctest: +ELLIPSIS
124 Traceback (most recent call last):
125 ...
126 jquantstats.exceptions.RowCountMismatchError: ...
127 """
129 def __init__(self, prices_rows: int, cashposition_rows: int) -> None:
130 """Initialize with the row counts of the two mismatched DataFrames."""
131 super().__init__(
132 f"cashposition and prices must have the same number of rows, "
133 f"got cashposition={cashposition_rows} and prices={prices_rows}."
134 )
135 self.prices_rows = prices_rows
136 self.cashposition_rows = cashposition_rows
139class IntegerIndexBoundError(JQuantStatsError, TypeError):
140 """Raised when a row-index bound is not an integer.
142 Args:
143 param: Name of the offending parameter (e.g. ``"start"`` or ``"end"``).
144 actual_type: The ``type.__name__`` of the value that was supplied.
146 Examples:
147 >>> raise IntegerIndexBoundError("start", "str")
148 Traceback (most recent call last):
149 ...
150 jquantstats.exceptions.IntegerIndexBoundError: start must be an integer, got str.
151 """
153 def __init__(self, param: str, actual_type: str) -> None:
154 """Initialize with the parameter name and the offending type."""
155 super().__init__(f"{param} must be an integer, got {actual_type}.")
156 self.param = param
157 self.actual_type = actual_type
160class PositionExprColumnError(JQuantStatsError, ValueError):
161 """Raised when a position expression creates columns that do not exist in prices.
163 Position expressions (``cash_position``, ``position``, ``risk_position``)
164 are evaluated against the prices frame and must overwrite existing asset
165 columns. An expression that creates a *new* column (e.g. via ``.alias``)
166 leaves the original asset columns untouched, which would silently treat
167 raw prices as positions.
169 Args:
170 param: Name of the offending parameter (e.g. ``"cash_position"``).
171 extra: Column names created by the expression that are absent from prices.
173 Examples:
174 >>> raise PositionExprColumnError("cash_position", ["A2"]) # doctest: +ELLIPSIS
175 Traceback (most recent call last):
176 ...
177 jquantstats.exceptions.PositionExprColumnError: ...
178 """
180 def __init__(self, param: str, extra: list[str]) -> None:
181 """Initialize with the parameter name and the unexpected columns it created."""
182 cols = ", ".join(f"'{c}'" for c in extra)
183 super().__init__(
184 f"{param} expression created new column(s) {cols} that do not exist in prices. "
185 f"Expressions must overwrite existing asset columns (e.g. pl.col('A') * 2); "
186 f"asset columns the expression does not overwrite keep their raw price values."
187 )
188 self.param = param
189 self.extra = list(extra)
192class NoAssetColumnsError(JQuantStatsError, ValueError):
193 """Raised when a DataFrame contains no numeric asset columns to aggregate.
195 Args:
196 frame_name: Descriptive name of the frame without asset columns (e.g. ``"profits"``).
198 Examples:
199 >>> raise NoAssetColumnsError("profits") # doctest: +ELLIPSIS
200 Traceback (most recent call last):
201 ...
202 jquantstats.exceptions.NoAssetColumnsError: ...
203 """
205 def __init__(self, frame_name: str) -> None:
206 """Initialize with the name of the frame lacking asset columns."""
207 super().__init__(
208 f"DataFrame '{frame_name}' contains no numeric asset columns; "
209 f"at least one numeric column besides 'date' is required."
210 )
211 self.frame_name = frame_name
214class NegativeCostBpsError(JQuantStatsError, ValueError):
215 """Raised when a trading cost in basis points is negative.
217 Args:
218 cost_bps: The negative cost value that was supplied.
220 Examples:
221 >>> raise NegativeCostBpsError(-1.0)
222 Traceback (most recent call last):
223 ...
224 jquantstats.exceptions.NegativeCostBpsError: cost_bps must be non-negative, got -1.0.
225 """
227 def __init__(self, cost_bps: float) -> None:
228 """Initialize with the offending cost value."""
229 super().__init__(f"cost_bps must be non-negative, got {cost_bps}.")
230 self.cost_bps = cost_bps
233class InvalidMaxBpsError(JQuantStatsError, ValueError):
234 """Raised when ``max_bps`` is not a positive integer.
236 Args:
237 max_bps: The invalid value that was supplied.
239 Examples:
240 >>> raise InvalidMaxBpsError(0)
241 Traceback (most recent call last):
242 ...
243 jquantstats.exceptions.InvalidMaxBpsError: max_bps must be a positive integer, got 0.
244 """
246 def __init__(self, max_bps: object) -> None:
247 """Initialize with the offending value."""
248 super().__init__(f"max_bps must be a positive integer, got {max_bps!r}.")
249 self.max_bps = max_bps
252class UncleanSeriesError(JQuantStatsError, ValueError):
253 """Raised when a derived series contains null or non-finite values.
255 Args:
256 name: Name of the offending series (may be empty when unknown).
257 reason: Either ``"null"`` or ``"non-finite"``.
259 Examples:
260 >>> raise UncleanSeriesError("profit", "null") # doctest: +ELLIPSIS
261 Traceback (most recent call last):
262 ...
263 jquantstats.exceptions.UncleanSeriesError: ...
264 """
266 def __init__(self, name: str, reason: str) -> None:
267 """Initialize with the series name and the kind of dirty value found."""
268 label = f"series '{name}'" if name else "series"
269 super().__init__(
270 f"{label} contains {reason} values; inputs must produce a clean, finite series. "
271 f"Check prices and positions for gaps or zero/negative prices."
272 )
273 self.name = name
274 self.reason = reason
277class MuSchemaError(JQuantStatsError, ValueError):
278 """Raised when a ``mu`` (expected-returns) frame doesn't match the portfolio's assets.
280 Args:
281 missing: Portfolio asset columns absent from the mu frame.
283 Examples:
284 >>> raise MuSchemaError(["AAPL"]) # doctest: +ELLIPSIS
285 Traceback (most recent call last):
286 ...
287 jquantstats.exceptions.MuSchemaError: ...
288 """
290 def __init__(self, missing: list[str]) -> None:
291 """Initialize with the asset columns missing from the mu frame."""
292 cols = ", ".join(f"'{c}'" for c in missing)
293 super().__init__(f"mu is missing expected-return columns for portfolio asset(s): {cols}.")
294 self.missing = missing
297class NullsInReturnsError(JQuantStatsError, ValueError):
298 """Raised when null values are detected in returns (or benchmark) data.
300 Polars propagates ``null`` through calculations whereas pandas silently
301 drops ``NaN``. Leaving nulls in place will cause most statistics to
302 return ``null`` instead of a numeric result.
304 Use the ``null_strategy`` parameter on `from_returns`
305 or `from_prices` to handle nulls automatically, or
306 clean the data before construction.
308 Args:
309 frame_name: Descriptive name of the frame that contains nulls
310 (e.g. ``"returns"`` or ``"benchmark"``).
311 columns: Names of the columns that contain at least one null.
313 Examples:
314 >>> raise NullsInReturnsError("returns", ["Asset1", "Asset2"])
315 Traceback (most recent call last):
316 ...
317 jquantstats.exceptions.NullsInReturnsError: ...
318 """
320 def __init__(self, frame_name: str, columns: list[str]) -> None:
321 """Initialize with the frame name and the columns that contain nulls."""
322 cols_str = ", ".join(f"'{c}'" for c in columns)
323 super().__init__(
324 f"DataFrame '{frame_name}' contains null values in column(s): {cols_str}. "
325 f"Pass null_strategy='drop' or null_strategy='forward_fill' to handle nulls "
326 f"automatically, or clean the data before construction."
327 )
328 self.frame_name = frame_name
329 self.columns = columns
332class BenchmarkAlignmentWarning(UserWarning):
333 """Emitted when aligning returns and benchmark drops rows from either side.
335 Returns and benchmark are aligned on their common dates with an inner
336 join. Rows whose date appears in only one of the two frames are
337 silently discarded by that join; this warning surfaces how many rows
338 were lost so a partially overlapping benchmark cannot truncate the
339 analysis unnoticed.
341 Suppress it once the overlap is understood::
343 import warnings
344 from jquantstats.exceptions import BenchmarkAlignmentWarning
346 warnings.filterwarnings("ignore", category=BenchmarkAlignmentWarning)
347 """