Coverage for src/jquantstats/portfolio.py: 100%
129 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"""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` / `from_weights` 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, or / previous NAV when ``compounding``)
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.
157 compounding: When False (the default), `returns` divides each period's
158 profit by the constant ``aum`` — the convention for a book traded at
159 a fixed notional. When True, it divides by the previous period's
160 NAV, the convention for a book whose positions are sized off its
161 own, compounding NAV (see `from_weights`). Turnover and the
162 investment ratio follow the same denominator.
163 realized_costs: Optional per-asset trading costs actually paid on each
164 row, in currency and aligned with ``prices``. When present they
165 are deducted from `profits`, so NAV and returns are net of them.
166 `from_weights` fills this from its simulation; transforms that
167 change the positions (`lag`, `smoothed_holding`, `tilt`,
168 `timing`) drop it, because those costs belong to the original
169 trades.
171 Analytics facades
172 -----------------
173 - ``.stats`` : delegates to the legacy ``Stats`` pipeline via ``.data``; all 50+ metrics available.
174 - ``.plots`` : portfolio-specific ``Plots``; NAV overlays, lead-lag IR, rolling Sharpe/vol, heatmaps.
175 - ``.report`` : HTML ``Report``; self-contained portfolio performance report.
176 - ``.data`` : bridge to the legacy ``Data`` / ``Stats`` / ``DataPlots`` pipeline.
177 - ``.as_data(frame)`` : the same bridge over a *derived* returns frame — cost-adjusted,
178 fee-deducted, or hand-built. Prefer it over ``Data.from_returns(frame)``, which would
179 also treat the ``'profit'`` and ``'NAV_accumulated'`` columns as assets.
181 ``.plots`` and ``.report`` are intentionally *not* delegated to the legacy path: the legacy
182 path operates on a bare returns series, while the analytics path has access to raw prices,
183 positions, and AUM for richer portfolio-specific visualisations.
185 Cost models
186 -----------
187 Two independent cost models are provided. They are not interchangeable:
189 **Model A — position-delta (stateful, set at construction):**
190 ``cost_per_unit: float`` — one-way cost per unit of position change (e.g. 0.01 per share).
191 Used by ``.position_delta_costs`` and ``.net_cost_nav``.
192 Best for: equity portfolios where cost scales with shares traded.
194 **Model B — turnover-bps (stateless, passed at call time):**
195 ``cost_bps: float`` — one-way cost in basis points of AUM turnover (e.g. 5 bps).
196 Used by ``.cost_adjusted_returns(cost_bps)`` and ``.trading_cost_impact(max_bps)``.
197 Best for: macro / fund-of-funds portfolios where cost scales with notional traded.
199 **Management fee (flat annual, set at construction or passed at call time):**
200 ``annual_fee: float`` — flat annual management fee as a fraction of AUM (e.g. 0.0085 for 85 bps p.a.).
201 Used by ``.deduct_management_fee(annual_fee)``.
202 The fee accrues pro-rata per calendar day (``annual_fee * days / 365``), so weekends and
203 holidays are charged to the next trading day and the deduction sums to ``annual_fee`` over a
204 full year. Cost and fee deductions compose linearly in any order.
206 To sweep a range of cost assumptions use ``trading_cost_impact(max_bps=20)`` (Model B).
207 To compute a net-NAV curve set ``cost_per_unit`` at construction and read ``.net_cost_nav`` (Model A).
209 Date column
210 -----------
211 The date axis is identified internally by the name ``date``, so a temporal column
212 (``pl.Date`` or ``pl.Datetime``) under any other label — ``'Date'``, ``'timestamp'`` —
213 is **renamed to ``date`` at construction**. ``prices`` and ``cashposition`` therefore
214 report ``date`` rather than the caller's original name, and every date-dependent
215 feature works regardless of what the input column was called.
217 Only genuinely temporal columns are normalised: a date column still held as strings
218 is left as-is and the portfolio is treated as integer-indexed. Parse it first
219 (``pl.col("Date").str.to_date()``) to get a temporal axis. When a frame contains
220 several temporal columns and none is named ``date``, the first is renamed.
222 Most analytics work with or without a date column. The following features require a
223 temporal ``date`` column:
225 - ``portfolio.plots.correlation_heatmap()``
226 - ``portfolio.plots.lead_lag_ir_plot()``
227 - ``stats.monthly_win_rate()`` — returns NaN per column when no date is present
228 - ``stats.annual_breakdown()`` — raises ``ValueError`` when no date is present
229 - ``stats.max_drawdown_duration()`` — returns period count (int) instead of days
231 Portfolios without a ``date`` column (integer-indexed) are fully supported for
232 NAV, returns, Sharpe, drawdown, cost analytics, and most rolling metrics.
234 Examples:
235 >>> import polars as pl
236 >>> from datetime import date
237 >>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]})
238 >>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]})
239 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6)
240 >>> pf.assets
241 ['A']
242 """
244 cashposition: pl.DataFrame
245 prices: pl.DataFrame
246 aum: float
247 cost_per_unit: float = 0.0
248 cost_bps: float = 0.0
249 annual_fee: float = 0.0
250 compounding: bool = False
251 realized_costs: pl.DataFrame | None = None
253 # ── Internal cache fields ─────────────────────────────────────────────────
254 # All cache fields are initialised to ``None`` in ``__post_init__`` via
255 # ``object.__setattr__`` (required for frozen dataclasses) and populated
256 # lazily on first property access.
257 #
258 # Lifecycle:
259 # - Initialised: ``__post_init__`` sets every field to ``None``.
260 # - Populated: each property computes its value on the first call and
261 # writes it back via ``object.__setattr__``.
262 # - Invalidation: not required — ``Portfolio`` is a *frozen* dataclass,
263 # so its inputs never change and all derived values remain valid for the
264 # lifetime of the instance.
265 _data_bridge: "Data | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
266 _stats_cache: "Stats | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
267 _plots_cache: "PortfolioPlots | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
268 _report_cache: "Report | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
269 _utils_cache: "PortfolioUtils | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
270 _profits_cache: "pl.DataFrame | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
271 _returns_cache: "pl.DataFrame | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
272 _tilt_cache: "Portfolio | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
273 _turnover_cache: "pl.DataFrame | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
275 @staticmethod
276 def _build_data_bridge(ret: pl.DataFrame) -> "Data":
277 """Build a `Data` bridge from a returns frame.
279 Narrows *ret* to its ``'returns'`` column and splits out ``'date'`` (if
280 present) into the index. The narrowing is the whole point: the
281 portfolio's returns-shaped frames also carry ``'profit'`` and
282 ``'NAV_accumulated'``, and passing those to `Data` would have it treat a
283 cash P&L series and a NAV level as two further "assets".
285 The date column is matched literally after a
286 `_normalise_date_column` pass, so a caller-supplied frame whose dates
287 arrived as ``'Date'`` is handled too; the positional-index fallback is
288 reached only by a frame that genuinely has no temporal column.
290 Args:
291 ret: Returns DataFrame with a ``'returns'`` column, optionally with
292 a date column and any number of columns to ignore.
294 Returns:
295 A `Data` instance backed by the ``'returns'`` column of *ret*.
297 Raises:
298 MissingReturnsColumnError: If *ret* has no ``'returns'`` column.
299 """
300 if "returns" not in ret.columns:
301 raise MissingReturnsColumnError(ret.columns)
302 ret = _normalise_date_column(ret)
303 returns_only = ret.select("returns")
304 if _DATE_COLUMN in ret.columns:
305 return Data(returns=returns_only, index=ret.select(_DATE_COLUMN))
306 return Data(returns=returns_only, index=pl.DataFrame({"index": list(range(ret.height))}))
308 def __post_init__(self) -> None:
309 """Validate input types, shapes, and parameters, and normalise the date column."""
310 if not isinstance(self.prices, pl.DataFrame):
311 raise InvalidPricesTypeError(type(self.prices).__name__)
312 if not isinstance(self.cashposition, pl.DataFrame):
313 raise InvalidCashPositionTypeError(type(self.cashposition).__name__)
314 # Canonicalise the date axis before anything downstream looks for it; the
315 # mixins match ``'date'`` by name, so this must happen on every
316 # construction path, including a direct ``Portfolio(...)`` call.
317 object.__setattr__(self, "prices", _normalise_date_column(self.prices))
318 object.__setattr__(self, "cashposition", _normalise_date_column(self.cashposition))
319 if self.cashposition.shape[0] != self.prices.shape[0]:
320 raise RowCountMismatchError(self.prices.shape[0], self.cashposition.shape[0])
321 if self.realized_costs is not None:
322 object.__setattr__(self, "realized_costs", _normalise_date_column(self.realized_costs))
323 if self.realized_costs.shape[0] != self.prices.shape[0]:
324 raise RowCountMismatchError(self.prices.shape[0], self.realized_costs.shape[0], "realized_costs")
325 if self.aum <= 0.0:
326 raise NonPositiveAumError(self.aum)
327 for slot in _CACHE_SLOTS:
328 object.__setattr__(self, slot, None)
330 def _date_range(self) -> tuple[int, date | datetime | None, date | datetime | None]:
331 """Return (rows, start, end) for the portfolio's returns series.
333 ``start`` and ``end`` are ``None`` when there is no ``'date'`` column.
334 """
335 ret = self.returns
336 rows = ret.height
337 if "date" in ret.columns:
338 return rows, cast(date | None, ret["date"].min()), cast(date | None, ret["date"].max())
339 return rows, None, None
341 @property
342 def cost_model(self) -> CostModel:
343 """Return the active cost model as a `CostModel` instance.
345 Returns:
346 A `CostModel` whose ``cost_per_unit`` and ``cost_bps`` fields
347 reflect the values stored on this portfolio.
348 """
349 return CostModel(cost_per_unit=self.cost_per_unit, cost_bps=self.cost_bps)
351 def __repr__(self) -> str:
352 """Return a string representation of the Portfolio object."""
353 rows, start, end = self._date_range()
354 if start is not None:
355 return f"Portfolio(assets={self.assets}, rows={rows}, start={start}, end={end})"
356 return f"Portfolio(assets={self.assets}, rows={rows})"
358 def describe(self) -> pl.DataFrame:
359 """Return a tidy summary of shape, date range and asset names.
361 Returns:
362 -------
363 pl.DataFrame
364 One row per asset with columns: asset, start, end, rows.
366 Examples:
367 >>> import polars as pl
368 >>> from datetime import date
369 >>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]})
370 >>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]})
371 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6)
372 >>> df = pf.describe()
373 >>> list(df.columns)
374 ['asset', 'start', 'end', 'rows']
375 """
376 rows, start, end = self._date_range()
377 return pl.DataFrame(
378 {
379 "asset": self.assets,
380 "start": [start] * len(self.assets),
381 "end": [end] * len(self.assets),
382 "rows": [rows] * len(self.assets),
383 }
384 )
386 # ── Factory classmethods ──────────────────────────────────────────────────
388 @classmethod
389 def from_cash_position(
390 cls,
391 prices: pl.DataFrame,
392 cash_position: pl.DataFrame | pl.Expr,
393 aum: float,
394 cost_per_unit: float = 0.0,
395 cost_bps: float = 0.0,
396 cost_model: CostModel | None = None,
397 annual_fee: float = 0.0,
398 compounding: bool = False,
399 realized_costs: pl.DataFrame | None = None,
400 ) -> Self:
401 """Create a Portfolio directly from cash positions aligned with prices.
403 Args:
404 prices: Price levels per asset over time. A temporal column is
405 normalised to ``'date'`` at construction, whatever it was named.
406 cash_position: Cash exposure per asset over time, either as a
407 DataFrame or as a Polars expression evaluated against *prices*.
408 aum: Assets under management used as the base NAV offset.
409 cost_per_unit: One-way trading cost per unit of position change.
410 Defaults to 0.0 (no cost). Ignored when *cost_model* is given.
411 cost_bps: One-way trading cost in basis points of AUM turnover.
412 Defaults to 0.0 (no cost). Ignored when *cost_model* is given.
413 cost_model: Optional `CostModel`
414 instance. When supplied, its ``cost_per_unit`` and
415 ``cost_bps`` values take precedence over the individual
416 parameters above.
417 annual_fee: Flat annual management fee as a fraction of AUM
418 (e.g. 0.0085 for 85 bps p.a.). Defaults to 0.0 (no fee).
419 Used as the default by `deduct_management_fee`.
420 compounding: Measure returns against the previous period's NAV
421 instead of the constant *aum*. Defaults to False.
422 realized_costs: Optional per-asset trading costs actually paid on
423 each row, in currency, deducted from `profits`. Defaults to
424 None (no realised costs).
426 Returns:
427 A Portfolio instance with the provided cash positions.
429 Raises:
430 PositionExprColumnError: If *cash_position* is an expression that
431 creates columns not present in *prices* (e.g. via ``.alias``);
432 such expressions leave the original asset columns untouched,
433 silently treating raw prices as positions.
434 """
435 if isinstance(cash_position, pl.Expr):
436 cash_position = _evaluate_position_expr(prices, cash_position, "cash_position")
437 if cost_model is not None:
438 cost_per_unit = cost_model.cost_per_unit
439 cost_bps = cost_model.cost_bps
440 return cls(
441 prices=prices,
442 cashposition=cash_position,
443 aum=aum,
444 cost_per_unit=cost_per_unit,
445 cost_bps=cost_bps,
446 annual_fee=annual_fee,
447 compounding=compounding,
448 realized_costs=realized_costs,
449 )
451 # ── Internal helpers ───────────────────────────────────────────────────────
453 @staticmethod
454 def _assert_clean_series(series: pl.Series, name: str = "") -> None:
455 """Raise `UncleanSeriesError` if *series* contains nulls or non-finite values.
457 Args:
458 series: The series to validate.
459 name: Optional series name included in the error message.
461 Raises:
462 UncleanSeriesError: If the series contains null or non-finite values.
463 """
464 if series.null_count() != 0:
465 raise UncleanSeriesError(name, "null")
466 if not series.is_finite().all():
467 raise UncleanSeriesError(name, "non-finite")
469 # ── Core data properties ───────────────────────────────────────────────────
471 @property
472 def assets(self) -> list[str]:
473 """List the asset column names from prices (numeric columns).
475 Returns:
476 list[str]: Names of numeric columns in prices; typically excludes
477 ``'date'``.
478 """
479 return [c for c in self.prices.columns if self.prices[c].dtype.is_numeric()]
481 # ── Lazy composition accessors ─────────────────────────────────────────────
483 @property
484 @cached_in_slot("_data_bridge")
485 def data(self) -> "Data":
486 """Build a legacy `Data` object from this portfolio's returns.
488 This bridges the two entry points: ``Portfolio`` compiles the NAV curve from
489 prices and positions; the returned `Data` object
490 gives access to the full legacy analytics pipeline (``data.stats``,
491 ``data.plots``, ``data.reports``).
493 Returns:
494 `Data`: A Data object whose ``returns`` column
495 is the portfolio's daily return series and whose ``index`` holds the date
496 column (or a synthetic integer index for date-free portfolios).
498 Examples:
499 >>> import polars as pl
500 >>> from datetime import date
501 >>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]})
502 >>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]})
503 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6)
504 >>> d = pf.data
505 >>> "returns" in d.returns.columns
506 True
507 """
508 return self.as_data()
510 def as_data(self, returns: pl.DataFrame | None = None) -> "Data":
511 """Bridge a returns-shaped frame into a `Data` object.
513 `data` is the same bridge hardwired to `returns`. Use this
514 method when the series you want analysed is a *derived* one — the output
515 of `cost_adjusted_returns`, `deduct_management_fee`, or any frame you
516 built yourself — so it reaches `Stats` through the same narrowing the
517 `data` property uses.
519 Building the `Data` by hand is the trap this exists to close:
520 the portfolio's returns-shaped frames carry ``'profit'`` and
521 ``'NAV_accumulated'`` alongside ``'returns'``, and
522 ``Data.from_returns(pf.returns)`` therefore reports a Sharpe ratio for
523 the cash P&L series and the NAV level as if they were two more assets.
524 This method keeps only ``'returns'``.
526 Args:
527 returns: Frame with a ``'returns'`` column, optionally a date
528 column, and any number of other columns (which are ignored).
529 Defaults to `returns`.
531 Returns:
532 `Data`: A Data object over the single return series, indexed by the
533 frame's date column when it has one and by row position otherwise.
535 Raises:
536 MissingReturnsColumnError: If *returns* has no ``'returns'`` column.
538 Examples:
539 >>> import polars as pl
540 >>> from datetime import date
541 >>> _d = [date(2020, 1, 1), date(2020, 1, 2), date(2020, 1, 3)]
542 >>> prices = pl.DataFrame({"date": _d, "A": [100.0, 110.0, 121.0]})
543 >>> pos = pl.DataFrame({"date": _d, "A": [1000.0, 1000.0, 1000.0]})
544 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6, cost_bps=5.0)
545 >>> net = pf.as_data(pf.cost_adjusted_returns())
546 >>> net.returns.columns
547 ['returns']
548 """
549 return Portfolio._build_data_bridge(self.returns if returns is None else returns)
551 @property
552 @cached_in_slot("_stats_cache")
553 def stats(self) -> "Stats":
554 """Return a Stats object built from the portfolio's daily returns.
556 Delegates to the legacy `Stats` pipeline via
557 `data`, so all analytics (Sharpe, drawdown, summary, etc.) are
558 available through the shared implementation.
560 The result is cached after first access so repeated calls are O(1).
561 """
562 return self.data.stats
564 @property
565 @cached_in_slot("_plots_cache")
566 def plots(self) -> PortfolioPlots:
567 """Convenience accessor returning a PortfolioPlots facade for this portfolio.
569 Use this to create Plotly visualizations such as snapshots, lagged
570 performance curves, and lead/lag IR charts.
572 Returns:
573 `PortfolioPlots`: Helper object with
574 plotting methods.
576 The result is cached after first access so repeated calls are O(1).
577 """
578 return PortfolioPlots(self)
580 @property
581 @cached_in_slot("_report_cache")
582 def report(self) -> Report:
583 """Convenience accessor returning a Report facade for this portfolio.
585 Use this to generate a self-contained HTML performance report
586 containing statistics tables and interactive charts.
588 Returns:
589 `Report`: Helper object with
590 report methods.
592 The result is cached after first access so repeated calls are O(1).
593 """
594 return Report(self)
596 @property
597 @cached_in_slot("_utils_cache")
598 def utils(self) -> "PortfolioUtils":
599 """Convenience accessor returning a PortfolioUtils facade for this portfolio.
601 Use this for common data transformations such as converting returns to
602 prices, computing log returns, rebasing, aggregating by period, and
603 computing exponential standard deviation.
605 Returns:
606 `PortfolioUtils`: Helper object with
607 utility transform methods.
609 The result is cached after first access so repeated calls are O(1).
610 """
611 return PortfolioUtils(self)