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
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 04:11 +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 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)
32@dataclasses.dataclass(frozen=True, slots=True)
33class Data(_ReshapeMixin):
34 """A container for financial returns data and an optional benchmark.
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.
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.
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.
56 """
58 returns: pl.DataFrame
59 index: pl.DataFrame
60 benchmark: pl.DataFrame | None = None
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}))
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
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
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
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
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.
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).
104 - If float: Constant risk-free rate applied to all dates.
105 - If NativeFrame: Time-varying risk-free rate with dates
106 matching returns.
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).
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.
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.
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.
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.
153 Warns:
154 BenchmarkAlignmentWarning: If aligning returns and benchmark on
155 their common dates drops rows from either frame.
157 Examples:
158 Basic usage. Note the ``Date`` column is canonicalised to ``date``:
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']
171 With benchmark and risk-free rate:
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']
180 Handling nulls automatically. ``"drop"`` mirrors pandas/'uantStats
181 behaviour and loses the offending row; ``"forward_fill"`` keeps it:
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]
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 )
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)
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 )
222 return cls(returns=excess_returns, benchmark=excess_benchmark, index=index)
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.
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.
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).
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.
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"``.
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.
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.
282 Examples:
283 Prices become returns, so the first row is consumed:
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]
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)
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)
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 )
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 )
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
332 @property
333 def plots(self) -> DataPlots:
334 """Provides access to visualization methods for the financial data.
336 Returns:
337 DataPlots: An instance of the DataPlots class initialized with this data.
339 """
340 return DataPlots(self)
342 @property
343 def stats(self) -> Stats:
344 """Provides access to statistical analysis methods for the financial data.
346 Returns:
347 Stats: An instance of the Stats class initialized with this data.
349 """
350 return Stats(self)
352 @property
353 def reports(self) -> Reports:
354 """Provides access to reporting methods for the financial data.
356 Returns:
357 Reports: An instance of the Reports class initialized with this data.
359 """
360 return Reports(self)
362 @property
363 def utils(self) -> DataUtils:
364 """Provides access to utility transforms and conversions for the financial data.
366 Returns:
367 DataUtils: An instance of the DataUtils class initialized with this data.
369 """
370 return DataUtils(self)
372 @property
373 def date_col(self) -> list[str]:
374 """Return the column names of the index DataFrame.
376 Returns:
377 list[str]: List of column names in the index DataFrame, typically containing
378 the date column name.
380 """
381 return list(self.index.columns)
383 @property
384 def assets(self) -> list[str]:
385 """Return the combined list of asset column names from returns and benchmark.
387 Returns:
388 list[str]: List of all asset column names from both returns and benchmark
389 (if available).
391 """
392 if self.benchmark is not None:
393 return list(self.returns.columns) + list(self.benchmark.columns)
394 return list(self.returns.columns)
396 @property
397 def all(self) -> pl.DataFrame:
398 """Combine index, returns, and benchmark data into a single DataFrame.
400 This property provides a convenient way to access all data in a single DataFrame,
401 which is useful for analysis and visualization.
403 Returns:
404 pl.DataFrame: A DataFrame containing the index, all returns data, and benchmark data
405 (if available) combined horizontally.
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")
413 def describe(self) -> pl.DataFrame:
414 """Return a tidy summary of shape, date range and asset names.
416 Returns:
417 pl.DataFrame: One row per asset with columns: asset, start, end,
418 rows, has_benchmark.
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 )
435 @property
436 def _periods_per_year(self) -> float:
437 """Estimate the number of periods per year based on average frequency in the index.
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).
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]]
447 if not datetime_col.dtype.is_temporal():
448 return 252.0
450 sorted_dt = datetime_col.sort()
451 diffs = sorted_dt.diff().drop_nulls()
452 mean_diff = diffs.mean()
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
459 return (365 * 24 * 60 * 60) / seconds
461 def items(self) -> Iterator[tuple[str, pl.Series]]:
462 """Iterate over all assets and their corresponding data series.
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.
467 Yields:
468 tuple[str, pl.Series]: A tuple containing the asset name and its data series.
470 """
471 matrix = self.all
473 for col in self.assets:
474 yield col, matrix.get_column(col)