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

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 

18 

19class JQuantStatsError(Exception): 

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

21 

22 

23class MissingDateColumnError(JQuantStatsError, ValueError): 

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

25 

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. 

33 

34 Examples: 

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

36 Traceback (most recent call last): 

37 ... 

38 jquantstats.exceptions.MissingDateColumnError: ... 

39 """ 

40 

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 

56 

57 

58class InvalidCashPositionTypeError(JQuantStatsError, TypeError): 

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

60 

61 Args: 

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

63 

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

70 

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 

75 

76 

77class InvalidPricesTypeError(JQuantStatsError, TypeError): 

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

79 

80 Args: 

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

82 

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

89 

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 

94 

95 

96class NonPositiveAumError(JQuantStatsError, ValueError): 

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

98 

99 Args: 

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

101 

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

108 

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 

113 

114 

115class RowCountMismatchError(JQuantStatsError, ValueError): 

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

117 

118 Args: 

119 prices_rows: Number of rows in the prices DataFrame. 

120 cashposition_rows: Number of rows in the cashposition DataFrame. 

121 

122 Examples: 

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

124 Traceback (most recent call last): 

125 ... 

126 jquantstats.exceptions.RowCountMismatchError: ... 

127 """ 

128 

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 

137 

138 

139class IntegerIndexBoundError(JQuantStatsError, TypeError): 

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

141 

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. 

145 

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

152 

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 

158 

159 

160class PositionExprColumnError(JQuantStatsError, ValueError): 

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

162 

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. 

168 

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. 

172 

173 Examples: 

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

175 Traceback (most recent call last): 

176 ... 

177 jquantstats.exceptions.PositionExprColumnError: ... 

178 """ 

179 

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) 

190 

191 

192class NoAssetColumnsError(JQuantStatsError, ValueError): 

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

194 

195 Args: 

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

197 

198 Examples: 

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

200 Traceback (most recent call last): 

201 ... 

202 jquantstats.exceptions.NoAssetColumnsError: ... 

203 """ 

204 

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 

212 

213 

214class NegativeCostBpsError(JQuantStatsError, ValueError): 

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

216 

217 Args: 

218 cost_bps: The negative cost value that was supplied. 

219 

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

226 

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 

231 

232 

233class InvalidMaxBpsError(JQuantStatsError, ValueError): 

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

235 

236 Args: 

237 max_bps: The invalid value that was supplied. 

238 

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

245 

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 

250 

251 

252class UncleanSeriesError(JQuantStatsError, ValueError): 

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

254 

255 Args: 

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

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

258 

259 Examples: 

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

261 Traceback (most recent call last): 

262 ... 

263 jquantstats.exceptions.UncleanSeriesError: ... 

264 """ 

265 

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 

275 

276 

277class MuSchemaError(JQuantStatsError, ValueError): 

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

279 

280 Args: 

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

282 

283 Examples: 

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

285 Traceback (most recent call last): 

286 ... 

287 jquantstats.exceptions.MuSchemaError: ... 

288 """ 

289 

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 

295 

296 

297class NullsInReturnsError(JQuantStatsError, ValueError): 

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

299 

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. 

303 

304 Use the ``null_strategy`` parameter on `from_returns` 

305 or `from_prices` to handle nulls automatically, or 

306 clean the data before construction. 

307 

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. 

312 

313 Examples: 

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

315 Traceback (most recent call last): 

316 ... 

317 jquantstats.exceptions.NullsInReturnsError: ... 

318 """ 

319 

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 

330 

331 

332class BenchmarkAlignmentWarning(UserWarning): 

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

334 

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. 

340 

341 Suppress it once the overlap is understood:: 

342 

343 import warnings 

344 from jquantstats.exceptions import BenchmarkAlignmentWarning 

345 

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

347 """