Coverage for src/jquantstats/portfolio.py: 100%
107 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-06 04:52 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-06 04: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` 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`
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 ._reports import Report
45from ._stats import Stats as Stats
46from ._utils import PortfolioUtils as PortfolioUtils
47from .data import Data as Data
48from .exceptions import (
49 InvalidCashPositionTypeError,
50 InvalidPricesTypeError,
51 NonPositiveAumError,
52 RowCountMismatchError,
53 UncleanSeriesError,
54)
56# Slot fields used as lazy caches; __post_init__ initialises each to None and
57# `cached_in_slot` fills them on first property access.
58_CACHE_SLOTS = (
59 "_data_bridge",
60 "_stats_cache",
61 "_plots_cache",
62 "_report_cache",
63 "_utils_cache",
64 "_profits_cache",
65 "_returns_cache",
66 "_tilt_cache",
67 "_turnover_cache",
68)
71@dataclasses.dataclass(frozen=True, slots=True)
72class Portfolio(
73 PortfolioNavMixin,
74 PortfolioAttributionMixin,
75 PortfolioTurnoverMixin,
76 PortfolioCostMixin,
77 PortfolioTransformMixin,
78 PortfolioConstructorMixin,
79):
80 """Portfolio analytics class for quant finance.
82 Stores the three raw inputs — cash positions, prices, and AUM — and
83 exposes the standard derived data series, analytics facades, transforms,
84 and attribution tools.
86 Derived data series:
88 - `profits` — per-asset daily cash P&L
89 - `profit` — aggregate daily portfolio profit
90 - `nav_accumulated` — cumulative additive NAV
91 - `nav_compounded` — compounded NAV
92 - `returns` — daily returns (profit / AUM)
93 - `monthly` — monthly compounded returns
94 - `highwater` — running high-water mark
95 - `drawdown` — drawdown from high-water mark
96 - `all` — merged view of all derived series
98 - Lazy composition accessors: `stats`, `plots`, `report`
99 - Portfolio transforms: `truncate`, `lag`,
100 `smoothed_holding`
101 - Attribution: `tilt`, `timing`, `tilt_timing_decomp`
102 - Turnover: `turnover`, `turnover_weekly`,
103 `turnover_summary`
104 - Cost analysis: `cost_adjusted_returns`,
105 `trading_cost_impact`, `deduct_management_fee`
106 - Utility: `correlation`
108 Attributes:
109 cashposition: Polars DataFrame of positions per asset over time
110 (includes date column if present).
111 prices: Polars DataFrame of prices per asset over time (includes date
112 column if present).
113 aum: Assets under management used as base NAV offset.
115 Analytics facades
116 -----------------
117 - ``.stats`` : delegates to the legacy ``Stats`` pipeline via ``.data``; all 50+ metrics available.
118 - ``.plots`` : portfolio-specific ``Plots``; NAV overlays, lead-lag IR, rolling Sharpe/vol, heatmaps.
119 - ``.report`` : HTML ``Report``; self-contained portfolio performance report.
120 - ``.data`` : bridge to the legacy ``Data`` / ``Stats`` / ``DataPlots`` pipeline.
122 ``.plots`` and ``.report`` are intentionally *not* delegated to the legacy path: the legacy
123 path operates on a bare returns series, while the analytics path has access to raw prices,
124 positions, and AUM for richer portfolio-specific visualisations.
126 Cost models
127 -----------
128 Two independent cost models are provided. They are not interchangeable:
130 **Model A — position-delta (stateful, set at construction):**
131 ``cost_per_unit: float`` — one-way cost per unit of position change (e.g. 0.01 per share).
132 Used by ``.position_delta_costs`` and ``.net_cost_nav``.
133 Best for: equity portfolios where cost scales with shares traded.
135 **Model B — turnover-bps (stateless, passed at call time):**
136 ``cost_bps: float`` — one-way cost in basis points of AUM turnover (e.g. 5 bps).
137 Used by ``.cost_adjusted_returns(cost_bps)`` and ``.trading_cost_impact(max_bps)``.
138 Best for: macro / fund-of-funds portfolios where cost scales with notional traded.
140 **Management fee (flat annual, set at construction or passed at call time):**
141 ``annual_fee: float`` — flat annual management fee as a fraction of AUM (e.g. 0.0085 for 85 bps p.a.).
142 Used by ``.deduct_management_fee(annual_fee)``.
143 The fee accrues pro-rata per calendar day (``annual_fee * days / 365``), so weekends and
144 holidays are charged to the next trading day and the deduction sums to ``annual_fee`` over a
145 full year. Cost and fee deductions compose linearly in any order.
147 To sweep a range of cost assumptions use ``trading_cost_impact(max_bps=20)`` (Model B).
148 To compute a net-NAV curve set ``cost_per_unit`` at construction and read ``.net_cost_nav`` (Model A).
150 Date column requirement
151 -----------------------
152 Most analytics work with or without a ``date`` column. The following features require a
153 temporal ``date`` column (``pl.Date`` or ``pl.Datetime``):
155 - ``portfolio.plots.correlation_heatmap()``
156 - ``portfolio.plots.lead_lag_ir_plot()``
157 - ``stats.monthly_win_rate()`` — returns NaN per column when no date is present
158 - ``stats.annual_breakdown()`` — raises ``ValueError`` when no date is present
159 - ``stats.max_drawdown_duration()`` — returns period count (int) instead of days
161 Portfolios without a ``date`` column (integer-indexed) are fully supported for
162 NAV, returns, Sharpe, drawdown, cost analytics, and most rolling metrics.
164 Examples:
165 >>> import polars as pl
166 >>> from datetime import date
167 >>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]})
168 >>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]})
169 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6)
170 >>> pf.assets
171 ['A']
172 """
174 cashposition: pl.DataFrame
175 prices: pl.DataFrame
176 aum: float
177 cost_per_unit: float = 0.0
178 cost_bps: float = 0.0
179 annual_fee: float = 0.0
181 # ── Internal cache fields ─────────────────────────────────────────────────
182 # All cache fields are initialised to ``None`` in ``__post_init__`` via
183 # ``object.__setattr__`` (required for frozen dataclasses) and populated
184 # lazily on first property access.
185 #
186 # Lifecycle:
187 # - Initialised: ``__post_init__`` sets every field to ``None``.
188 # - Populated: each property computes its value on the first call and
189 # writes it back via ``object.__setattr__``.
190 # - Invalidation: not required — ``Portfolio`` is a *frozen* dataclass,
191 # so its inputs never change and all derived values remain valid for the
192 # lifetime of the instance.
193 _data_bridge: "Data | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
194 _stats_cache: "Stats | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
195 _plots_cache: "PortfolioPlots | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
196 _report_cache: "Report | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
197 _utils_cache: "PortfolioUtils | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
198 _profits_cache: "pl.DataFrame | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
199 _returns_cache: "pl.DataFrame | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
200 _tilt_cache: "Portfolio | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
201 _turnover_cache: "pl.DataFrame | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
203 @staticmethod
204 def _build_data_bridge(ret: pl.DataFrame) -> "Data":
205 """Build a `Data` bridge from a returns frame.
207 Splits out the ``'date'`` column (if present) into an index and passes
208 the remaining numeric columns as returns. Used internally to populate
209 ``_data_bridge`` at construction time so the ``data`` property is O(1).
211 Args:
212 ret: Returns DataFrame, optionally with a leading ``'date'`` column.
214 Returns:
215 A `Data` instance backed by *ret*.
216 """
217 returns_only = ret.select("returns")
218 if "date" in ret.columns:
219 return Data(returns=returns_only, index=ret.select("date"))
220 return Data(returns=returns_only, index=pl.DataFrame({"index": list(range(ret.height))}))
222 def __post_init__(self) -> None:
223 """Validate input types, shapes, and parameters post-initialization."""
224 if not isinstance(self.prices, pl.DataFrame):
225 raise InvalidPricesTypeError(type(self.prices).__name__)
226 if not isinstance(self.cashposition, pl.DataFrame):
227 raise InvalidCashPositionTypeError(type(self.cashposition).__name__)
228 if self.cashposition.shape[0] != self.prices.shape[0]:
229 raise RowCountMismatchError(self.prices.shape[0], self.cashposition.shape[0])
230 if self.aum <= 0.0:
231 raise NonPositiveAumError(self.aum)
232 for slot in _CACHE_SLOTS:
233 object.__setattr__(self, slot, None)
235 def _date_range(self) -> tuple[int, date | datetime | None, date | datetime | None]:
236 """Return (rows, start, end) for the portfolio's returns series.
238 ``start`` and ``end`` are ``None`` when there is no ``'date'`` column.
239 """
240 ret = self.returns
241 rows = ret.height
242 if "date" in ret.columns:
243 return rows, cast(date | None, ret["date"].min()), cast(date | None, ret["date"].max())
244 return rows, None, None
246 @property
247 def cost_model(self) -> CostModel:
248 """Return the active cost model as a `CostModel` instance.
250 Returns:
251 A `CostModel` whose ``cost_per_unit`` and ``cost_bps`` fields
252 reflect the values stored on this portfolio.
253 """
254 return CostModel(cost_per_unit=self.cost_per_unit, cost_bps=self.cost_bps)
256 def __repr__(self) -> str:
257 """Return a string representation of the Portfolio object."""
258 rows, start, end = self._date_range()
259 if start is not None:
260 return f"Portfolio(assets={self.assets}, rows={rows}, start={start}, end={end})"
261 return f"Portfolio(assets={self.assets}, rows={rows})"
263 def describe(self) -> pl.DataFrame:
264 """Return a tidy summary of shape, date range and asset names.
266 Returns:
267 -------
268 pl.DataFrame
269 One row per asset with columns: asset, start, end, rows.
271 Examples:
272 >>> import polars as pl
273 >>> from datetime import date
274 >>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]})
275 >>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]})
276 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6)
277 >>> df = pf.describe()
278 >>> list(df.columns)
279 ['asset', 'start', 'end', 'rows']
280 """
281 rows, start, end = self._date_range()
282 return pl.DataFrame(
283 {
284 "asset": self.assets,
285 "start": [start] * len(self.assets),
286 "end": [end] * len(self.assets),
287 "rows": [rows] * len(self.assets),
288 }
289 )
291 # ── Factory classmethods ──────────────────────────────────────────────────
293 @classmethod
294 def from_cash_position(
295 cls,
296 prices: pl.DataFrame,
297 cash_position: pl.DataFrame | pl.Expr,
298 aum: float,
299 cost_per_unit: float = 0.0,
300 cost_bps: float = 0.0,
301 cost_model: CostModel | None = None,
302 annual_fee: float = 0.0,
303 ) -> Self:
304 """Create a Portfolio directly from cash positions aligned with prices.
306 Args:
307 prices: Price levels per asset over time (may include a date column).
308 cash_position: Cash exposure per asset over time, either as a
309 DataFrame or as a Polars expression evaluated against *prices*.
310 aum: Assets under management used as the base NAV offset.
311 cost_per_unit: One-way trading cost per unit of position change.
312 Defaults to 0.0 (no cost). Ignored when *cost_model* is given.
313 cost_bps: One-way trading cost in basis points of AUM turnover.
314 Defaults to 0.0 (no cost). Ignored when *cost_model* is given.
315 cost_model: Optional `CostModel`
316 instance. When supplied, its ``cost_per_unit`` and
317 ``cost_bps`` values take precedence over the individual
318 parameters above.
319 annual_fee: Flat annual management fee as a fraction of AUM
320 (e.g. 0.0085 for 85 bps p.a.). Defaults to 0.0 (no fee).
321 Used as the default by `deduct_management_fee`.
323 Returns:
324 A Portfolio instance with the provided cash positions.
326 Raises:
327 PositionExprColumnError: If *cash_position* is an expression that
328 creates columns not present in *prices* (e.g. via ``.alias``);
329 such expressions leave the original asset columns untouched,
330 silently treating raw prices as positions.
331 """
332 if isinstance(cash_position, pl.Expr):
333 cash_position = _evaluate_position_expr(prices, cash_position, "cash_position")
334 if cost_model is not None:
335 cost_per_unit = cost_model.cost_per_unit
336 cost_bps = cost_model.cost_bps
337 return cls(
338 prices=prices,
339 cashposition=cash_position,
340 aum=aum,
341 cost_per_unit=cost_per_unit,
342 cost_bps=cost_bps,
343 annual_fee=annual_fee,
344 )
346 # ── Internal helpers ───────────────────────────────────────────────────────
348 @staticmethod
349 def _assert_clean_series(series: pl.Series, name: str = "") -> None:
350 """Raise `UncleanSeriesError` if *series* contains nulls or non-finite values.
352 Args:
353 series: The series to validate.
354 name: Optional series name included in the error message.
356 Raises:
357 UncleanSeriesError: If the series contains null or non-finite values.
358 """
359 if series.null_count() != 0:
360 raise UncleanSeriesError(name, "null")
361 if not series.is_finite().all():
362 raise UncleanSeriesError(name, "non-finite")
364 # ── Core data properties ───────────────────────────────────────────────────
366 @property
367 def assets(self) -> list[str]:
368 """List the asset column names from prices (numeric columns).
370 Returns:
371 list[str]: Names of numeric columns in prices; typically excludes
372 ``'date'``.
373 """
374 return [c for c in self.prices.columns if self.prices[c].dtype.is_numeric()]
376 # ── Lazy composition accessors ─────────────────────────────────────────────
378 @property
379 @cached_in_slot("_data_bridge")
380 def data(self) -> "Data":
381 """Build a legacy `Data` object from this portfolio's returns.
383 This bridges the two entry points: ``Portfolio`` compiles the NAV curve from
384 prices and positions; the returned `Data` object
385 gives access to the full legacy analytics pipeline (``data.stats``,
386 ``data.plots``, ``data.reports``).
388 Returns:
389 `Data`: A Data object whose ``returns`` column
390 is the portfolio's daily return series and whose ``index`` holds the date
391 column (or a synthetic integer index for date-free portfolios).
393 Examples:
394 >>> import polars as pl
395 >>> from datetime import date
396 >>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]})
397 >>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]})
398 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6)
399 >>> d = pf.data
400 >>> "returns" in d.returns.columns
401 True
402 """
403 return Portfolio._build_data_bridge(self.returns)
405 @property
406 @cached_in_slot("_stats_cache")
407 def stats(self) -> "Stats":
408 """Return a Stats object built from the portfolio's daily returns.
410 Delegates to the legacy `Stats` pipeline via
411 `data`, so all analytics (Sharpe, drawdown, summary, etc.) are
412 available through the shared implementation.
414 The result is cached after first access so repeated calls are O(1).
415 """
416 return self.data.stats
418 @property
419 @cached_in_slot("_plots_cache")
420 def plots(self) -> PortfolioPlots:
421 """Convenience accessor returning a PortfolioPlots facade for this portfolio.
423 Use this to create Plotly visualizations such as snapshots, lagged
424 performance curves, and lead/lag IR charts.
426 Returns:
427 `PortfolioPlots`: Helper object with
428 plotting methods.
430 The result is cached after first access so repeated calls are O(1).
431 """
432 return PortfolioPlots(self)
434 @property
435 @cached_in_slot("_report_cache")
436 def report(self) -> Report:
437 """Convenience accessor returning a Report facade for this portfolio.
439 Use this to generate a self-contained HTML performance report
440 containing statistics tables and interactive charts.
442 Returns:
443 `Report`: Helper object with
444 report methods.
446 The result is cached after first access so repeated calls are O(1).
447 """
448 return Report(self)
450 @property
451 @cached_in_slot("_utils_cache")
452 def utils(self) -> "PortfolioUtils":
453 """Convenience accessor returning a PortfolioUtils facade for this portfolio.
455 Use this for common data transformations such as converting returns to
456 prices, computing log returns, rebasing, aggregating by period, and
457 computing exponential standard deviation.
459 Returns:
460 `PortfolioUtils`: Helper object with
461 utility transform methods.
463 The result is cached after first access so repeated calls are O(1).
464 """
465 return PortfolioUtils(self)