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

109 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-23 04:11 +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 # Canonicalise here rather than in the constructors alone, so a Data built 

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

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

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

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

69 

70 # You need at least two points 

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

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

73 

74 # Check index is monotonically increasing 

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

76 if not datetime_col.is_sorted(): 

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

78 

79 # Check row count matches returns 

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

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

82 

83 # Check row count matches benchmark (if provided) 

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

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

86 

87 @classmethod 

88 def from_returns( 

89 cls, 

90 returns: NativeFrame, 

91 rf: NativeFrameOrScalar = 0.0, 

92 benchmark: NativeFrame | None = None, 

93 date_col: str | None = None, 

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

95 ) -> Data: 

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

97 

98 Args: 

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

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

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

102 risk-free rate adjustment). 

103 

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

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

106 matching returns. 

107 

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

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

110 remaining columns are benchmark returns. Returns and 

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

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

113 `BenchmarkAlignmentWarning` is emitted. 

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

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

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

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

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

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

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

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

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

123 

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

125 downstream calculations. 

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

127 found. 

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

129 one null. 

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

131 non-null value in the same column. 

132 

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

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

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

136 

137 Returns: 

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

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

140 and ``plots`` properties. 

141 

142 Raises: 

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

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

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

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

147 offending frame is named explicitly. 

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

149 data contains null values. 

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

151 benchmark. 

152 

153 Warns: 

154 BenchmarkAlignmentWarning: If aligning returns and benchmark on 

155 their common dates drops rows from either frame. 

156 

157 Examples: 

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

159 

160 >>> import polars as pl 

161 >>> from jquantstats import Data 

162 >>> returns = pl.DataFrame( 

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

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

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

166 >>> data.assets 

167 ['Asset1'] 

168 >>> data.all.columns 

169 ['date', 'Asset1'] 

170 

171 With benchmark and risk-free rate: 

172 

173 >>> benchmark = pl.DataFrame( 

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

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

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

177 >>> data.benchmark.columns 

178 ['Market'] 

179 

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

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

182 

183 >>> returns_with_nulls = pl.DataFrame( 

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

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

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

187 [0.01, 0.03] 

188 >>> Data.from_returns( 

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

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

191 [0.01, 0.01, 0.03] 

192 

193 """ 

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

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

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

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

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

199 benchmark_pl = ( 

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

201 if benchmark is not None 

202 else None 

203 ) 

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

205 rf_converted: float | pl.DataFrame = ( 

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

207 ) 

208 

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

210 if benchmark_pl is not None: 

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

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

213 

214 index = returns_pl.select(resolved_col) 

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

216 excess_benchmark = ( 

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

218 if benchmark_pl is not None 

219 else None 

220 ) 

221 

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

223 

224 @classmethod 

225 def from_prices( 

226 cls, 

227 prices: NativeFrame, 

228 rf: NativeFrameOrScalar = 0.0, 

229 benchmark: NativeFrame | None = None, 

230 date_col: str | None = None, 

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

232 ) -> Data: 

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

234 

235 Converts price levels to returns via percentage change and delegates 

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

237 prior price is available to compute a return. 

238 

239 Args: 

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

241 the date column; remaining columns are asset prices. 

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

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

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

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

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

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

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

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

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

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

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

253 resulting object. 

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

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

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

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

258 

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

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

261 found in the derived returns. 

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

263 one null. 

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

265 non-null value. 

266 

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

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

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

270 

271 Returns: 

272 Data: Object containing excess returns derived from the supplied 

273 prices, with methods for analysis and visualization through the 

274 ``stats`` and ``plots`` properties. 

275 

276 Raises: 

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

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

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

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

281 

282 Examples: 

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

284 

285 >>> import polars as pl 

286 >>> from jquantstats import Data 

287 >>> prices = pl.DataFrame( 

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

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

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

291 >>> data.returns.height 

292 2 

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

294 [0.01, -0.019802] 

295 

296 """ 

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

298 returns_pl = _prices_to_returns(prices_pl, resolved_col) 

299 

300 benchmark_returns: NativeFrame | None = None 

301 if benchmark is not None: 

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

303 benchmark_returns = _prices_to_returns(benchmark_pl, resolved_col) 

304 

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

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

307 rf_forwarded: NativeFrameOrScalar = ( 

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

309 ) 

310 

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

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

313 return cls.from_returns( 

314 returns=returns_pl, 

315 rf=rf_forwarded, 

316 benchmark=benchmark_returns, 

317 date_col=resolved_col, 

318 null_strategy=null_strategy, 

319 ) 

320 

321 def __repr__(self) -> str: 

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

323 rows = len(self.index) 

324 date_cols = self.date_col 

325 if date_cols: 

326 date_column = date_cols[0] 

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

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

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

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

331 

332 @property 

333 def plots(self) -> DataPlots: 

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

335 

336 Returns: 

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

338 

339 """ 

340 return DataPlots(self) 

341 

342 @property 

343 def stats(self) -> Stats: 

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

345 

346 Returns: 

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

348 

349 """ 

350 return Stats(self) 

351 

352 @property 

353 def reports(self) -> Reports: 

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

355 

356 Returns: 

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

358 

359 """ 

360 return Reports(self) 

361 

362 @property 

363 def utils(self) -> DataUtils: 

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

365 

366 Returns: 

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

368 

369 """ 

370 return DataUtils(self) 

371 

372 @property 

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

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

375 

376 Returns: 

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

378 the date column name. 

379 

380 """ 

381 return list(self.index.columns) 

382 

383 @property 

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

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

386 

387 Returns: 

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

389 (if available). 

390 

391 """ 

392 if self.benchmark is not None: 

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

394 return list(self.returns.columns) 

395 

396 @property 

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

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

399 

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

401 which is useful for analysis and visualization. 

402 

403 Returns: 

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

405 (if available) combined horizontally. 

406 

407 """ 

408 if self.benchmark is None: 

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

410 else: 

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

412 

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

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

415 

416 Returns: 

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

418 rows, has_benchmark. 

419 

420 """ 

421 date_column = self.date_col[0] 

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

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

424 rows = len(self.index) 

425 return pl.DataFrame( 

426 { 

427 "asset": self.returns.columns, 

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

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

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

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

432 } 

433 ) 

434 

435 @property 

436 def _periods_per_year(self) -> float: 

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

438 

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

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

441 

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

443 because integer diffs have no time meaning. 

444 """ 

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

446 

447 if not datetime_col.dtype.is_temporal(): 

448 return 252.0 

449 

450 sorted_dt = datetime_col.sort() 

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

452 mean_diff = diffs.mean() 

453 

454 if isinstance(mean_diff, timedelta): 

455 seconds = mean_diff.total_seconds() 

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

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

458 

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

460 

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

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

463 

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

465 yielding each asset name and its corresponding data series. 

466 

467 Yields: 

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

469 

470 """ 

471 matrix = self.all 

472 

473 for col in self.assets: 

474 yield col, matrix.get_column(col)