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

82 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-09 04:18 +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 @property 

419 def trade_volume(self) -> pd.Series: 

420 """Get the total traded value in currency at each point in time. 

421 

422 This sums the absolute turnover across all assets, giving one number 

423 per timestamp for the cash value that changed hands, buys and sells 

424 alike. 

425 

426 Returns: 

427 ------- 

428 pd.Series 

429 Series with the total traded value at each point in time, 

430 indexed by date 

431 

432 Notes: 

433 ----- 

434 Calculated as turnover summed across assets. Sum the series for the 

435 total traded value over the whole backtest. 

436 

437 Examples: 

438 -------- 

439 Buy into A and B, double the B position, then sell out of A: 

440 

441 >>> import pandas as pd 

442 >>> from cvx.simulator import Portfolio 

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

444 >>> prices = pd.DataFrame( 

445 ... {"A": [100.0, 110.0, 120.0], "B": [50.0, 50.0, 50.0]}, 

446 ... index=dates, 

447 ... ) 

448 >>> units = pd.DataFrame({"A": [1.0, 1.0, 0.0], "B": [2.0, 4.0, 4.0]}, index=dates) 

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

450 >>> portfolio.trade_volume 

451 2020-01-01 200.0 

452 2020-01-02 100.0 

453 2020-01-03 120.0 

454 Freq: D, dtype: float64 

455 >>> float(portfolio.trade_volume.sum()) 

456 420.0 

457 

458 """ 

459 return self.turnover.sum(axis=1) 

460 

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

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

463 

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

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

466 

467 Parameters 

468 ---------- 

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

470 The time index for which to retrieve the positions 

471 

472 Returns: 

473 ------- 

474 pd.Series 

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

476 

477 Raises: 

478 ------ 

479 KeyError 

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

481 

482 Examples: 

483 -------- 

484 >>> import pandas as pd 

485 >>> from cvx.simulator import Portfolio 

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

487 >>> prices = pd.DataFrame( 

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

489 ... index=dates, 

490 ... ) 

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

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

493 

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

495 

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

497 A 5.0 

498 B 10.0 

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

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

501 True 

502 

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

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

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

506 

507 >>> try: 

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

509 ... except KeyError as err: 

510 ... print(err) 

511 '2021-01-01' 

512 

513 """ 

514 return self.units.loc[time] 

515 

516 @property 

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

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

519 

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

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

522 

523 Returns: 

524 ------- 

525 pd.DataFrame 

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

527 with dates as index and assets as columns 

528 

529 Notes: 

530 ----- 

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

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

533 not to be confused with the equity asset class. 

534 

535 """ 

536 return self.cashposition 

537 

538 @property 

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

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

541 

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

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

544 value (NAV) at each time point. 

545 

546 Returns: 

547 ------- 

548 pd.DataFrame 

549 DataFrame with the weight of each asset over time, 

550 with dates as index and assets as columns 

551 

552 Notes: 

553 ----- 

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

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

556 for short positions. 

557 

558 Examples: 

559 -------- 

560 >>> import pandas as pd 

561 >>> from cvx.simulator import Portfolio 

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

563 >>> prices = pd.DataFrame( 

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

565 ... index=dates, 

566 ... ) 

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

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

569 

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

571 

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

573 A B 

574 2020-01-01 0.5000 0.5000 

575 2020-01-02 0.5000 0.5000 

576 2020-01-03 0.5000 0.5000 

577 2020-01-04 0.5024 0.4976 

578 

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

580 

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

582 [1.0] 

583 

584 """ 

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

586 

587 @classmethod 

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

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

590 

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

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

593 

594 Parameters 

595 ---------- 

596 prices : pd.DataFrame 

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

598 cashposition : pd.DataFrame 

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

600 aum : float 

601 Assets under management 

602 

603 Returns: 

604 ------- 

605 Portfolio 

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

607 

608 Notes: 

609 ----- 

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

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

612 than the number of units. 

613 

614 Examples: 

615 -------- 

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

617 

618 >>> import pandas as pd 

619 >>> from cvx.simulator import Portfolio 

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

621 >>> prices = pd.DataFrame( 

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

623 ... index=dates, 

624 ... ) 

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

626 >>> portfolio = Portfolio.from_cashpos_prices( 

627 ... prices=prices, cashposition=cashposition, aum=1000.0 

628 ... ) 

629 

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

631 

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

633 A B 

634 2020-01-01 5.0000 10.0000 

635 2020-01-02 4.9020 9.8039 

636 2020-01-03 4.8077 9.6154 

637 2020-01-04 4.8544 9.8039 

638 

639 """ 

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

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