Coverage for src/jquantstats/exceptions.py: 100%

86 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-06 04: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. 

8 

9Examples: 

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

11 Traceback (most recent call last): 

12 ... 

13 jquantstats.exceptions.MissingDateColumnError: ... 

14""" 

15 

16from __future__ import annotations 

17 

18from typing import Any 

19 

20 

21class JQuantStatsError(Exception): 

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

23 

24 

25class MissingDateColumnError(JQuantStatsError, ValueError): 

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

27 

28 Args: 

29 frame_name: Descriptive name of the frame missing the column (e.g. ``"prices"``). 

30 column: Name of the date column that was looked up (e.g. the 

31 ``date_col`` argument). When omitted, the default ``'date'`` 

32 column is assumed. 

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

34 the error message to help diagnose the mismatch. 

35 

36 Examples: 

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

38 Traceback (most recent call last): 

39 ... 

40 jquantstats.exceptions.MissingDateColumnError: ... 

41 """ 

42 

43 def __init__(self, frame_name: str, column: str | None = None, available: list[str] | None = None) -> None: 

44 """Initialize with the frame name and, optionally, the missing column and available columns.""" 

45 available = [] if available is None else list(available) 

46 if column is None: 

47 msg = f"DataFrame '{frame_name}' is missing the required 'date' column." 

48 else: 

49 cols = ", ".join(f"'{c}'" for c in available) if available else "" 

50 msg = ( 

51 f"DataFrame '{frame_name}' has no column '{column}' to use as the date column" 

52 + (f"; available columns: {cols}" if cols else "") 

53 + ". Pass date_col=<name of an existing column>." 

54 ) 

55 super().__init__(msg) 

56 self.frame_name = frame_name 

57 self.column = column 

58 self.available = available 

59 

60 

61class InvalidCashPositionTypeError(JQuantStatsError, TypeError): 

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

63 

64 Args: 

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

66 

67 Examples: 

68 >>> raise InvalidCashPositionTypeError("dict") 

69 Traceback (most recent call last): 

70 ... 

71 jquantstats.exceptions.InvalidCashPositionTypeError: cashposition must be pl.DataFrame, got dict. 

72 """ 

73 

74 def __init__(self, actual_type: str) -> None: 

75 """Initialize with the offending type name.""" 

76 super().__init__(f"cashposition must be pl.DataFrame, got {actual_type}.") 

77 self.actual_type = actual_type 

78 

79 

80class InvalidPricesTypeError(JQuantStatsError, TypeError): 

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

82 

83 Args: 

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

85 

86 Examples: 

87 >>> raise InvalidPricesTypeError("list") 

88 Traceback (most recent call last): 

89 ... 

90 jquantstats.exceptions.InvalidPricesTypeError: prices must be pl.DataFrame, got list. 

91 """ 

92 

93 def __init__(self, actual_type: str) -> None: 

94 """Initialize with the offending type name.""" 

95 super().__init__(f"prices must be pl.DataFrame, got {actual_type}.") 

96 self.actual_type = actual_type 

97 

98 

99class NonPositiveAumError(JQuantStatsError, ValueError): 

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

101 

102 Args: 

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

104 

105 Examples: 

106 >>> raise NonPositiveAumError(0.0) 

107 Traceback (most recent call last): 

108 ... 

109 jquantstats.exceptions.NonPositiveAumError: aum must be strictly positive, got 0.0. 

110 """ 

111 

112 def __init__(self, aum: float) -> None: 

113 """Initialize with the offending aum value.""" 

114 super().__init__(f"aum must be strictly positive, got {aum}.") 

115 self.aum = aum 

116 

117 

118class RowCountMismatchError(JQuantStatsError, ValueError): 

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

120 

121 Args: 

122 prices_rows: Number of rows in the prices DataFrame. 

123 cashposition_rows: Number of rows in the cashposition DataFrame. 

124 

125 Examples: 

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

127 Traceback (most recent call last): 

128 ... 

129 jquantstats.exceptions.RowCountMismatchError: ... 

130 """ 

131 

132 def __init__(self, prices_rows: int, cashposition_rows: int) -> None: 

133 """Initialize with the row counts of the two mismatched DataFrames.""" 

134 super().__init__( 

135 f"cashposition and prices must have the same number of rows, " 

136 f"got cashposition={cashposition_rows} and prices={prices_rows}." 

137 ) 

138 self.prices_rows = prices_rows 

139 self.cashposition_rows = cashposition_rows 

140 

141 

142class IntegerIndexBoundError(JQuantStatsError, TypeError): 

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

144 

145 Args: 

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

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

148 

149 Examples: 

150 >>> raise IntegerIndexBoundError("start", "str") 

151 Traceback (most recent call last): 

152 ... 

153 jquantstats.exceptions.IntegerIndexBoundError: start must be an integer, got str. 

154 """ 

155 

156 def __init__(self, param: str, actual_type: str) -> None: 

157 """Initialize with the parameter name and the offending type.""" 

158 super().__init__(f"{param} must be an integer, got {actual_type}.") 

159 self.param = param 

160 self.actual_type = actual_type 

161 

162 

163class PositionExprColumnError(JQuantStatsError, ValueError): 

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

165 

166 Position expressions (``cash_position``, ``position``, ``risk_position``) 

167 are evaluated against the prices frame and must overwrite existing asset 

168 columns. An expression that creates a *new* column (e.g. via ``.alias``) 

169 leaves the original asset columns untouched, which would silently treat 

170 raw prices as positions. 

171 

172 Args: 

173 param: Name of the offending parameter (e.g. ``"cash_position"``). 

174 extra: Column names created by the expression that are absent from prices. 

175 

176 Examples: 

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

178 Traceback (most recent call last): 

179 ... 

180 jquantstats.exceptions.PositionExprColumnError: ... 

181 """ 

182 

183 def __init__(self, param: str, extra: list[str]) -> None: 

184 """Initialize with the parameter name and the unexpected columns it created.""" 

185 cols = ", ".join(f"'{c}'" for c in extra) 

186 super().__init__( 

187 f"{param} expression created new column(s) {cols} that do not exist in prices. " 

188 f"Expressions must overwrite existing asset columns (e.g. pl.col('A') * 2); " 

189 f"asset columns the expression does not overwrite keep their raw price values." 

190 ) 

191 self.param = param 

192 self.extra = list(extra) 

193 

194 

195class NoAssetColumnsError(JQuantStatsError, ValueError): 

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

197 

198 Args: 

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

200 

201 Examples: 

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

203 Traceback (most recent call last): 

204 ... 

205 jquantstats.exceptions.NoAssetColumnsError: ... 

206 """ 

207 

208 def __init__(self, frame_name: str) -> None: 

209 """Initialize with the name of the frame lacking asset columns.""" 

210 super().__init__( 

211 f"DataFrame '{frame_name}' contains no numeric asset columns; " 

212 f"at least one numeric column besides 'date' is required." 

213 ) 

214 self.frame_name = frame_name 

215 

216 

217class NegativeCostBpsError(JQuantStatsError, ValueError): 

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

219 

220 Args: 

221 cost_bps: The negative cost value that was supplied. 

222 

223 Examples: 

224 >>> raise NegativeCostBpsError(-1.0) 

225 Traceback (most recent call last): 

226 ... 

227 jquantstats.exceptions.NegativeCostBpsError: cost_bps must be non-negative, got -1.0. 

228 """ 

229 

230 def __init__(self, cost_bps: float) -> None: 

231 """Initialize with the offending cost value.""" 

232 super().__init__(f"cost_bps must be non-negative, got {cost_bps}.") 

233 self.cost_bps = cost_bps 

234 

235 

236class NegativeAnnualFeeError(JQuantStatsError, ValueError): 

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

238 

239 Args: 

240 annual_fee: The negative fee value that was supplied. 

241 

242 Examples: 

243 >>> raise NegativeAnnualFeeError(-0.01) 

244 Traceback (most recent call last): 

245 ... 

246 jquantstats.exceptions.NegativeAnnualFeeError: annual_fee must be non-negative, got -0.01. 

247 """ 

248 

249 def __init__(self, annual_fee: float) -> None: 

250 """Initialize with the offending fee value.""" 

251 super().__init__(f"annual_fee must be non-negative, got {annual_fee}.") 

252 self.annual_fee = annual_fee 

253 

254 

255class InvalidMaxBpsError(JQuantStatsError, ValueError): 

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

257 

258 Args: 

259 max_bps: The invalid value that was supplied. 

260 

261 Examples: 

262 >>> raise InvalidMaxBpsError(0) 

263 Traceback (most recent call last): 

264 ... 

265 jquantstats.exceptions.InvalidMaxBpsError: max_bps must be a positive integer, got 0. 

266 """ 

267 

268 def __init__(self, max_bps: Any) -> None: 

269 """Initialize with the offending value.""" 

270 super().__init__(f"max_bps must be a positive integer, got {max_bps!r}.") 

271 self.max_bps = max_bps 

272 

273 

274class UncleanSeriesError(JQuantStatsError, ValueError): 

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

276 

277 Args: 

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

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

280 

281 Examples: 

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

283 Traceback (most recent call last): 

284 ... 

285 jquantstats.exceptions.UncleanSeriesError: ... 

286 """ 

287 

288 def __init__(self, name: str, reason: str) -> None: 

289 """Initialize with the series name and the kind of dirty value found.""" 

290 label = f"series '{name}'" if name else "series" 

291 super().__init__( 

292 f"{label} contains {reason} values; inputs must produce a clean, finite series. " 

293 f"Check prices and positions for gaps or zero/negative prices." 

294 ) 

295 self.name = name 

296 self.reason = reason 

297 

298 

299class MuSchemaError(JQuantStatsError, ValueError): 

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

301 

302 Args: 

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

304 

305 Examples: 

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

307 Traceback (most recent call last): 

308 ... 

309 jquantstats.exceptions.MuSchemaError: ... 

310 """ 

311 

312 def __init__(self, missing: list[str]) -> None: 

313 """Initialize with the asset columns missing from the mu frame.""" 

314 cols = ", ".join(f"'{c}'" for c in missing) 

315 super().__init__(f"mu is missing expected-return columns for portfolio asset(s): {cols}.") 

316 self.missing = missing 

317 

318 

319class NullsInReturnsError(JQuantStatsError, ValueError): 

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

321 

322 Polars propagates ``null`` through calculations whereas pandas silently 

323 drops ``NaN``. Leaving nulls in place will cause most statistics to 

324 return ``null`` instead of a numeric result. 

325 

326 Use the ``null_strategy`` parameter on `from_returns` 

327 or `from_prices` to handle nulls automatically, or 

328 clean the data before construction. 

329 

330 Args: 

331 frame_name: Descriptive name of the frame that contains nulls 

332 (e.g. ``"returns"`` or ``"benchmark"``). 

333 columns: Names of the columns that contain at least one null. 

334 

335 Examples: 

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

337 Traceback (most recent call last): 

338 ... 

339 jquantstats.exceptions.NullsInReturnsError: ... 

340 """ 

341 

342 def __init__(self, frame_name: str, columns: list[str]) -> None: 

343 """Initialize with the frame name and the columns that contain nulls.""" 

344 cols_str = ", ".join(f"'{c}'" for c in columns) 

345 super().__init__( 

346 f"DataFrame '{frame_name}' contains null values in column(s): {cols_str}. " 

347 f"Pass null_strategy='drop' or null_strategy='forward_fill' to handle nulls " 

348 f"automatically, or clean the data before construction." 

349 ) 

350 self.frame_name = frame_name 

351 self.columns = columns 

352 

353 

354class NoBenchmarkError(JQuantStatsError, AttributeError): 

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

356 

357 Subclasses `AttributeError` so existing callers that catch 

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

359 

360 Examples: 

361 >>> raise NoBenchmarkError() 

362 Traceback (most recent call last): 

363 ... 

364 jquantstats.exceptions.NoBenchmarkError: No benchmark data available 

365 """ 

366 

367 def __init__(self) -> None: 

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

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

370 

371 

372class NonPositiveWindowError(JQuantStatsError, ValueError): 

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

374 

375 Args: 

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

377 ``"rolling_period"``). 

378 

379 Examples: 

380 >>> raise NonPositiveWindowError("window") 

381 Traceback (most recent call last): 

382 ... 

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

384 """ 

385 

386 def __init__(self, param: str) -> None: 

387 """Initialize with the name of the offending window parameter.""" 

388 super().__init__(f"{param} must be a positive integer") 

389 self.param = param 

390 

391 

392class NonPositivePeriodsPerYearError(JQuantStatsError, ValueError): 

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

394 

395 Examples: 

396 >>> raise NonPositivePeriodsPerYearError() 

397 Traceback (most recent call last): 

398 ... 

399 jquantstats.exceptions.NonPositivePeriodsPerYearError: periods_per_year must be positive 

400 """ 

401 

402 def __init__(self) -> None: 

403 """Initialize with the fixed non-positive periods-per-year message.""" 

404 super().__init__("periods_per_year must be positive") 

405 

406 

407class BenchmarkAlignmentWarning(UserWarning): 

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

409 

410 Returns and benchmark are aligned on their common dates with an inner 

411 join. Rows whose date appears in only one of the two frames are 

412 silently discarded by that join; this warning surfaces how many rows 

413 were lost so a partially overlapping benchmark cannot truncate the 

414 analysis unnoticed. 

415 

416 Suppress it once the overlap is understood:: 

417 

418 import warnings 

419 from jquantstats.exceptions import BenchmarkAlignmentWarning 

420 

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

422 """