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

1"""NAV & returns chain mixin for Portfolio.""" 

2 

3from __future__ import annotations 

4 

5from typing import TYPE_CHECKING 

6 

7import polars as pl 

8 

9from ._cache import cached_in_slot 

10from ._portfolio_base import _PortfolioMembers 

11from .exceptions import MissingDateColumnError, NoAssetColumnsError 

12 

13 

14class PortfolioNavMixin(_PortfolioMembers): 

15 """Mixin providing NAV & returns chain properties for Portfolio.""" 

16 

17 if TYPE_CHECKING: 

18 _profits_cache: pl.DataFrame | None 

19 _returns_cache: pl.DataFrame | None 

20 

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 ... 

25 

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. 

30 

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. 

36 

37 The result is cached after first access so repeated calls are O(1). 

38 

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. 

44 

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()] 

55 

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 ) 

62 

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 ) 

67 

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 ) 

73 

74 return result 

75 

76 @property 

77 def profit(self) -> pl.DataFrame: 

78 """Return total daily portfolio profit including the ``'date'`` column. 

79 

80 Aggregates per-asset profits into a single ``'profit'`` column and 

81 validates that no day's total profit is NaN/null. 

82 

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()] 

88 

89 if not assets: 

90 raise NoAssetColumnsError("profits") 

91 

92 non_assets = [c for c in df_profits.columns if c not in set(assets)] 

93 

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]) 

96 

97 self._assert_clean_series(series=result["profit"]) 

98 return result 

99 

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")) 

104 

105 @property 

106 def _return_base(self) -> pl.Series: 

107 """Capital each period's profit is measured against. 

108 

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. 

111 

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) 

118 

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'``. 

123 

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. 

130 

131 The result is cached after first access so repeated calls are O(1). 

132 

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 ) 

142 

143 @property 

144 def monthly(self) -> pl.DataFrame: 

145 """Return monthly compounded returns and calendar columns. 

146 

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: 

150 

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"``) 

158 

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 

193 

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")) 

198 

199 @property 

200 def highwater(self) -> pl.DataFrame: 

201 """Return the cumulative maximum of NAV as the high-water mark series. 

202 

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")) 

208 

209 @property 

210 def drawdown(self) -> pl.DataFrame: 

211 """Return drawdown as the distance from high-water mark to current NAV. 

212 

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 ) 

220 

221 @property 

222 def all(self) -> pl.DataFrame: 

223 """Return a merged view of drawdown and compounded NAV. 

224 

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)