Coverage for src/jquantstats/_portfolio_nav.py: 100%
65 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"""NAV & returns chain mixin for Portfolio."""
3from __future__ import annotations
5from typing import TYPE_CHECKING
7import polars as pl
9from ._cache import cached_in_slot
10from ._portfolio_base import _PortfolioMembers
11from .exceptions import MissingDateColumnError, NoAssetColumnsError
14class PortfolioNavMixin(_PortfolioMembers):
15 """Mixin providing NAV & returns chain properties for Portfolio."""
17 if TYPE_CHECKING:
18 _profits_cache: pl.DataFrame | None
19 _returns_cache: pl.DataFrame | None
21 @staticmethod
22 def _assert_clean_series(series: pl.Series, name: str = "") -> None:
23 """Raise ValueError if *series* contains nulls or non-finite values."""
24 ...
26 @property
27 @cached_in_slot("_profits_cache")
28 def profits(self) -> pl.DataFrame:
29 """Compute per-asset daily cash profits, preserving non-numeric columns.
31 Returns:
32 pl.DataFrame: Per-asset daily profit series along with any
33 non-numeric columns (e.g., ``'date'``). When the portfolio
34 carries ``realized_costs`` they are deducted per asset, so the
35 series is net of the trading costs actually paid.
37 The result is cached after first access so repeated calls are O(1).
39 Note:
40 Caching is not thread-safe. Concurrent access from multiple
41 threads may trigger redundant computation, but will never produce
42 incorrect results because each thread stores the same deterministic
43 value.
45 Examples:
46 >>> from jquantstats.portfolio import Portfolio
47 >>> import polars as pl
48 >>> prices = pl.DataFrame({"A": [100.0, 110.0, 105.0]})
49 >>> pos = pl.DataFrame({"A": [1000.0, 1000.0, 1000.0]})
50 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6)
51 >>> pf.profits.columns
52 ['A']
53 """
54 assets = [c for c in self.prices.columns if self.prices[c].dtype.is_numeric()]
56 result = self.prices.with_columns(
57 (self.prices[asset].pct_change().fill_null(0.0) * self.cashposition[asset].shift(n=1).fill_null(0.0)).alias(
58 asset
59 )
60 for asset in assets
61 )
63 if assets:
64 result = result.with_columns(
65 pl.when(pl.col(c).is_finite()).then(pl.col(c)).otherwise(0.0).fill_null(0.0).alias(c) for c in assets
66 )
68 if self.realized_costs is not None:
69 costs = self.realized_costs
70 result = result.with_columns(
71 (pl.col(asset) - costs[asset].fill_null(0.0)).alias(asset) for asset in assets if asset in costs.columns
72 )
74 return result
76 @property
77 def profit(self) -> pl.DataFrame:
78 """Return total daily portfolio profit including the ``'date'`` column.
80 Aggregates per-asset profits into a single ``'profit'`` column and
81 validates that no day's total profit is NaN/null.
83 Raises:
84 NoAssetColumnsError: If the profits frame has no numeric asset columns.
85 """
86 df_profits = self.profits
87 assets = [c for c in df_profits.columns if df_profits[c].dtype.is_numeric()]
89 if not assets:
90 raise NoAssetColumnsError("profits")
92 non_assets = [c for c in df_profits.columns if c not in set(assets)]
94 portfolio_daily_profit = pl.sum_horizontal([pl.col(c).fill_null(0.0) for c in assets]).alias("profit")
95 result = df_profits.select([*non_assets, portfolio_daily_profit])
97 self._assert_clean_series(series=result["profit"])
98 return result
100 @property
101 def nav_accumulated(self) -> pl.DataFrame:
102 """Compute cumulative additive NAV of the portfolio, preserving ``'date'``."""
103 return self.profit.with_columns((pl.col("profit").cum_sum() + self.aum).alias("NAV_accumulated"))
105 @property
106 def _return_base(self) -> pl.Series:
107 """Capital each period's profit is measured against.
109 The constant ``aum`` for a fixed-notional book; the previous period's
110 accumulated NAV (``aum`` on the first row) when ``compounding`` is set.
112 Returns:
113 pl.Series: One denominator per row, aligned with `profit`.
114 """
115 if not self.compounding:
116 return pl.repeat(self.aum, self.prices.height, eager=True, dtype=pl.Float64)
117 return self.nav_accumulated["NAV_accumulated"].shift(1).fill_null(self.aum)
119 @property
120 @cached_in_slot("_returns_cache")
121 def returns(self) -> pl.DataFrame:
122 """Return daily returns as profit scaled by capital, preserving ``'date'``.
124 The returned DataFrame contains the original ``'date'`` column, the
125 ``'profit'`` and ``'NAV_accumulated'`` columns, and a ``'returns'``
126 column holding profit divided by capital. Capital is the constant AUM
127 by default, and the previous period's NAV when the portfolio is
128 ``compounding`` — in which case compounding `returns` reproduces
129 ``NAV_accumulated`` exactly.
131 The result is cached after first access so repeated calls are O(1).
133 Note:
134 Caching is not thread-safe. Concurrent access from multiple
135 threads may trigger redundant computation, but will never produce
136 incorrect results because each thread stores the same deterministic
137 value.
138 """
139 return self.nav_accumulated.with_columns(
140 (pl.col("profit") / self._return_base).alias("returns"),
141 )
143 @property
144 def monthly(self) -> pl.DataFrame:
145 """Return monthly compounded returns and calendar columns.
147 Aggregates daily returns (profit/AUM) by calendar month and computes
148 the compounded monthly return: prod(1 + r_d) - 1. The resulting frame
149 includes:
151 - ``date``: month-end label as a Polars Date (end of the grouping window)
152 - ``returns``: compounded monthly return
153 - ``NAV_accumulated``: last NAV within the month
154 - ``profit``: summed profit within the month
155 - ``year``: integer year (e.g., 2020)
156 - ``month``: integer month number (1-12)
157 - ``month_name``: abbreviated month name (e.g., ``"Jan"``, ``"Feb"``)
159 Raises:
160 MissingDateColumnError: If the portfolio data has no ``'date'``
161 column.
162 """
163 if "date" not in self.prices.columns:
164 raise MissingDateColumnError("monthly")
165 daily = self.returns.select(["date", "returns", "profit", "NAV_accumulated"])
166 monthly = (
167 daily.group_by_dynamic(
168 "date",
169 every="1mo",
170 period="1mo",
171 label="left",
172 closed="right",
173 )
174 .agg(
175 [
176 pl.col("profit").sum().alias("profit"),
177 pl.col("NAV_accumulated").last().alias("NAV_accumulated"),
178 (pl.col("returns") + 1.0).product().alias("gross"),
179 ]
180 )
181 .with_columns((pl.col("gross") - 1.0).alias("returns"))
182 .select(["date", "returns", "NAV_accumulated", "profit"])
183 .with_columns(
184 [
185 pl.col("date").dt.year().alias("year"),
186 pl.col("date").dt.month().alias("month"),
187 pl.col("date").dt.strftime("%b").alias("month_name"),
188 ]
189 )
190 .sort("date")
191 )
192 return monthly
194 @property
195 def nav_compounded(self) -> pl.DataFrame:
196 """Compute compounded NAV from returns (profit/AUM), preserving ``'date'``."""
197 return self.returns.with_columns(((pl.col("returns") + 1.0).cum_prod() * self.aum).alias("NAV_compounded"))
199 @property
200 def highwater(self) -> pl.DataFrame:
201 """Return the cumulative maximum of NAV as the high-water mark series.
203 The resulting DataFrame preserves the ``'date'`` column and adds a
204 ``'highwater'`` column computed as the cumulative maximum of
205 ``'NAV_accumulated'``.
206 """
207 return self.returns.with_columns(pl.col("NAV_accumulated").cum_max().alias("highwater"))
209 @property
210 def drawdown(self) -> pl.DataFrame:
211 """Return drawdown as the distance from high-water mark to current NAV.
213 Computes ``'drawdown'`` = ``'highwater'`` - ``'NAV_accumulated'`` and
214 preserves the ``'date'`` column alongside the intermediate columns.
215 """
216 return self.highwater.with_columns(
217 (pl.col("highwater") - pl.col("NAV_accumulated")).alias("drawdown"),
218 ((pl.col("highwater") - pl.col("NAV_accumulated")) / pl.col("highwater")).alias("drawdown_pct"),
219 )
221 @property
222 def all(self) -> pl.DataFrame:
223 """Return a merged view of drawdown and compounded NAV.
225 When a ``'date'`` column is present the two frames are joined on that
226 column to ensure temporal alignment. When the data is integer-indexed
227 (no ``'date'`` column) the frames are stacked horizontally — they are
228 guaranteed to have identical row counts because both are derived from
229 the same source portfolio.
230 """
231 left = self.drawdown
232 if "date" in left.columns:
233 right = self.nav_compounded.select(["date", "NAV_compounded"])
234 return left.join(right, on="date", how="inner")
235 else:
236 right = self.nav_compounded.select(["NAV_compounded"])
237 return left.hstack(right)