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

1"""Domain-specific exception types for the jquantstats package. 

2 

3This module defines a hierarchy of exceptions that provide meaningful context 

4when data-validation errors occur within the package. 

5 

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. 

11 

12Argument validation is deliberately *not* in this hierarchy 

13----------------------------------------------------------- 

14 

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. 

19 

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. 

29 

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. 

37 

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. 

42 

43Examples: 

44 >>> raise MissingDateColumnError("prices") # doctest: +ELLIPSIS 

45 Traceback (most recent call last): 

46 ... 

47 jquantstats.exceptions.MissingDateColumnError: ... 

48""" 

49 

50from __future__ import annotations 

51 

52from typing import Any 

53 

54 

55class JQuantStatsError(Exception): 

56 """Base class for all JQuantStats domain errors.""" 

57 

58 

59class MissingDateColumnError(JQuantStatsError, ValueError): 

60 """Raised when a required date column is absent from a DataFrame. 

61 

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. 

71 

72 Examples: 

73 >>> raise MissingDateColumnError("prices") # doctest: +ELLIPSIS 

74 Traceback (most recent call last): 

75 ... 

76 jquantstats.exceptions.MissingDateColumnError: ... 

77 """ 

78 

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 

100 

101 

102class InvalidCashPositionTypeError(JQuantStatsError, TypeError): 

103 """Raised when ``cashposition`` is not a `polars.DataFrame`. 

104 

105 Args: 

106 actual_type: The ``type.__name__`` of the value that was supplied. 

107 

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 """ 

114 

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 

119 

120 

121class InvalidPricesTypeError(JQuantStatsError, TypeError): 

122 """Raised when ``prices`` is not a `polars.DataFrame`. 

123 

124 Args: 

125 actual_type: The ``type.__name__`` of the value that was supplied. 

126 

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 """ 

133 

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 

138 

139 

140class NonPositiveAumError(JQuantStatsError, ValueError): 

141 """Raised when ``aum`` is not strictly positive. 

142 

143 Args: 

144 aum: The non-positive value that was supplied. 

145 

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 """ 

152 

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 

157 

158 

159class RowCountMismatchError(JQuantStatsError, ValueError): 

160 """Raised when ``prices`` and ``cashposition`` have different numbers of rows. 

161 

162 Args: 

163 prices_rows: Number of rows in the prices DataFrame. 

164 cashposition_rows: Number of rows in the cashposition DataFrame. 

165 

166 Examples: 

167 >>> raise RowCountMismatchError(10, 9) # doctest: +ELLIPSIS 

168 Traceback (most recent call last): 

169 ... 

170 jquantstats.exceptions.RowCountMismatchError: ... 

171 """ 

172 

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 

181 

182 

183class IntegerIndexBoundError(JQuantStatsError, TypeError): 

184 """Raised when a row-index bound is not an integer. 

185 

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. 

189 

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 """ 

196 

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 

202 

203 

204class InvalidTruncateBoundError(JQuantStatsError, ValueError): 

205 """Raised when a truncation bound is neither a row index nor a usable date. 

206 

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. 

211 

212 Args: 

213 param: Name of the offending parameter (e.g. ``"start"`` or ``"end"``). 

214 value: The value that was supplied. 

215 

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 """ 

223 

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 

229 

230 

231class MixedTruncateBoundsError(JQuantStatsError, TypeError): 

232 """Raised when ``start`` and ``end`` mix a row index with a date. 

233 

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. 

236 

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. 

240 

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 """ 

248 

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 

257 

258 

259class PositionExprColumnError(JQuantStatsError, ValueError): 

260 """Raised when a position expression creates columns that do not exist in prices. 

261 

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. 

267 

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. 

271 

272 Examples: 

273 >>> raise PositionExprColumnError("cash_position", ["A2"]) # doctest: +ELLIPSIS 

274 Traceback (most recent call last): 

275 ... 

276 jquantstats.exceptions.PositionExprColumnError: ... 

277 """ 

278 

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) 

289 

290 

291class NoAssetColumnsError(JQuantStatsError, ValueError): 

292 """Raised when a DataFrame contains no numeric asset columns to aggregate. 

293 

294 Args: 

295 frame_name: Descriptive name of the frame without asset columns (e.g. ``"profits"``). 

296 

297 Examples: 

298 >>> raise NoAssetColumnsError("profits") # doctest: +ELLIPSIS 

299 Traceback (most recent call last): 

300 ... 

301 jquantstats.exceptions.NoAssetColumnsError: ... 

302 """ 

303 

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 

311 

312 

313class NegativeCostBpsError(JQuantStatsError, ValueError): 

314 """Raised when a trading cost in basis points is negative. 

315 

316 Args: 

317 cost_bps: The negative cost value that was supplied. 

318 

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 """ 

325 

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 

330 

331 

332class NegativeAnnualFeeError(JQuantStatsError, ValueError): 

333 """Raised when an annual management fee is negative. 

334 

335 Args: 

336 annual_fee: The negative fee value that was supplied. 

337 

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 """ 

344 

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 

349 

350 

351class InvalidMaxBpsError(JQuantStatsError, ValueError): 

352 """Raised when ``max_bps`` is not a positive integer. 

353 

354 Args: 

355 max_bps: The invalid value that was supplied. 

356 

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 """ 

363 

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 

368 

369 

370class UncleanSeriesError(JQuantStatsError, ValueError): 

371 """Raised when a derived series contains null or non-finite values. 

372 

373 Args: 

374 name: Name of the offending series (may be empty when unknown). 

375 reason: Either ``"null"`` or ``"non-finite"``. 

376 

377 Examples: 

378 >>> raise UncleanSeriesError("profit", "null") # doctest: +ELLIPSIS 

379 Traceback (most recent call last): 

380 ... 

381 jquantstats.exceptions.UncleanSeriesError: ... 

382 """ 

383 

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 

393 

394 

395class MissingReturnsColumnError(JQuantStatsError, ValueError): 

396 """Raised when a frame handed to the returns bridge has no ``'returns'`` column. 

397 

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. 

402 

403 Args: 

404 available: Column names actually present in the frame, included in the 

405 error message to help diagnose the mismatch. 

406 

407 Examples: 

408 >>> raise MissingReturnsColumnError(["date", "profit"]) # doctest: +ELLIPSIS 

409 Traceback (most recent call last): 

410 ... 

411 jquantstats.exceptions.MissingReturnsColumnError: ... 

412 """ 

413 

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 

425 

426 

427class MuSchemaError(JQuantStatsError, ValueError): 

428 """Raised when a ``mu`` (expected-returns) frame doesn't match the portfolio's assets. 

429 

430 Args: 

431 missing: Portfolio asset columns absent from the mu frame. 

432 

433 Examples: 

434 >>> raise MuSchemaError(["AAPL"]) # doctest: +ELLIPSIS 

435 Traceback (most recent call last): 

436 ... 

437 jquantstats.exceptions.MuSchemaError: ... 

438 """ 

439 

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 

445 

446 

447class NullsInReturnsError(JQuantStatsError, ValueError): 

448 """Raised when null values are detected in returns (or benchmark) data. 

449 

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. 

453 

454 Use the ``null_strategy`` parameter on `from_returns` 

455 or `from_prices` to handle nulls automatically, or 

456 clean the data before construction. 

457 

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. 

462 

463 Examples: 

464 >>> raise NullsInReturnsError("returns", ["Asset1", "Asset2"]) 

465 Traceback (most recent call last): 

466 ... 

467 jquantstats.exceptions.NullsInReturnsError: ... 

468 """ 

469 

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 

480 

481 

482class NoBenchmarkError(JQuantStatsError, AttributeError): 

483 """Raised when a benchmark-dependent statistic is requested without a benchmark. 

484 

485 Subclasses `AttributeError` so existing callers that catch 

486 ``AttributeError`` for the no-benchmark path keep working unchanged. 

487 

488 Examples: 

489 >>> raise NoBenchmarkError() 

490 Traceback (most recent call last): 

491 ... 

492 jquantstats.exceptions.NoBenchmarkError: No benchmark data available 

493 """ 

494 

495 def __init__(self) -> None: 

496 """Initialize with the fixed no-benchmark message.""" 

497 super().__init__("No benchmark data available") 

498 

499 

500class NonPositiveWindowError(JQuantStatsError, ValueError): 

501 """Raised when a rolling-window size is not a positive integer. 

502 

503 Args: 

504 param: Name of the offending parameter (e.g. ``"window"`` or 

505 ``"rolling_period"``). 

506 

507 Examples: 

508 >>> raise NonPositiveWindowError("window") 

509 Traceback (most recent call last): 

510 ... 

511 jquantstats.exceptions.NonPositiveWindowError: window must be a positive integer 

512 """ 

513 

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 

518 

519 

520class NonPositivePeriodsPerYearError(JQuantStatsError, ValueError): 

521 """Raised when ``periods_per_year`` is not strictly positive. 

522 

523 Examples: 

524 >>> raise NonPositivePeriodsPerYearError() 

525 Traceback (most recent call last): 

526 ... 

527 jquantstats.exceptions.NonPositivePeriodsPerYearError: periods_per_year must be positive 

528 """ 

529 

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") 

533 

534 

535class BenchmarkAlignmentWarning(UserWarning): 

536 """Emitted when aligning returns and benchmark drops rows from either side. 

537 

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. 

543 

544 Suppress it once the overlap is understood:: 

545 

546 import warnings 

547 from jquantstats.exceptions import BenchmarkAlignmentWarning 

548 

549 warnings.filterwarnings("ignore", category=BenchmarkAlignmentWarning) 

550 """ 

551 

552 

553class UnknownPlotBackendError(JQuantStatsError, ValueError): 

554 """Raised when an unrecognised plotting backend is selected. 

555 

556 Args: 

557 backend: The rejected backend name. 

558 supported: The backend names that are accepted, in display order. 

559 

560 Examples: 

561 >>> raise UnknownPlotBackendError("ggplot", ["matplotlib", "plotly"]) 

562 Traceback (most recent call last): 

563 ... 

564 jquantstats.exceptions.UnknownPlotBackendError: unknown plot backend 'ggplot'; ... 

565 """ 

566 

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 

573 

574 

575class MissingBackendError(JQuantStatsError, ImportError): 

576 """Raised when a plotting backend is selected but its library is not installed. 

577 

578 Subclasses `ImportError` as well as `JQuantStatsError` so that callers 

579 already guarding optional rendering with ``except ImportError`` keep 

580 working unchanged. 

581 

582 Args: 

583 backend: The backend that could not be loaded. 

584 extra: The packaging extra that installs it. 

585 

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 """ 

592 

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