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

115 statements  

« prev     ^ index     » next       coverage.py v7.17.1, created at 2026-11-03 15:52 +0000

1"""Financial returns data container and manipulation utilities.""" 

2 

3from __future__ import annotations 

4 

5import dataclasses 

6from collections.abc import Iterator 

7from datetime import timedelta 

8from typing import Literal, cast 

9 

10import polars as pl 

11 

12from ._data_reshape import _ReshapeMixin 

13from ._plots import DataPlots 

14from ._reports import Reports 

15from ._stats import Stats 

16from ._types import NativeFrame, NativeFrameOrScalar 

17from ._utils import DataUtils 

18from ._utils._construction import ( 

19 DATE_COLUMN, 

20 _align_returns_benchmark, 

21 _apply_null_strategy, 

22 _canonicalise_date_column, 

23 _prices_to_returns, 

24 _subtract_risk_free, 

25 _to_polars, 

26) 

27from ._utils._construction import ( 

28 interpolate as interpolate, # re-exported for `from jquantstats import interpolate` 

29) 

30 

31 

32@dataclasses.dataclass(frozen=True, slots=True) 

33class Data(_ReshapeMixin): 

34 """A container for financial returns data and an optional benchmark. 

35 

36 Provides methods for analyzing and manipulating financial returns data, 

37 including resampling, truncation, and access to statistical metrics and 

38 visualizations via the ``stats`` and ``plots`` properties. 

39 

40 Attributes: 

41 returns (pl.DataFrame): DataFrame containing returns data with assets 

42 as columns. 

43 benchmark (pl.DataFrame | None): Optional benchmark returns DataFrame. 

44 Defaults to None. 

45 index (pl.DataFrame): DataFrame containing the date index for the 

46 returns data. A temporal index column is always named ``'date'`` 

47 — see *Date column* below. 

48 

49 Date column: 

50 Whatever the input frames called it, a temporal index column ends up 

51 as ``'date'``, the same name the `Portfolio` internals use. So 

52 ``data.index['date']`` works on every `Data` object, however it was 

53 built, and ``Data.date_col`` stays the safest way to read the name — 

54 a date-free (integer-indexed) object keeps its own index column name. 

55 

56 """ 

57 

58 returns: pl.DataFrame 

59 index: pl.DataFrame 

60 benchmark: pl.DataFrame | None = None 

61 

62 def __post_init__(self) -> None: 

63 """Normalise the index column name and validate the Data object.""" 

64 self._canonicalise_index() 

65 self._validate_index() 

66 self._validate_row_counts() 

67 

68 def _canonicalise_index(self) -> None: 

69 """Rename the first temporal index column to ``'date'``. 

70 

71 Canonicalise here rather than in the constructors alone, so a Data built 

72 by hand (or rebuilt by a reshape) carries the same index column name. 

73 """ 

74 temporal = [name for name, dtype in self.index.schema.items() if dtype.is_temporal()] 

75 if temporal and temporal[0] != DATE_COLUMN: 

76 object.__setattr__(self, "index", self.index.rename({temporal[0]: DATE_COLUMN})) 

77 

78 def _validate_index(self) -> None: 

79 """Require at least two timestamps in monotonically increasing order. 

80 

81 Raises: 

82 ValueError: If the index has fewer than two rows or is not sorted. 

83 """ 

84 # You need at least two points 

85 if self.index.shape[0] < 2: 

86 raise ValueError("Index must contain at least two timestamps.") # noqa: TRY003 

87 

88 # Check index is monotonically increasing 

89 datetime_col = self.index[self.index.columns[0]] 

90 if not datetime_col.is_sorted(): 

91 raise ValueError("Index must be monotonically increasing.") # noqa: TRY003 

92 

93 def _validate_row_counts(self) -> None: 

94 """Require returns (and benchmark, if any) to have one row per index entry. 

95 

96 Raises: 

97 ValueError: If returns or benchmark row counts differ from the index. 

98 """ 

99 # Check row count matches returns 

100 if self.returns.shape[0] != self.index.shape[0]: 

101 raise ValueError("Returns and index must have the same number of rows.") # noqa: TRY003 

102 

103 # Check row count matches benchmark (if provided) 

104 if self.benchmark is not None and self.benchmark.shape[0] != self.index.shape[0]: 

105 raise ValueError("Benchmark and index must have the same number of rows.") # noqa: TRY003 

106 

107 @classmethod 

108 def from_returns( 

109 cls, 

110 returns: NativeFrame, 

111 rf: NativeFrameOrScalar = 0.0, 

112 benchmark: NativeFrame | None = None, 

113 date_col: str | None = None, 

114 null_strategy: Literal["raise", "drop", "forward_fill"] | None = None, 

115 ) -> Data: 

116 """Create a Data object from returns and optional benchmark. 

117 

118 Args: 

119 returns (NativeFrame): Financial returns data. First column should 

120 be the date column, remaining columns are asset returns. 

121 rf (float | NativeFrame): Risk-free rate. Defaults to 0.0 (no 

122 risk-free rate adjustment). 

123 

124 - If float: Constant risk-free rate applied to all dates. 

125 - If NativeFrame: Time-varying risk-free rate with dates 

126 matching returns. 

127 

128 benchmark (NativeFrame | None): Benchmark returns. Defaults to 

129 None (no benchmark). First column should be the date column, 

130 remaining columns are benchmark returns. Returns and 

131 benchmark are aligned on their common dates; if either frame 

132 contains dates the other lacks, those rows are dropped and a 

133 `BenchmarkAlignmentWarning` is emitted. 

134 date_col (str | None): Name of the date column in the DataFrames. 

135 Defaults to ``None``, which auto-detects the first temporal 

136 column of each frame. Pass a name to nominate a column 

137 explicitly — required for a non-temporal (e.g. integer) index, 

138 which auto-detection will not find. Whichever column is used, 

139 it is renamed to ``'date'`` on the resulting object. 

140 null_strategy ({"raise", "drop", "forward_fill"} | None): How to 

141 handle ``null`` (missing) values in *returns* and *benchmark*. 

142 Defaults to ``None`` (nulls propagate through calculations). 

143 

144 - ``None`` — no null checking; nulls propagate through all 

145 downstream calculations. 

146 - ``"raise"`` — raise `NullsInReturnsError` if any null is 

147 found. 

148 - ``"drop"`` — silently drop every row that contains at least 

149 one null. 

150 - ``"forward_fill"`` — fill each null with the most recent 

151 non-null value in the same column. 

152 

153 Note: Affects only Polars ``null`` values (i.e. ``None`` / 

154 missing entries). IEEE-754 ``NaN`` values are **not** affected 

155 and continue to propagate as per IEEE-754 semantics. 

156 

157 Returns: 

158 Data: Object containing excess returns and benchmark (if any), 

159 with methods for analysis and visualization through the ``stats`` 

160 and ``plots`` properties. 

161 

162 Raises: 

163 MissingDateColumnError: If *date_col* is not a column of 

164 *returns*, *benchmark*, or a DataFrame-valued *rf* — or, when 

165 *date_col* is ``None``, if one of those frames has no temporal 

166 column to auto-detect. Raised before any joins so the 

167 offending frame is named explicitly. 

168 NullsInReturnsError: If *null_strategy* is ``"raise"`` and the 

169 data contains null values. 

170 ValueError: If there are no overlapping dates between returns and 

171 benchmark. 

172 

173 Warns: 

174 BenchmarkAlignmentWarning: If aligning returns and benchmark on 

175 their common dates drops rows from either frame. 

176 

177 Examples: 

178 Basic usage. Note the ``Date`` column is canonicalised to ``date``: 

179 

180 >>> import polars as pl 

181 >>> from jquantstats import Data 

182 >>> returns = pl.DataFrame( 

183 ... {"Date": ["2023-01-01", "2023-01-02", "2023-01-03"], "Asset1": [0.01, -0.02, 0.03]} 

184 ... ).with_columns(pl.col("Date").str.to_date()) 

185 >>> data = Data.from_returns(returns=returns) 

186 >>> data.assets 

187 ['Asset1'] 

188 >>> data.all.columns 

189 ['date', 'Asset1'] 

190 

191 With benchmark and risk-free rate: 

192 

193 >>> benchmark = pl.DataFrame( 

194 ... {"Date": ["2023-01-01", "2023-01-02", "2023-01-03"], "Market": [0.005, -0.01, 0.02]} 

195 ... ).with_columns(pl.col("Date").str.to_date()) 

196 >>> data = Data.from_returns(returns=returns, benchmark=benchmark, rf=0.0002) 

197 >>> data.benchmark.columns 

198 ['Market'] 

199 

200 Handling nulls automatically. ``"drop"`` mirrors pandas/'uantStats 

201 behaviour and loses the offending row; ``"forward_fill"`` keeps it: 

202 

203 >>> returns_with_nulls = pl.DataFrame( 

204 ... {"Date": ["2023-01-01", "2023-01-02", "2023-01-03"], "Asset1": [0.01, None, 0.03]} 

205 ... ).with_columns(pl.col("Date").str.to_date()) 

206 >>> Data.from_returns(returns=returns_with_nulls, null_strategy="drop").returns["Asset1"].to_list() 

207 [0.01, 0.03] 

208 >>> Data.from_returns( 

209 ... returns=returns_with_nulls, null_strategy="forward_fill" 

210 ... ).returns["Asset1"].to_list() 

211 [0.01, 0.01, 0.03] 

212 

213 """ 

214 # Resolve the date column once, up front: a temporal axis is renamed to the 

215 # canonical 'date' on every frame, so the joins below — and the resulting 

216 # object — speak one column name whatever the caller's frames were labelled. 

217 # A frame-valued rf carries the same date_col, so it resolves the same way. 

218 returns_pl, resolved_col = _canonicalise_date_column(_to_polars(returns), "returns", date_col) 

219 benchmark_pl = ( 

220 _canonicalise_date_column(_to_polars(benchmark), "benchmark", date_col)[0] 

221 if benchmark is not None 

222 else None 

223 ) 

224 # accept ints (e.g. rf=0) by coercing to float 

225 rf_converted: float | pl.DataFrame = ( 

226 float(rf) if isinstance(rf, int | float) else _canonicalise_date_column(_to_polars(rf), "rf", date_col)[0] 

227 ) 

228 

229 returns_pl = _apply_null_strategy(returns_pl, resolved_col, "returns", null_strategy) 

230 if benchmark_pl is not None: 

231 benchmark_pl = _apply_null_strategy(benchmark_pl, resolved_col, "benchmark", null_strategy) 

232 returns_pl, benchmark_pl = _align_returns_benchmark(returns_pl, benchmark_pl, resolved_col) 

233 

234 index = returns_pl.select(resolved_col) 

235 excess_returns = _subtract_risk_free(returns_pl, rf_converted, resolved_col).drop(resolved_col) 

236 excess_benchmark = ( 

237 _subtract_risk_free(benchmark_pl, rf_converted, resolved_col).drop(resolved_col) 

238 if benchmark_pl is not None 

239 else None 

240 ) 

241 

242 return cls(returns=excess_returns, benchmark=excess_benchmark, index=index) 

243 

244 @classmethod 

245 def from_prices( 

246 cls, 

247 prices: NativeFrame, 

248 rf: NativeFrameOrScalar = 0.0, 

249 benchmark: NativeFrame | None = None, 

250 date_col: str | None = None, 

251 null_strategy: Literal["raise", "drop", "forward_fill"] | None = None, 

252 ) -> Data: 

253 """Create a Data object from prices and optional benchmark. 

254 

255 Converts price levels to returns via percentage change and delegates 

256 to `from_returns`. The first row of each asset is dropped because no 

257 prior price is available to compute a return. 

258 

259 Args: 

260 prices (NativeFrame): Price-level data. First column should be 

261 the date column; remaining columns are asset prices. 

262 rf (float | NativeFrame): Risk-free rate. Forwarded to 

263 `from_returns`; a frame-valued *rf* has its date column 

264 canonicalised on the way, exactly as *prices* does. Defaults 

265 to 0.0 (no risk-free rate adjustment). 

266 benchmark (NativeFrame | None): Benchmark prices. Converted to 

267 returns in the same way as ``prices`` before being forwarded 

268 to `from_returns`. Defaults to None (no benchmark). 

269 date_col (str | None): Name of the date column in the DataFrames. 

270 Defaults to ``None``, which auto-detects the first temporal 

271 column of each frame. Forwarded unchanged to `from_returns`; 

272 whichever column is used is renamed to ``'date'`` on the 

273 resulting object. 

274 null_strategy ({"raise", "drop", "forward_fill"} | None): How to 

275 handle ``null`` (missing) values after converting prices to 

276 returns. Forwarded unchanged to `from_returns`. Defaults to 

277 ``None`` (nulls propagate through calculations). 

278 

279 - ``None`` — no null checking; nulls propagate. 

280 - ``"raise"`` — raise `NullsInReturnsError` if any null is 

281 found in the derived returns. 

282 - ``"drop"`` — silently drop every row that contains at least 

283 one null. 

284 - ``"forward_fill"`` — fill each null with the most recent 

285 non-null value. 

286 

287 Note: Prices that contain nulls will produce null returns via 

288 ``pct_change()``. If you expect missing price entries, pass 

289 ``null_strategy="drop"`` or ``null_strategy="forward_fill"``. 

290 

291 Returns: 

292 Data: Object containing excess returns derived from the supplied 

293 prices, with methods for analysis and visualization through the 

294 ``stats`` and ``plots`` properties. 

295 

296 Raises: 

297 MissingDateColumnError: If *date_col* is not a column of *prices* 

298 or *benchmark* — or, when *date_col* is ``None``, if either 

299 frame has no temporal column to auto-detect. Raised before 

300 returns are derived so the offending frame is named explicitly. 

301 

302 Examples: 

303 Prices become returns, so the first row is consumed: 

304 

305 >>> import polars as pl 

306 >>> from jquantstats import Data 

307 >>> prices = pl.DataFrame( 

308 ... {"Date": ["2023-01-01", "2023-01-02", "2023-01-03"], "Asset1": [100.0, 101.0, 99.0]} 

309 ... ).with_columns(pl.col("Date").str.to_date()) 

310 >>> data = Data.from_prices(prices=prices) 

311 >>> data.returns.height 

312 2 

313 >>> [round(r, 6) for r in data.returns["Asset1"].to_list()] 

314 [0.01, -0.019802] 

315 

316 """ 

317 prices_pl, resolved_col = _canonicalise_date_column(_to_polars(prices), "prices", date_col) 

318 returns_pl = _prices_to_returns(prices_pl, resolved_col) 

319 

320 benchmark_returns: NativeFrame | None = None 

321 if benchmark is not None: 

322 benchmark_pl, _ = _canonicalise_date_column(_to_polars(benchmark), "benchmark", date_col) 

323 benchmark_returns = _prices_to_returns(benchmark_pl, resolved_col) 

324 

325 # A frame-valued rf is canonicalised here too, since the frames handed on 

326 # below are resolved already and date_col no longer describes them. 

327 rf_forwarded: NativeFrameOrScalar = ( 

328 rf if isinstance(rf, int | float) else _canonicalise_date_column(_to_polars(rf), "rf", date_col)[0] 

329 ) 

330 

331 # Naming the resolved column keeps auto-detection from picking a different 

332 # one downstream — it would not find a non-temporal (e.g. integer) index. 

333 return cls.from_returns( 

334 returns=returns_pl, 

335 rf=rf_forwarded, 

336 benchmark=benchmark_returns, 

337 date_col=resolved_col, 

338 null_strategy=null_strategy, 

339 ) 

340 

341 def __repr__(self) -> str: 

342 """Return a string representation of the Data object.""" 

343 rows = len(self.index) 

344 date_cols = self.date_col 

345 if date_cols: 

346 date_column = date_cols[0] 

347 start = self.index[date_column].min() 

348 end = self.index[date_column].max() 

349 return f"Data(assets={self.assets}, rows={rows}, start={start!s}, end={end!s})" 

350 return f"Data(assets={self.assets}, rows={rows})" # pragma: no cover # __post_init__ requires ≥1 index column 

351 

352 @property 

353 def plots(self) -> DataPlots: 

354 """Provides access to visualization methods for the financial data. 

355 

356 Returns: 

357 DataPlots: An instance of the DataPlots class initialized with this data. 

358 

359 """ 

360 return DataPlots(self) 

361 

362 @property 

363 def stats(self) -> Stats: 

364 """Provides access to statistical analysis methods for the financial data. 

365 

366 Returns: 

367 Stats: An instance of the Stats class initialized with this data. 

368 

369 """ 

370 return Stats(self) 

371 

372 @property 

373 def reports(self) -> Reports: 

374 """Provides access to reporting methods for the financial data. 

375 

376 Returns: 

377 Reports: An instance of the Reports class initialized with this data. 

378 

379 """ 

380 return Reports(self) 

381 

382 @property 

383 def utils(self) -> DataUtils: 

384 """Provides access to utility transforms and conversions for the financial data. 

385 

386 Returns: 

387 DataUtils: An instance of the DataUtils class initialized with this data. 

388 

389 """ 

390 return DataUtils(self) 

391 

392 @property 

393 def date_col(self) -> list[str]: 

394 """Return the column names of the index DataFrame. 

395 

396 Returns: 

397 list[str]: List of column names in the index DataFrame, typically containing 

398 the date column name. 

399 

400 """ 

401 return list(self.index.columns) 

402 

403 @property 

404 def assets(self) -> list[str]: 

405 """Return the combined list of asset column names from returns and benchmark. 

406 

407 Returns: 

408 list[str]: List of all asset column names from both returns and benchmark 

409 (if available). 

410 

411 """ 

412 if self.benchmark is not None: 

413 return list(self.returns.columns) + list(self.benchmark.columns) 

414 return list(self.returns.columns) 

415 

416 @property 

417 def all(self) -> pl.DataFrame: 

418 """Combine index, returns, and benchmark data into a single DataFrame. 

419 

420 This property provides a convenient way to access all data in a single DataFrame, 

421 which is useful for analysis and visualization. 

422 

423 Returns: 

424 pl.DataFrame: A DataFrame containing the index, all returns data, and benchmark data 

425 (if available) combined horizontally. 

426 

427 """ 

428 if self.benchmark is None: 

429 return pl.concat([self.index, self.returns], how="horizontal_extend") 

430 else: 

431 return pl.concat([self.index, self.returns, self.benchmark], how="horizontal_extend") 

432 

433 def describe(self) -> pl.DataFrame: 

434 """Return a tidy summary of shape, date range and asset names. 

435 

436 Returns: 

437 pl.DataFrame: One row per asset with columns: asset, start, end, 

438 rows, has_benchmark. 

439 

440 """ 

441 date_column = self.date_col[0] 

442 start = self.index[date_column].min() 

443 end = self.index[date_column].max() 

444 rows = len(self.index) 

445 return pl.DataFrame( 

446 { 

447 "asset": self.returns.columns, 

448 "start": [start] * len(self.returns.columns), 

449 "end": [end] * len(self.returns.columns), 

450 "rows": [rows] * len(self.returns.columns), 

451 "has_benchmark": [self.benchmark is not None] * len(self.returns.columns), 

452 } 

453 ) 

454 

455 @property 

456 def _periods_per_year(self) -> float: 

457 """Estimate the number of periods per year based on average frequency in the index. 

458 

459 For temporal (Date/Datetime) indices, computes the mean gap between observations 

460 and converts to an annualised period count (e.g. ~252 for daily, ~52 for weekly). 

461 

462 For integer indices (date-free portfolios), falls back to 252 trading days per year 

463 because integer diffs have no time meaning. 

464 """ 

465 datetime_col = self.index[self.index.columns[0]] 

466 

467 if not datetime_col.dtype.is_temporal(): 

468 return 252.0 

469 

470 sorted_dt = datetime_col.sort() 

471 diffs = sorted_dt.diff().drop_nulls() 

472 mean_diff = diffs.mean() 

473 

474 if isinstance(mean_diff, timedelta): 

475 seconds = mean_diff.total_seconds() 

476 else: # pragma: no cover # Polars always returns timedelta for temporal diff 

477 seconds = cast(float, mean_diff) if mean_diff is not None else 1.0 

478 

479 return (365 * 24 * 60 * 60) / seconds 

480 

481 def items(self) -> Iterator[tuple[str, pl.Series]]: 

482 """Iterate over all assets and their corresponding data series. 

483 

484 This method provides a convenient way to iterate over all assets in the data, 

485 yielding each asset name and its corresponding data series. 

486 

487 Yields: 

488 tuple[str, pl.Series]: A tuple containing the asset name and its data series. 

489 

490 """ 

491 matrix = self.all 

492 

493 for col in self.assets: 

494 yield col, matrix.get_column(col)