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

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 a frame aligned with it 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 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. 

167 

168 Examples: 

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

170 Traceback (most recent call last): 

171 ... 

172 jquantstats.exceptions.RowCountMismatchError: ... 

173 """ 

174 

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 

183 

184 

185class IntegerIndexBoundError(JQuantStatsError, TypeError): 

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

187 

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. 

191 

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

198 

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 

204 

205 

206class InvalidTruncateBoundError(JQuantStatsError, ValueError): 

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

208 

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. 

213 

214 Args: 

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

216 value: The value that was supplied. 

217 

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

225 

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 

231 

232 

233class MixedTruncateBoundsError(JQuantStatsError, TypeError): 

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

235 

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. 

238 

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. 

242 

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

250 

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 

259 

260 

261class PositionExprColumnError(JQuantStatsError, ValueError): 

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

263 

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. 

269 

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. 

273 

274 Examples: 

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

276 Traceback (most recent call last): 

277 ... 

278 jquantstats.exceptions.PositionExprColumnError: ... 

279 """ 

280 

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) 

291 

292 

293class WeightsColumnError(JQuantStatsError, ValueError): 

294 """Raised when a target-weights frame names assets that are not in prices. 

295 

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. 

299 

300 Args: 

301 unknown: Weight column names that have no matching price column. 

302 

303 Examples: 

304 >>> raise WeightsColumnError(["C"]) # doctest: +ELLIPSIS 

305 Traceback (most recent call last): 

306 ... 

307 jquantstats.exceptions.WeightsColumnError: ... 

308 """ 

309 

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) 

315 

316 

317class RebalancePriceError(JQuantStatsError, ValueError): 

318 """Raised when a held or targeted asset has no usable price on a rebalancing row. 

319 

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. 

323 

324 Args: 

325 row: 0-based row index of the rebalancing. 

326 assets: Assets that are held or targeted but have no usable price. 

327 

328 Examples: 

329 >>> raise RebalancePriceError(3, ["A"]) # doctest: +ELLIPSIS 

330 Traceback (most recent call last): 

331 ... 

332 jquantstats.exceptions.RebalancePriceError: ... 

333 """ 

334 

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) 

344 

345 

346class NoAssetColumnsError(JQuantStatsError, ValueError): 

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

348 

349 Args: 

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

351 

352 Examples: 

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

354 Traceback (most recent call last): 

355 ... 

356 jquantstats.exceptions.NoAssetColumnsError: ... 

357 """ 

358 

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 

366 

367 

368class NegativeCostBpsError(JQuantStatsError, ValueError): 

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

370 

371 Args: 

372 cost_bps: The negative cost value that was supplied. 

373 

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

380 

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 

385 

386 

387class NegativeAnnualFeeError(JQuantStatsError, ValueError): 

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

389 

390 Args: 

391 annual_fee: The negative fee value that was supplied. 

392 

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

399 

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 

404 

405 

406class InvalidMaxBpsError(JQuantStatsError, ValueError): 

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

408 

409 Args: 

410 max_bps: The invalid value that was supplied. 

411 

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

418 

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 

423 

424 

425class UncleanSeriesError(JQuantStatsError, ValueError): 

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

427 

428 Args: 

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

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

431 

432 Examples: 

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

434 Traceback (most recent call last): 

435 ... 

436 jquantstats.exceptions.UncleanSeriesError: ... 

437 """ 

438 

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 

448 

449 

450class MissingReturnsColumnError(JQuantStatsError, ValueError): 

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

452 

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. 

457 

458 Args: 

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

460 error message to help diagnose the mismatch. 

461 

462 Examples: 

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

464 Traceback (most recent call last): 

465 ... 

466 jquantstats.exceptions.MissingReturnsColumnError: ... 

467 """ 

468 

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 

480 

481 

482class MuSchemaError(JQuantStatsError, ValueError): 

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

484 

485 Args: 

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

487 

488 Examples: 

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

490 Traceback (most recent call last): 

491 ... 

492 jquantstats.exceptions.MuSchemaError: ... 

493 """ 

494 

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 

500 

501 

502class NullsInReturnsError(JQuantStatsError, ValueError): 

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

504 

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. 

508 

509 Use the ``null_strategy`` parameter on `from_returns` 

510 or `from_prices` to handle nulls automatically, or 

511 clean the data before construction. 

512 

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. 

517 

518 Examples: 

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

520 Traceback (most recent call last): 

521 ... 

522 jquantstats.exceptions.NullsInReturnsError: ... 

523 """ 

524 

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 

535 

536 

537class NoBenchmarkError(JQuantStatsError, AttributeError): 

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

539 

540 Subclasses `AttributeError` so existing callers that catch 

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

542 

543 Examples: 

544 >>> raise NoBenchmarkError() 

545 Traceback (most recent call last): 

546 ... 

547 jquantstats.exceptions.NoBenchmarkError: No benchmark data available 

548 """ 

549 

550 def __init__(self) -> None: 

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

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

553 

554 

555class NonPositiveWindowError(JQuantStatsError, ValueError): 

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

557 

558 Args: 

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

560 ``"rolling_period"``). 

561 

562 Examples: 

563 >>> raise NonPositiveWindowError("window") 

564 Traceback (most recent call last): 

565 ... 

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

567 """ 

568 

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 

573 

574 

575class NonPositivePeriodsPerYearError(JQuantStatsError, ValueError): 

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

577 

578 Examples: 

579 >>> raise NonPositivePeriodsPerYearError() 

580 Traceback (most recent call last): 

581 ... 

582 jquantstats.exceptions.NonPositivePeriodsPerYearError: periods_per_year must be positive 

583 """ 

584 

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

588 

589 

590class BenchmarkAlignmentWarning(UserWarning): 

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

592 

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. 

598 

599 Suppress it once the overlap is understood:: 

600 

601 import warnings 

602 from jquantstats.exceptions import BenchmarkAlignmentWarning 

603 

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

605 """ 

606 

607 

608class UnknownPlotBackendError(JQuantStatsError, ValueError): 

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

610 

611 Args: 

612 backend: The rejected backend name. 

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

614 

615 Examples: 

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

617 Traceback (most recent call last): 

618 ... 

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

620 """ 

621 

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 

628 

629 

630class MissingBackendError(JQuantStatsError, ImportError): 

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

632 

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

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

635 working unchanged. 

636 

637 Args: 

638 backend: The backend that could not be loaded. 

639 extra: The packaging extra that installs it. 

640 

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

647 

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