Coverage for src/jquantstats/_utils/_portfolio.py: 100%
37 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-23 06:13 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-23 06:13 +0000
1"""Utility methods for Portfolio objects."""
3from __future__ import annotations
5from collections.abc import Callable, Hashable
7import numpy as np
8import polars as pl
10from ._data import DataUtils
11from ._protocol import PortfolioLike
13__all__ = ["PortfolioUtils"]
16class PortfolioUtils:
17 """Utility transforms and conversions for Portfolio objects.
19 Exposes the same API as `DataUtils`
20 but is initialised from a `Portfolio`
21 and routes all calls through ``portfolio.data``.
22 """
24 __slots__ = ("_portfolio",)
26 def __init__(self, portfolio: PortfolioLike) -> None:
27 self._portfolio = portfolio
29 def __repr__(self) -> str:
30 """Return a string representation of the PortfolioUtils object."""
31 return f"PortfolioUtils(assets={self._portfolio.assets})"
33 def _du(self) -> DataUtils:
34 """Return a DataUtils instance backed by this portfolio's data bridge."""
35 return DataUtils(self._portfolio.data)
37 # ── delegated API (mirrors DataUtils) ─────────────────────────────────────
39 def to_prices(self, base: float = 1e5) -> pl.DataFrame:
40 """Convert portfolio returns to a cumulative price series.
42 See `to_prices` for full
43 documentation.
45 Args:
46 base: Starting value for the price series. Defaults to ``1e5``.
48 Returns:
49 DataFrame with date column (if present) and one price column per asset.
51 """
52 return self._du().to_prices(base=base)
54 def to_log_returns(self) -> pl.DataFrame:
55 """Convert portfolio returns to log returns: ``ln(1 + r)``.
57 See `to_log_returns` for
58 full documentation.
60 Returns:
61 DataFrame of log returns.
63 """
64 return self._du().to_log_returns()
66 def log_returns(self) -> pl.DataFrame:
67 """Alias for `to_log_returns`.
69 Returns:
70 DataFrame of log returns.
72 """
73 return self._du().log_returns()
75 def rebase(self, base: float = 100.0) -> pl.DataFrame:
76 """Normalise the portfolio's returns as a price series starting at *base*.
78 See `rebase` for full
79 documentation.
81 Args:
82 base: Target starting value. Defaults to ``100.0``.
84 Returns:
85 DataFrame with price columns anchored to *base* at t = 0.
87 """
88 return self._du().rebase(base=base)
90 def group_returns(self, period: str = "1mo", compounded: bool = True) -> pl.DataFrame:
91 """Aggregate portfolio returns by a calendar period.
93 See `group_returns` for
94 full documentation.
96 Args:
97 period: Aggregation period. Defaults to ``"1mo"`` (monthly).
98 compounded: Whether to compound returns. Defaults to ``True``.
100 Returns:
101 DataFrame with one row per period and one column per asset.
103 """
104 return self._du().group_returns(period=period, compounded=compounded)
106 def aggregate_returns(self, period: str = "1mo", compounded: bool = True) -> pl.DataFrame:
107 """Alias for `group_returns`.
109 Args:
110 period: Aggregation period. Defaults to ``"1mo"`` (monthly).
111 compounded: Whether to compound returns. Defaults to ``True``.
113 Returns:
114 DataFrame with one row per period and one column per asset.
116 """
117 return self._du().aggregate_returns(period=period, compounded=compounded)
119 def to_excess_returns(self, rf: float = 0.0, nperiods: int | None = None) -> pl.DataFrame:
120 """Subtract a risk-free rate from portfolio returns.
122 See `to_excess_returns`
123 for full documentation.
125 Args:
126 rf: Annual risk-free rate as a decimal. Defaults to ``0.0``.
127 nperiods: Periods per year for rate conversion. Defaults to ``None``.
129 Returns:
130 DataFrame of excess returns.
132 """
133 return self._du().to_excess_returns(rf=rf, nperiods=nperiods)
135 def to_volatility_adjusted_returns(
136 self,
137 window: int = 60,
138 vol_estimator: Callable[[pl.Expr], pl.Expr] | None = None,
139 ) -> pl.DataFrame:
140 """Convert portfolio returns to volatility-adjusted returns.
142 See `to_volatility_adjusted_returns` for full documentation.
144 Args:
145 window: Rolling lookback for volatility. Defaults to ``60``.
146 vol_estimator: A callable ``(pl.Expr) -> pl.Expr`` that
147 produces a volatility series. Defaults to ``None``
148 (uses ``rolling_std(window)``).
150 Returns:
151 DataFrame of volatility-adjusted returns.
153 """
154 return self._du().to_volatility_adjusted_returns(window=window, vol_estimator=vol_estimator)
156 def exponential_stdev(self, window: int = 30, is_halflife: bool = False) -> pl.DataFrame:
157 """Compute exponentially weighted standard deviation of portfolio returns.
159 See `exponential_stdev`
160 for full documentation.
162 Args:
163 window: Span or half-life of the EWMA decay. Defaults to ``30``.
164 is_halflife: Interpret *window* as half-life when ``True``.
165 Defaults to ``False``.
167 Returns:
168 DataFrame of rolling EWMA standard deviations.
170 """
171 return self._du().exponential_stdev(window=window, is_halflife=is_halflife)
173 def winsorise(self, window: int = 7, n_sigma: float = 3.0) -> pl.DataFrame:
174 """Winsorise portfolio returns by clipping to within *n_sigma* rolling standard deviations.
176 See `DataUtils.winsorise` for full
177 documentation.
179 Args:
180 window: Rolling lookback for mean and standard deviation.
181 Defaults to ``7``.
182 n_sigma: Number of standard deviations for the clip bounds.
183 Defaults to ``3.0``.
185 Returns:
186 DataFrame with the same columns as the input returns, extreme
187 values clipped.
189 """
190 return self._du().winsorise(window=window, n_sigma=n_sigma)
192 def exponential_cov(
193 self, window: int = 30, is_halflife: bool = False, warmup: int = 0
194 ) -> dict[Hashable, np.ndarray]:
195 """Compute the exponentially weighted covariance matrix of portfolio returns.
197 See `DataUtils.exponential_cov` for full
198 documentation.
200 Args:
201 window: Span (default) or half-life (when *is_halflife* is
202 ``True``) of the exponential decay. Defaults to ``30``.
203 is_halflife: When ``True`` *window* is interpreted as the
204 half-life; otherwise it is the EWMA span. Defaults to
205 ``False``.
206 warmup: Minimum number of common observations required before
207 a pair's cell is non-NaN. Defaults to ``0``.
209 Returns:
210 Dictionary keyed by index value (date or integer) mapping to
211 a square symmetric ``numpy.ndarray`` whose dimensions match
212 the return columns exposed by ``portfolio.data``. In this
213 facade that is typically only the portfolio-level ``returns``
214 column, so the matrices are usually ``(1, 1)`` even for
215 multi-asset portfolios. Unavailable cells are ``NaN``.
217 """
218 return self._du().exponential_cov(window=window, is_halflife=is_halflife, warmup=warmup)