Coverage for src/jquantstats/_portfolio_attribution.py: 100%
29 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"""Tilt/timing attribution mixin for Portfolio."""
3from __future__ import annotations
5from typing import TYPE_CHECKING, Self
7import polars as pl
9from ._cache import cached_in_slot
10from ._portfolio_base import _PortfolioMembers
13class PortfolioAttributionMixin(_PortfolioMembers):
14 """Mixin providing tilt/timing attribution properties for Portfolio."""
16 if TYPE_CHECKING:
17 _tilt_cache: Self | None
19 @classmethod
20 def from_cash_position(
21 cls,
22 prices: pl.DataFrame,
23 cash_position: pl.DataFrame,
24 aum: float,
25 cost_per_unit: float = 0.0,
26 cost_bps: float = 0.0,
27 ) -> Self:
28 """Create a Portfolio directly from cash positions aligned with prices."""
29 ...
31 @property
32 @cached_in_slot("_tilt_cache")
33 def tilt(self) -> Self:
34 """Return the 'tilt' portfolio with constant average weights.
36 Computes the time-average of each asset's cash position (ignoring
37 nulls/NaNs) and builds a new Portfolio with those constant weights
38 applied across time. Prices and AUM are preserved.
40 The result is cached after first access so repeated calls are O(1).
42 Note:
43 Caching is not thread-safe. Concurrent access from multiple
44 threads may trigger redundant computation, but will never produce
45 incorrect results because each thread stores the same deterministic
46 value.
47 """
48 const_position = self.cashposition.with_columns(
49 pl.col(col).drop_nulls().drop_nans().mean().alias(col) for col in self.assets
50 )
51 return type(self).from_cash_position(
52 self.prices,
53 const_position,
54 aum=self.aum,
55 cost_per_unit=self.cost_per_unit,
56 cost_bps=self.cost_bps,
57 )
59 @property
60 def timing(self) -> Self:
61 """Return the 'timing' portfolio capturing deviations from the tilt.
63 Constructs weights as original cash positions minus the tilt's
64 constant positions, per asset. This isolates timing (alloc-demeaned)
65 effects. Prices and AUM are preserved.
66 """
67 const_position = self.tilt.cashposition
68 position = self.cashposition.with_columns((pl.col(col) - const_position[col]).alias(col) for col in self.assets)
69 return type(self).from_cash_position(
70 self.prices,
71 position,
72 aum=self.aum,
73 cost_per_unit=self.cost_per_unit,
74 cost_bps=self.cost_bps,
75 )
77 @property
78 def tilt_timing_decomp(self) -> pl.DataFrame:
79 """Return the portfolio's tilt/timing NAV decomposition.
81 When a ``'date'`` column is present the three NAV series are joined on
82 it. When data is integer-indexed the frames are stacked horizontally.
83 """
84 if "date" in self.nav_accumulated.columns:
85 nav_portfolio = self.nav_accumulated.select(["date", "NAV_accumulated"])
86 nav_tilt = self.tilt.nav_accumulated.select(["date", "NAV_accumulated"])
87 nav_timing = self.timing.nav_accumulated.select(["date", "NAV_accumulated"])
89 merged_df = nav_portfolio.join(nav_tilt, on="date", how="inner", suffix="_tilt").join(
90 nav_timing, on="date", how="inner", suffix="_timing"
91 )
92 else:
93 nav_portfolio = self.nav_accumulated.select(["NAV_accumulated"])
94 nav_tilt = self.tilt.nav_accumulated.select(["NAV_accumulated"]).rename(
95 {"NAV_accumulated": "NAV_accumulated_tilt"}
96 )
97 nav_timing = self.timing.nav_accumulated.select(["NAV_accumulated"]).rename(
98 {"NAV_accumulated": "NAV_accumulated_timing"}
99 )
100 merged_df = nav_portfolio.hstack(nav_tilt).hstack(nav_timing)
102 merged_df = merged_df.rename(
103 {"NAV_accumulated_tilt": "tilt", "NAV_accumulated_timing": "timing", "NAV_accumulated": "portfolio"}
104 )
105 return merged_df