Coverage for src / jquantstats / portfolio.py: 100%
191 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-07 14:36 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-07 14:36 +0000
1"""Portfolio analytics class for quant finance.
3This module provides :class:`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 four focused mixin modules:
9- :class:`~jquantstats._portfolio_nav.PortfolioNavMixin` — NAV & returns chain
10- :class:`~jquantstats._portfolio_attribution.PortfolioAttributionMixin` — tilt/timing attribution
11- :class:`~jquantstats._portfolio_turnover.PortfolioTurnoverMixin` — turnover analytics
12- :class:`~jquantstats._portfolio_cost.PortfolioCostMixin` — cost analysis
14Public API is unchanged:
16- Derived data series — :attr:`profits`, :attr:`profit`, :attr:`nav_accumulated`,
17 :attr:`returns`, :attr:`monthly`, :attr:`nav_compounded`, :attr:`highwater`,
18 :attr:`drawdown`, :attr:`all`
19- Lazy composition accessors — :attr:`stats`, :attr:`plots`, :attr:`report`
20- Portfolio transforms — :meth:`truncate`, :meth:`lag`, :meth:`smoothed_holding`
21- Attribution — :attr:`tilt`, :attr:`timing`, :attr:`tilt_timing_decomp`
22- Turnover analysis — :attr:`turnover`, :attr:`turnover_weekly`, :meth:`turnover_summary`
23- Cost analysis — :meth:`cost_adjusted_returns`, :meth:`trading_cost_impact`
24- Utility — :meth:`correlation`
25"""
27import dataclasses
28from datetime import date, datetime
29from typing import TYPE_CHECKING, Self, cast
31if TYPE_CHECKING:
32 from ._stats import Stats as Stats
33 from ._utils import PortfolioUtils as PortfolioUtils
34 from .data import Data as Data
36import polars as pl
37import polars.selectors as cs
39from ._cost_model import CostModel
40from ._plots import PortfolioPlots
41from ._portfolio_attribution import PortfolioAttributionMixin
42from ._portfolio_cost import PortfolioCostMixin
43from ._portfolio_nav import PortfolioNavMixin
44from ._portfolio_turnover import PortfolioTurnoverMixin
45from ._reports import Report
46from .exceptions import (
47 IntegerIndexBoundError,
48 InvalidCashPositionTypeError,
49 InvalidPricesTypeError,
50 NonPositiveAumError,
51 RowCountMismatchError,
52)
55@dataclasses.dataclass(frozen=True, slots=True)
56class Portfolio(
57 PortfolioNavMixin,
58 PortfolioAttributionMixin,
59 PortfolioTurnoverMixin,
60 PortfolioCostMixin,
61):
62 """Portfolio analytics class for quant finance.
64 Stores the three raw inputs — cash positions, prices, and AUM — and
65 exposes the standard derived data series, analytics facades, transforms,
66 and attribution tools.
68 Derived data series:
70 - :attr:`profits` — per-asset daily cash P&L
71 - :attr:`profit` — aggregate daily portfolio profit
72 - :attr:`nav_accumulated` — cumulative additive NAV
73 - :attr:`nav_compounded` — compounded NAV
74 - :attr:`returns` — daily returns (profit / AUM)
75 - :attr:`monthly` — monthly compounded returns
76 - :attr:`highwater` — running high-water mark
77 - :attr:`drawdown` — drawdown from high-water mark
78 - :attr:`all` — merged view of all derived series
80 - Lazy composition accessors: :attr:`stats`, :attr:`plots`, :attr:`report`
81 - Portfolio transforms: :meth:`truncate`, :meth:`lag`,
82 :meth:`smoothed_holding`
83 - Attribution: :attr:`tilt`, :attr:`timing`, :attr:`tilt_timing_decomp`
84 - Turnover: :attr:`turnover`, :attr:`turnover_weekly`,
85 :meth:`turnover_summary`
86 - Cost analysis: :meth:`cost_adjusted_returns`,
87 :meth:`trading_cost_impact`
88 - Utility: :meth:`correlation`
90 Attributes:
91 cashposition: Polars DataFrame of positions per asset over time
92 (includes date column if present).
93 prices: Polars DataFrame of prices per asset over time (includes date
94 column if present).
95 aum: Assets under management used as base NAV offset.
97 Analytics facades
98 -----------------
99 - ``.stats`` : delegates to the legacy ``Stats`` pipeline via ``.data``; all 50+ metrics available.
100 - ``.plots`` : portfolio-specific ``Plots``; NAV overlays, lead-lag IR, rolling Sharpe/vol, heatmaps.
101 - ``.report`` : HTML ``Report``; self-contained portfolio performance report.
102 - ``.data`` : bridge to the legacy ``Data`` / ``Stats`` / ``DataPlots`` pipeline.
104 ``.plots`` and ``.report`` are intentionally *not* delegated to the legacy path: the legacy
105 path operates on a bare returns series, while the analytics path has access to raw prices,
106 positions, and AUM for richer portfolio-specific visualisations.
108 Cost models
109 -----------
110 Two independent cost models are provided. They are not interchangeable:
112 **Model A — position-delta (stateful, set at construction):**
113 ``cost_per_unit: float`` — one-way cost per unit of position change (e.g. 0.01 per share).
114 Used by ``.position_delta_costs`` and ``.net_cost_nav``.
115 Best for: equity portfolios where cost scales with shares traded.
117 **Model B — turnover-bps (stateless, passed at call time):**
118 ``cost_bps: float`` — one-way cost in basis points of AUM turnover (e.g. 5 bps).
119 Used by ``.cost_adjusted_returns(cost_bps)`` and ``.trading_cost_impact(max_bps)``.
120 Best for: macro / fund-of-funds portfolios where cost scales with notional traded.
122 To sweep a range of cost assumptions use ``trading_cost_impact(max_bps=20)`` (Model B).
123 To compute a net-NAV curve set ``cost_per_unit`` at construction and read ``.net_cost_nav`` (Model A).
125 Date column requirement
126 -----------------------
127 Most analytics work with or without a ``date`` column. The following features require a
128 temporal ``date`` column (``pl.Date`` or ``pl.Datetime``):
130 - ``portfolio.plots.correlation_heatmap()``
131 - ``portfolio.plots.lead_lag_ir_plot()``
132 - ``stats.monthly_win_rate()`` — returns NaN per column when no date is present
133 - ``stats.annual_breakdown()`` — raises ``ValueError`` when no date is present
134 - ``stats.max_drawdown_duration()`` — returns period count (int) instead of days
136 Portfolios without a ``date`` column (integer-indexed) are fully supported for
137 NAV, returns, Sharpe, drawdown, cost analytics, and most rolling metrics.
139 Examples:
140 >>> import polars as pl
141 >>> from datetime import date
142 >>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]})
143 >>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]})
144 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6)
145 >>> pf.assets
146 ['A']
147 """
149 cashposition: pl.DataFrame
150 prices: pl.DataFrame
151 aum: float
152 cost_per_unit: float = 0.0
153 cost_bps: float = 0.0
155 # ── Internal cache fields ─────────────────────────────────────────────────
156 # All cache fields are initialised to ``None`` in ``__post_init__`` via
157 # ``object.__setattr__`` (required for frozen dataclasses) and populated
158 # lazily on first property access.
159 #
160 # Lifecycle:
161 # - Initialised: ``__post_init__`` sets every field to ``None``.
162 # - Populated: each property computes its value on the first call and
163 # writes it back via ``object.__setattr__``.
164 # - Invalidation: not required — ``Portfolio`` is a *frozen* dataclass,
165 # so its inputs never change and all derived values remain valid for the
166 # lifetime of the instance.
167 _data_bridge: "Data | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
168 _stats_cache: "Stats | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
169 _plots_cache: "PortfolioPlots | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
170 _report_cache: "Report | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
171 _utils_cache: "PortfolioUtils | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
172 _profits_cache: "pl.DataFrame | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
173 _returns_cache: "pl.DataFrame | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
174 _tilt_cache: "Portfolio | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
175 _turnover_cache: "pl.DataFrame | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
177 @staticmethod
178 def _build_data_bridge(ret: pl.DataFrame) -> "Data":
179 """Build a :class:`~jquantstats._data.Data` bridge from a returns frame.
181 Splits out the ``'date'`` column (if present) into an index and passes
182 the remaining numeric columns as returns. Used internally to populate
183 ``_data_bridge`` at construction time so the ``data`` property is O(1).
185 Args:
186 ret: Returns DataFrame, optionally with a leading ``'date'`` column.
188 Returns:
189 A :class:`~jquantstats._data.Data` instance backed by *ret*.
190 """
191 from .data import Data
193 returns_only = ret.select("returns")
194 if "date" in ret.columns:
195 return Data(returns=returns_only, index=ret.select("date"))
196 return Data(returns=returns_only, index=pl.DataFrame({"index": list(range(ret.height))}))
198 def __post_init__(self) -> None:
199 """Validate input types, shapes, and parameters post-initialization."""
200 if not isinstance(self.prices, pl.DataFrame):
201 raise InvalidPricesTypeError(type(self.prices).__name__)
202 if not isinstance(self.cashposition, pl.DataFrame):
203 raise InvalidCashPositionTypeError(type(self.cashposition).__name__)
204 if self.cashposition.shape[0] != self.prices.shape[0]:
205 raise RowCountMismatchError(self.prices.shape[0], self.cashposition.shape[0])
206 if self.aum <= 0.0:
207 raise NonPositiveAumError(self.aum)
208 object.__setattr__(self, "_data_bridge", None)
209 object.__setattr__(self, "_stats_cache", None)
210 object.__setattr__(self, "_plots_cache", None)
211 object.__setattr__(self, "_report_cache", None)
212 object.__setattr__(self, "_utils_cache", None)
213 object.__setattr__(self, "_profits_cache", None)
214 object.__setattr__(self, "_returns_cache", None)
215 object.__setattr__(self, "_tilt_cache", None)
216 object.__setattr__(self, "_turnover_cache", None)
218 def _date_range(self) -> tuple[int, date | datetime | None, date | datetime | None]:
219 """Return (rows, start, end) for the portfolio's returns series.
221 ``start`` and ``end`` are ``None`` when there is no ``'date'`` column.
222 """
223 ret = self.returns
224 rows = ret.height
225 if "date" in ret.columns:
226 return rows, cast(date | None, ret["date"].min()), cast(date | None, ret["date"].max())
227 return rows, None, None
229 @property
230 def cost_model(self) -> CostModel:
231 """Return the active cost model as a :class:`~jquantstats.CostModel` instance.
233 Returns:
234 A :class:`CostModel` whose ``cost_per_unit`` and ``cost_bps`` fields
235 reflect the values stored on this portfolio.
236 """
237 return CostModel(cost_per_unit=self.cost_per_unit, cost_bps=self.cost_bps)
239 def __repr__(self) -> str:
240 """Return a string representation of the Portfolio object."""
241 rows, start, end = self._date_range()
242 if start is not None:
243 return f"Portfolio(assets={self.assets}, rows={rows}, start={start}, end={end})"
244 return f"Portfolio(assets={self.assets}, rows={rows})"
246 def describe(self) -> pl.DataFrame:
247 """Return a tidy summary of shape, date range and asset names.
249 Returns:
250 -------
251 pl.DataFrame
252 One row per asset with columns: asset, start, end, rows.
254 Examples:
255 >>> import polars as pl
256 >>> from datetime import date
257 >>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]})
258 >>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]})
259 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6)
260 >>> df = pf.describe()
261 >>> list(df.columns)
262 ['asset', 'start', 'end', 'rows']
263 """
264 rows, start, end = self._date_range()
265 return pl.DataFrame(
266 {
267 "asset": self.assets,
268 "start": [start] * len(self.assets),
269 "end": [end] * len(self.assets),
270 "rows": [rows] * len(self.assets),
271 }
272 )
274 # ── Factory classmethods ──────────────────────────────────────────────────
276 @classmethod
277 def from_risk_position(
278 cls,
279 prices: pl.DataFrame,
280 risk_position: pl.DataFrame,
281 aum: float,
282 vola: int | dict[str, int] = 32,
283 vol_cap: float | None = None,
284 cost_per_unit: float = 0.0,
285 cost_bps: float = 0.0,
286 cost_model: CostModel | None = None,
287 ) -> Self:
288 """Create a Portfolio from per-asset risk positions.
290 De-volatizes each risk position using an EWMA volatility estimate
291 derived from the corresponding price series.
293 Args:
294 prices: Price levels per asset over time (may include a date column).
295 risk_position: Risk units per asset aligned with prices.
296 vola: EWMA lookback (span-equivalent) used to estimate volatility.
297 Pass an ``int`` to apply the same span to every asset, or a
298 ``dict[str, int]`` to set a per-asset span (assets absent from
299 the dict default to ``32``). Every span value must be a
300 positive integer; a ``ValueError`` is raised otherwise. Dict
301 keys that do not correspond to any numeric column in *prices*
302 also raise a ``ValueError``.
303 vol_cap: Optional lower bound for the EWMA volatility estimate.
304 When provided, the vol series is clipped from below at this
305 value before dividing the risk position, preventing
306 position blow-up in calm, low-volatility regimes. For
307 example, ``vol_cap=0.05`` ensures annualised vol is never
308 estimated below 5%. Must be positive when not ``None``.
309 aum: Assets under management used as the base NAV offset.
310 cost_per_unit: One-way trading cost per unit of position change.
311 Defaults to 0.0 (no cost). Ignored when *cost_model* is given.
312 cost_bps: One-way trading cost in basis points of AUM turnover.
313 Defaults to 0.0 (no cost). Ignored when *cost_model* is given.
314 cost_model: Optional :class:`~jquantstats.CostModel`
315 instance. When supplied, its ``cost_per_unit`` and
316 ``cost_bps`` values take precedence over the individual
317 parameters above.
319 Returns:
320 A Portfolio instance whose cash positions are risk_position
321 divided by EWMA volatility.
323 Raises:
324 ValueError: If any span value in *vola* is ≤ 0, or if a key in a
325 *vola* dict does not match any numeric column in *prices*, or
326 if *vol_cap* is provided but is not positive.
327 """
328 if cost_model is not None:
329 cost_per_unit = cost_model.cost_per_unit
330 cost_bps = cost_model.cost_bps
331 assets = [col for col, dtype in prices.schema.items() if dtype.is_numeric()]
333 # ── Validate vol_cap ──────────────────────────────────────────────────
334 if vol_cap is not None and vol_cap <= 0:
335 raise ValueError(f"vol_cap must be a positive number when provided, got {vol_cap!r}") # noqa: TRY003
337 # ── Validate vola ─────────────────────────────────────────────────────
338 if isinstance(vola, dict):
339 unknown = set(vola.keys()) - set(assets)
340 if unknown:
341 raise ValueError( # noqa: TRY003
342 f"vola dict contains keys that do not match any numeric column in prices: {sorted(unknown)}"
343 )
344 for asset, span in vola.items():
345 if int(span) <= 0:
346 raise ValueError(f"vola span for '{asset}' must be a positive integer, got {span!r}") # noqa: TRY003
347 else:
348 if int(vola) <= 0:
349 raise ValueError(f"vola span must be a positive integer, got {vola!r}") # noqa: TRY003
351 def _span(asset: str) -> int:
352 """Return the EWMA span for *asset*, falling back to 32 if not specified."""
353 if isinstance(vola, dict):
354 return int(vola.get(asset, 32))
355 return int(vola)
357 def _vol(asset: str) -> pl.Series:
358 """Return the EWMA volatility series for *asset*, optionally clipped from below."""
359 vol = prices[asset].pct_change().ewm_std(com=_span(asset) - 1, adjust=True, min_samples=_span(asset))
360 if vol_cap is not None:
361 vol = vol.clip(lower_bound=vol_cap)
362 return vol
364 cash_position = risk_position.with_columns((pl.col(asset) / _vol(asset)).alias(asset) for asset in assets)
365 return cls(prices=prices, cashposition=cash_position, aum=aum, cost_per_unit=cost_per_unit, cost_bps=cost_bps)
367 @classmethod
368 def from_position(
369 cls,
370 prices: pl.DataFrame,
371 position: pl.DataFrame,
372 aum: float,
373 cost_per_unit: float = 0.0,
374 cost_bps: float = 0.0,
375 cost_model: CostModel | None = None,
376 ) -> Self:
377 """Create a Portfolio from share/unit positions.
379 Converts *position* (number of units held per asset) to cash exposure
380 by multiplying element-wise with *prices*, then delegates to
381 :py:meth:`from_cash_position`.
383 Args:
384 prices: Price levels per asset over time (may include a date column).
385 position: Number of units held per asset over time, aligned with
386 *prices*. Non-numeric columns (e.g. ``'date'``) are passed
387 through unchanged.
388 aum: Assets under management used as the base NAV offset.
389 cost_per_unit: One-way trading cost per unit of position change.
390 Defaults to 0.0 (no cost). Ignored when *cost_model* is given.
391 cost_bps: One-way trading cost in basis points of AUM turnover.
392 Defaults to 0.0 (no cost). Ignored when *cost_model* is given.
393 cost_model: Optional :class:`~jquantstats.CostModel` instance.
394 When supplied, its ``cost_per_unit`` and ``cost_bps`` values
395 take precedence over the individual parameters above.
397 Returns:
398 A Portfolio instance whose cash positions equal *position* x *prices*.
400 Examples:
401 >>> import polars as pl
402 >>> prices = pl.DataFrame({"A": [100.0, 110.0, 105.0]})
403 >>> pos = pl.DataFrame({"A": [10.0, 10.0, 10.0]})
404 >>> pf = Portfolio.from_position(prices=prices, position=pos, aum=1e6)
405 >>> pf.cashposition["A"].to_list()
406 [1000.0, 1100.0, 1050.0]
407 """
408 assets = [col for col, dtype in prices.schema.items() if dtype.is_numeric()]
409 cash_position = position.with_columns((pl.col(asset) * prices[asset]).alias(asset) for asset in assets)
410 return cls.from_cash_position(
411 prices=prices,
412 cash_position=cash_position,
413 aum=aum,
414 cost_per_unit=cost_per_unit,
415 cost_bps=cost_bps,
416 cost_model=cost_model,
417 )
419 @classmethod
420 def from_cash_position(
421 cls,
422 prices: pl.DataFrame,
423 cash_position: pl.DataFrame,
424 aum: float,
425 cost_per_unit: float = 0.0,
426 cost_bps: float = 0.0,
427 cost_model: CostModel | None = None,
428 ) -> Self:
429 """Create a Portfolio directly from cash positions aligned with prices.
431 Args:
432 prices: Price levels per asset over time (may include a date column).
433 cash_position: Cash exposure per asset over time.
434 aum: Assets under management used as the base NAV offset.
435 cost_per_unit: One-way trading cost per unit of position change.
436 Defaults to 0.0 (no cost). Ignored when *cost_model* is given.
437 cost_bps: One-way trading cost in basis points of AUM turnover.
438 Defaults to 0.0 (no cost). Ignored when *cost_model* is given.
439 cost_model: Optional :class:`~jquantstats.CostModel`
440 instance. When supplied, its ``cost_per_unit`` and
441 ``cost_bps`` values take precedence over the individual
442 parameters above.
444 Returns:
445 A Portfolio instance with the provided cash positions.
446 """
447 if cost_model is not None:
448 cost_per_unit = cost_model.cost_per_unit
449 cost_bps = cost_model.cost_bps
450 return cls(prices=prices, cashposition=cash_position, aum=aum, cost_per_unit=cost_per_unit, cost_bps=cost_bps)
452 # ── Internal helpers ───────────────────────────────────────────────────────
454 @staticmethod
455 def _assert_clean_series(series: pl.Series, name: str = "") -> None:
456 """Raise ValueError if *series* contains nulls or non-finite values."""
457 if series.null_count() != 0:
458 raise ValueError
459 if not series.is_finite().all():
460 raise ValueError
462 # ── Core data properties ───────────────────────────────────────────────────
464 @property
465 def assets(self) -> list[str]:
466 """List the asset column names from prices (numeric columns).
468 Returns:
469 list[str]: Names of numeric columns in prices; typically excludes
470 ``'date'``.
471 """
472 return [c for c in self.prices.columns if self.prices[c].dtype.is_numeric()]
474 # ── Lazy composition accessors ─────────────────────────────────────────────
476 @property
477 def data(self) -> "Data":
478 """Build a legacy :class:`~jquantstats._data.Data` object from this portfolio's returns.
480 This bridges the two entry points: ``Portfolio`` compiles the NAV curve from
481 prices and positions; the returned :class:`~jquantstats._data.Data` object
482 gives access to the full legacy analytics pipeline (``data.stats``,
483 ``data.plots``, ``data.reports``).
485 Returns:
486 :class:`~jquantstats._data.Data`: A Data object whose ``returns`` column
487 is the portfolio's daily return series and whose ``index`` holds the date
488 column (or a synthetic integer index for date-free portfolios).
490 Examples:
491 >>> import polars as pl
492 >>> from datetime import date
493 >>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]})
494 >>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]})
495 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6)
496 >>> d = pf.data
497 >>> "returns" in d.returns.columns
498 True
499 """
500 if self._data_bridge is not None:
501 return self._data_bridge
502 bridge = Portfolio._build_data_bridge(self.returns)
503 object.__setattr__(self, "_data_bridge", bridge)
504 return bridge
506 @property
507 def stats(self) -> "Stats":
508 """Return a Stats object built from the portfolio's daily returns.
510 Delegates to the legacy :class:`~jquantstats._stats.Stats` pipeline via
511 :attr:`data`, so all analytics (Sharpe, drawdown, summary, etc.) are
512 available through the shared implementation.
514 The result is cached after first access so repeated calls are O(1).
515 """
516 if self._stats_cache is None:
517 object.__setattr__(self, "_stats_cache", self.data.stats)
518 return self._stats_cache # type: ignore[return-value]
520 @property
521 def plots(self) -> PortfolioPlots:
522 """Convenience accessor returning a PortfolioPlots facade for this portfolio.
524 Use this to create Plotly visualizations such as snapshots, lagged
525 performance curves, and lead/lag IR charts.
527 Returns:
528 :class:`~jquantstats._plots.PortfolioPlots`: Helper object with
529 plotting methods.
531 The result is cached after first access so repeated calls are O(1).
532 """
533 if self._plots_cache is None:
534 object.__setattr__(self, "_plots_cache", PortfolioPlots(self))
535 return self._plots_cache # type: ignore[return-value]
537 @property
538 def report(self) -> Report:
539 """Convenience accessor returning a Report facade for this portfolio.
541 Use this to generate a self-contained HTML performance report
542 containing statistics tables and interactive charts.
544 Returns:
545 :class:`~jquantstats._reports.Report`: Helper object with
546 report methods.
548 The result is cached after first access so repeated calls are O(1).
549 """
550 if self._report_cache is None:
551 object.__setattr__(self, "_report_cache", Report(self))
552 return self._report_cache # type: ignore[return-value]
554 @property
555 def utils(self) -> "PortfolioUtils":
556 """Convenience accessor returning a PortfolioUtils facade for this portfolio.
558 Use this for common data transformations such as converting returns to
559 prices, computing log returns, rebasing, aggregating by period, and
560 computing exponential standard deviation.
562 Returns:
563 :class:`~jquantstats._utils.PortfolioUtils`: Helper object with
564 utility transform methods.
566 The result is cached after first access so repeated calls are O(1).
567 """
568 if self._utils_cache is None:
569 from ._utils import PortfolioUtils
571 object.__setattr__(self, "_utils_cache", PortfolioUtils(self))
572 return self._utils_cache # type: ignore[return-value]
574 # ── Portfolio transforms ───────────────────────────────────────────────────
576 def truncate(
577 self,
578 start: date | datetime | str | int | None = None,
579 end: date | datetime | str | int | None = None,
580 ) -> "Portfolio":
581 """Return a new Portfolio truncated to the inclusive [start, end] range.
583 When a ``'date'`` column is present in both prices and cash positions,
584 truncation is performed by comparing the ``'date'`` column against
585 ``start`` and ``end`` (which should be date/datetime values or strings
586 parseable by Polars).
588 When the ``'date'`` column is absent, integer-based row slicing is
589 used instead. In this case ``start`` and ``end`` must be non-negative
590 integers representing 0-based row indices. Passing non-integer bounds
591 to an integer-indexed portfolio raises :exc:`TypeError`.
593 In all cases the ``aum`` value is preserved.
595 Args:
596 start: Optional lower bound (inclusive). A date/datetime or
597 Polars-parseable string when a ``'date'`` column exists; a
598 non-negative int row index when the data has no ``'date'``
599 column.
600 end: Optional upper bound (inclusive). Same type rules as
601 ``start``.
603 Returns:
604 A new Portfolio instance with prices and cash positions filtered
605 to the specified range.
607 Raises:
608 TypeError: When the portfolio has no ``'date'`` column and a
609 non-integer bound is supplied.
610 """
611 has_date = "date" in self.prices.columns
612 if has_date:
613 cond = pl.lit(True)
614 if start is not None:
615 cond = cond & (pl.col("date") >= pl.lit(start))
616 if end is not None:
617 cond = cond & (pl.col("date") <= pl.lit(end))
618 pr = self.prices.filter(cond)
619 cp = self.cashposition.filter(cond)
620 else:
621 if start is not None and not isinstance(start, int):
622 raise IntegerIndexBoundError("start", type(start).__name__)
623 if end is not None and not isinstance(end, int):
624 raise IntegerIndexBoundError("end", type(end).__name__)
625 row_start = int(start) if start is not None else 0
626 row_end = int(end) + 1 if end is not None else self.prices.height
627 length = max(0, row_end - row_start)
628 pr = self.prices.slice(row_start, length)
629 cp = self.cashposition.slice(row_start, length)
630 return Portfolio(
631 prices=pr,
632 cashposition=cp,
633 aum=self.aum,
634 cost_per_unit=self.cost_per_unit,
635 cost_bps=self.cost_bps,
636 )
638 def lag(self, n: int) -> "Portfolio":
639 """Return a new Portfolio with cash positions lagged by ``n`` steps.
641 This method shifts the numeric asset columns in the cashposition
642 DataFrame by ``n`` rows, preserving the ``'date'`` column and any
643 non-numeric columns unchanged. Positive ``n`` delays weights (moves
644 them down); negative ``n`` leads them (moves them up); ``n == 0``
645 returns the current portfolio unchanged.
647 Notes:
648 Missing values introduced by the shift are left as nulls;
649 downstream profit computation already guards and treats nulls as
650 zero when multiplying by returns.
652 Args:
653 n: Number of rows to shift (can be negative, zero, or positive).
655 Returns:
656 A new Portfolio instance with lagged cash positions and the same
657 prices/AUM as the original.
658 """
659 if not isinstance(n, int):
660 raise TypeError
661 if n == 0:
662 return self
664 assets = [c for c in self.cashposition.columns if c != "date" and self.cashposition[c].dtype.is_numeric()]
665 cp_lagged = self.cashposition.with_columns(pl.col(c).shift(n) for c in assets)
666 return Portfolio(
667 prices=self.prices,
668 cashposition=cp_lagged,
669 aum=self.aum,
670 cost_per_unit=self.cost_per_unit,
671 cost_bps=self.cost_bps,
672 )
674 def smoothed_holding(self, n: int) -> "Portfolio":
675 """Return a new Portfolio with cash positions smoothed by a rolling mean.
677 Applies a trailing window average over the last ``n`` steps for each
678 numeric asset column (excluding ``'date'``). The window length is
679 ``n + 1`` so that:
681 - n=0 returns the original weights (no smoothing),
682 - n=1 averages the current and previous weights,
683 - n=k averages the current and last k weights.
685 Args:
686 n: Non-negative integer specifying how many previous steps to
687 include.
689 Returns:
690 A new Portfolio with smoothed cash positions and the same
691 prices/AUM.
692 """
693 if not isinstance(n, int):
694 raise TypeError
695 if n < 0:
696 raise ValueError
697 if n == 0:
698 return self
700 assets = [c for c in self.cashposition.columns if c != "date" and self.cashposition[c].dtype.is_numeric()]
701 window = n + 1
702 cp_smoothed = self.cashposition.with_columns(
703 pl.col(c).rolling_mean(window_size=window, min_samples=1).alias(c) for c in assets
704 )
705 return Portfolio(
706 prices=self.prices,
707 cashposition=cp_smoothed,
708 aum=self.aum,
709 cost_per_unit=self.cost_per_unit,
710 cost_bps=self.cost_bps,
711 )
713 # ── Utility ────────────────────────────────────────────────────────────────
715 def correlation(self, frame: pl.DataFrame, name: str = "portfolio") -> pl.DataFrame:
716 """Compute a correlation matrix of asset returns plus the portfolio.
718 Computes percentage changes for all numeric columns in ``frame``,
719 appends the portfolio profit series under the provided ``name``, and
720 returns the Pearson correlation matrix across all numeric columns.
722 Args:
723 frame: A Polars DataFrame containing at least the asset price
724 columns (and a date column which will be ignored if
725 non-numeric).
726 name: The column name to use when adding the portfolio profit
727 series to the input frame.
729 Returns:
730 A square Polars DataFrame where each cell is the correlation
731 between a pair of series (values in [-1, 1]).
732 """
733 p = frame.with_columns(cs.by_dtype(pl.Float32, pl.Float64).pct_change())
734 p = p.with_columns(pl.Series(name, self.profit["profit"]))
735 corr_matrix = p.select(cs.numeric()).fill_null(0.0).corr()
736 return corr_matrix