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
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-06 04:52 +0000
1"""Financial returns data container and manipulation utilities."""
3from __future__ import annotations
5import dataclasses
6from collections.abc import Iterator
7from datetime import timedelta
8from typing import Literal, cast
10import polars as pl
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)
31@dataclasses.dataclass(frozen=True, slots=True)
32class Data(_ReshapeMixin):
33 """A container for financial returns data and an optional benchmark.
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.
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.
47 """
49 returns: pl.DataFrame
50 index: pl.DataFrame
51 benchmark: pl.DataFrame | None = None
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
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
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
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
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.
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).
89 - If float: Constant risk-free rate applied to all dates.
90 - If NativeFrame: Time-varying risk-free rate with dates
91 matching returns.
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).
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.
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.
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.
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.
132 Warns:
133 BenchmarkAlignmentWarning: If aligning returns and benchmark on
134 their common dates drops rows from either frame.
136 Examples:
137 Basic usage:
139 ```python
140 from jquantstats import Data
141 import polars as pl
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())
148 data = Data.from_returns(returns=returns)
149 ```
151 With benchmark and risk-free rate:
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())
159 data = Data.from_returns(returns=returns, benchmark=benchmark, rf=0.0002)
160 ```
162 Handling nulls automatically:
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())
170 # Drop rows with nulls (mirrors pandas/QuantStats behaviour)
171 data = Data.from_returns(returns=returns_with_nulls, null_strategy="drop")
173 # Or forward-fill nulls
174 data = Data.from_returns(returns=returns_with_nulls, null_strategy="forward_fill")
175 ```
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)
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)
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)
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 )
201 return cls(returns=excess_returns, benchmark=excess_benchmark, index=index)
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.
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.
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).
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.
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"``.
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.
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.
256 Examples:
257 ```python
258 from jquantstats import Data
259 import polars as pl
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())
266 data = Data.from_prices(prices=prices)
267 ```
269 """
270 returns_pl = _prices_to_returns(_to_polars(prices), date_col, "prices")
272 benchmark_returns: NativeFrame | None = None
273 if benchmark is not None:
274 benchmark_returns = _prices_to_returns(_to_polars(benchmark), date_col, "benchmark")
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 )
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
295 @property
296 def plots(self) -> DataPlots:
297 """Provides access to visualization methods for the financial data.
299 Returns:
300 DataPlots: An instance of the DataPlots class initialized with this data.
302 """
303 return DataPlots(self)
305 @property
306 def stats(self) -> Stats:
307 """Provides access to statistical analysis methods for the financial data.
309 Returns:
310 Stats: An instance of the Stats class initialized with this data.
312 """
313 return Stats(self)
315 @property
316 def reports(self) -> Reports:
317 """Provides access to reporting methods for the financial data.
319 Returns:
320 Reports: An instance of the Reports class initialized with this data.
322 """
323 return Reports(self)
325 @property
326 def utils(self) -> DataUtils:
327 """Provides access to utility transforms and conversions for the financial data.
329 Returns:
330 DataUtils: An instance of the DataUtils class initialized with this data.
332 """
333 return DataUtils(self)
335 @property
336 def date_col(self) -> list[str]:
337 """Return the column names of the index DataFrame.
339 Returns:
340 list[str]: List of column names in the index DataFrame, typically containing
341 the date column name.
343 """
344 return list(self.index.columns)
346 @property
347 def assets(self) -> list[str]:
348 """Return the combined list of asset column names from returns and benchmark.
350 Returns:
351 list[str]: List of all asset column names from both returns and benchmark
352 (if available).
354 """
355 if self.benchmark is not None:
356 return list(self.returns.columns) + list(self.benchmark.columns)
357 return list(self.returns.columns)
359 @property
360 def all(self) -> pl.DataFrame:
361 """Combine index, returns, and benchmark data into a single DataFrame.
363 This property provides a convenient way to access all data in a single DataFrame,
364 which is useful for analysis and visualization.
366 Returns:
367 pl.DataFrame: A DataFrame containing the index, all returns data, and benchmark data
368 (if available) combined horizontally.
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")
376 def describe(self) -> pl.DataFrame:
377 """Return a tidy summary of shape, date range and asset names.
379 Returns:
380 pl.DataFrame: One row per asset with columns: asset, start, end,
381 rows, has_benchmark.
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 )
398 @property
399 def _periods_per_year(self) -> float:
400 """Estimate the number of periods per year based on average frequency in the index.
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).
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]]
410 if not datetime_col.dtype.is_temporal():
411 return 252.0
413 sorted_dt = datetime_col.sort()
414 diffs = sorted_dt.diff().drop_nulls()
415 mean_diff = diffs.mean()
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
422 return (365 * 24 * 60 * 60) / seconds
424 def items(self) -> Iterator[tuple[str, pl.Series]]:
425 """Iterate over all assets and their corresponding data series.
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.
430 Yields:
431 tuple[str, pl.Series]: A tuple containing the asset name and its data series.
433 """
434 matrix = self.all
436 for col in self.assets:
437 yield col, matrix.get_column(col)