Coverage for src/cvxsimulator/portfolio.py: 100%
95 statements
« prev ^ index » next coverage.py v7.8.2, created at 2025-06-10 18:45 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2025-06-10 18:45 +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.
20"""
22from __future__ import annotations
24from dataclasses import dataclass, field
25from datetime import datetime
27import pandas as pd
28from jquantstats._data import Data
29from jquantstats.api import build_data
32@dataclass(frozen=True)
33class Portfolio:
34 """Represents a portfolio of assets with methods for analysis and visualization.
36 The Portfolio class is a frozen dataclass (immutable) that represents a portfolio
37 of assets with their prices and positions (units). It provides methods for
38 calculating various metrics like NAV, profit, drawdown, and for visualizing
39 the portfolio's performance.
41 Attributes
42 ----------
43 prices : pd.DataFrame
44 DataFrame of asset prices over time, with dates as index and assets as columns
45 units : pd.DataFrame
46 DataFrame of asset positions (units) over time, with dates as index and assets as columns
47 aum : Union[float, pd.Series]
48 Assets under management, either as a constant float or as a Series over time
50 """
52 prices: pd.DataFrame
53 units: pd.DataFrame
54 aum: float | pd.Series
55 _data: Data = field(init=False)
57 def __post_init__(self) -> None:
58 """Validate the portfolio data after initialization.
60 This method is automatically called after an instance of the Portfolio
61 class has been initialized. It performs a series of validation checks
62 to ensure that the prices and units dataframes are in the expected format
63 with no duplicates or missing data.
65 The method checks that:
66 - Both prices and units dataframes have monotonic increasing indices
67 - Both prices and units dataframes have unique indices
68 - The index of units is a subset of the index of prices
69 - The columns of units is a subset of the columns of prices
71 Raises
72 ------
73 AssertionError
74 If any of the validation checks fail
76 """
77 if not self.prices.index.is_monotonic_increasing:
78 raise ValueError("`prices` index must be monotonic increasing.")
80 if not self.prices.index.is_unique:
81 raise ValueError("`prices` index must be unique.")
83 if not self.units.index.is_monotonic_increasing:
84 raise ValueError("`units` index must be monotonic increasing.")
86 if not self.units.index.is_unique:
87 raise ValueError("`units` index must be unique.")
89 missing_dates = self.units.index.difference(self.prices.index)
90 if not missing_dates.empty:
91 raise ValueError(f"`units` index contains dates not present in `prices`: {missing_dates.tolist()}")
93 missing_assets = self.units.columns.difference(self.prices.columns)
94 if not missing_assets.empty:
95 raise ValueError(f"`units` contains assets not present in `prices`: {missing_assets.tolist()}")
97 frame = self.nav.pct_change().to_frame()
98 frame.index.name = "Date"
99 d = build_data(returns=frame)
101 object.__setattr__(self, "_data", d)
103 @property
104 def index(self) -> list[datetime]:
105 """Get the time index of the portfolio.
107 Returns
108 -------
109 pd.DatetimeIndex
110 A DatetimeIndex representing the time period for which portfolio
111 data is available
113 Notes
114 -----
115 This property extracts the index from the prices DataFrame, which
116 represents all time points in the portfolio history.
118 """
119 return pd.DatetimeIndex(self.prices.index).to_list()
121 @property
122 def assets(self) -> list[str]:
123 """Get the list of assets in the portfolio.
125 Returns
126 -------
127 pd.Index
128 An Index containing the names of all assets in the portfolio
130 Notes
131 -----
132 This property extracts the column names from the prices DataFrame,
133 which correspond to all assets for which price data is available.
135 """
136 return self.prices.columns.to_list()
138 @property
139 def nav(self) -> pd.Series:
140 """Get the net asset value (NAV) of the portfolio over time.
142 The NAV represents the total value of the portfolio at each point in time.
143 If aum is provided as a Series, it is used directly. Otherwise, the NAV
144 is calculated from the cumulative profit plus the initial aum.
146 Returns
147 -------
148 pd.Series
149 Series representing the NAV of the portfolio over time
151 """
152 if isinstance(self.aum, pd.Series):
153 series = self.aum
154 else:
155 profit = (self.cashposition.shift(1) * self.returns.fillna(0.0)).sum(axis=1)
156 series = profit.cumsum() + self.aum
158 series.name = "NAV"
159 return series
161 @property
162 def profit(self) -> pd.Series:
163 """Get the profit/loss of the portfolio at each time point.
165 This calculates the profit or loss at each time point based on the
166 previous positions and the returns of each asset.
168 Returns
169 -------
170 pd.Series
171 Series representing the profit/loss at each time point
173 Notes
174 -----
175 The profit is calculated by multiplying the previous day's positions
176 (in currency terms) by the returns of each asset, and then summing
177 across all assets.
179 """
180 series = (self.cashposition.shift(1) * self.returns.fillna(0.0)).sum(axis=1)
181 series.name = "Profit"
182 return series
184 @property
185 def cashposition(self) -> pd.DataFrame:
186 """Get the cash value of each position over time.
188 This calculates the cash value of each position by multiplying
189 the number of units by the price for each asset at each time point.
191 Returns
192 -------
193 pd.DataFrame
194 DataFrame with the cash value of each position over time,
195 with dates as index and assets as columns
197 """
198 return self.prices * self.units
200 @property
201 def returns(self) -> pd.DataFrame:
202 """Get the returns of individual assets over time.
204 This calculates the percentage change in price for each asset
205 from one time point to the next.
207 Returns
208 -------
209 pd.DataFrame
210 DataFrame with the returns of each asset over time,
211 with dates as index and assets as columns
213 """
214 return self.prices.pct_change()
216 @property
217 def trades_units(self) -> pd.DataFrame:
218 """Get the trades made in the portfolio in terms of units.
220 This calculates the changes in position (units) from one time point
221 to the next for each asset.
223 Returns
224 -------
225 pd.DataFrame
226 DataFrame with the trades (changes in units) for each asset over time,
227 with dates as index and assets as columns
229 Notes
230 -----
231 Calculated as the difference between consecutive position values.
232 Positive values represent buys, negative values represent sells.
233 The first row contains the initial positions, as there are no previous
234 positions to compare with.
236 """
237 t = self.units.fillna(0.0).diff()
238 t.loc[self.index[0]] = self.units.loc[self.index[0]]
239 return t.fillna(0.0)
241 @property
242 def trades_currency(self) -> pd.DataFrame:
243 """Get the trades made in the portfolio in terms of currency.
245 This calculates the cash value of trades by multiplying the changes
246 in position (units) by the current prices.
248 Returns
249 -------
250 pd.DataFrame
251 DataFrame with the cash value of trades for each asset over time,
252 with dates as index and assets as columns
254 Notes
255 -----
256 Calculated by multiplying trades_units by prices.
257 Positive values represent buys (cash outflows),
258 negative values represent sells (cash inflows).
260 """
261 return self.trades_units * self.prices
263 @property
264 def turnover_relative(self) -> pd.DataFrame:
265 """Get the turnover relative to the portfolio NAV.
267 This calculates the trades as a percentage of the portfolio NAV,
268 which provides a measure of trading activity relative to portfolio size.
270 Returns
271 -------
272 pd.DataFrame
273 DataFrame with the relative turnover for each asset over time,
274 with dates as index and assets as columns
276 Notes
277 -----
278 Calculated by dividing trades_currency by NAV.
279 Positive values represent buys, negative values represent sells.
280 A value of 0.05 means a buy equal to 5% of the portfolio NAV.
282 """
283 return self.trades_currency.div(self.nav, axis=0)
285 @property
286 def turnover(self) -> pd.DataFrame:
287 """Get the absolute turnover in the portfolio.
289 This calculates the absolute value of trades in currency terms,
290 which provides a measure of total trading activity regardless of
291 direction (buy or sell).
293 Returns
294 -------
295 pd.DataFrame
296 DataFrame with the absolute turnover for each asset over time,
297 with dates as index and assets as columns
299 Notes
300 -----
301 Calculated as the absolute value of trades_currency.
302 This is useful for calculating trading costs that apply equally
303 to buys and sells.
305 """
306 return self.trades_currency.abs()
308 def __getitem__(self, time: datetime | str | pd.Timestamp) -> pd.Series:
309 """Get the portfolio positions (units) at a specific time.
311 This method allows for dictionary-like access to the portfolio positions
312 at a specific time point using the syntax: portfolio[time].
314 Parameters
315 ----------
316 time : Union[datetime, str, pd.Timestamp]
317 The time index for which to retrieve the positions
319 Returns
320 -------
321 pd.Series
322 Series containing the positions (units) for each asset at the specified time
324 Raises
325 ------
326 KeyError
327 If the specified time is not in the portfolio's index
329 Examples
330 --------
331 ```
332 portfolio['2023-01-01'] # Get positions on January 1, 2023
333 portfolio[pd.Timestamp('2023-01-01')] # Same as above
334 ```
336 """
337 return self.units.loc[time]
339 @property
340 def equity(self) -> pd.DataFrame:
341 """Get the equity (cash value) of each position over time.
343 This property returns the cash value of each position in the portfolio,
344 calculated by multiplying the number of units by the price for each asset.
346 Returns
347 -------
348 pd.DataFrame
349 DataFrame with the cash value of each position over time,
350 with dates as index and assets as columns
352 Notes
353 -----
354 This is an alias for the cashposition property and returns the same values.
355 The term "equity" is used in the context of the cash value of positions,
356 not to be confused with the equity asset class.
358 """
359 return self.cashposition
361 @property
362 def weights(self) -> pd.DataFrame:
363 """Get the weight of each asset in the portfolio over time.
365 This calculates the relative weight of each asset in the portfolio
366 by dividing the cash value of each position by the total portfolio
367 value (NAV) at each time point.
369 Returns
370 -------
371 pd.DataFrame
372 DataFrame with the weight of each asset over time,
373 with dates as index and assets as columns
375 Notes
376 -----
377 The sum of weights across all assets at any given time should equal 1.0
378 for a fully invested portfolio with no leverage. Weights can be negative
379 for short positions.
381 """
382 return self.equity.apply(lambda x: x / self.nav)
384 @property
385 def stats(self):
386 """Get statistical analysis data for the portfolio.
388 This property provides access to various statistical metrics calculated
389 for the portfolio, such as Sharpe ratio, volatility, drawdowns, etc.
391 Returns
392 -------
393 object
394 An object containing various statistical metrics for the portfolio
396 Notes
397 -----
398 The statistics are calculated by the underlying jquantstats library
399 and are based on the portfolio's NAV time series.
401 """
402 return self._data.stats
404 @property
405 def plots(self):
406 """Get visualization tools for the portfolio.
408 This property provides access to various plotting functions for visualizing
409 the portfolio's performance, returns, drawdowns, etc.
411 Returns
412 -------
413 object
414 An object containing various plotting methods for the portfolio
416 Notes
417 -----
418 The plotting functions are provided by the underlying jquantstats library
419 and operate on the portfolio's NAV time series.
421 """
422 return self._data.plots
424 @property
425 def reports(self):
426 """Get reporting tools for the portfolio.
428 This property provides access to various reporting functions for generating
429 performance reports, risk metrics, and other analytics for the portfolio.
431 Returns
432 -------
433 object
434 An object containing various reporting methods for the portfolio
436 Notes
437 -----
438 The reporting functions are provided by the underlying jquantstats library
439 and operate on the portfolio's NAV time series.
441 """
442 return self._data.reports
444 def sharpe(self, periods=None):
445 """Calculate the Sharpe ratio for the portfolio.
447 The Sharpe ratio is a measure of risk-adjusted return, calculated as
448 the portfolio's excess return divided by its volatility.
450 Parameters
451 ----------
452 periods : int, optional
453 The number of periods per year for annualization.
454 For daily data, use 252; for weekly data, use 52; for monthly data, use 12.
455 If None, no annualization is performed.
457 Returns
458 -------
459 float
460 The Sharpe ratio of the portfolio
462 Notes
463 -----
464 The Sharpe ratio is calculated using the portfolio's NAV time series.
465 A higher Sharpe ratio indicates better risk-adjusted performance.
467 """
468 return self.stats.sharpe(periods=periods)["NAV"]
470 @classmethod
471 def from_cashpos_prices(cls, prices: pd.DataFrame, cashposition: pd.DataFrame, aum: float):
472 """Create a Portfolio instance from cash positions and prices.
474 This class method provides an alternative way to create a Portfolio instance
475 when you have the cash positions rather than the number of units.
477 Parameters
478 ----------
479 prices : pd.DataFrame
480 DataFrame of asset prices over time, with dates as index and assets as columns
481 cashposition : pd.DataFrame
482 DataFrame of cash positions over time, with dates as index and assets as columns
483 aum : float
484 Assets under management
486 Returns
487 -------
488 Portfolio
489 A new Portfolio instance with units calculated from cash positions and prices
491 Notes
492 -----
493 The units are calculated by dividing the cash positions by the prices.
494 This is useful when you have the monetary value of each position rather
495 than the number of units.
497 """
498 units = cashposition.div(prices, fill_value=0.0)
499 return cls(prices=prices, units=units, aum=aum)
501 def snapshot(self, title: str = "Portfolio Summary", log_scale: bool = True):
502 """Generate and display a snapshot of the portfolio summary.
504 This method creates a visual representation of the portfolio summary
505 using the associated plot functionalities. The snapshot can be
506 configured with a title and whether to use a logarithmic scale.
508 Args:
509 title: A string specifying the title of the snapshot.
510 Default is "Portfolio Summary".
511 log_scale: A boolean indicating whether to display the plot
512 using a logarithmic scale. Default is True.
514 Returns:
515 The generated plot object representing the portfolio snapshot.
517 """
518 return self.plots.plot_snapshot(title=title, log_scale=log_scale)