Coverage for cvx/simulator/portfolio.py: 100%
134 statements
« prev ^ index » next coverage.py v7.6.8, created at 2025-01-10 14:11 +0000
« prev ^ index » next coverage.py v7.6.8, created at 2025-01-10 14:11 +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.
14from __future__ import annotations
16from dataclasses import dataclass
17from datetime import datetime
18from typing import Any
20import numpy as np
21import pandas as pd
22import plotly.graph_objects as go
23from plotly.subplots import make_subplots
25from .utils.metric import sharpe
26from .utils.rescale import returns2prices
29@dataclass(frozen=True)
30class Portfolio:
31 prices: pd.DataFrame
32 units: pd.DataFrame
33 aum: float | pd.Series
35 def __post_init__(self) -> None:
36 """A class method that performs input validation after object initialization.
37 Notes: The post_init method is called after an instance of the Portfolio
38 class has been initialized, and performs a series of input validation
39 checks to ensure that the prices and units dataframes are in the
40 expected format with no duplicates or missing data,
41 and that the units dataframe represents valid equity positions
42 for the assets held in the portfolio.
43 Specifically, the method checks that both the prices and units dataframes
44 have a monotonic increasing and unique index,
45 and that the index and columns of the units dataframe are subsets
46 of the index and columns of the prices dataframe, respectively.
47 If any of these checks fail, an assertion error will be raised."""
49 assert self.prices.index.is_monotonic_increasing
50 assert self.prices.index.is_unique
51 assert self.units.index.is_monotonic_increasing
52 assert self.units.index.is_unique
54 assert set(self.units.index).issubset(set(self.prices.index))
55 assert set(self.units.columns).issubset(set(self.prices.columns))
57 @property
58 def index(self) -> pd.DatetimeIndex:
59 """A property that returns the index of the EquityPortfolio instance,
60 which is the time period for which the portfolio data is available.
62 Returns: pd.Index: A pandas index representing the time period for which the
63 portfolio data is available.
65 Notes: The function extracts the index of the prices dataframe,
66 which represents the time periods for which data is available for the portfolio.
67 The resulting index will be a pandas index object with the same length
68 as the number of rows in the prices dataframe."""
69 return pd.DatetimeIndex(self.prices.index)
71 @property
72 def assets(self) -> pd.Index:
73 """A property that returns a list of the assets held by the EquityPortfolio object.
75 Returns: list: A list of the assets held by the EquityPortfolio object.
77 Notes: The function extracts the column names of the prices dataframe,
78 which correspond to the assets held by the EquityPortfolio object.
79 The resulting list will contain the names of all assets held by the portfolio, without any duplicates.
80 """
81 return self.prices.columns
83 @property
84 def nav(self) -> pd.Series:
85 """Return a pandas series representing the NAV"""
86 if isinstance(self.aum, pd.Series):
87 series = self.aum
88 else:
89 profit = (self.cashposition.shift(1) * self.returns.fillna(0.0)).sum(axis=1)
90 series = profit.cumsum() + self.aum
92 series.name = "NAV"
93 return series
95 @property
96 def profit(self) -> pd.Series:
97 """A property that returns a pandas series representing the
98 profit gained or lost in the portfolio based on changes in asset prices.
100 Returns: pd.Series: A pandas series representing the profit
101 gained or lost in the portfolio based on changes in asset prices.
102 """
103 series = (self.cashposition.shift(1) * self.returns.fillna(0.0)).sum(axis=1)
104 series.name = "Profit"
105 return series
107 @property
108 def highwater(self) -> pd.Series:
109 """A function that returns a pandas series representing
110 the high-water mark of the portfolio, which is the highest point
111 the portfolio value has reached over time.
113 Returns: pd.Series: A pandas series representing the
114 high-water mark of the portfolio.
116 Notes: The function performs a rolling computation based on
117 the cumulative maximum of the portfolio's value over time,
118 starting from the beginning of the time period being considered.
119 Min_periods argument is set to 1 to include the minimum period of one day.
120 The resulting series will show the highest value the portfolio has reached at each point in time.
121 """
122 series = self.nav.expanding(min_periods=1).max()
123 series.name = "Highwater"
124 return series
126 @property
127 def drawdown(self) -> pd.Series:
128 """A property that returns a pandas series representing the
129 drawdown of the portfolio, which measures the decline
130 in the portfolio's value from its (previously) highest
131 point to its current point.
133 Returns: pd.Series: A pandas series representing the
134 drawdown of the portfolio.
136 Notes: The function calculates the ratio of the portfolio's current value
137 vs. its current high-water-mark and then subtracting the result from 1.
138 A positive drawdown means the portfolio is currently worth
139 less than its high-water mark. A drawdown of 0.1 implies that the nav is currently 0.9 times the high-water mark
140 """
141 series = 1.0 - self.nav / self.highwater
142 series.name = "Drawdown"
143 return series
145 @property
146 def cashposition(self):
147 """
148 Return a pandas dataframe representing the cash position
149 """
150 return self.prices * self.units
152 @property
153 def returns(self):
154 """
155 The returns property exposes the returns of the individual assets
156 """
157 return self.prices.pct_change()
159 @property
160 def trades_units(self) -> pd.DataFrame:
161 """A property that returns a pandas dataframe representing the trades made in the portfolio in terms of units.
163 Returns: pd.DataFrame: A pandas dataframe representing the trades made in the portfolio in terms of units.
165 Notes: The function calculates the trades made by the portfolio by taking
166 the difference between the current and previous values of the units dataframe.
167 The resulting values will represent the number of shares of each asset
168 bought or sold by the portfolio at each point in time.
169 The resulting dataframe will have the same dimensions
170 as the units dataframe, with NaN values filled with zeros."""
171 t = self.units.fillna(0.0).diff()
172 t.loc[self.index[0]] = self.units.loc[self.index[0]]
173 return t.fillna(0.0)
175 @property
176 def trades_currency(self) -> pd.DataFrame:
177 """A property that returns a pandas dataframe representing
178 the trades made in the portfolio in terms of currency.
180 Returns: pd.DataFrame: A pandas dataframe representing the trades made in the portfolio in terms of currency.
182 Notes: The function calculates the trades made in currency by multiplying
183 the number of shares of each asset bought or sold (as represented in the trades_units dataframe)
184 with the current prices of each asset (as represented in the prices dataframe).
185 The resulting dataframe will have the same dimensions as the units and prices dataframes.
186 """
187 return self.trades_units * self.prices
189 @property
190 def turnover_relative(self) -> pd.DataFrame:
191 """A property that returns a pandas dataframe representing the turnover
192 relative to the NAV. Can be positive (if bought) or negative (if sold)."""
193 return self.trades_currency.div(self.nav, axis=0)
195 @property
196 def turnover(self) -> pd.DataFrame:
197 return self.trades_currency.abs()
199 def __getitem__(self, time: datetime) -> pd.Series:
200 """The `__getitem__` method retrieves the stock data for a specific time in the dataframe.
201 It returns the stock data for that time.
203 The method takes one input parameter:
204 - `time`: the time index for which to retrieve the stock data
206 Returns:
207 - stock data for the input time
209 Note that the input time must be in the index of the dataframe,
210 otherwise a KeyError will be raised."""
211 return self.units.loc[time]
213 @property
214 def equity(self) -> pd.DataFrame:
215 """A property that returns a pandas dataframe
216 representing the equity positions of the portfolio,
217 which is the value of each asset held by the portfolio.
218 Returns: pd.DataFrame: A pandas dataframe representing
219 the equity positions of the portfolio.
221 Notes: The function calculates the equity of the portfolio
222 by multiplying the current prices of each asset
223 by the number of shares held by the portfolio.
224 The equity dataframe will have the same dimensions
225 as the prices and units dataframes."""
227 return self.cashposition
229 @property
230 def weights(self) -> pd.DataFrame:
231 """A property that returns a pandas dataframe representing
232 the weights of various assets in the portfolio.
234 Returns: pd.DataFrame: A pandas dataframe representing the weights
235 of various assets in the portfolio.
237 Notes: The function calculates the weights of various assets
238 in the portfolio by dividing the equity positions
239 for each asset (as represented in the equity dataframe)
240 by the total portfolio value (as represented in the nav dataframe).
241 Both dataframes are assumed to have the same dimensions.
242 The resulting dataframe will show the relative weight
243 of each asset in the portfolio at each point in time."""
244 return self.equity.apply(lambda x: x / self.nav)
246 def snapshot(
247 self,
248 benchmark: Any = None,
249 title: str = "Portfolio Summary",
250 table: pd.DataFrame = None,
251 aggregate: bool = False,
252 log_scale: bool = False,
253 label_strategy: str = "Strategy",
254 label_benchmark: str = "Benchmark",
255 ) -> Any:
256 """
257 The snapshot method creates a snapshot of the performance of a Portfolio object.
258 """
259 fig = make_subplots(
260 rows=4,
261 cols=1,
262 shared_xaxes=True,
263 vertical_spacing=0.01,
264 row_heights=[0.13, 0.47, 0.2, 0.2],
265 specs=[
266 [{"type": "table"}],
267 [{"type": "scatter"}],
268 [{"type": "scatter"}],
269 [{"type": "bar"}],
270 ],
271 )
273 # display the NAV
274 fig.add_trace(go.Scatter(x=self.nav.index, y=self.nav, name=label_strategy), row=2, col=1)
276 # change the title of the yaxis
277 fig.update_yaxes(title_text="Cumulative Return", row=2, col=1)
279 # change yaxis to log scale
280 if log_scale:
281 fig.update_yaxes(type="log", row=2, col=1)
283 # Drawdown data
284 fig.add_trace(
285 go.Scatter(x=self.drawdown.index, y=-self.drawdown, name="Drawdown", fill="tozeroy"),
286 row=3,
287 col=1,
288 )
289 fig.update_yaxes(title_text="Drawdown", row=3, col=1)
291 # Daily/Monthly Returns
292 if aggregate:
293 self.nav.index.name = "date"
294 nav = self.nav.resample("ME").last()
295 returns = 100 * nav.pct_change().dropna()
297 df = returns.to_frame("value")
298 df["color"] = np.where(df["value"] >= 0, "green", "red")
300 fig.add_trace(
301 go.Bar(
302 x=df.index,
303 y=df["value"],
304 xperiod="M1",
305 xperiodalignment="middle",
306 marker_color=df["color"],
307 name="Monthly Returns",
308 ),
309 row=4,
310 col=1,
311 )
312 fig.update_yaxes(title_text="Monthly Returns", row=4, col=1)
314 else:
315 self.nav.index.name = "date"
316 returns = 100 * self.nav.pct_change().dropna()
318 df = returns.to_frame("value") # .reset_index()
319 df["color"] = np.where(df["value"] >= 0, "green", "red")
321 fig.add_trace(
322 go.Bar(
323 x=df.index,
324 y=df["value"],
325 marker_color=df["color"],
326 name="Daily Returns",
327 ),
328 row=4,
329 col=1,
330 )
331 fig.update_yaxes(title_text="Daily Returns", row=4, col=1)
333 fig.update_traces(showlegend=True)
334 fig.update_layout(height=800, title_text=title)
336 if benchmark is not None:
337 fig.add_trace(
338 go.Scatter(x=benchmark.index, y=benchmark.values, name=label_benchmark),
339 row=2,
340 col=1,
341 )
343 if table is None:
344 table = pd.DataFrame(
345 index=[label_strategy],
346 columns=["start", "end", "# assets", "Sharpe ratio"],
347 )
348 table.index.name = "Portfolio"
350 table.loc[label_strategy, "start"] = self.nav.index[0].strftime("%Y-%m-%d")
351 table.loc[label_strategy, "end"] = self.nav.index[-1].strftime("%Y-%m-%d")
352 table.loc[label_strategy, "# assets"] = len(self.assets)
354 s = sharpe(self.nav.ffill().pct_change(fill_method=None).dropna())
355 table.loc[label_strategy, "Sharpe ratio"] = f"{s:.2f}"
357 if benchmark is not None:
358 table_bench = pd.DataFrame(
359 index=[label_benchmark],
360 columns=["start", "end", "# assets", "Sharpe ratio"],
361 )
362 table_bench.loc[label_benchmark, "start"] = benchmark.index[0].strftime("%Y-%m-%d")
363 table_bench.loc[label_benchmark, "end"] = benchmark.index[-1].strftime("%Y-%m-%d")
364 table_bench.loc[label_benchmark, "# assets"] = ""
365 table_bench.loc[label_benchmark, "Sharpe ratio"] = (
366 f"{sharpe(benchmark.ffill().pct_change(fill_method=None).dropna()):.2f}"
367 )
369 table = pd.concat([table, table_bench], axis=0)
371 table = table.reset_index()
373 # if table is not None:
374 fig.add_trace(
375 go.Table(
376 header=dict(values=list(table.columns), font=dict(size=10), align="left"),
377 cells=dict(
378 values=[table[column].values for column in table.columns],
379 align="left",
380 ),
381 ),
382 row=1,
383 col=1,
384 )
386 return fig
388 def sharpe(self, n=None):
389 """Simple Sharpe ratio"""
391 ts = self.nav.pct_change().dropna()
392 return sharpe(ts, n=n)
394 # return ts.mean() / ts.std() * sqrt(n)
396 @classmethod
397 def from_cashpos_prices(cls, prices: pd.DataFrame, cashposition: pd.DataFrame, aum: float):
398 """Build Futures Portfolio from cashposition"""
399 units = cashposition.div(prices, fill_value=0.0)
400 return cls(prices=prices, units=units, aum=aum)
402 @classmethod
403 def from_cashpos_returns(cls, returns: pd.DataFrame, cashposition: pd.DataFrame, aum: float):
404 """Build Futures Portfolio from cashposition"""
405 prices = returns2prices(returns)
406 return cls.from_cashpos_prices(prices, cashposition, aum)