Coverage for src/cvx/simulator/portfolio.py: 100%

79 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-19 02:49 +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 

21The jquantstats-backed statistics, plotting, and reporting surface lives in the 

22companion :class:`~cvx.simulator._analytics.PortfolioAnalytics` mixin. 

23""" 

24 

25from __future__ import annotations 

26 

27from dataclasses import dataclass, field 

28from datetime import datetime 

29 

30import pandas as pd 

31from jquantstats.data import Data 

32 

33from ._analytics import PortfolioAnalytics 

34 

35 

36@dataclass(frozen=True) 

37class Portfolio(PortfolioAnalytics): 

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

39 

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. 

44 

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 

53 

54 Examples: 

55 -------- 

56 A portfolio is usually produced by :meth:`Builder.build`, but it can be 

57 constructed directly from prices, units and a starting AUM: 

58 

59 >>> import pandas as pd 

60 >>> from cvx.simulator import Portfolio 

61 >>> dates = pd.date_range("2020-01-01", periods=4) 

62 >>> prices = pd.DataFrame( 

63 ... {"A": [100.0, 102.0, 104.0, 103.0], "B": [50.0, 51.0, 52.0, 51.0]}, 

64 ... index=dates, 

65 ... ) 

66 >>> units = pd.DataFrame({"A": [5.0] * 4, "B": [10.0] * 4}, index=dates) 

67 >>> portfolio = Portfolio(prices=prices, units=units, aum=1000.0) 

68 >>> portfolio.assets 

69 ['A', 'B'] 

70 

71 The cash value of each holding, and the NAV it rolls up to: 

72 

73 >>> portfolio.cashposition 

74 A B 

75 2020-01-01 500.0 500.0 

76 2020-01-02 510.0 510.0 

77 2020-01-03 520.0 520.0 

78 2020-01-04 515.0 510.0 

79 >>> portfolio.nav 

80 2020-01-01 1000.0 

81 2020-01-02 1020.0 

82 2020-01-03 1040.0 

83 2020-01-04 1025.0 

84 Freq: D, Name: NAV, dtype: float64 

85 

86 The object is frozen, so analysis can never mutate the record: 

87 

88 >>> portfolio.aum = 2000.0 

89 Traceback (most recent call last): 

90 ... 

91 dataclasses.FrozenInstanceError: cannot assign to field 'aum' 

92 

93 """ 

94 

95 prices: pd.DataFrame 

96 units: pd.DataFrame 

97 aum: float | pd.Series 

98 _data: Data = field(init=False) 

99 

100 def __post_init__(self) -> None: 

101 """Validate the portfolio data after initialization. 

102 

103 This method is automatically called after an instance of the Portfolio 

104 class has been initialized. It performs a series of validation checks 

105 to ensure that the prices and units dataframes are in the expected format 

106 with no duplicates or missing data. 

107 

108 The method checks that: 

109 - Both prices and units dataframes have monotonic increasing indices 

110 - Both prices and units dataframes have unique indices 

111 - The index of units is a subset of the index of prices 

112 - The columns of units is a subset of the columns of prices 

113 

114 Raises: 

115 ------ 

116 ValueError 

117 If any of the validation checks fail 

118 

119 """ 

120 self._validate() 

121 self._build_data() 

122 

123 def _validate(self) -> None: 

124 """Validate the prices and units dataframes. 

125 

126 Checks that both frames have monotonic increasing, unique indices and 

127 that the units index and columns are subsets of the prices index and 

128 columns respectively. 

129 

130 Raises: 

131 ------ 

132 ValueError 

133 If any of the validation checks fail 

134 """ 

135 index_checks = ( 

136 (self.prices.index.is_monotonic_increasing, "`prices` index must be monotonic increasing."), 

137 (self.prices.index.is_unique, "`prices` index must be unique."), 

138 (self.units.index.is_monotonic_increasing, "`units` index must be monotonic increasing."), 

139 (self.units.index.is_unique, "`units` index must be unique."), 

140 ) 

141 for is_valid, message in index_checks: 

142 if not is_valid: 

143 raise ValueError(message) 

144 

145 missing_dates = self.units.index.difference(self.prices.index) 

146 if not missing_dates.empty: 

147 msg = f"`units` index contains dates not present in `prices`: {missing_dates.tolist()}" 

148 raise ValueError(msg) 

149 

150 missing_assets = self.units.columns.difference(self.prices.columns) 

151 if not missing_assets.empty: 

152 msg = f"`units` contains assets not present in `prices`: {missing_assets.tolist()}" 

153 raise ValueError(msg) 

154 

155 @property 

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

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

158 

159 Returns: 

160 ------- 

161 pd.DatetimeIndex 

162 A DatetimeIndex representing the time period for which portfolio 

163 data is available 

164 

165 Notes: 

166 ----- 

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

168 represents all time points in the portfolio history. 

169 

170 """ 

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

172 

173 @property 

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

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

176 

177 Returns: 

178 ------- 

179 pd.Index 

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

181 

182 Notes: 

183 ----- 

184 This property extracts the column names from the prices DataFrame, 

185 which correspond to all assets for which price data is available. 

186 

187 """ 

188 return list(self.prices.columns) 

189 

190 @property 

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

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

193 

194 The NAV represents the total value of the portfolio at each point in time. 

195 If aum is provided as a Series, it is used directly. Otherwise, the NAV 

196 is calculated from the cumulative profit plus the initial aum. 

197 

198 Returns: 

199 ------- 

200 pd.Series 

201 Series representing the NAV of the portfolio over time 

202 

203 Examples: 

204 -------- 

205 >>> import pandas as pd 

206 >>> from cvx.simulator import Portfolio 

207 >>> dates = pd.date_range("2020-01-01", periods=4) 

208 >>> prices = pd.DataFrame( 

209 ... {"A": [100.0, 102.0, 104.0, 103.0], "B": [50.0, 51.0, 52.0, 51.0]}, 

210 ... index=dates, 

211 ... ) 

212 >>> units = pd.DataFrame({"A": [5.0] * 4, "B": [10.0] * 4}, index=dates) 

213 

214 Passing a scalar ``aum`` makes the NAV the cumulative profit on top of it: 

215 

216 >>> Portfolio(prices=prices, units=units, aum=1000.0).nav 

217 2020-01-01 1000.0 

218 2020-01-02 1020.0 

219 2020-01-03 1040.0 

220 2020-01-04 1025.0 

221 Freq: D, Name: NAV, dtype: float64 

222 

223 Passing a Series instead uses it verbatim — which is what 

224 :meth:`Builder.build` does, so a strategy that adds or withdraws capital 

225 is recorded rather than inferred: 

226 

227 >>> aum = pd.Series([1000.0, 1100.0, 1200.0, 1300.0], index=dates) 

228 >>> Portfolio(prices=prices, units=units, aum=aum).nav 

229 2020-01-01 1000.0 

230 2020-01-02 1100.0 

231 2020-01-03 1200.0 

232 2020-01-04 1300.0 

233 Freq: D, Name: NAV, dtype: float64 

234 

235 """ 

236 if isinstance(self.aum, pd.Series): 

237 series = self.aum 

238 else: 

239 profit = (self.cashposition.shift(1) * self.returns.fillna(0.0)).sum(axis=1) 

240 series = profit.cumsum() + self.aum 

241 

242 series.name = "NAV" 

243 return series 

244 

245 @property 

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

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

248 

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

250 previous positions and the returns of each asset. 

251 

252 Returns: 

253 ------- 

254 pd.Series 

255 Series representing the profit/loss at each time point 

256 

257 Notes: 

258 ----- 

259 The profit is calculated by multiplying the previous day's positions 

260 (in currency terms) by the returns of each asset, and then summing 

261 across all assets. 

262 

263 Examples: 

264 -------- 

265 >>> import pandas as pd 

266 >>> from cvx.simulator import Portfolio 

267 >>> dates = pd.date_range("2020-01-01", periods=4) 

268 >>> prices = pd.DataFrame( 

269 ... {"A": [100.0, 102.0, 104.0, 103.0], "B": [50.0, 51.0, 52.0, 51.0]}, 

270 ... index=dates, 

271 ... ) 

272 >>> units = pd.DataFrame({"A": [5.0] * 4, "B": [10.0] * 4}, index=dates) 

273 >>> portfolio = Portfolio(prices=prices, units=units, aum=1000.0) 

274 

275 The first day has no previous position, so it books no profit: 

276 

277 >>> portfolio.profit 

278 2020-01-01 0.0 

279 2020-01-02 20.0 

280 2020-01-03 20.0 

281 2020-01-04 -15.0 

282 Freq: D, Name: Profit, dtype: float64 

283 

284 Cumulating the profit onto the starting AUM reproduces the NAV: 

285 

286 >>> (portfolio.profit.cumsum() + 1000.0).equals(portfolio.nav.rename("Profit")) 

287 True 

288 

289 """ 

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

291 series.name = "Profit" 

292 return series 

293 

294 @property 

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

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

297 

298 This calculates the cash value of each position by multiplying 

299 the number of units by the price for each asset at each time point. 

300 

301 Returns: 

302 ------- 

303 pd.DataFrame 

304 DataFrame with the cash value of each position over time, 

305 with dates as index and assets as columns 

306 

307 """ 

308 return self.prices * self.units 

309 

310 @property 

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

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

313 

314 This calculates the percentage change in price for each asset 

315 from one time point to the next. 

316 

317 Returns: 

318 ------- 

319 pd.DataFrame 

320 DataFrame with the returns of each asset over time, 

321 with dates as index and assets as columns 

322 

323 """ 

324 return self.prices.pct_change() 

325 

326 @property 

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

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

329 

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

331 to the next for each asset. 

332 

333 Returns: 

334 ------- 

335 pd.DataFrame 

336 DataFrame with the trades (changes in units) for each asset over time, 

337 with dates as index and assets as columns 

338 

339 Notes: 

340 ----- 

341 Calculated as the difference between consecutive position values. 

342 Positive values represent buys, negative values represent sells. 

343 The first row contains the initial positions, as there are no previous 

344 positions to compare with. 

345 

346 """ 

347 t = self.units.fillna(0.0).diff() 

348 t.loc[self.index[0]] = self.units.loc[self.index[0]] 

349 return t.fillna(0.0) 

350 

351 @property 

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

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

354 

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

356 in position (units) by the current prices. 

357 

358 Returns: 

359 ------- 

360 pd.DataFrame 

361 DataFrame with the cash value of trades for each asset over time, 

362 with dates as index and assets as columns 

363 

364 Notes: 

365 ----- 

366 Calculated by multiplying trades_units by prices. 

367 Positive values represent buys (cash outflows), 

368 negative values represent sells (cash inflows). 

369 

370 """ 

371 return self.trades_units * self.prices 

372 

373 @property 

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

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

376 

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

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

379 

380 Returns: 

381 ------- 

382 pd.DataFrame 

383 DataFrame with the relative turnover for each asset over time, 

384 with dates as index and assets as columns 

385 

386 Notes: 

387 ----- 

388 Calculated by dividing trades_currency by NAV. 

389 Positive values represent buys, negative values represent sells. 

390 A value of 0.05 means a buy equal to 5% of the portfolio NAV. 

391 

392 """ 

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

394 

395 @property 

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

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

398 

399 This calculates the absolute value of trades in currency terms, 

400 which provides a measure of total trading activity regardless of 

401 direction (buy or sell). 

402 

403 Returns: 

404 ------- 

405 pd.DataFrame 

406 DataFrame with the absolute turnover for each asset over time, 

407 with dates as index and assets as columns 

408 

409 Notes: 

410 ----- 

411 Calculated as the absolute value of trades_currency. 

412 This is useful for calculating trading costs that apply equally 

413 to buys and sells. 

414 

415 """ 

416 return self.trades_currency.abs() 

417 

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

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

420 

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

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

423 

424 Parameters 

425 ---------- 

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

427 The time index for which to retrieve the positions 

428 

429 Returns: 

430 ------- 

431 pd.Series 

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

433 

434 Raises: 

435 ------ 

436 KeyError 

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

438 

439 Examples: 

440 -------- 

441 >>> import pandas as pd 

442 >>> from cvx.simulator import Portfolio 

443 >>> dates = pd.date_range("2020-01-01", periods=4) 

444 >>> prices = pd.DataFrame( 

445 ... {"A": [100.0, 102.0, 104.0, 103.0], "B": [50.0, 51.0, 52.0, 51.0]}, 

446 ... index=dates, 

447 ... ) 

448 >>> units = pd.DataFrame({"A": [5.0] * 4, "B": [10.0] * 4}, index=dates) 

449 >>> portfolio = Portfolio(prices=prices, units=units, aum=1000.0) 

450 

451 Index with a string or a Timestamp — both reach the same row: 

452 

453 >>> portfolio["2020-01-02"] 

454 A 5.0 

455 B 10.0 

456 Name: 2020-01-02 00:00:00, dtype: float64 

457 >>> portfolio[pd.Timestamp("2020-01-02")].equals(portfolio["2020-01-02"]) 

458 True 

459 

460 A date outside the index raises. (Caught here rather than shown as a 

461 traceback: pandas chains several exceptions on the way out, and the 

462 intermediate frames are an implementation detail, not a contract.) 

463 

464 >>> try: 

465 ... portfolio["2021-01-01"] 

466 ... except KeyError as err: 

467 ... print(err) 

468 '2021-01-01' 

469 

470 """ 

471 return self.units.loc[time] 

472 

473 @property 

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

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

476 

477 This property returns the cash value of each position in the portfolio, 

478 calculated by multiplying the number of units by the price for each asset. 

479 

480 Returns: 

481 ------- 

482 pd.DataFrame 

483 DataFrame with the cash value of each position over time, 

484 with dates as index and assets as columns 

485 

486 Notes: 

487 ----- 

488 This is an alias for the cashposition property and returns the same values. 

489 The term "equity" is used in the context of the cash value of positions, 

490 not to be confused with the equity asset class. 

491 

492 """ 

493 return self.cashposition 

494 

495 @property 

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

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

498 

499 This calculates the relative weight of each asset in the portfolio 

500 by dividing the cash value of each position by the total portfolio 

501 value (NAV) at each time point. 

502 

503 Returns: 

504 ------- 

505 pd.DataFrame 

506 DataFrame with the weight of each asset over time, 

507 with dates as index and assets as columns 

508 

509 Notes: 

510 ----- 

511 The sum of weights across all assets at any given time should equal 1.0 

512 for a fully invested portfolio with no leverage. Weights can be negative 

513 for short positions. 

514 

515 Examples: 

516 -------- 

517 >>> import pandas as pd 

518 >>> from cvx.simulator import Portfolio 

519 >>> dates = pd.date_range("2020-01-01", periods=4) 

520 >>> prices = pd.DataFrame( 

521 ... {"A": [100.0, 102.0, 104.0, 103.0], "B": [50.0, 51.0, 52.0, 51.0]}, 

522 ... index=dates, 

523 ... ) 

524 >>> units = pd.DataFrame({"A": [5.0] * 4, "B": [10.0] * 4}, index=dates) 

525 >>> portfolio = Portfolio(prices=prices, units=units, aum=1000.0) 

526 

527 Holding the units fixed, the weights drift with the relative prices: 

528 

529 >>> portfolio.weights.round(4) 

530 A B 

531 2020-01-01 0.5000 0.5000 

532 2020-01-02 0.5000 0.5000 

533 2020-01-03 0.5000 0.5000 

534 2020-01-04 0.5024 0.4976 

535 

536 This book is fully invested, so each row sums to 1.0: 

537 

538 >>> portfolio.weights.sum(axis=1).round(6).unique().tolist() 

539 [1.0] 

540 

541 """ 

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

543 

544 @classmethod 

545 def from_cashpos_prices(cls, prices: pd.DataFrame, cashposition: pd.DataFrame, aum: float) -> Portfolio: 

546 """Create a Portfolio instance from cash positions and prices. 

547 

548 This class method provides an alternative way to create a Portfolio instance 

549 when you have the cash positions rather than the number of units. 

550 

551 Parameters 

552 ---------- 

553 prices : pd.DataFrame 

554 DataFrame of asset prices over time, with dates as index and assets as columns 

555 cashposition : pd.DataFrame 

556 DataFrame of cash positions over time, with dates as index and assets as columns 

557 aum : float 

558 Assets under management 

559 

560 Returns: 

561 ------- 

562 Portfolio 

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

564 

565 Notes: 

566 ----- 

567 The units are calculated by dividing the cash positions by the prices. 

568 This is useful when you have the monetary value of each position rather 

569 than the number of units. 

570 

571 Examples: 

572 -------- 

573 Specify the book in currency rather than units — 500 in each name: 

574 

575 >>> import pandas as pd 

576 >>> from cvx.simulator import Portfolio 

577 >>> dates = pd.date_range("2020-01-01", periods=4) 

578 >>> prices = pd.DataFrame( 

579 ... {"A": [100.0, 102.0, 104.0, 103.0], "B": [50.0, 51.0, 52.0, 51.0]}, 

580 ... index=dates, 

581 ... ) 

582 >>> cashposition = pd.DataFrame({"A": [500.0] * 4, "B": [500.0] * 4}, index=dates) 

583 >>> portfolio = Portfolio.from_cashpos_prices( 

584 ... prices=prices, cashposition=cashposition, aum=1000.0 

585 ... ) 

586 

587 The units are the cash amounts divided by the prices: 

588 

589 >>> portfolio.units.round(4) 

590 A B 

591 2020-01-01 5.0000 10.0000 

592 2020-01-02 4.9020 9.8039 

593 2020-01-03 4.8077 9.6154 

594 2020-01-04 4.8544 9.8039 

595 

596 """ 

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

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