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

79 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-31 13:14 +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 """ 

55 

56 prices: pd.DataFrame 

57 units: pd.DataFrame 

58 aum: float | pd.Series 

59 _data: Data = field(init=False) 

60 

61 def __post_init__(self) -> None: 

62 """Validate the portfolio data after initialization. 

63 

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

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

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

67 with no duplicates or missing data. 

68 

69 The method checks that: 

70 - Both prices and units dataframes have monotonic increasing indices 

71 - Both prices and units dataframes have unique indices 

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

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

74 

75 Raises: 

76 ------ 

77 ValueError 

78 If any of the validation checks fail 

79 

80 """ 

81 self._validate() 

82 self._build_data() 

83 

84 def _validate(self) -> None: 

85 """Validate the prices and units dataframes. 

86 

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

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

89 columns respectively. 

90 

91 Raises: 

92 ------ 

93 ValueError 

94 If any of the validation checks fail 

95 """ 

96 index_checks = ( 

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

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

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

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

101 ) 

102 for is_valid, message in index_checks: 

103 if not is_valid: 

104 raise ValueError(message) 

105 

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

107 if not missing_dates.empty: 

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

109 raise ValueError(msg) 

110 

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

112 if not missing_assets.empty: 

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

114 raise ValueError(msg) 

115 

116 @property 

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

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

119 

120 Returns: 

121 ------- 

122 pd.DatetimeIndex 

123 A DatetimeIndex representing the time period for which portfolio 

124 data is available 

125 

126 Notes: 

127 ----- 

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

129 represents all time points in the portfolio history. 

130 

131 """ 

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

133 

134 @property 

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

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

137 

138 Returns: 

139 ------- 

140 pd.Index 

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

142 

143 Notes: 

144 ----- 

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

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

147 

148 """ 

149 return list(self.prices.columns) 

150 

151 @property 

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

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

154 

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

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

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

158 

159 Returns: 

160 ------- 

161 pd.Series 

162 Series representing the NAV of the portfolio over time 

163 

164 """ 

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

166 series = self.aum 

167 else: 

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

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

170 

171 series.name = "NAV" 

172 return series 

173 

174 @property 

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

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

177 

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

179 previous positions and the returns of each asset. 

180 

181 Returns: 

182 ------- 

183 pd.Series 

184 Series representing the profit/loss at each time point 

185 

186 Notes: 

187 ----- 

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

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

190 across all assets. 

191 

192 """ 

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

194 series.name = "Profit" 

195 return series 

196 

197 @property 

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

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

200 

201 This calculates the cash value of each position by multiplying 

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

203 

204 Returns: 

205 ------- 

206 pd.DataFrame 

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

208 with dates as index and assets as columns 

209 

210 """ 

211 return self.prices * self.units 

212 

213 @property 

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

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

216 

217 This calculates the percentage change in price for each asset 

218 from one time point to the next. 

219 

220 Returns: 

221 ------- 

222 pd.DataFrame 

223 DataFrame with the returns of each asset over time, 

224 with dates as index and assets as columns 

225 

226 """ 

227 return self.prices.pct_change() 

228 

229 @property 

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

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

232 

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

234 to the next for each asset. 

235 

236 Returns: 

237 ------- 

238 pd.DataFrame 

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

240 with dates as index and assets as columns 

241 

242 Notes: 

243 ----- 

244 Calculated as the difference between consecutive position values. 

245 Positive values represent buys, negative values represent sells. 

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

247 positions to compare with. 

248 

249 """ 

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

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

252 return t.fillna(0.0) 

253 

254 @property 

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

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

257 

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

259 in position (units) by the current prices. 

260 

261 Returns: 

262 ------- 

263 pd.DataFrame 

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

265 with dates as index and assets as columns 

266 

267 Notes: 

268 ----- 

269 Calculated by multiplying trades_units by prices. 

270 Positive values represent buys (cash outflows), 

271 negative values represent sells (cash inflows). 

272 

273 """ 

274 return self.trades_units * self.prices 

275 

276 @property 

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

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

279 

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

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

282 

283 Returns: 

284 ------- 

285 pd.DataFrame 

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

287 with dates as index and assets as columns 

288 

289 Notes: 

290 ----- 

291 Calculated by dividing trades_currency by NAV. 

292 Positive values represent buys, negative values represent sells. 

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

294 

295 """ 

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

297 

298 @property 

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

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

301 

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

303 which provides a measure of total trading activity regardless of 

304 direction (buy or sell). 

305 

306 Returns: 

307 ------- 

308 pd.DataFrame 

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

310 with dates as index and assets as columns 

311 

312 Notes: 

313 ----- 

314 Calculated as the absolute value of trades_currency. 

315 This is useful for calculating trading costs that apply equally 

316 to buys and sells. 

317 

318 """ 

319 return self.trades_currency.abs() 

320 

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

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

323 

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

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

326 

327 Parameters 

328 ---------- 

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

330 The time index for which to retrieve the positions 

331 

332 Returns: 

333 ------- 

334 pd.Series 

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

336 

337 Raises: 

338 ------ 

339 KeyError 

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

341 

342 Examples: 

343 -------- 

344 ``` 

345 portfolio['2023-01-01'] # Get positions on January 1, 2023 

346 portfolio[pd.Timestamp('2023-01-01')] # Same as above 

347 ``` 

348 

349 """ 

350 return self.units.loc[time] 

351 

352 @property 

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

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

355 

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

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

358 

359 Returns: 

360 ------- 

361 pd.DataFrame 

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

363 with dates as index and assets as columns 

364 

365 Notes: 

366 ----- 

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

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

369 not to be confused with the equity asset class. 

370 

371 """ 

372 return self.cashposition 

373 

374 @property 

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

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

377 

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

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

380 value (NAV) at each time point. 

381 

382 Returns: 

383 ------- 

384 pd.DataFrame 

385 DataFrame with the weight of each asset over time, 

386 with dates as index and assets as columns 

387 

388 Notes: 

389 ----- 

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

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

392 for short positions. 

393 

394 """ 

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

396 

397 @classmethod 

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

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

400 

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

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

403 

404 Parameters 

405 ---------- 

406 prices : pd.DataFrame 

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

408 cashposition : pd.DataFrame 

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

410 aum : float 

411 Assets under management 

412 

413 Returns: 

414 ------- 

415 Portfolio 

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

417 

418 Notes: 

419 ----- 

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

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

422 than the number of units. 

423 

424 """ 

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

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