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
« prev ^ index » next coverage.py v7.17.1, created at 2026-11-03 15: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 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 self._canonicalise_index()
65 self._validate_index()
66 self._validate_row_counts()
68 def _canonicalise_index(self) -> None:
69 """Rename the first temporal index column to ``'date'``.
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}))
78 def _validate_index(self) -> None:
79 """Require at least two timestamps in monotonically increasing order.
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
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
93 def _validate_row_counts(self) -> None:
94 """Require returns (and benchmark, if any) to have one row per index entry.
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
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
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.
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).
124 - If float: Constant risk-free rate applied to all dates.
125 - If NativeFrame: Time-varying risk-free rate with dates
126 matching returns.
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).
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.
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.
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.
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.
173 Warns:
174 BenchmarkAlignmentWarning: If aligning returns and benchmark on
175 their common dates drops rows from either frame.
177 Examples:
178 Basic usage. Note the ``Date`` column is canonicalised to ``date``:
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']
191 With benchmark and risk-free rate:
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']
200 Handling nulls automatically. ``"drop"`` mirrors pandas/'uantStats
201 behaviour and loses the offending row; ``"forward_fill"`` keeps it:
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]
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 )
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)
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 )
242 return cls(returns=excess_returns, benchmark=excess_benchmark, index=index)
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.
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.
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).
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.
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"``.
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.
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.
302 Examples:
303 Prices become returns, so the first row is consumed:
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]
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)
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)
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 )
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 )
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
352 @property
353 def plots(self) -> DataPlots:
354 """Provides access to visualization methods for the financial data.
356 Returns:
357 DataPlots: An instance of the DataPlots class initialized with this data.
359 """
360 return DataPlots(self)
362 @property
363 def stats(self) -> Stats:
364 """Provides access to statistical analysis methods for the financial data.
366 Returns:
367 Stats: An instance of the Stats class initialized with this data.
369 """
370 return Stats(self)
372 @property
373 def reports(self) -> Reports:
374 """Provides access to reporting methods for the financial data.
376 Returns:
377 Reports: An instance of the Reports class initialized with this data.
379 """
380 return Reports(self)
382 @property
383 def utils(self) -> DataUtils:
384 """Provides access to utility transforms and conversions for the financial data.
386 Returns:
387 DataUtils: An instance of the DataUtils class initialized with this data.
389 """
390 return DataUtils(self)
392 @property
393 def date_col(self) -> list[str]:
394 """Return the column names of the index DataFrame.
396 Returns:
397 list[str]: List of column names in the index DataFrame, typically containing
398 the date column name.
400 """
401 return list(self.index.columns)
403 @property
404 def assets(self) -> list[str]:
405 """Return the combined list of asset column names from returns and benchmark.
407 Returns:
408 list[str]: List of all asset column names from both returns and benchmark
409 (if available).
411 """
412 if self.benchmark is not None:
413 return list(self.returns.columns) + list(self.benchmark.columns)
414 return list(self.returns.columns)
416 @property
417 def all(self) -> pl.DataFrame:
418 """Combine index, returns, and benchmark data into a single DataFrame.
420 This property provides a convenient way to access all data in a single DataFrame,
421 which is useful for analysis and visualization.
423 Returns:
424 pl.DataFrame: A DataFrame containing the index, all returns data, and benchmark data
425 (if available) combined horizontally.
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")
433 def describe(self) -> pl.DataFrame:
434 """Return a tidy summary of shape, date range and asset names.
436 Returns:
437 pl.DataFrame: One row per asset with columns: asset, start, end,
438 rows, has_benchmark.
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 )
455 @property
456 def _periods_per_year(self) -> float:
457 """Estimate the number of periods per year based on average frequency in the index.
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).
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]]
467 if not datetime_col.dtype.is_temporal():
468 return 252.0
470 sorted_dt = datetime_col.sort()
471 diffs = sorted_dt.diff().drop_nulls()
472 mean_diff = diffs.mean()
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
479 return (365 * 24 * 60 * 60) / seconds
481 def items(self) -> Iterator[tuple[str, pl.Series]]:
482 """Iterate over all assets and their corresponding data series.
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.
487 Yields:
488 tuple[str, pl.Series]: A tuple containing the asset name and its data series.
490 """
491 matrix = self.all
493 for col in self.assets:
494 yield col, matrix.get_column(col)