Coverage for src/cvx/simulator/portfolio.py: 100%
79 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 13:14 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 13:14 +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 """
56 prices: pd.DataFrame
57 units: pd.DataFrame
58 aum: float | pd.Series
59 _data: Data = field(init=False)
61 def __post_init__(self) -> None:
62 """Validate the portfolio data after initialization.
64 This method is automatically called after an instance of the Portfolio
65 class has been initialized. It performs a series of validation checks
66 to ensure that the prices and units dataframes are in the expected format
67 with no duplicates or missing data.
69 The method checks that:
70 - Both prices and units dataframes have monotonic increasing indices
71 - Both prices and units dataframes have unique indices
72 - The index of units is a subset of the index of prices
73 - The columns of units is a subset of the columns of prices
75 Raises:
76 ------
77 ValueError
78 If any of the validation checks fail
80 """
81 self._validate()
82 self._build_data()
84 def _validate(self) -> None:
85 """Validate the prices and units dataframes.
87 Checks that both frames have monotonic increasing, unique indices and
88 that the units index and columns are subsets of the prices index and
89 columns respectively.
91 Raises:
92 ------
93 ValueError
94 If any of the validation checks fail
95 """
96 index_checks = (
97 (self.prices.index.is_monotonic_increasing, "`prices` index must be monotonic increasing."),
98 (self.prices.index.is_unique, "`prices` index must be unique."),
99 (self.units.index.is_monotonic_increasing, "`units` index must be monotonic increasing."),
100 (self.units.index.is_unique, "`units` index must be unique."),
101 )
102 for is_valid, message in index_checks:
103 if not is_valid:
104 raise ValueError(message)
106 missing_dates = self.units.index.difference(self.prices.index)
107 if not missing_dates.empty:
108 msg = f"`units` index contains dates not present in `prices`: {missing_dates.tolist()}"
109 raise ValueError(msg)
111 missing_assets = self.units.columns.difference(self.prices.columns)
112 if not missing_assets.empty:
113 msg = f"`units` contains assets not present in `prices`: {missing_assets.tolist()}"
114 raise ValueError(msg)
116 @property
117 def index(self) -> list[datetime]:
118 """Get the time index of the portfolio.
120 Returns:
121 -------
122 pd.DatetimeIndex
123 A DatetimeIndex representing the time period for which portfolio
124 data is available
126 Notes:
127 -----
128 This property extracts the index from the prices DataFrame, which
129 represents all time points in the portfolio history.
131 """
132 return list(pd.DatetimeIndex(self.prices.index))
134 @property
135 def assets(self) -> list[str]:
136 """Get the list of assets in the portfolio.
138 Returns:
139 -------
140 pd.Index
141 An Index containing the names of all assets in the portfolio
143 Notes:
144 -----
145 This property extracts the column names from the prices DataFrame,
146 which correspond to all assets for which price data is available.
148 """
149 return list(self.prices.columns)
151 @property
152 def nav(self) -> pd.Series:
153 """Get the net asset value (NAV) of the portfolio over time.
155 The NAV represents the total value of the portfolio at each point in time.
156 If aum is provided as a Series, it is used directly. Otherwise, the NAV
157 is calculated from the cumulative profit plus the initial aum.
159 Returns:
160 -------
161 pd.Series
162 Series representing the NAV of the portfolio over time
164 """
165 if isinstance(self.aum, pd.Series):
166 series = self.aum
167 else:
168 profit = (self.cashposition.shift(1) * self.returns.fillna(0.0)).sum(axis=1)
169 series = profit.cumsum() + self.aum
171 series.name = "NAV"
172 return series
174 @property
175 def profit(self) -> pd.Series:
176 """Get the profit/loss of the portfolio at each time point.
178 This calculates the profit or loss at each time point based on the
179 previous positions and the returns of each asset.
181 Returns:
182 -------
183 pd.Series
184 Series representing the profit/loss at each time point
186 Notes:
187 -----
188 The profit is calculated by multiplying the previous day's positions
189 (in currency terms) by the returns of each asset, and then summing
190 across all assets.
192 """
193 series = (self.cashposition.shift(1) * self.returns.fillna(0.0)).sum(axis=1)
194 series.name = "Profit"
195 return series
197 @property
198 def cashposition(self) -> pd.DataFrame:
199 """Get the cash value of each position over time.
201 This calculates the cash value of each position by multiplying
202 the number of units by the price for each asset at each time point.
204 Returns:
205 -------
206 pd.DataFrame
207 DataFrame with the cash value of each position over time,
208 with dates as index and assets as columns
210 """
211 return self.prices * self.units
213 @property
214 def returns(self) -> pd.DataFrame:
215 """Get the returns of individual assets over time.
217 This calculates the percentage change in price for each asset
218 from one time point to the next.
220 Returns:
221 -------
222 pd.DataFrame
223 DataFrame with the returns of each asset over time,
224 with dates as index and assets as columns
226 """
227 return self.prices.pct_change()
229 @property
230 def trades_units(self) -> pd.DataFrame:
231 """Get the trades made in the portfolio in terms of units.
233 This calculates the changes in position (units) from one time point
234 to the next for each asset.
236 Returns:
237 -------
238 pd.DataFrame
239 DataFrame with the trades (changes in units) for each asset over time,
240 with dates as index and assets as columns
242 Notes:
243 -----
244 Calculated as the difference between consecutive position values.
245 Positive values represent buys, negative values represent sells.
246 The first row contains the initial positions, as there are no previous
247 positions to compare with.
249 """
250 t = self.units.fillna(0.0).diff()
251 t.loc[self.index[0]] = self.units.loc[self.index[0]]
252 return t.fillna(0.0)
254 @property
255 def trades_currency(self) -> pd.DataFrame:
256 """Get the trades made in the portfolio in terms of currency.
258 This calculates the cash value of trades by multiplying the changes
259 in position (units) by the current prices.
261 Returns:
262 -------
263 pd.DataFrame
264 DataFrame with the cash value of trades for each asset over time,
265 with dates as index and assets as columns
267 Notes:
268 -----
269 Calculated by multiplying trades_units by prices.
270 Positive values represent buys (cash outflows),
271 negative values represent sells (cash inflows).
273 """
274 return self.trades_units * self.prices
276 @property
277 def turnover_relative(self) -> pd.DataFrame:
278 """Get the turnover relative to the portfolio NAV.
280 This calculates the trades as a percentage of the portfolio NAV,
281 which provides a measure of trading activity relative to portfolio size.
283 Returns:
284 -------
285 pd.DataFrame
286 DataFrame with the relative turnover for each asset over time,
287 with dates as index and assets as columns
289 Notes:
290 -----
291 Calculated by dividing trades_currency by NAV.
292 Positive values represent buys, negative values represent sells.
293 A value of 0.05 means a buy equal to 5% of the portfolio NAV.
295 """
296 return self.trades_currency.div(self.nav, axis=0)
298 @property
299 def turnover(self) -> pd.DataFrame:
300 """Get the absolute turnover in the portfolio.
302 This calculates the absolute value of trades in currency terms,
303 which provides a measure of total trading activity regardless of
304 direction (buy or sell).
306 Returns:
307 -------
308 pd.DataFrame
309 DataFrame with the absolute turnover for each asset over time,
310 with dates as index and assets as columns
312 Notes:
313 -----
314 Calculated as the absolute value of trades_currency.
315 This is useful for calculating trading costs that apply equally
316 to buys and sells.
318 """
319 return self.trades_currency.abs()
321 def __getitem__(self, time: datetime | str | pd.Timestamp) -> pd.Series:
322 """Get the portfolio positions (units) at a specific time.
324 This method allows for dictionary-like access to the portfolio positions
325 at a specific time point using the syntax: portfolio[time].
327 Parameters
328 ----------
329 time : Union[datetime, str, pd.Timestamp]
330 The time index for which to retrieve the positions
332 Returns:
333 -------
334 pd.Series
335 Series containing the positions (units) for each asset at the specified time
337 Raises:
338 ------
339 KeyError
340 If the specified time is not in the portfolio's index
342 Examples:
343 --------
344 ```
345 portfolio['2023-01-01'] # Get positions on January 1, 2023
346 portfolio[pd.Timestamp('2023-01-01')] # Same as above
347 ```
349 """
350 return self.units.loc[time]
352 @property
353 def equity(self) -> pd.DataFrame:
354 """Get the equity (cash value) of each position over time.
356 This property returns the cash value of each position in the portfolio,
357 calculated by multiplying the number of units by the price for each asset.
359 Returns:
360 -------
361 pd.DataFrame
362 DataFrame with the cash value of each position over time,
363 with dates as index and assets as columns
365 Notes:
366 -----
367 This is an alias for the cashposition property and returns the same values.
368 The term "equity" is used in the context of the cash value of positions,
369 not to be confused with the equity asset class.
371 """
372 return self.cashposition
374 @property
375 def weights(self) -> pd.DataFrame:
376 """Get the weight of each asset in the portfolio over time.
378 This calculates the relative weight of each asset in the portfolio
379 by dividing the cash value of each position by the total portfolio
380 value (NAV) at each time point.
382 Returns:
383 -------
384 pd.DataFrame
385 DataFrame with the weight of each asset over time,
386 with dates as index and assets as columns
388 Notes:
389 -----
390 The sum of weights across all assets at any given time should equal 1.0
391 for a fully invested portfolio with no leverage. Weights can be negative
392 for short positions.
394 """
395 return self.equity.apply(lambda x: x / self.nav)
397 @classmethod
398 def from_cashpos_prices(cls, prices: pd.DataFrame, cashposition: pd.DataFrame, aum: float) -> Portfolio:
399 """Create a Portfolio instance from cash positions and prices.
401 This class method provides an alternative way to create a Portfolio instance
402 when you have the cash positions rather than the number of units.
404 Parameters
405 ----------
406 prices : pd.DataFrame
407 DataFrame of asset prices over time, with dates as index and assets as columns
408 cashposition : pd.DataFrame
409 DataFrame of cash positions over time, with dates as index and assets as columns
410 aum : float
411 Assets under management
413 Returns:
414 -------
415 Portfolio
416 A new Portfolio instance with units calculated from cash positions and prices
418 Notes:
419 -----
420 The units are calculated by dividing the cash positions by the prices.
421 This is useful when you have the monetary value of each position rather
422 than the number of units.
424 """
425 units = cashposition.div(prices, fill_value=0.0)
426 return cls(prices=prices, units=units, aum=aum)