Coverage for src / cvx / simulator / portfolio.py: 100%
96 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-10 05:38 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-10 05:38 +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
26from typing import Any
28import pandas as pd
29from jquantstats._data import Data
30from jquantstats.api import build_data
33@dataclass(frozen=True)
34class Portfolio:
35 """Represents a portfolio of assets with methods for analysis and visualization.
37 The Portfolio class is a frozen dataclass (immutable) that represents a portfolio
38 of assets with their prices and positions (units). It provides methods for
39 calculating various metrics like NAV, profit, drawdown, and for visualizing
40 the portfolio's performance.
42 Attributes:
43 ----------
44 prices : pd.DataFrame
45 DataFrame of asset prices over time, with dates as index and assets as columns
46 units : pd.DataFrame
47 DataFrame of asset positions (units) over time, with dates as index and assets as columns
48 aum : Union[float, pd.Series]
49 Assets under management, either as a constant float or as a Series over time
51 """
53 prices: pd.DataFrame
54 units: pd.DataFrame
55 aum: float | pd.Series
56 _data: Data = field(init=False)
58 def __post_init__(self) -> None:
59 """Validate the portfolio data after initialization.
61 This method is automatically called after an instance of the Portfolio
62 class has been initialized. It performs a series of validation checks
63 to ensure that the prices and units dataframes are in the expected format
64 with no duplicates or missing data.
66 The method checks that:
67 - Both prices and units dataframes have monotonic increasing indices
68 - Both prices and units dataframes have unique indices
69 - The index of units is a subset of the index of prices
70 - The columns of units is a subset of the columns of prices
72 Raises:
73 ------
74 AssertionError
75 If any of the validation checks fail
77 """
78 if not self.prices.index.is_monotonic_increasing:
79 raise ValueError("`prices` index must be monotonic increasing.") # noqa: TRY003
81 if not self.prices.index.is_unique:
82 raise ValueError("`prices` index must be unique.") # noqa: TRY003
84 if not self.units.index.is_monotonic_increasing:
85 raise ValueError("`units` index must be monotonic increasing.") # noqa: TRY003
87 if not self.units.index.is_unique:
88 raise ValueError("`units` index must be unique.") # noqa: TRY003
90 missing_dates = self.units.index.difference(self.prices.index)
91 if not missing_dates.empty:
92 raise ValueError(f"`units` index contains dates not present in `prices`: {missing_dates.tolist()}") # noqa: TRY003
94 missing_assets = self.units.columns.difference(self.prices.columns)
95 if not missing_assets.empty:
96 raise ValueError(f"`units` contains assets not present in `prices`: {missing_assets.tolist()}") # noqa: TRY003
98 frame = self.nav.pct_change().to_frame()
99 frame.index.name = "Date"
100 d = build_data(returns=frame)
102 object.__setattr__(self, "_data", d)
104 @property
105 def index(self) -> list[datetime]:
106 """Get the time index of the portfolio.
108 Returns:
109 -------
110 pd.DatetimeIndex
111 A DatetimeIndex representing the time period for which portfolio
112 data is available
114 Notes:
115 -----
116 This property extracts the index from the prices DataFrame, which
117 represents all time points in the portfolio history.
119 """
120 return list(pd.DatetimeIndex(self.prices.index))
122 @property
123 def assets(self) -> list[str]:
124 """Get the list of assets in the portfolio.
126 Returns:
127 -------
128 pd.Index
129 An Index containing the names of all assets in the portfolio
131 Notes:
132 -----
133 This property extracts the column names from the prices DataFrame,
134 which correspond to all assets for which price data is available.
136 """
137 return list(self.prices.columns)
139 @property
140 def nav(self) -> pd.Series:
141 """Get the net asset value (NAV) of the portfolio over time.
143 The NAV represents the total value of the portfolio at each point in time.
144 If aum is provided as a Series, it is used directly. Otherwise, the NAV
145 is calculated from the cumulative profit plus the initial aum.
147 Returns:
148 -------
149 pd.Series
150 Series representing the NAV of the portfolio over time
152 """
153 if isinstance(self.aum, pd.Series):
154 series = self.aum
155 else:
156 profit = (self.cashposition.shift(1) * self.returns.fillna(0.0)).sum(axis=1)
157 series = profit.cumsum() + self.aum
159 series.name = "NAV"
160 return series
162 @property
163 def profit(self) -> pd.Series:
164 """Get the profit/loss of the portfolio at each time point.
166 This calculates the profit or loss at each time point based on the
167 previous positions and the returns of each asset.
169 Returns:
170 -------
171 pd.Series
172 Series representing the profit/loss at each time point
174 Notes:
175 -----
176 The profit is calculated by multiplying the previous day's positions
177 (in currency terms) by the returns of each asset, and then summing
178 across all assets.
180 """
181 series = (self.cashposition.shift(1) * self.returns.fillna(0.0)).sum(axis=1)
182 series.name = "Profit"
183 return series
185 @property
186 def cashposition(self) -> pd.DataFrame:
187 """Get the cash value of each position over time.
189 This calculates the cash value of each position by multiplying
190 the number of units by the price for each asset at each time point.
192 Returns:
193 -------
194 pd.DataFrame
195 DataFrame with the cash value of each position over time,
196 with dates as index and assets as columns
198 """
199 return self.prices * self.units
201 @property
202 def returns(self) -> pd.DataFrame:
203 """Get the returns of individual assets over time.
205 This calculates the percentage change in price for each asset
206 from one time point to the next.
208 Returns:
209 -------
210 pd.DataFrame
211 DataFrame with the returns of each asset over time,
212 with dates as index and assets as columns
214 """
215 return self.prices.pct_change()
217 @property
218 def trades_units(self) -> pd.DataFrame:
219 """Get the trades made in the portfolio in terms of units.
221 This calculates the changes in position (units) from one time point
222 to the next for each asset.
224 Returns:
225 -------
226 pd.DataFrame
227 DataFrame with the trades (changes in units) for each asset over time,
228 with dates as index and assets as columns
230 Notes:
231 -----
232 Calculated as the difference between consecutive position values.
233 Positive values represent buys, negative values represent sells.
234 The first row contains the initial positions, as there are no previous
235 positions to compare with.
237 """
238 t = self.units.fillna(0.0).diff()
239 t.loc[self.index[0]] = self.units.loc[self.index[0]]
240 return t.fillna(0.0)
242 @property
243 def trades_currency(self) -> pd.DataFrame:
244 """Get the trades made in the portfolio in terms of currency.
246 This calculates the cash value of trades by multiplying the changes
247 in position (units) by the current prices.
249 Returns:
250 -------
251 pd.DataFrame
252 DataFrame with the cash value of trades for each asset over time,
253 with dates as index and assets as columns
255 Notes:
256 -----
257 Calculated by multiplying trades_units by prices.
258 Positive values represent buys (cash outflows),
259 negative values represent sells (cash inflows).
261 """
262 return self.trades_units * self.prices
264 @property
265 def turnover_relative(self) -> pd.DataFrame:
266 """Get the turnover relative to the portfolio NAV.
268 This calculates the trades as a percentage of the portfolio NAV,
269 which provides a measure of trading activity relative to portfolio size.
271 Returns:
272 -------
273 pd.DataFrame
274 DataFrame with the relative turnover for each asset over time,
275 with dates as index and assets as columns
277 Notes:
278 -----
279 Calculated by dividing trades_currency by NAV.
280 Positive values represent buys, negative values represent sells.
281 A value of 0.05 means a buy equal to 5% of the portfolio NAV.
283 """
284 return self.trades_currency.div(self.nav, axis=0)
286 @property
287 def turnover(self) -> pd.DataFrame:
288 """Get the absolute turnover in the portfolio.
290 This calculates the absolute value of trades in currency terms,
291 which provides a measure of total trading activity regardless of
292 direction (buy or sell).
294 Returns:
295 -------
296 pd.DataFrame
297 DataFrame with the absolute turnover for each asset over time,
298 with dates as index and assets as columns
300 Notes:
301 -----
302 Calculated as the absolute value of trades_currency.
303 This is useful for calculating trading costs that apply equally
304 to buys and sells.
306 """
307 return self.trades_currency.abs()
309 def __getitem__(self, time: datetime | str | pd.Timestamp) -> pd.Series:
310 """Get the portfolio positions (units) at a specific time.
312 This method allows for dictionary-like access to the portfolio positions
313 at a specific time point using the syntax: portfolio[time].
315 Parameters
316 ----------
317 time : Union[datetime, str, pd.Timestamp]
318 The time index for which to retrieve the positions
320 Returns:
321 -------
322 pd.Series
323 Series containing the positions (units) for each asset at the specified time
325 Raises:
326 ------
327 KeyError
328 If the specified time is not in the portfolio's index
330 Examples:
331 --------
332 ```
333 portfolio['2023-01-01'] # Get positions on January 1, 2023
334 portfolio[pd.Timestamp('2023-01-01')] # Same as above
335 ```
337 """
338 return self.units.loc[time]
340 @property
341 def equity(self) -> pd.DataFrame:
342 """Get the equity (cash value) of each position over time.
344 This property returns the cash value of each position in the portfolio,
345 calculated by multiplying the number of units by the price for each asset.
347 Returns:
348 -------
349 pd.DataFrame
350 DataFrame with the cash value of each position over time,
351 with dates as index and assets as columns
353 Notes:
354 -----
355 This is an alias for the cashposition property and returns the same values.
356 The term "equity" is used in the context of the cash value of positions,
357 not to be confused with the equity asset class.
359 """
360 return self.cashposition
362 @property
363 def weights(self) -> pd.DataFrame:
364 """Get the weight of each asset in the portfolio over time.
366 This calculates the relative weight of each asset in the portfolio
367 by dividing the cash value of each position by the total portfolio
368 value (NAV) at each time point.
370 Returns:
371 -------
372 pd.DataFrame
373 DataFrame with the weight of each asset over time,
374 with dates as index and assets as columns
376 Notes:
377 -----
378 The sum of weights across all assets at any given time should equal 1.0
379 for a fully invested portfolio with no leverage. Weights can be negative
380 for short positions.
382 """
383 return self.equity.apply(lambda x: x / self.nav)
385 @property
386 def stats(self) -> Any:
387 """Get statistical analysis data for the portfolio.
389 This property provides access to various statistical metrics calculated
390 for the portfolio, such as Sharpe ratio, volatility, drawdowns, etc.
392 Returns:
393 -------
394 object
395 An object containing various statistical metrics for the portfolio
397 Notes:
398 -----
399 The statistics are calculated by the underlying jquantstats library
400 and are based on the portfolio's NAV time series.
402 """
403 return self._data.stats
405 @property
406 def plots(self) -> Any:
407 """Get visualization tools for the portfolio.
409 This property provides access to various plotting functions for visualizing
410 the portfolio's performance, returns, drawdowns, etc.
412 Returns:
413 -------
414 object
415 An object containing various plotting methods for the portfolio
417 Notes:
418 -----
419 The plotting functions are provided by the underlying jquantstats library
420 and operate on the portfolio's NAV time series.
422 """
423 return self._data.plots
425 @property
426 def reports(self) -> Any:
427 """Get reporting tools for the portfolio.
429 This property provides access to various reporting functions for generating
430 performance reports, risk metrics, and other analytics for the portfolio.
432 Returns:
433 -------
434 object
435 An object containing various reporting methods for the portfolio
437 Notes:
438 -----
439 The reporting functions are provided by the underlying jquantstats library
440 and operate on the portfolio's NAV time series.
442 """
443 return self._data.reports
445 def sharpe(self, periods: int | None = None) -> float:
446 """Calculate the Sharpe ratio for the portfolio.
448 The Sharpe ratio is a measure of risk-adjusted return, calculated as
449 the portfolio's excess return divided by its volatility.
451 Parameters
452 ----------
453 periods : int, optional
454 The number of periods per year for annualization.
455 For daily data, use 252; for weekly data, use 52; for monthly data, use 12.
456 If None, no annualization is performed.
458 Returns:
459 -------
460 float
461 The Sharpe ratio of the portfolio
463 Notes:
464 -----
465 The Sharpe ratio is calculated using the portfolio's NAV time series.
466 A higher Sharpe ratio indicates better risk-adjusted performance.
468 """
469 return float(self.stats.sharpe(periods=periods)["NAV"])
471 @classmethod
472 def from_cashpos_prices(cls, prices: pd.DataFrame, cashposition: pd.DataFrame, aum: float) -> Portfolio:
473 """Create a Portfolio instance from cash positions and prices.
475 This class method provides an alternative way to create a Portfolio instance
476 when you have the cash positions rather than the number of units.
478 Parameters
479 ----------
480 prices : pd.DataFrame
481 DataFrame of asset prices over time, with dates as index and assets as columns
482 cashposition : pd.DataFrame
483 DataFrame of cash positions over time, with dates as index and assets as columns
484 aum : float
485 Assets under management
487 Returns:
488 -------
489 Portfolio
490 A new Portfolio instance with units calculated from cash positions and prices
492 Notes:
493 -----
494 The units are calculated by dividing the cash positions by the prices.
495 This is useful when you have the monetary value of each position rather
496 than the number of units.
498 """
499 units = cashposition.div(prices, fill_value=0.0)
500 return cls(prices=prices, units=units, aum=aum)
502 def snapshot(self, title: str = "Portfolio Summary", log_scale: bool = True) -> Any:
503 """Generate and display a snapshot of the portfolio summary.
505 This method creates a visual representation of the portfolio summary
506 using the associated plot functionalities. The snapshot can be
507 configured with a title and whether to use a logarithmic scale.
509 Args:
510 title: A string specifying the title of the snapshot.
511 Default is "Portfolio Summary".
512 log_scale: A boolean indicating whether to display the plot
513 using a logarithmic scale. Default is True.
515 Returns:
516 The generated plot object representing the portfolio snapshot.
518 """
519 return self.plots.plot_snapshot(title=title, log_scale=log_scale)