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

107 statements  

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

20 _apply_null_strategy, 

21 _prices_to_returns, 

22 _require_date_col, 

23 _subtract_risk_free, 

24 _to_polars, 

25) 

26from ._utils._construction import ( 

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

28) 

29 

30 

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

32class Data(_ReshapeMixin): 

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

34 

35 Provides methods for analyzing and manipulating financial returns data, 

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

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

38 

39 Attributes: 

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

41 as columns. 

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

43 Defaults to None. 

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

45 returns data. 

46 

47 """ 

48 

49 returns: pl.DataFrame 

50 index: pl.DataFrame 

51 benchmark: pl.DataFrame | None = None 

52 

53 def __post_init__(self) -> None: 

54 """Validate the Data object after initialization.""" 

55 # You need at least two points 

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

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

58 

59 # Check index is monotonically increasing 

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

61 if not datetime_col.is_sorted(): 

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

63 

64 # Check row count matches returns 

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

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

67 

68 # Check row count matches benchmark (if provided) 

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

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

71 

72 @classmethod 

73 def from_returns( 

74 cls, 

75 returns: NativeFrame, 

76 rf: NativeFrameOrScalar = 0.0, 

77 benchmark: NativeFrame | None = None, 

78 date_col: str = "Date", 

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

80 ) -> Data: 

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

82 

83 Args: 

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

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

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

87 risk-free rate adjustment). 

88 

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

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

91 matching returns. 

92 

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

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

95 remaining columns are benchmark returns. Returns and 

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

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

98 `BenchmarkAlignmentWarning` is emitted. 

99 date_col (str): Name of the date column in the DataFrames. 

100 Defaults to ``"Date"``. 

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

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

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

104 

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

106 downstream calculations. 

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

108 found. 

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

110 one null. 

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

112 non-null value in the same column. 

113 

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

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

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

117 

118 Returns: 

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

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

121 and ``plots`` properties. 

122 

123 Raises: 

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

125 *returns*, *benchmark*, or a DataFrame-valued *rf*. Raised 

126 before any joins so the offending frame is named explicitly. 

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

128 data contains null values. 

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

130 benchmark. 

131 

132 Warns: 

133 BenchmarkAlignmentWarning: If aligning returns and benchmark on 

134 their common dates drops rows from either frame. 

135 

136 Examples: 

137 Basic usage: 

138 

139 ```python 

140 from jquantstats import Data 

141 import polars as pl 

142 

143 returns = pl.DataFrame({ 

144 "Date": ["2023-01-01", "2023-01-02", "2023-01-03"], 

145 "Asset1": [0.01, -0.02, 0.03] 

146 }).with_columns(pl.col("Date").str.to_date()) 

147 

148 data = Data.from_returns(returns=returns) 

149 ``` 

150 

151 With benchmark and risk-free rate: 

152 

153 ```python 

154 benchmark = pl.DataFrame({ 

155 "Date": ["2023-01-01", "2023-01-02", "2023-01-03"], 

156 "Market": [0.005, -0.01, 0.02] 

157 }).with_columns(pl.col("Date").str.to_date()) 

158 

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

160 ``` 

161 

162 Handling nulls automatically: 

163 

164 ```python 

165 returns_with_nulls = pl.DataFrame({ 

166 "Date": ["2023-01-01", "2023-01-02", "2023-01-03"], 

167 "Asset1": [0.01, None, 0.03] 

168 }).with_columns(pl.col("Date").str.to_date()) 

169 

170 # Drop rows with nulls (mirrors pandas/QuantStats behaviour) 

171 data = Data.from_returns(returns=returns_with_nulls, null_strategy="drop") 

172 

173 # Or forward-fill nulls 

174 data = Data.from_returns(returns=returns_with_nulls, null_strategy="forward_fill") 

175 ``` 

176 

177 """ 

178 returns_pl = _to_polars(returns) 

179 benchmark_pl = _to_polars(benchmark) if benchmark is not None else None 

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

181 rf_converted: float | pl.DataFrame = float(rf) if isinstance(rf, int | float) else _to_polars(rf) 

182 

183 frames: list[tuple[str, pl.DataFrame | None]] = [("returns", returns_pl), ("benchmark", benchmark_pl)] 

184 if isinstance(rf_converted, pl.DataFrame): 

185 frames.append(("rf", rf_converted)) 

186 _require_date_col(frames, date_col) 

187 

188 returns_pl = _apply_null_strategy(returns_pl, date_col, "returns", null_strategy) 

189 if benchmark_pl is not None: 

190 benchmark_pl = _apply_null_strategy(benchmark_pl, date_col, "benchmark", null_strategy) 

191 returns_pl, benchmark_pl = _align_returns_benchmark(returns_pl, benchmark_pl, date_col) 

192 

193 index = returns_pl.select(date_col) 

194 excess_returns = _subtract_risk_free(returns_pl, rf_converted, date_col).drop(date_col) 

195 excess_benchmark = ( 

196 _subtract_risk_free(benchmark_pl, rf_converted, date_col).drop(date_col) 

197 if benchmark_pl is not None 

198 else None 

199 ) 

200 

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

202 

203 @classmethod 

204 def from_prices( 

205 cls, 

206 prices: NativeFrame, 

207 rf: NativeFrameOrScalar = 0.0, 

208 benchmark: NativeFrame | None = None, 

209 date_col: str = "Date", 

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

211 ) -> Data: 

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

213 

214 Converts price levels to returns via percentage change and delegates 

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

216 prior price is available to compute a return. 

217 

218 Args: 

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

220 the date column; remaining columns are asset prices. 

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

222 `from_returns`. Defaults to 0.0 (no risk-free rate 

223 adjustment). 

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

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

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

227 date_col (str): Name of the date column in the DataFrames. 

228 Defaults to ``"Date"``. 

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

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

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

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

233 

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

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

236 found in the derived returns. 

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

238 one null. 

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

240 non-null value. 

241 

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

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

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

245 

246 Returns: 

247 Data: Object containing excess returns derived from the supplied 

248 prices, with methods for analysis and visualization through the 

249 ``stats`` and ``plots`` properties. 

250 

251 Raises: 

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

253 or *benchmark*. Raised before returns are derived so the 

254 offending frame is named explicitly. 

255 

256 Examples: 

257 ```python 

258 from jquantstats import Data 

259 import polars as pl 

260 

261 prices = pl.DataFrame({ 

262 "Date": ["2023-01-01", "2023-01-02", "2023-01-03"], 

263 "Asset1": [100.0, 101.0, 99.0] 

264 }).with_columns(pl.col("Date").str.to_date()) 

265 

266 data = Data.from_prices(prices=prices) 

267 ``` 

268 

269 """ 

270 returns_pl = _prices_to_returns(_to_polars(prices), date_col, "prices") 

271 

272 benchmark_returns: NativeFrame | None = None 

273 if benchmark is not None: 

274 benchmark_returns = _prices_to_returns(_to_polars(benchmark), date_col, "benchmark") 

275 

276 return cls.from_returns( 

277 returns=returns_pl, 

278 rf=rf, 

279 benchmark=benchmark_returns, 

280 date_col=date_col, 

281 null_strategy=null_strategy, 

282 ) 

283 

284 def __repr__(self) -> str: 

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

286 rows = len(self.index) 

287 date_cols = self.date_col 

288 if date_cols: 

289 date_column = date_cols[0] 

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

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

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

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

294 

295 @property 

296 def plots(self) -> DataPlots: 

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

298 

299 Returns: 

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

301 

302 """ 

303 return DataPlots(self) 

304 

305 @property 

306 def stats(self) -> Stats: 

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

308 

309 Returns: 

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

311 

312 """ 

313 return Stats(self) 

314 

315 @property 

316 def reports(self) -> Reports: 

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

318 

319 Returns: 

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

321 

322 """ 

323 return Reports(self) 

324 

325 @property 

326 def utils(self) -> DataUtils: 

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

328 

329 Returns: 

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

331 

332 """ 

333 return DataUtils(self) 

334 

335 @property 

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

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

338 

339 Returns: 

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

341 the date column name. 

342 

343 """ 

344 return list(self.index.columns) 

345 

346 @property 

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

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

349 

350 Returns: 

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

352 (if available). 

353 

354 """ 

355 if self.benchmark is not None: 

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

357 return list(self.returns.columns) 

358 

359 @property 

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

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

362 

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

364 which is useful for analysis and visualization. 

365 

366 Returns: 

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

368 (if available) combined horizontally. 

369 

370 """ 

371 if self.benchmark is None: 

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

373 else: 

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

375 

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

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

378 

379 Returns: 

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

381 rows, has_benchmark. 

382 

383 """ 

384 date_column = self.date_col[0] 

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

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

387 rows = len(self.index) 

388 return pl.DataFrame( 

389 { 

390 "asset": self.returns.columns, 

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

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

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

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

395 } 

396 ) 

397 

398 @property 

399 def _periods_per_year(self) -> float: 

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

401 

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

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

404 

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

406 because integer diffs have no time meaning. 

407 """ 

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

409 

410 if not datetime_col.dtype.is_temporal(): 

411 return 252.0 

412 

413 sorted_dt = datetime_col.sort() 

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

415 mean_diff = diffs.mean() 

416 

417 if isinstance(mean_diff, timedelta): 

418 seconds = mean_diff.total_seconds() 

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

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

421 

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

423 

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

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

426 

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

428 yielding each asset name and its corresponding data series. 

429 

430 Yields: 

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

432 

433 """ 

434 matrix = self.all 

435 

436 for col in self.assets: 

437 yield col, matrix.get_column(col)