Coverage for src/jquantstats/_portfolio_units.py: 100%

33 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-23 04:11 +0000

1"""Units, trades and weights mixin for Portfolio. 

2 

3`Portfolio` stores *cash* positions, because every analytic downstream of it — 

4NAV, returns, turnover, cost — is denominated in currency. The share-count view 

5is the other half of the same picture, and a simulator that decides *how many 

6units to hold* needs it back: how many units are held, how many changed hands 

7between two rows, and what fraction of NAV each position represents. 

8 

9Everything here is **derived**, never stored. ``units`` is ``cashposition / 

10prices``, so the whole surface is available on a portfolio built through any 

11constructor — `from_cash_position`, `from_position`, or `from_risk_position` — 

12rather than only on one that happened to be handed units to begin with. 

13 

14Trades are deliberately computed in *units*, not as a difference of cash 

15positions. A cash position moves when the price moves, with no trade taking 

16place at all, so ``cashposition.diff()`` would report phantom turnover on a 

17buy-and-hold book. The difference is taken on the share count and only then 

18converted back to currency at the traded price. 

19""" 

20 

21from __future__ import annotations 

22 

23import polars as pl 

24 

25from ._portfolio_base import _PortfolioMembers 

26 

27 

28class PortfolioUnitsMixin(_PortfolioMembers): 

29 """Mixin providing the share-count view of a Portfolio: units, trades, weights.""" 

30 

31 def _numeric_frame(self, values: pl.DataFrame) -> pl.DataFrame: 

32 """Return *values* carrying the date column, if the portfolio has one. 

33 

34 The mixin's properties all build a frame of per-asset numbers and then 

35 need the same date column re-attached. Centralising it keeps every 

36 property returning a frame shaped like ``cashposition``. 

37 

38 Args: 

39 values: Frame of per-asset columns, without a date column. 

40 

41 Returns: 

42 *values* with the portfolio's ``'date'`` column prepended when one 

43 exists, otherwise *values* unchanged. 

44 """ 

45 if "date" not in self.prices.columns: 

46 return values 

47 return values.insert_column(0, self.prices["date"]) 

48 

49 @property 

50 def units(self) -> pl.DataFrame: 

51 """Number of units held per asset over time. 

52 

53 Derived as ``cashposition / prices``. A zero price yields a null rather 

54 than an infinity, so a delisted or not-yet-listed asset does not poison 

55 the trade and weight frames built on top of this one. 

56 

57 Returns: 

58 pl.DataFrame: Units per asset, with the ``'date'`` column when the 

59 portfolio has one. 

60 

61 Examples: 

62 >>> import polars as pl 

63 >>> from jquantstats.portfolio import Portfolio 

64 >>> prices = pl.DataFrame({"A": [100.0, 110.0, 105.0]}) 

65 >>> pos = pl.DataFrame({"A": [1000.0, 1100.0, 1050.0]}) 

66 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6) 

67 >>> pf.units["A"].to_list() 

68 [10.0, 10.0, 10.0] 

69 """ 

70 values = pl.select( 

71 pl.when(self.prices[asset] != 0.0) 

72 .then(self.cashposition[asset] / self.prices[asset]) 

73 .otherwise(None) 

74 .alias(asset) 

75 for asset in self.assets 

76 ) 

77 return self._numeric_frame(values) 

78 

79 @property 

80 def equity(self) -> pl.DataFrame: 

81 """Cash value of each position over time. 

82 

83 An alias for ``cashposition``, kept because "equity" is the term the 

84 simulator vocabulary uses for the same quantity. Not to be confused with 

85 the equity asset class. 

86 

87 Returns: 

88 pl.DataFrame: The portfolio's cash positions, unchanged. 

89 """ 

90 return self.cashposition 

91 

92 @property 

93 def trades_units(self) -> pl.DataFrame: 

94 """Units bought (positive) or sold (negative) at each step. 

95 

96 The first row is the opening position: there is no prior row to 

97 difference against, and treating it as zero would hide the initial 

98 trade that established the book. 

99 

100 Returns: 

101 pl.DataFrame: Unit trades per asset, with the ``'date'`` column when 

102 the portfolio has one. 

103 

104 Examples: 

105 >>> import polars as pl 

106 >>> from jquantstats.portfolio import Portfolio 

107 >>> prices = pl.DataFrame({"A": [100.0, 100.0, 100.0]}) 

108 >>> pos = pl.DataFrame({"A": [1000.0, 1500.0, 500.0]}) 

109 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6) 

110 >>> pf.trades_units["A"].to_list() 

111 [10.0, 5.0, -10.0] 

112 """ 

113 units = self.units 

114 

115 def _trades(asset: str) -> pl.Series: 

116 """Differences of *asset*'s unit series, seeded with the opening position.""" 

117 held = units[asset].fill_null(0.0) 

118 # `diff` leaves exactly one null, at row 0; filling it with the 

119 # opening position records the trade that established the book. 

120 return held.diff().fill_null(held[0]) if held.len() else held 

121 

122 values = pl.select(_trades(asset).alias(asset) for asset in self.assets) 

123 return self._numeric_frame(values) 

124 

125 @property 

126 def trades_currency(self) -> pl.DataFrame: 

127 """Cash value of the trades at each step, priced at the traded row. 

128 

129 Computed as ``trades_units * prices`` rather than as a difference of 

130 cash positions, so a price move on an untraded book reports zero rather 

131 than phantom turnover. 

132 

133 Returns: 

134 pl.DataFrame: Currency trades per asset, with the ``'date'`` column 

135 when the portfolio has one. Positive values are buys (cash out), 

136 negative values are sells (cash in). 

137 

138 Examples: 

139 >>> import polars as pl 

140 >>> from jquantstats.portfolio import Portfolio 

141 >>> prices = pl.DataFrame({"A": [100.0, 200.0]}) 

142 >>> pos = pl.DataFrame({"A": [1000.0, 2000.0]}) 

143 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6) 

144 >>> pf.trades_currency["A"].to_list() 

145 [1000.0, 0.0] 

146 """ 

147 trades = self.trades_units 

148 values = pl.select((trades[asset] * self.prices[asset]).alias(asset) for asset in self.assets) 

149 return self._numeric_frame(values) 

150 

151 @property 

152 def weights(self) -> pl.DataFrame: 

153 """Fraction of NAV held in each asset over time. 

154 

155 Each cash position divided by the accumulated NAV of the same row. For a 

156 fully invested, unlevered book the weights sum to 1.0; short positions 

157 are negative. 

158 

159 Returns: 

160 pl.DataFrame: Weights per asset, with the ``'date'`` column when the 

161 portfolio has one. 

162 

163 Examples: 

164 >>> import polars as pl 

165 >>> from jquantstats.portfolio import Portfolio 

166 >>> prices = pl.DataFrame({"A": [100.0, 100.0]}) 

167 >>> pos = pl.DataFrame({"A": [100.0, 100.0]}) 

168 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1000.0) 

169 >>> pf.weights["A"].to_list() 

170 [0.1, 0.1] 

171 """ 

172 nav = self.nav_accumulated["NAV_accumulated"] 

173 values = pl.select( 

174 pl.when(nav != 0.0).then(self.cashposition[asset] / nav).otherwise(None).alias(asset) 

175 for asset in self.assets 

176 ) 

177 return self._numeric_frame(values)