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

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. 

15 

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

21 

22from __future__ import annotations 

23 

24from dataclasses import dataclass, field 

25from datetime import datetime 

26 

27import pandas as pd 

28from jquantstats._data import Data 

29from jquantstats.api import build_data 

30 

31 

32@dataclass(frozen=True) 

33class Portfolio: 

34 """Represents a portfolio of assets with methods for analysis and visualization. 

35 

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. 

40 

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 

49 

50 """ 

51 

52 prices: pd.DataFrame 

53 units: pd.DataFrame 

54 aum: float | pd.Series 

55 _data: Data = field(init=False) 

56 

57 def __post_init__(self) -> None: 

58 """Validate the portfolio data after initialization. 

59 

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. 

64 

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 

70 

71 Raises 

72 ------ 

73 AssertionError 

74 If any of the validation checks fail 

75 

76 """ 

77 if not self.prices.index.is_monotonic_increasing: 

78 raise ValueError("`prices` index must be monotonic increasing.") 

79 

80 if not self.prices.index.is_unique: 

81 raise ValueError("`prices` index must be unique.") 

82 

83 if not self.units.index.is_monotonic_increasing: 

84 raise ValueError("`units` index must be monotonic increasing.") 

85 

86 if not self.units.index.is_unique: 

87 raise ValueError("`units` index must be unique.") 

88 

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

92 

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

96 

97 frame = self.nav.pct_change().to_frame() 

98 frame.index.name = "Date" 

99 d = build_data(returns=frame) 

100 

101 object.__setattr__(self, "_data", d) 

102 

103 @property 

104 def index(self) -> list[datetime]: 

105 """Get the time index of the portfolio. 

106 

107 Returns 

108 ------- 

109 pd.DatetimeIndex 

110 A DatetimeIndex representing the time period for which portfolio 

111 data is available 

112 

113 Notes 

114 ----- 

115 This property extracts the index from the prices DataFrame, which 

116 represents all time points in the portfolio history. 

117 

118 """ 

119 return pd.DatetimeIndex(self.prices.index).to_list() 

120 

121 @property 

122 def assets(self) -> list[str]: 

123 """Get the list of assets in the portfolio. 

124 

125 Returns 

126 ------- 

127 pd.Index 

128 An Index containing the names of all assets in the portfolio 

129 

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. 

134 

135 """ 

136 return self.prices.columns.to_list() 

137 

138 @property 

139 def nav(self) -> pd.Series: 

140 """Get the net asset value (NAV) of the portfolio over time. 

141 

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. 

145 

146 Returns 

147 ------- 

148 pd.Series 

149 Series representing the NAV of the portfolio over time 

150 

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 

157 

158 series.name = "NAV" 

159 return series 

160 

161 @property 

162 def profit(self) -> pd.Series: 

163 """Get the profit/loss of the portfolio at each time point. 

164 

165 This calculates the profit or loss at each time point based on the 

166 previous positions and the returns of each asset. 

167 

168 Returns 

169 ------- 

170 pd.Series 

171 Series representing the profit/loss at each time point 

172 

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. 

178 

179 """ 

180 series = (self.cashposition.shift(1) * self.returns.fillna(0.0)).sum(axis=1) 

181 series.name = "Profit" 

182 return series 

183 

184 @property 

185 def cashposition(self) -> pd.DataFrame: 

186 """Get the cash value of each position over time. 

187 

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. 

190 

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 

196 

197 """ 

198 return self.prices * self.units 

199 

200 @property 

201 def returns(self) -> pd.DataFrame: 

202 """Get the returns of individual assets over time. 

203 

204 This calculates the percentage change in price for each asset 

205 from one time point to the next. 

206 

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 

212 

213 """ 

214 return self.prices.pct_change() 

215 

216 @property 

217 def trades_units(self) -> pd.DataFrame: 

218 """Get the trades made in the portfolio in terms of units. 

219 

220 This calculates the changes in position (units) from one time point 

221 to the next for each asset. 

222 

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 

228 

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. 

235 

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) 

240 

241 @property 

242 def trades_currency(self) -> pd.DataFrame: 

243 """Get the trades made in the portfolio in terms of currency. 

244 

245 This calculates the cash value of trades by multiplying the changes 

246 in position (units) by the current prices. 

247 

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 

253 

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

259 

260 """ 

261 return self.trades_units * self.prices 

262 

263 @property 

264 def turnover_relative(self) -> pd.DataFrame: 

265 """Get the turnover relative to the portfolio NAV. 

266 

267 This calculates the trades as a percentage of the portfolio NAV, 

268 which provides a measure of trading activity relative to portfolio size. 

269 

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 

275 

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. 

281 

282 """ 

283 return self.trades_currency.div(self.nav, axis=0) 

284 

285 @property 

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

287 """Get the absolute turnover in the portfolio. 

288 

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

292 

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 

298 

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. 

304 

305 """ 

306 return self.trades_currency.abs() 

307 

308 def __getitem__(self, time: datetime | str | pd.Timestamp) -> pd.Series: 

309 """Get the portfolio positions (units) at a specific time. 

310 

311 This method allows for dictionary-like access to the portfolio positions 

312 at a specific time point using the syntax: portfolio[time]. 

313 

314 Parameters 

315 ---------- 

316 time : Union[datetime, str, pd.Timestamp] 

317 The time index for which to retrieve the positions 

318 

319 Returns 

320 ------- 

321 pd.Series 

322 Series containing the positions (units) for each asset at the specified time 

323 

324 Raises 

325 ------ 

326 KeyError 

327 If the specified time is not in the portfolio's index 

328 

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 ``` 

335 

336 """ 

337 return self.units.loc[time] 

338 

339 @property 

340 def equity(self) -> pd.DataFrame: 

341 """Get the equity (cash value) of each position over time. 

342 

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. 

345 

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 

351 

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. 

357 

358 """ 

359 return self.cashposition 

360 

361 @property 

362 def weights(self) -> pd.DataFrame: 

363 """Get the weight of each asset in the portfolio over time. 

364 

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. 

368 

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 

374 

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. 

380 

381 """ 

382 return self.equity.apply(lambda x: x / self.nav) 

383 

384 @property 

385 def stats(self): 

386 """Get statistical analysis data for the portfolio. 

387 

388 This property provides access to various statistical metrics calculated 

389 for the portfolio, such as Sharpe ratio, volatility, drawdowns, etc. 

390 

391 Returns 

392 ------- 

393 object 

394 An object containing various statistical metrics for the portfolio 

395 

396 Notes 

397 ----- 

398 The statistics are calculated by the underlying jquantstats library 

399 and are based on the portfolio's NAV time series. 

400 

401 """ 

402 return self._data.stats 

403 

404 @property 

405 def plots(self): 

406 """Get visualization tools for the portfolio. 

407 

408 This property provides access to various plotting functions for visualizing 

409 the portfolio's performance, returns, drawdowns, etc. 

410 

411 Returns 

412 ------- 

413 object 

414 An object containing various plotting methods for the portfolio 

415 

416 Notes 

417 ----- 

418 The plotting functions are provided by the underlying jquantstats library 

419 and operate on the portfolio's NAV time series. 

420 

421 """ 

422 return self._data.plots 

423 

424 @property 

425 def reports(self): 

426 """Get reporting tools for the portfolio. 

427 

428 This property provides access to various reporting functions for generating 

429 performance reports, risk metrics, and other analytics for the portfolio. 

430 

431 Returns 

432 ------- 

433 object 

434 An object containing various reporting methods for the portfolio 

435 

436 Notes 

437 ----- 

438 The reporting functions are provided by the underlying jquantstats library 

439 and operate on the portfolio's NAV time series. 

440 

441 """ 

442 return self._data.reports 

443 

444 def sharpe(self, periods=None): 

445 """Calculate the Sharpe ratio for the portfolio. 

446 

447 The Sharpe ratio is a measure of risk-adjusted return, calculated as 

448 the portfolio's excess return divided by its volatility. 

449 

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. 

456 

457 Returns 

458 ------- 

459 float 

460 The Sharpe ratio of the portfolio 

461 

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. 

466 

467 """ 

468 return self.stats.sharpe(periods=periods)["NAV"] 

469 

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. 

473 

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. 

476 

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 

485 

486 Returns 

487 ------- 

488 Portfolio 

489 A new Portfolio instance with units calculated from cash positions and prices 

490 

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. 

496 

497 """ 

498 units = cashposition.div(prices, fill_value=0.0) 

499 return cls(prices=prices, units=units, aum=aum) 

500 

501 def snapshot(self, title: str = "Portfolio Summary", log_scale: bool = True): 

502 """Generate and display a snapshot of the portfolio summary. 

503 

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. 

507 

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. 

513 

514 Returns: 

515 The generated plot object representing the portfolio snapshot. 

516 

517 """ 

518 return self.plots.plot_snapshot(title=title, log_scale=log_scale)