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

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 

26from typing import Any 

27 

28import pandas as pd 

29from jquantstats._data import Data 

30from jquantstats.api import build_data 

31 

32 

33@dataclass(frozen=True) 

34class Portfolio: 

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

36 

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. 

41 

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 

50 

51 """ 

52 

53 prices: pd.DataFrame 

54 units: pd.DataFrame 

55 aum: float | pd.Series 

56 _data: Data = field(init=False) 

57 

58 def __post_init__(self) -> None: 

59 """Validate the portfolio data after initialization. 

60 

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. 

65 

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 

71 

72 Raises: 

73 ------ 

74 AssertionError 

75 If any of the validation checks fail 

76 

77 """ 

78 if not self.prices.index.is_monotonic_increasing: 

79 raise ValueError("`prices` index must be monotonic increasing.") # noqa: TRY003 

80 

81 if not self.prices.index.is_unique: 

82 raise ValueError("`prices` index must be unique.") # noqa: TRY003 

83 

84 if not self.units.index.is_monotonic_increasing: 

85 raise ValueError("`units` index must be monotonic increasing.") # noqa: TRY003 

86 

87 if not self.units.index.is_unique: 

88 raise ValueError("`units` index must be unique.") # noqa: TRY003 

89 

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 

93 

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 

97 

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

99 frame.index.name = "Date" 

100 d = build_data(returns=frame) 

101 

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

103 

104 @property 

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

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

107 

108 Returns: 

109 ------- 

110 pd.DatetimeIndex 

111 A DatetimeIndex representing the time period for which portfolio 

112 data is available 

113 

114 Notes: 

115 ----- 

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

117 represents all time points in the portfolio history. 

118 

119 """ 

120 return list(pd.DatetimeIndex(self.prices.index)) 

121 

122 @property 

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

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

125 

126 Returns: 

127 ------- 

128 pd.Index 

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

130 

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. 

135 

136 """ 

137 return list(self.prices.columns) 

138 

139 @property 

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

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

142 

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. 

146 

147 Returns: 

148 ------- 

149 pd.Series 

150 Series representing the NAV of the portfolio over time 

151 

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 

158 

159 series.name = "NAV" 

160 return series 

161 

162 @property 

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

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

165 

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

167 previous positions and the returns of each asset. 

168 

169 Returns: 

170 ------- 

171 pd.Series 

172 Series representing the profit/loss at each time point 

173 

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. 

179 

180 """ 

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

182 series.name = "Profit" 

183 return series 

184 

185 @property 

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

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

188 

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. 

191 

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 

197 

198 """ 

199 return self.prices * self.units 

200 

201 @property 

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

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

204 

205 This calculates the percentage change in price for each asset 

206 from one time point to the next. 

207 

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 

213 

214 """ 

215 return self.prices.pct_change() 

216 

217 @property 

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

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

220 

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

222 to the next for each asset. 

223 

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 

229 

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. 

236 

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) 

241 

242 @property 

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

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

245 

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

247 in position (units) by the current prices. 

248 

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 

254 

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

260 

261 """ 

262 return self.trades_units * self.prices 

263 

264 @property 

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

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

267 

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

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

270 

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 

276 

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. 

282 

283 """ 

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

285 

286 @property 

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

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

289 

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

293 

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 

299 

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. 

305 

306 """ 

307 return self.trades_currency.abs() 

308 

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

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

311 

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

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

314 

315 Parameters 

316 ---------- 

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

318 The time index for which to retrieve the positions 

319 

320 Returns: 

321 ------- 

322 pd.Series 

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

324 

325 Raises: 

326 ------ 

327 KeyError 

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

329 

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

336 

337 """ 

338 return self.units.loc[time] 

339 

340 @property 

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

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

343 

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. 

346 

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 

352 

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. 

358 

359 """ 

360 return self.cashposition 

361 

362 @property 

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

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

365 

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. 

369 

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 

375 

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. 

381 

382 """ 

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

384 

385 @property 

386 def stats(self) -> Any: 

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

388 

389 This property provides access to various statistical metrics calculated 

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

391 

392 Returns: 

393 ------- 

394 object 

395 An object containing various statistical metrics for the portfolio 

396 

397 Notes: 

398 ----- 

399 The statistics are calculated by the underlying jquantstats library 

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

401 

402 """ 

403 return self._data.stats 

404 

405 @property 

406 def plots(self) -> Any: 

407 """Get visualization tools for the portfolio. 

408 

409 This property provides access to various plotting functions for visualizing 

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

411 

412 Returns: 

413 ------- 

414 object 

415 An object containing various plotting methods for the portfolio 

416 

417 Notes: 

418 ----- 

419 The plotting functions are provided by the underlying jquantstats library 

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

421 

422 """ 

423 return self._data.plots 

424 

425 @property 

426 def reports(self) -> Any: 

427 """Get reporting tools for the portfolio. 

428 

429 This property provides access to various reporting functions for generating 

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

431 

432 Returns: 

433 ------- 

434 object 

435 An object containing various reporting methods for the portfolio 

436 

437 Notes: 

438 ----- 

439 The reporting functions are provided by the underlying jquantstats library 

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

441 

442 """ 

443 return self._data.reports 

444 

445 def sharpe(self, periods: int | None = None) -> float: 

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

447 

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

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

450 

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. 

457 

458 Returns: 

459 ------- 

460 float 

461 The Sharpe ratio of the portfolio 

462 

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. 

467 

468 """ 

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

470 

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. 

474 

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. 

477 

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 

486 

487 Returns: 

488 ------- 

489 Portfolio 

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

491 

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. 

497 

498 """ 

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

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

501 

502 def snapshot(self, title: str = "Portfolio Summary", log_scale: bool = True) -> Any: 

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

504 

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. 

508 

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. 

514 

515 Returns: 

516 The generated plot object representing the portfolio snapshot. 

517 

518 """ 

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