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

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 

15 

16from dataclasses import dataclass 

17from datetime import datetime 

18from typing import Any 

19 

20import numpy as np 

21import pandas as pd 

22import plotly.graph_objects as go 

23from plotly.subplots import make_subplots 

24 

25from .utils.metric import sharpe 

26from .utils.rescale import returns2prices 

27 

28 

29@dataclass(frozen=True) 

30class Portfolio: 

31 prices: pd.DataFrame 

32 units: pd.DataFrame 

33 aum: float | pd.Series 

34 

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.""" 

48 

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 

53 

54 assert set(self.units.index).issubset(set(self.prices.index)) 

55 assert set(self.units.columns).issubset(set(self.prices.columns)) 

56 

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. 

61 

62 Returns: pd.Index: A pandas index representing the time period for which the 

63 portfolio data is available. 

64 

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) 

70 

71 @property 

72 def assets(self) -> pd.Index: 

73 """A property that returns a list of the assets held by the EquityPortfolio object. 

74 

75 Returns: list: A list of the assets held by the EquityPortfolio object. 

76 

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 

82 

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 

91 

92 series.name = "NAV" 

93 return series 

94 

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. 

99 

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 

106 

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. 

112 

113 Returns: pd.Series: A pandas series representing the 

114 high-water mark of the portfolio. 

115 

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 

125 

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. 

132 

133 Returns: pd.Series: A pandas series representing the 

134 drawdown of the portfolio. 

135 

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 

144 

145 @property 

146 def cashposition(self): 

147 """ 

148 Return a pandas dataframe representing the cash position 

149 """ 

150 return self.prices * self.units 

151 

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() 

158 

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. 

162 

163 Returns: pd.DataFrame: A pandas dataframe representing the trades made in the portfolio in terms of units. 

164 

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) 

174 

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. 

179 

180 Returns: pd.DataFrame: A pandas dataframe representing the trades made in the portfolio in terms of currency. 

181 

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 

188 

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) 

194 

195 @property 

196 def turnover(self) -> pd.DataFrame: 

197 return self.trades_currency.abs() 

198 

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. 

202 

203 The method takes one input parameter: 

204 - `time`: the time index for which to retrieve the stock data 

205 

206 Returns: 

207 - stock data for the input time 

208 

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] 

212 

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. 

220 

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.""" 

226 

227 return self.cashposition 

228 

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. 

233 

234 Returns: pd.DataFrame: A pandas dataframe representing the weights 

235 of various assets in the portfolio. 

236 

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) 

245 

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 ) 

272 

273 # display the NAV 

274 fig.add_trace(go.Scatter(x=self.nav.index, y=self.nav, name=label_strategy), row=2, col=1) 

275 

276 # change the title of the yaxis 

277 fig.update_yaxes(title_text="Cumulative Return", row=2, col=1) 

278 

279 # change yaxis to log scale 

280 if log_scale: 

281 fig.update_yaxes(type="log", row=2, col=1) 

282 

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) 

290 

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() 

296 

297 df = returns.to_frame("value") 

298 df["color"] = np.where(df["value"] >= 0, "green", "red") 

299 

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) 

313 

314 else: 

315 self.nav.index.name = "date" 

316 returns = 100 * self.nav.pct_change().dropna() 

317 

318 df = returns.to_frame("value") # .reset_index() 

319 df["color"] = np.where(df["value"] >= 0, "green", "red") 

320 

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) 

332 

333 fig.update_traces(showlegend=True) 

334 fig.update_layout(height=800, title_text=title) 

335 

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 ) 

342 

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" 

349 

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) 

353 

354 s = sharpe(self.nav.ffill().pct_change(fill_method=None).dropna()) 

355 table.loc[label_strategy, "Sharpe ratio"] = f"{s:.2f}" 

356 

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 ) 

368 

369 table = pd.concat([table, table_bench], axis=0) 

370 

371 table = table.reset_index() 

372 

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 ) 

385 

386 return fig 

387 

388 def sharpe(self, n=None): 

389 """Simple Sharpe ratio""" 

390 

391 ts = self.nav.pct_change().dropna() 

392 return sharpe(ts, n=n) 

393 

394 # return ts.mean() / ts.std() * sqrt(n) 

395 

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) 

401 

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)