Coverage for src/cvx/simulator/portfolio.py: 100%
79 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 02:49 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 02:49 +0000
1# Copyright 2023 Stanford University Convex Optimization Group
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14"""Portfolio representation and analysis for the CVX Simulator.
16This module provides the Portfolio class, which represents a portfolio of assets
17with methods for calculating various metrics (NAV, profit, drawdown, etc.) and
18analyzing performance. The Portfolio class is typically created by the Builder
19class after a simulation is complete.
21The jquantstats-backed statistics, plotting, and reporting surface lives in the
22companion :class:`~cvx.simulator._analytics.PortfolioAnalytics` mixin.
23"""
25from __future__ import annotations
27from dataclasses import dataclass, field
28from datetime import datetime
30import pandas as pd
31from jquantstats.data import Data
33from ._analytics import PortfolioAnalytics
36@dataclass(frozen=True)
37class Portfolio(PortfolioAnalytics):
38 """Represents a portfolio of assets with methods for analysis and visualization.
40 The Portfolio class is a frozen dataclass (immutable) that represents a portfolio
41 of assets with their prices and positions (units). It provides methods for
42 calculating various metrics like NAV, profit, drawdown, and for visualizing
43 the portfolio's performance.
45 Attributes:
46 ----------
47 prices : pd.DataFrame
48 DataFrame of asset prices over time, with dates as index and assets as columns
49 units : pd.DataFrame
50 DataFrame of asset positions (units) over time, with dates as index and assets as columns
51 aum : Union[float, pd.Series]
52 Assets under management, either as a constant float or as a Series over time
54 Examples:
55 --------
56 A portfolio is usually produced by :meth:`Builder.build`, but it can be
57 constructed directly from prices, units and a starting AUM:
59 >>> import pandas as pd
60 >>> from cvx.simulator import Portfolio
61 >>> dates = pd.date_range("2020-01-01", periods=4)
62 >>> prices = pd.DataFrame(
63 ... {"A": [100.0, 102.0, 104.0, 103.0], "B": [50.0, 51.0, 52.0, 51.0]},
64 ... index=dates,
65 ... )
66 >>> units = pd.DataFrame({"A": [5.0] * 4, "B": [10.0] * 4}, index=dates)
67 >>> portfolio = Portfolio(prices=prices, units=units, aum=1000.0)
68 >>> portfolio.assets
69 ['A', 'B']
71 The cash value of each holding, and the NAV it rolls up to:
73 >>> portfolio.cashposition
74 A B
75 2020-01-01 500.0 500.0
76 2020-01-02 510.0 510.0
77 2020-01-03 520.0 520.0
78 2020-01-04 515.0 510.0
79 >>> portfolio.nav
80 2020-01-01 1000.0
81 2020-01-02 1020.0
82 2020-01-03 1040.0
83 2020-01-04 1025.0
84 Freq: D, Name: NAV, dtype: float64
86 The object is frozen, so analysis can never mutate the record:
88 >>> portfolio.aum = 2000.0
89 Traceback (most recent call last):
90 ...
91 dataclasses.FrozenInstanceError: cannot assign to field 'aum'
93 """
95 prices: pd.DataFrame
96 units: pd.DataFrame
97 aum: float | pd.Series
98 _data: Data = field(init=False)
100 def __post_init__(self) -> None:
101 """Validate the portfolio data after initialization.
103 This method is automatically called after an instance of the Portfolio
104 class has been initialized. It performs a series of validation checks
105 to ensure that the prices and units dataframes are in the expected format
106 with no duplicates or missing data.
108 The method checks that:
109 - Both prices and units dataframes have monotonic increasing indices
110 - Both prices and units dataframes have unique indices
111 - The index of units is a subset of the index of prices
112 - The columns of units is a subset of the columns of prices
114 Raises:
115 ------
116 ValueError
117 If any of the validation checks fail
119 """
120 self._validate()
121 self._build_data()
123 def _validate(self) -> None:
124 """Validate the prices and units dataframes.
126 Checks that both frames have monotonic increasing, unique indices and
127 that the units index and columns are subsets of the prices index and
128 columns respectively.
130 Raises:
131 ------
132 ValueError
133 If any of the validation checks fail
134 """
135 index_checks = (
136 (self.prices.index.is_monotonic_increasing, "`prices` index must be monotonic increasing."),
137 (self.prices.index.is_unique, "`prices` index must be unique."),
138 (self.units.index.is_monotonic_increasing, "`units` index must be monotonic increasing."),
139 (self.units.index.is_unique, "`units` index must be unique."),
140 )
141 for is_valid, message in index_checks:
142 if not is_valid:
143 raise ValueError(message)
145 missing_dates = self.units.index.difference(self.prices.index)
146 if not missing_dates.empty:
147 msg = f"`units` index contains dates not present in `prices`: {missing_dates.tolist()}"
148 raise ValueError(msg)
150 missing_assets = self.units.columns.difference(self.prices.columns)
151 if not missing_assets.empty:
152 msg = f"`units` contains assets not present in `prices`: {missing_assets.tolist()}"
153 raise ValueError(msg)
155 @property
156 def index(self) -> list[datetime]:
157 """Get the time index of the portfolio.
159 Returns:
160 -------
161 pd.DatetimeIndex
162 A DatetimeIndex representing the time period for which portfolio
163 data is available
165 Notes:
166 -----
167 This property extracts the index from the prices DataFrame, which
168 represents all time points in the portfolio history.
170 """
171 return list(pd.DatetimeIndex(self.prices.index))
173 @property
174 def assets(self) -> list[str]:
175 """Get the list of assets in the portfolio.
177 Returns:
178 -------
179 pd.Index
180 An Index containing the names of all assets in the portfolio
182 Notes:
183 -----
184 This property extracts the column names from the prices DataFrame,
185 which correspond to all assets for which price data is available.
187 """
188 return list(self.prices.columns)
190 @property
191 def nav(self) -> pd.Series:
192 """Get the net asset value (NAV) of the portfolio over time.
194 The NAV represents the total value of the portfolio at each point in time.
195 If aum is provided as a Series, it is used directly. Otherwise, the NAV
196 is calculated from the cumulative profit plus the initial aum.
198 Returns:
199 -------
200 pd.Series
201 Series representing the NAV of the portfolio over time
203 Examples:
204 --------
205 >>> import pandas as pd
206 >>> from cvx.simulator import Portfolio
207 >>> dates = pd.date_range("2020-01-01", periods=4)
208 >>> prices = pd.DataFrame(
209 ... {"A": [100.0, 102.0, 104.0, 103.0], "B": [50.0, 51.0, 52.0, 51.0]},
210 ... index=dates,
211 ... )
212 >>> units = pd.DataFrame({"A": [5.0] * 4, "B": [10.0] * 4}, index=dates)
214 Passing a scalar ``aum`` makes the NAV the cumulative profit on top of it:
216 >>> Portfolio(prices=prices, units=units, aum=1000.0).nav
217 2020-01-01 1000.0
218 2020-01-02 1020.0
219 2020-01-03 1040.0
220 2020-01-04 1025.0
221 Freq: D, Name: NAV, dtype: float64
223 Passing a Series instead uses it verbatim — which is what
224 :meth:`Builder.build` does, so a strategy that adds or withdraws capital
225 is recorded rather than inferred:
227 >>> aum = pd.Series([1000.0, 1100.0, 1200.0, 1300.0], index=dates)
228 >>> Portfolio(prices=prices, units=units, aum=aum).nav
229 2020-01-01 1000.0
230 2020-01-02 1100.0
231 2020-01-03 1200.0
232 2020-01-04 1300.0
233 Freq: D, Name: NAV, dtype: float64
235 """
236 if isinstance(self.aum, pd.Series):
237 series = self.aum
238 else:
239 profit = (self.cashposition.shift(1) * self.returns.fillna(0.0)).sum(axis=1)
240 series = profit.cumsum() + self.aum
242 series.name = "NAV"
243 return series
245 @property
246 def profit(self) -> pd.Series:
247 """Get the profit/loss of the portfolio at each time point.
249 This calculates the profit or loss at each time point based on the
250 previous positions and the returns of each asset.
252 Returns:
253 -------
254 pd.Series
255 Series representing the profit/loss at each time point
257 Notes:
258 -----
259 The profit is calculated by multiplying the previous day's positions
260 (in currency terms) by the returns of each asset, and then summing
261 across all assets.
263 Examples:
264 --------
265 >>> import pandas as pd
266 >>> from cvx.simulator import Portfolio
267 >>> dates = pd.date_range("2020-01-01", periods=4)
268 >>> prices = pd.DataFrame(
269 ... {"A": [100.0, 102.0, 104.0, 103.0], "B": [50.0, 51.0, 52.0, 51.0]},
270 ... index=dates,
271 ... )
272 >>> units = pd.DataFrame({"A": [5.0] * 4, "B": [10.0] * 4}, index=dates)
273 >>> portfolio = Portfolio(prices=prices, units=units, aum=1000.0)
275 The first day has no previous position, so it books no profit:
277 >>> portfolio.profit
278 2020-01-01 0.0
279 2020-01-02 20.0
280 2020-01-03 20.0
281 2020-01-04 -15.0
282 Freq: D, Name: Profit, dtype: float64
284 Cumulating the profit onto the starting AUM reproduces the NAV:
286 >>> (portfolio.profit.cumsum() + 1000.0).equals(portfolio.nav.rename("Profit"))
287 True
289 """
290 series = (self.cashposition.shift(1) * self.returns.fillna(0.0)).sum(axis=1)
291 series.name = "Profit"
292 return series
294 @property
295 def cashposition(self) -> pd.DataFrame:
296 """Get the cash value of each position over time.
298 This calculates the cash value of each position by multiplying
299 the number of units by the price for each asset at each time point.
301 Returns:
302 -------
303 pd.DataFrame
304 DataFrame with the cash value of each position over time,
305 with dates as index and assets as columns
307 """
308 return self.prices * self.units
310 @property
311 def returns(self) -> pd.DataFrame:
312 """Get the returns of individual assets over time.
314 This calculates the percentage change in price for each asset
315 from one time point to the next.
317 Returns:
318 -------
319 pd.DataFrame
320 DataFrame with the returns of each asset over time,
321 with dates as index and assets as columns
323 """
324 return self.prices.pct_change()
326 @property
327 def trades_units(self) -> pd.DataFrame:
328 """Get the trades made in the portfolio in terms of units.
330 This calculates the changes in position (units) from one time point
331 to the next for each asset.
333 Returns:
334 -------
335 pd.DataFrame
336 DataFrame with the trades (changes in units) for each asset over time,
337 with dates as index and assets as columns
339 Notes:
340 -----
341 Calculated as the difference between consecutive position values.
342 Positive values represent buys, negative values represent sells.
343 The first row contains the initial positions, as there are no previous
344 positions to compare with.
346 """
347 t = self.units.fillna(0.0).diff()
348 t.loc[self.index[0]] = self.units.loc[self.index[0]]
349 return t.fillna(0.0)
351 @property
352 def trades_currency(self) -> pd.DataFrame:
353 """Get the trades made in the portfolio in terms of currency.
355 This calculates the cash value of trades by multiplying the changes
356 in position (units) by the current prices.
358 Returns:
359 -------
360 pd.DataFrame
361 DataFrame with the cash value of trades for each asset over time,
362 with dates as index and assets as columns
364 Notes:
365 -----
366 Calculated by multiplying trades_units by prices.
367 Positive values represent buys (cash outflows),
368 negative values represent sells (cash inflows).
370 """
371 return self.trades_units * self.prices
373 @property
374 def turnover_relative(self) -> pd.DataFrame:
375 """Get the turnover relative to the portfolio NAV.
377 This calculates the trades as a percentage of the portfolio NAV,
378 which provides a measure of trading activity relative to portfolio size.
380 Returns:
381 -------
382 pd.DataFrame
383 DataFrame with the relative turnover for each asset over time,
384 with dates as index and assets as columns
386 Notes:
387 -----
388 Calculated by dividing trades_currency by NAV.
389 Positive values represent buys, negative values represent sells.
390 A value of 0.05 means a buy equal to 5% of the portfolio NAV.
392 """
393 return self.trades_currency.div(self.nav, axis=0)
395 @property
396 def turnover(self) -> pd.DataFrame:
397 """Get the absolute turnover in the portfolio.
399 This calculates the absolute value of trades in currency terms,
400 which provides a measure of total trading activity regardless of
401 direction (buy or sell).
403 Returns:
404 -------
405 pd.DataFrame
406 DataFrame with the absolute turnover for each asset over time,
407 with dates as index and assets as columns
409 Notes:
410 -----
411 Calculated as the absolute value of trades_currency.
412 This is useful for calculating trading costs that apply equally
413 to buys and sells.
415 """
416 return self.trades_currency.abs()
418 def __getitem__(self, time: datetime | str | pd.Timestamp) -> pd.Series:
419 """Get the portfolio positions (units) at a specific time.
421 This method allows for dictionary-like access to the portfolio positions
422 at a specific time point using the syntax: portfolio[time].
424 Parameters
425 ----------
426 time : Union[datetime, str, pd.Timestamp]
427 The time index for which to retrieve the positions
429 Returns:
430 -------
431 pd.Series
432 Series containing the positions (units) for each asset at the specified time
434 Raises:
435 ------
436 KeyError
437 If the specified time is not in the portfolio's index
439 Examples:
440 --------
441 >>> import pandas as pd
442 >>> from cvx.simulator import Portfolio
443 >>> dates = pd.date_range("2020-01-01", periods=4)
444 >>> prices = pd.DataFrame(
445 ... {"A": [100.0, 102.0, 104.0, 103.0], "B": [50.0, 51.0, 52.0, 51.0]},
446 ... index=dates,
447 ... )
448 >>> units = pd.DataFrame({"A": [5.0] * 4, "B": [10.0] * 4}, index=dates)
449 >>> portfolio = Portfolio(prices=prices, units=units, aum=1000.0)
451 Index with a string or a Timestamp — both reach the same row:
453 >>> portfolio["2020-01-02"]
454 A 5.0
455 B 10.0
456 Name: 2020-01-02 00:00:00, dtype: float64
457 >>> portfolio[pd.Timestamp("2020-01-02")].equals(portfolio["2020-01-02"])
458 True
460 A date outside the index raises. (Caught here rather than shown as a
461 traceback: pandas chains several exceptions on the way out, and the
462 intermediate frames are an implementation detail, not a contract.)
464 >>> try:
465 ... portfolio["2021-01-01"]
466 ... except KeyError as err:
467 ... print(err)
468 '2021-01-01'
470 """
471 return self.units.loc[time]
473 @property
474 def equity(self) -> pd.DataFrame:
475 """Get the equity (cash value) of each position over time.
477 This property returns the cash value of each position in the portfolio,
478 calculated by multiplying the number of units by the price for each asset.
480 Returns:
481 -------
482 pd.DataFrame
483 DataFrame with the cash value of each position over time,
484 with dates as index and assets as columns
486 Notes:
487 -----
488 This is an alias for the cashposition property and returns the same values.
489 The term "equity" is used in the context of the cash value of positions,
490 not to be confused with the equity asset class.
492 """
493 return self.cashposition
495 @property
496 def weights(self) -> pd.DataFrame:
497 """Get the weight of each asset in the portfolio over time.
499 This calculates the relative weight of each asset in the portfolio
500 by dividing the cash value of each position by the total portfolio
501 value (NAV) at each time point.
503 Returns:
504 -------
505 pd.DataFrame
506 DataFrame with the weight of each asset over time,
507 with dates as index and assets as columns
509 Notes:
510 -----
511 The sum of weights across all assets at any given time should equal 1.0
512 for a fully invested portfolio with no leverage. Weights can be negative
513 for short positions.
515 Examples:
516 --------
517 >>> import pandas as pd
518 >>> from cvx.simulator import Portfolio
519 >>> dates = pd.date_range("2020-01-01", periods=4)
520 >>> prices = pd.DataFrame(
521 ... {"A": [100.0, 102.0, 104.0, 103.0], "B": [50.0, 51.0, 52.0, 51.0]},
522 ... index=dates,
523 ... )
524 >>> units = pd.DataFrame({"A": [5.0] * 4, "B": [10.0] * 4}, index=dates)
525 >>> portfolio = Portfolio(prices=prices, units=units, aum=1000.0)
527 Holding the units fixed, the weights drift with the relative prices:
529 >>> portfolio.weights.round(4)
530 A B
531 2020-01-01 0.5000 0.5000
532 2020-01-02 0.5000 0.5000
533 2020-01-03 0.5000 0.5000
534 2020-01-04 0.5024 0.4976
536 This book is fully invested, so each row sums to 1.0:
538 >>> portfolio.weights.sum(axis=1).round(6).unique().tolist()
539 [1.0]
541 """
542 return self.equity.apply(lambda x: x / self.nav)
544 @classmethod
545 def from_cashpos_prices(cls, prices: pd.DataFrame, cashposition: pd.DataFrame, aum: float) -> Portfolio:
546 """Create a Portfolio instance from cash positions and prices.
548 This class method provides an alternative way to create a Portfolio instance
549 when you have the cash positions rather than the number of units.
551 Parameters
552 ----------
553 prices : pd.DataFrame
554 DataFrame of asset prices over time, with dates as index and assets as columns
555 cashposition : pd.DataFrame
556 DataFrame of cash positions over time, with dates as index and assets as columns
557 aum : float
558 Assets under management
560 Returns:
561 -------
562 Portfolio
563 A new Portfolio instance with units calculated from cash positions and prices
565 Notes:
566 -----
567 The units are calculated by dividing the cash positions by the prices.
568 This is useful when you have the monetary value of each position rather
569 than the number of units.
571 Examples:
572 --------
573 Specify the book in currency rather than units — 500 in each name:
575 >>> import pandas as pd
576 >>> from cvx.simulator import Portfolio
577 >>> dates = pd.date_range("2020-01-01", periods=4)
578 >>> prices = pd.DataFrame(
579 ... {"A": [100.0, 102.0, 104.0, 103.0], "B": [50.0, 51.0, 52.0, 51.0]},
580 ... index=dates,
581 ... )
582 >>> cashposition = pd.DataFrame({"A": [500.0] * 4, "B": [500.0] * 4}, index=dates)
583 >>> portfolio = Portfolio.from_cashpos_prices(
584 ... prices=prices, cashposition=cashposition, aum=1000.0
585 ... )
587 The units are the cash amounts divided by the prices:
589 >>> portfolio.units.round(4)
590 A B
591 2020-01-01 5.0000 10.0000
592 2020-01-02 4.9020 9.8039
593 2020-01-03 4.8077 9.6154
594 2020-01-04 4.8544 9.8039
596 """
597 units = cashposition.div(prices, fill_value=0.0)
598 return cls(prices=prices, units=units, aum=aum)