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
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 04:11 +0000
1"""Units, trades and weights mixin for Portfolio.
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.
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.
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"""
21from __future__ import annotations
23import polars as pl
25from ._portfolio_base import _PortfolioMembers
28class PortfolioUnitsMixin(_PortfolioMembers):
29 """Mixin providing the share-count view of a Portfolio: units, trades, weights."""
31 def _numeric_frame(self, values: pl.DataFrame) -> pl.DataFrame:
32 """Return *values* carrying the date column, if the portfolio has one.
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``.
38 Args:
39 values: Frame of per-asset columns, without a date column.
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"])
49 @property
50 def units(self) -> pl.DataFrame:
51 """Number of units held per asset over time.
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.
57 Returns:
58 pl.DataFrame: Units per asset, with the ``'date'`` column when the
59 portfolio has one.
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)
79 @property
80 def equity(self) -> pl.DataFrame:
81 """Cash value of each position over time.
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.
87 Returns:
88 pl.DataFrame: The portfolio's cash positions, unchanged.
89 """
90 return self.cashposition
92 @property
93 def trades_units(self) -> pl.DataFrame:
94 """Units bought (positive) or sold (negative) at each step.
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.
100 Returns:
101 pl.DataFrame: Unit trades per asset, with the ``'date'`` column when
102 the portfolio has one.
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
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
122 values = pl.select(_trades(asset).alias(asset) for asset in self.assets)
123 return self._numeric_frame(values)
125 @property
126 def trades_currency(self) -> pl.DataFrame:
127 """Cash value of the trades at each step, priced at the traded row.
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.
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).
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)
151 @property
152 def weights(self) -> pl.DataFrame:
153 """Fraction of NAV held in each asset over time.
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.
159 Returns:
160 pl.DataFrame: Weights per asset, with the ``'date'`` column when the
161 portfolio has one.
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)