Coverage for src/jquantstats/portfolio.py: 100%
123 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"""Portfolio analytics class for quant finance.
3This module provides `Portfolio`, a frozen dataclass that stores the
4raw portfolio inputs (prices, cash positions, AUM) and exposes both the
5derived data series and the full analytics / visualisation suite.
7The class is composed from focused mixin modules:
9- `PortfolioNavMixin` — NAV & returns chain
10- `PortfolioAttributionMixin` — tilt/timing attribution
11- `PortfolioTurnoverMixin` — turnover analytics
12- `PortfolioCostMixin` — cost analysis
13- `PortfolioTransformMixin` — range/lag/smoothing transforms & correlation
14- `PortfolioConstructorMixin` — `from_risk_position` / `from_position` factories
16Public API is unchanged:
18- Derived data series — `profits`, `profit`, `nav_accumulated`,
19 `returns`, `monthly`, `nav_compounded`, `highwater`,
20 `drawdown`, `all`
21- Lazy composition accessors — `stats`, `plots`, `report`, `data`, `as_data`
22- Portfolio transforms — `truncate`, `lag`, `smoothed_holding`
23- Attribution — `tilt`, `timing`, `tilt_timing_decomp`
24- Turnover analysis — `turnover`, `turnover_weekly`, `turnover_summary`
25- Cost analysis — `cost_adjusted_returns`, `trading_cost_impact`, `deduct_management_fee`
26- Utility — `correlation`
27"""
29import dataclasses
30from datetime import date, datetime
31from typing import Self, cast
33import polars as pl
35from ._cache import cached_in_slot
36from ._cost_model import CostModel
37from ._plots import PortfolioPlots
38from ._portfolio_attribution import PortfolioAttributionMixin
39from ._portfolio_constructors import PortfolioConstructorMixin, _evaluate_position_expr
40from ._portfolio_cost import PortfolioCostMixin
41from ._portfolio_nav import PortfolioNavMixin
42from ._portfolio_transform import PortfolioTransformMixin
43from ._portfolio_turnover import PortfolioTurnoverMixin
44from ._portfolio_units import PortfolioUnitsMixin
45from ._reports import Report
46from ._stats import Stats as Stats
47from ._utils import PortfolioUtils as PortfolioUtils
48from .data import Data as Data
49from .exceptions import (
50 InvalidCashPositionTypeError,
51 InvalidPricesTypeError,
52 MissingReturnsColumnError,
53 NonPositiveAumError,
54 RowCountMismatchError,
55 UncleanSeriesError,
56)
58# Slot fields used as lazy caches; __post_init__ initialises each to None and
59# `cached_in_slot` fills them on first property access.
60_CACHE_SLOTS = (
61 "_data_bridge",
62 "_stats_cache",
63 "_plots_cache",
64 "_report_cache",
65 "_utils_cache",
66 "_profits_cache",
67 "_returns_cache",
68 "_tilt_cache",
69 "_turnover_cache",
70)
72# The canonical name of the date column throughout the Portfolio internals. Every
73# _portfolio_* mixin tests for this exact name to decide whether it has a temporal
74# axis, so inputs are normalised to it once at construction rather than each site
75# having to cope with an arbitrary caller-chosen name.
76_DATE_COLUMN = "date"
79def _normalise_date_column(frame: pl.DataFrame) -> pl.DataFrame:
80 """Rename *frame*'s temporal column to ``'date'``.
82 The Portfolio internals identify the date axis by name, so a frame whose
83 dates live under any other label (``'Date'``, ``'timestamp'``, …) would be
84 treated as having no temporal axis at all — silently falling back to a
85 positional index and a default ``periods_per_year``. Renaming here makes
86 that impossible.
88 A frame that already has a ``'date'`` column is returned untouched, so this is
89 idempotent and cheap on the internal rebuild paths (`lag`, `truncate`,
90 `smoothed_holding`) where the input is already canonical. When several
91 temporal columns are present the first one wins, matching the
92 leading-date-column convention used throughout.
94 Args:
95 frame: A price or cash-position frame, with or without dates.
97 Returns:
98 *frame* with its date column named ``'date'``, or *frame* unchanged
99 when it already has one or has no temporal column to rename.
100 """
101 if _DATE_COLUMN in frame.columns:
102 return frame
103 temporal = [name for name, dtype in frame.schema.items() if dtype.is_temporal()]
104 if not temporal:
105 return frame
106 return frame.rename({temporal[0]: _DATE_COLUMN})
109@dataclasses.dataclass(frozen=True, slots=True)
110class Portfolio(
111 PortfolioNavMixin,
112 PortfolioAttributionMixin,
113 PortfolioTurnoverMixin,
114 PortfolioCostMixin,
115 PortfolioTransformMixin,
116 PortfolioUnitsMixin,
117 PortfolioConstructorMixin,
118):
119 """Portfolio analytics class for quant finance.
121 Stores the three raw inputs — cash positions, prices, and AUM — and
122 exposes the standard derived data series, analytics facades, transforms,
123 and attribution tools.
125 Derived data series:
127 - `profits` — per-asset daily cash P&L
128 - `profit` — aggregate daily portfolio profit
129 - `nav_accumulated` — cumulative additive NAV
130 - `nav_compounded` — compounded NAV
131 - `returns` — daily returns (profit / AUM)
132 - `monthly` — monthly compounded returns
133 - `highwater` — running high-water mark
134 - `drawdown` — drawdown from high-water mark
135 - `all` — merged view of all derived series
137 - Lazy composition accessors: `stats`, `plots`, `report`, `data`,
138 `as_data` (the same bridge over a derived returns frame)
139 - Portfolio transforms: `truncate`, `lag`,
140 `smoothed_holding`
141 - Attribution: `tilt`, `timing`, `tilt_timing_decomp`
142 - Turnover: `turnover`, `turnover_weekly`,
143 `turnover_summary`
144 - Share-count view: `units`, `equity`, `trades_units`,
145 `trades_currency`, `weights` — all derived from cash positions and
146 prices, so they are available however the portfolio was constructed
147 - Cost analysis: `cost_adjusted_returns`,
148 `trading_cost_impact`, `deduct_management_fee`
149 - Utility: `correlation`
151 Attributes:
152 cashposition: Polars DataFrame of positions per asset over time. Any
153 temporal column is present as ``'date'`` — see *Date column* below.
154 prices: Polars DataFrame of prices per asset over time. Any temporal
155 column is present as ``'date'`` — see *Date column* below.
156 aum: Assets under management used as base NAV offset.
158 Analytics facades
159 -----------------
160 - ``.stats`` : delegates to the legacy ``Stats`` pipeline via ``.data``; all 50+ metrics available.
161 - ``.plots`` : portfolio-specific ``Plots``; NAV overlays, lead-lag IR, rolling Sharpe/vol, heatmaps.
162 - ``.report`` : HTML ``Report``; self-contained portfolio performance report.
163 - ``.data`` : bridge to the legacy ``Data`` / ``Stats`` / ``DataPlots`` pipeline.
164 - ``.as_data(frame)`` : the same bridge over a *derived* returns frame — cost-adjusted,
165 fee-deducted, or hand-built. Prefer it over ``Data.from_returns(frame)``, which would
166 also treat the ``'profit'`` and ``'NAV_accumulated'`` columns as assets.
168 ``.plots`` and ``.report`` are intentionally *not* delegated to the legacy path: the legacy
169 path operates on a bare returns series, while the analytics path has access to raw prices,
170 positions, and AUM for richer portfolio-specific visualisations.
172 Cost models
173 -----------
174 Two independent cost models are provided. They are not interchangeable:
176 **Model A — position-delta (stateful, set at construction):**
177 ``cost_per_unit: float`` — one-way cost per unit of position change (e.g. 0.01 per share).
178 Used by ``.position_delta_costs`` and ``.net_cost_nav``.
179 Best for: equity portfolios where cost scales with shares traded.
181 **Model B — turnover-bps (stateless, passed at call time):**
182 ``cost_bps: float`` — one-way cost in basis points of AUM turnover (e.g. 5 bps).
183 Used by ``.cost_adjusted_returns(cost_bps)`` and ``.trading_cost_impact(max_bps)``.
184 Best for: macro / fund-of-funds portfolios where cost scales with notional traded.
186 **Management fee (flat annual, set at construction or passed at call time):**
187 ``annual_fee: float`` — flat annual management fee as a fraction of AUM (e.g. 0.0085 for 85 bps p.a.).
188 Used by ``.deduct_management_fee(annual_fee)``.
189 The fee accrues pro-rata per calendar day (``annual_fee * days / 365``), so weekends and
190 holidays are charged to the next trading day and the deduction sums to ``annual_fee`` over a
191 full year. Cost and fee deductions compose linearly in any order.
193 To sweep a range of cost assumptions use ``trading_cost_impact(max_bps=20)`` (Model B).
194 To compute a net-NAV curve set ``cost_per_unit`` at construction and read ``.net_cost_nav`` (Model A).
196 Date column
197 -----------
198 The date axis is identified internally by the name ``date``, so a temporal column
199 (``pl.Date`` or ``pl.Datetime``) under any other label — ``'Date'``, ``'timestamp'`` —
200 is **renamed to ``date`` at construction**. ``prices`` and ``cashposition`` therefore
201 report ``date`` rather than the caller's original name, and every date-dependent
202 feature works regardless of what the input column was called.
204 Only genuinely temporal columns are normalised: a date column still held as strings
205 is left as-is and the portfolio is treated as integer-indexed. Parse it first
206 (``pl.col("Date").str.to_date()``) to get a temporal axis. When a frame contains
207 several temporal columns and none is named ``date``, the first is renamed.
209 Most analytics work with or without a date column. The following features require a
210 temporal ``date`` column:
212 - ``portfolio.plots.correlation_heatmap()``
213 - ``portfolio.plots.lead_lag_ir_plot()``
214 - ``stats.monthly_win_rate()`` — returns NaN per column when no date is present
215 - ``stats.annual_breakdown()`` — raises ``ValueError`` when no date is present
216 - ``stats.max_drawdown_duration()`` — returns period count (int) instead of days
218 Portfolios without a ``date`` column (integer-indexed) are fully supported for
219 NAV, returns, Sharpe, drawdown, cost analytics, and most rolling metrics.
221 Examples:
222 >>> import polars as pl
223 >>> from datetime import date
224 >>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]})
225 >>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]})
226 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6)
227 >>> pf.assets
228 ['A']
229 """
231 cashposition: pl.DataFrame
232 prices: pl.DataFrame
233 aum: float
234 cost_per_unit: float = 0.0
235 cost_bps: float = 0.0
236 annual_fee: float = 0.0
238 # ── Internal cache fields ─────────────────────────────────────────────────
239 # All cache fields are initialised to ``None`` in ``__post_init__`` via
240 # ``object.__setattr__`` (required for frozen dataclasses) and populated
241 # lazily on first property access.
242 #
243 # Lifecycle:
244 # - Initialised: ``__post_init__`` sets every field to ``None``.
245 # - Populated: each property computes its value on the first call and
246 # writes it back via ``object.__setattr__``.
247 # - Invalidation: not required — ``Portfolio`` is a *frozen* dataclass,
248 # so its inputs never change and all derived values remain valid for the
249 # lifetime of the instance.
250 _data_bridge: "Data | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
251 _stats_cache: "Stats | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
252 _plots_cache: "PortfolioPlots | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
253 _report_cache: "Report | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
254 _utils_cache: "PortfolioUtils | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
255 _profits_cache: "pl.DataFrame | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
256 _returns_cache: "pl.DataFrame | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
257 _tilt_cache: "Portfolio | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
258 _turnover_cache: "pl.DataFrame | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
260 @staticmethod
261 def _build_data_bridge(ret: pl.DataFrame) -> "Data":
262 """Build a `Data` bridge from a returns frame.
264 Narrows *ret* to its ``'returns'`` column and splits out ``'date'`` (if
265 present) into the index. The narrowing is the whole point: the
266 portfolio's returns-shaped frames also carry ``'profit'`` and
267 ``'NAV_accumulated'``, and passing those to `Data` would have it treat a
268 cash P&L series and a NAV level as two further "assets".
270 The date column is matched literally after a
271 `_normalise_date_column` pass, so a caller-supplied frame whose dates
272 arrived as ``'Date'`` is handled too; the positional-index fallback is
273 reached only by a frame that genuinely has no temporal column.
275 Args:
276 ret: Returns DataFrame with a ``'returns'`` column, optionally with
277 a date column and any number of columns to ignore.
279 Returns:
280 A `Data` instance backed by the ``'returns'`` column of *ret*.
282 Raises:
283 MissingReturnsColumnError: If *ret* has no ``'returns'`` column.
284 """
285 if "returns" not in ret.columns:
286 raise MissingReturnsColumnError(ret.columns)
287 ret = _normalise_date_column(ret)
288 returns_only = ret.select("returns")
289 if _DATE_COLUMN in ret.columns:
290 return Data(returns=returns_only, index=ret.select(_DATE_COLUMN))
291 return Data(returns=returns_only, index=pl.DataFrame({"index": list(range(ret.height))}))
293 def __post_init__(self) -> None:
294 """Validate input types, shapes, and parameters, and normalise the date column."""
295 if not isinstance(self.prices, pl.DataFrame):
296 raise InvalidPricesTypeError(type(self.prices).__name__)
297 if not isinstance(self.cashposition, pl.DataFrame):
298 raise InvalidCashPositionTypeError(type(self.cashposition).__name__)
299 # Canonicalise the date axis before anything downstream looks for it; the
300 # mixins match ``'date'`` by name, so this must happen on every
301 # construction path, including a direct ``Portfolio(...)`` call.
302 object.__setattr__(self, "prices", _normalise_date_column(self.prices))
303 object.__setattr__(self, "cashposition", _normalise_date_column(self.cashposition))
304 if self.cashposition.shape[0] != self.prices.shape[0]:
305 raise RowCountMismatchError(self.prices.shape[0], self.cashposition.shape[0])
306 if self.aum <= 0.0:
307 raise NonPositiveAumError(self.aum)
308 for slot in _CACHE_SLOTS:
309 object.__setattr__(self, slot, None)
311 def _date_range(self) -> tuple[int, date | datetime | None, date | datetime | None]:
312 """Return (rows, start, end) for the portfolio's returns series.
314 ``start`` and ``end`` are ``None`` when there is no ``'date'`` column.
315 """
316 ret = self.returns
317 rows = ret.height
318 if "date" in ret.columns:
319 return rows, cast(date | None, ret["date"].min()), cast(date | None, ret["date"].max())
320 return rows, None, None
322 @property
323 def cost_model(self) -> CostModel:
324 """Return the active cost model as a `CostModel` instance.
326 Returns:
327 A `CostModel` whose ``cost_per_unit`` and ``cost_bps`` fields
328 reflect the values stored on this portfolio.
329 """
330 return CostModel(cost_per_unit=self.cost_per_unit, cost_bps=self.cost_bps)
332 def __repr__(self) -> str:
333 """Return a string representation of the Portfolio object."""
334 rows, start, end = self._date_range()
335 if start is not None:
336 return f"Portfolio(assets={self.assets}, rows={rows}, start={start}, end={end})"
337 return f"Portfolio(assets={self.assets}, rows={rows})"
339 def describe(self) -> pl.DataFrame:
340 """Return a tidy summary of shape, date range and asset names.
342 Returns:
343 -------
344 pl.DataFrame
345 One row per asset with columns: asset, start, end, rows.
347 Examples:
348 >>> import polars as pl
349 >>> from datetime import date
350 >>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]})
351 >>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]})
352 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6)
353 >>> df = pf.describe()
354 >>> list(df.columns)
355 ['asset', 'start', 'end', 'rows']
356 """
357 rows, start, end = self._date_range()
358 return pl.DataFrame(
359 {
360 "asset": self.assets,
361 "start": [start] * len(self.assets),
362 "end": [end] * len(self.assets),
363 "rows": [rows] * len(self.assets),
364 }
365 )
367 # ── Factory classmethods ──────────────────────────────────────────────────
369 @classmethod
370 def from_cash_position(
371 cls,
372 prices: pl.DataFrame,
373 cash_position: pl.DataFrame | pl.Expr,
374 aum: float,
375 cost_per_unit: float = 0.0,
376 cost_bps: float = 0.0,
377 cost_model: CostModel | None = None,
378 annual_fee: float = 0.0,
379 ) -> Self:
380 """Create a Portfolio directly from cash positions aligned with prices.
382 Args:
383 prices: Price levels per asset over time. A temporal column is
384 normalised to ``'date'`` at construction, whatever it was named.
385 cash_position: Cash exposure per asset over time, either as a
386 DataFrame or as a Polars expression evaluated against *prices*.
387 aum: Assets under management used as the base NAV offset.
388 cost_per_unit: One-way trading cost per unit of position change.
389 Defaults to 0.0 (no cost). Ignored when *cost_model* is given.
390 cost_bps: One-way trading cost in basis points of AUM turnover.
391 Defaults to 0.0 (no cost). Ignored when *cost_model* is given.
392 cost_model: Optional `CostModel`
393 instance. When supplied, its ``cost_per_unit`` and
394 ``cost_bps`` values take precedence over the individual
395 parameters above.
396 annual_fee: Flat annual management fee as a fraction of AUM
397 (e.g. 0.0085 for 85 bps p.a.). Defaults to 0.0 (no fee).
398 Used as the default by `deduct_management_fee`.
400 Returns:
401 A Portfolio instance with the provided cash positions.
403 Raises:
404 PositionExprColumnError: If *cash_position* is an expression that
405 creates columns not present in *prices* (e.g. via ``.alias``);
406 such expressions leave the original asset columns untouched,
407 silently treating raw prices as positions.
408 """
409 if isinstance(cash_position, pl.Expr):
410 cash_position = _evaluate_position_expr(prices, cash_position, "cash_position")
411 if cost_model is not None:
412 cost_per_unit = cost_model.cost_per_unit
413 cost_bps = cost_model.cost_bps
414 return cls(
415 prices=prices,
416 cashposition=cash_position,
417 aum=aum,
418 cost_per_unit=cost_per_unit,
419 cost_bps=cost_bps,
420 annual_fee=annual_fee,
421 )
423 # ── Internal helpers ───────────────────────────────────────────────────────
425 @staticmethod
426 def _assert_clean_series(series: pl.Series, name: str = "") -> None:
427 """Raise `UncleanSeriesError` if *series* contains nulls or non-finite values.
429 Args:
430 series: The series to validate.
431 name: Optional series name included in the error message.
433 Raises:
434 UncleanSeriesError: If the series contains null or non-finite values.
435 """
436 if series.null_count() != 0:
437 raise UncleanSeriesError(name, "null")
438 if not series.is_finite().all():
439 raise UncleanSeriesError(name, "non-finite")
441 # ── Core data properties ───────────────────────────────────────────────────
443 @property
444 def assets(self) -> list[str]:
445 """List the asset column names from prices (numeric columns).
447 Returns:
448 list[str]: Names of numeric columns in prices; typically excludes
449 ``'date'``.
450 """
451 return [c for c in self.prices.columns if self.prices[c].dtype.is_numeric()]
453 # ── Lazy composition accessors ─────────────────────────────────────────────
455 @property
456 @cached_in_slot("_data_bridge")
457 def data(self) -> "Data":
458 """Build a legacy `Data` object from this portfolio's returns.
460 This bridges the two entry points: ``Portfolio`` compiles the NAV curve from
461 prices and positions; the returned `Data` object
462 gives access to the full legacy analytics pipeline (``data.stats``,
463 ``data.plots``, ``data.reports``).
465 Returns:
466 `Data`: A Data object whose ``returns`` column
467 is the portfolio's daily return series and whose ``index`` holds the date
468 column (or a synthetic integer index for date-free portfolios).
470 Examples:
471 >>> import polars as pl
472 >>> from datetime import date
473 >>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]})
474 >>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]})
475 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6)
476 >>> d = pf.data
477 >>> "returns" in d.returns.columns
478 True
479 """
480 return self.as_data()
482 def as_data(self, returns: pl.DataFrame | None = None) -> "Data":
483 """Bridge a returns-shaped frame into a `Data` object.
485 `data` is the same bridge hardwired to `returns`. Use this
486 method when the series you want analysed is a *derived* one — the output
487 of `cost_adjusted_returns`, `deduct_management_fee`, or any frame you
488 built yourself — so it reaches `Stats` through the same narrowing the
489 `data` property uses.
491 Building the `Data` by hand is the trap this exists to close:
492 the portfolio's returns-shaped frames carry ``'profit'`` and
493 ``'NAV_accumulated'`` alongside ``'returns'``, and
494 ``Data.from_returns(pf.returns)`` therefore reports a Sharpe ratio for
495 the cash P&L series and the NAV level as if they were two more assets.
496 This method keeps only ``'returns'``.
498 Args:
499 returns: Frame with a ``'returns'`` column, optionally a date
500 column, and any number of other columns (which are ignored).
501 Defaults to `returns`.
503 Returns:
504 `Data`: A Data object over the single return series, indexed by the
505 frame's date column when it has one and by row position otherwise.
507 Raises:
508 MissingReturnsColumnError: If *returns* has no ``'returns'`` column.
510 Examples:
511 >>> import polars as pl
512 >>> from datetime import date
513 >>> _d = [date(2020, 1, 1), date(2020, 1, 2), date(2020, 1, 3)]
514 >>> prices = pl.DataFrame({"date": _d, "A": [100.0, 110.0, 121.0]})
515 >>> pos = pl.DataFrame({"date": _d, "A": [1000.0, 1000.0, 1000.0]})
516 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6, cost_bps=5.0)
517 >>> net = pf.as_data(pf.cost_adjusted_returns())
518 >>> net.returns.columns
519 ['returns']
520 """
521 return Portfolio._build_data_bridge(self.returns if returns is None else returns)
523 @property
524 @cached_in_slot("_stats_cache")
525 def stats(self) -> "Stats":
526 """Return a Stats object built from the portfolio's daily returns.
528 Delegates to the legacy `Stats` pipeline via
529 `data`, so all analytics (Sharpe, drawdown, summary, etc.) are
530 available through the shared implementation.
532 The result is cached after first access so repeated calls are O(1).
533 """
534 return self.data.stats
536 @property
537 @cached_in_slot("_plots_cache")
538 def plots(self) -> PortfolioPlots:
539 """Convenience accessor returning a PortfolioPlots facade for this portfolio.
541 Use this to create Plotly visualizations such as snapshots, lagged
542 performance curves, and lead/lag IR charts.
544 Returns:
545 `PortfolioPlots`: Helper object with
546 plotting methods.
548 The result is cached after first access so repeated calls are O(1).
549 """
550 return PortfolioPlots(self)
552 @property
553 @cached_in_slot("_report_cache")
554 def report(self) -> Report:
555 """Convenience accessor returning a Report facade for this portfolio.
557 Use this to generate a self-contained HTML performance report
558 containing statistics tables and interactive charts.
560 Returns:
561 `Report`: Helper object with
562 report methods.
564 The result is cached after first access so repeated calls are O(1).
565 """
566 return Report(self)
568 @property
569 @cached_in_slot("_utils_cache")
570 def utils(self) -> "PortfolioUtils":
571 """Convenience accessor returning a PortfolioUtils facade for this portfolio.
573 Use this for common data transformations such as converting returns to
574 prices, computing log returns, rebasing, aggregating by period, and
575 computing exponential standard deviation.
577 Returns:
578 `PortfolioUtils`: Helper object with
579 utility transform methods.
581 The result is cached after first access so repeated calls are O(1).
582 """
583 return PortfolioUtils(self)