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

99 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 state management for the CVX Simulator. 

15 

16This module provides the State class, which represents the current state of a portfolio 

17during simulation. It tracks positions, prices, cash, and other portfolio metrics, 

18and is updated by the Builder class during the simulation process. 

19""" 

20 

21from dataclasses import dataclass 

22from datetime import datetime 

23 

24import numpy as np 

25import pandas as pd 

26 

27 

28@dataclass() 

29class State: 

30 """Represents the current state of a portfolio during simulation. 

31 

32 The State class tracks the current positions, prices, cash, and other metrics 

33 of a portfolio at a specific point in time. It is updated within a loop by the 

34 Builder class during the simulation process. 

35 

36 The class provides properties for accessing various portfolio metrics like 

37 cash, NAV, value, weights, and leverage. It also provides setter methods 

38 for updating the portfolio state (aum, cash, position, prices). 

39 

40 Attributes: 

41 ---------- 

42 _prices : pd.Series 

43 Current prices of assets in the portfolio 

44 _position : pd.Series 

45 Current positions (units) of assets in the portfolio 

46 _trades : pd.Series 

47 Trades needed to reach the current position 

48 _time : datetime 

49 Current time in the simulation 

50 _days : int 

51 Number of days between the current and previous time 

52 _profit : float 

53 Profit achieved between the previous and current prices 

54 _aum : float 

55 Current assets under management (AUM) of the portfolio 

56 

57 Examples: 

58 -------- 

59 A state starts empty and is filled in by assignment. Order matters: prices 

60 define which assets exist, so they are set before any position. 

61 

62 >>> import pandas as pd 

63 >>> from cvx.simulator import State 

64 >>> state = State() 

65 >>> state.aum = 1000.0 

66 >>> state.prices = pd.Series({"A": 100.0, "B": 50.0}) 

67 >>> state.position = pd.Series({"A": 5.0, "B": 4.0}) 

68 

69 The derived quantities follow from those three assignments: 

70 

71 >>> state.cashposition 

72 A 500.0 

73 B 200.0 

74 dtype: float64 

75 >>> state.value 

76 700.0 

77 >>> float(state.cash) 

78 300.0 

79 

80 Updating the prices books the profit and moves the AUM with it: 

81 

82 >>> state.prices = pd.Series({"A": 110.0, "B": 50.0}) 

83 >>> float(state.profit) 

84 50.0 

85 >>> float(state.nav) 

86 1050.0 

87 

88 """ 

89 

90 _prices: pd.Series | None = None 

91 _position: pd.Series | None = None 

92 _trades: pd.Series | None = None 

93 _time: datetime | None = None 

94 _days: int = 0 

95 _profit: float = 0.0 

96 _aum: float = 0.0 

97 

98 @property 

99 def cash(self) -> float: 

100 """Get the current amount of cash available in the portfolio. 

101 

102 Returns: 

103 ------- 

104 float 

105 The cash component of the portfolio, calculated as NAV minus 

106 the value of all positions 

107 

108 Examples: 

109 -------- 

110 >>> import pandas as pd 

111 >>> from cvx.simulator import State 

112 >>> state = State() 

113 >>> state.aum = 1000.0 

114 >>> state.prices = pd.Series({"A": 100.0, "B": 50.0}) 

115 

116 With nothing invested the whole AUM is cash: 

117 

118 >>> float(state.cash) 

119 1000.0 

120 

121 Buying 700 of stock moves that amount out of cash: 

122 

123 >>> state.position = pd.Series({"A": 5.0, "B": 4.0}) 

124 >>> float(state.cash) 

125 300.0 

126 

127 """ 

128 return self.nav - self.value 

129 

130 @cash.setter 

131 def cash(self, cash: float) -> None: 

132 """Update the amount of cash available in the portfolio. 

133 

134 This updates the AUM (assets under management) based on the new 

135 cash amount while keeping the value of positions constant. 

136 

137 Parameters 

138 ---------- 

139 cash : float 

140 The new cash amount to set 

141 

142 """ 

143 self.aum = cash + self.value 

144 

145 @property 

146 def nav(self) -> float: 

147 """Get the net asset value (NAV) of the portfolio. 

148 

149 The NAV represents the total value of the portfolio, including 

150 both the value of positions and available cash. 

151 

152 Returns: 

153 ------- 

154 float 

155 The net asset value of the portfolio 

156 

157 Notes: 

158 ----- 

159 This is equivalent to the AUM (assets under management). 

160 

161 """ 

162 return self.aum 

163 

164 @property 

165 def value(self) -> float: 

166 """Get the value of all positions in the portfolio. 

167 

168 This computes the total value of all holdings at current prices, 

169 not including cash. 

170 

171 Returns: 

172 ------- 

173 float 

174 The sum of values of all positions 

175 

176 Notes: 

177 ----- 

178 If positions are missing (None), the sum will effectively be zero. 

179 

180 """ 

181 return float(self.cashposition.sum()) 

182 

183 @property 

184 def cashposition(self) -> pd.Series: 

185 """Get the cash value of each position in the portfolio. 

186 

187 This computes the cash value of each position by multiplying 

188 the number of units by the current price for each asset. 

189 

190 Returns: 

191 ------- 

192 pd.Series 

193 Series with the cash value of each position, indexed by asset 

194 

195 """ 

196 return self.prices * self.position 

197 

198 @property 

199 def position(self) -> pd.Series: 

200 """Get the current position (number of units) for each asset. 

201 

202 Returns: 

203 ------- 

204 pd.Series 

205 Series with the number of units held for each asset, indexed by asset. 

206 If the position is not yet set, returns an empty series with the 

207 correct index. 

208 

209 Examples: 

210 -------- 

211 Before anything is bought the position carries the assets, but no units: 

212 

213 >>> import pandas as pd 

214 >>> from cvx.simulator import State 

215 >>> state = State() 

216 >>> state.aum = 1000.0 

217 >>> state.prices = pd.Series({"A": 100.0, "B": 50.0}) 

218 >>> state.position 

219 A NaN 

220 B NaN 

221 dtype: float64 

222 

223 Assigning a position also records the trades needed to reach it: 

224 

225 >>> state.position = pd.Series({"A": 5.0, "B": 4.0}) 

226 >>> state.position 

227 A 5.0 

228 B 4.0 

229 dtype: float64 

230 >>> state.trades 

231 A 5.0 

232 B 4.0 

233 dtype: float64 

234 

235 """ 

236 if self._position is None: 

237 return pd.Series(index=self.assets, dtype=float) 

238 

239 return self._position 

240 

241 @position.setter 

242 def position(self, position: np.ndarray | pd.Series) -> None: 

243 """Update the position of the portfolio. 

244 

245 This method updates the position (number of units) for each asset, 

246 computes the required trades to reach the new position, and updates 

247 the internal state. 

248 

249 Parameters 

250 ---------- 

251 position : Union[np.ndarray, pd.Series] 

252 The new position to set, either as a numpy array or pandas Series. 

253 If a numpy array, it must have the same length as self.assets. 

254 

255 """ 

256 # update the position 

257 position = pd.Series(index=self.assets, data=position) 

258 

259 # compute the trades (can be fractional) 

260 self._trades = position.subtract(self.position, fill_value=0.0) 

261 

262 # update only now as otherwise the trades would be wrong 

263 self._position = position 

264 

265 @property 

266 def gmv(self) -> float: 

267 """Get the gross market value of the portfolio. 

268 

269 The gross market value is the sum of the absolute values of all positions, 

270 which represents the total market exposure including both long and short positions. 

271 

272 Returns: 

273 ------- 

274 float 

275 The gross market value (abs(short) + long) 

276 

277 """ 

278 return float(self.cashposition.abs().sum()) 

279 

280 @property 

281 def time(self) -> datetime | None: 

282 """Get the current time of the portfolio state. 

283 

284 Returns: 

285 ------- 

286 Optional[datetime] 

287 The current time in the simulation, or None if not set 

288 

289 """ 

290 return self._time 

291 

292 @time.setter 

293 def time(self, time: datetime) -> None: 

294 """Update the time of the portfolio state. 

295 

296 This method updates the current time and computes the number of days 

297 between the new time and the previous time. 

298 

299 Parameters 

300 ---------- 

301 time : datetime 

302 The new time to set 

303 

304 """ 

305 if self.time is None: 

306 self._days = 0 

307 self._time = time 

308 else: 

309 self._days = (time - self.time).days 

310 self._time = time 

311 

312 @property 

313 def days(self) -> int: 

314 """Get the number of days between the current and previous time. 

315 

316 Returns: 

317 ------- 

318 int 

319 Number of days between the current and previous time 

320 

321 Notes: 

322 ----- 

323 This is useful for computing interest when holding cash or for 

324 time-dependent calculations. 

325 

326 """ 

327 return self._days 

328 

329 @property 

330 def assets(self) -> pd.Index: 

331 """Get the assets currently in the portfolio. 

332 

333 Returns: 

334 ------- 

335 pd.Index 

336 Index of assets with valid prices in the portfolio. 

337 If no prices are set, returns an empty index. 

338 

339 """ 

340 if self._prices is None: 

341 return pd.Index(data=[], dtype=str) 

342 

343 return self.prices.dropna().index 

344 

345 @property 

346 def trades(self) -> pd.Series | None: 

347 """Get the trades needed to reach the current position. 

348 

349 Returns: 

350 ------- 

351 Optional[pd.Series] 

352 Series of trades (changes in position) needed to reach the current position. 

353 None if no trades have been calculated yet. 

354 

355 Notes: 

356 ----- 

357 This is helpful when computing trading costs following a position change. 

358 Positive values represent buys, negative values represent sells. 

359 

360 """ 

361 return self._trades 

362 

363 @property 

364 def mask(self) -> np.ndarray: 

365 """Get a boolean mask for assets with valid (non-NaN) prices. 

366 

367 Returns: 

368 ------- 

369 np.ndarray 

370 Boolean array where True indicates a valid price and False indicates 

371 a missing (NaN) price. Returns an empty array if no prices are set. 

372 

373 """ 

374 if self._prices is None: 

375 return np.empty(0, dtype=bool) 

376 

377 return np.asarray(np.isfinite(self.prices.values)) 

378 

379 @property 

380 def prices(self) -> pd.Series: 

381 """Get the current prices of assets in the portfolio. 

382 

383 Returns: 

384 ------- 

385 pd.Series 

386 Series of current prices indexed by asset. 

387 Returns an empty series if no prices are set. 

388 

389 """ 

390 if self._prices is None: 

391 return pd.Series(dtype=float) 

392 return self._prices 

393 

394 @prices.setter 

395 def prices(self, prices: pd.Series | dict[str, float]) -> None: 

396 """Update the prices of assets in the portfolio. 

397 

398 This method updates the prices and calculates the profit achieved 

399 due to price changes. It also updates the portfolio's AUM by adding 

400 the profit. 

401 

402 Parameters 

403 ---------- 

404 prices : pd.Series 

405 New prices for assets in the portfolio 

406 

407 Notes: 

408 ----- 

409 The profit is calculated as the difference between the portfolio value 

410 before and after the price update. 

411 

412 """ 

413 # Convert dict to Series if necessary 

414 prices = pd.Series(prices) 

415 

416 value_before = (self.prices * self.position).sum() # self.cashposition.sum() 

417 value_after = (prices * self.position).sum() 

418 

419 self._prices = prices 

420 self._profit = value_after - value_before 

421 self.aum += self.profit 

422 

423 @property 

424 def profit(self) -> float: 

425 """Get the profit achieved between the previous and current prices. 

426 

427 Returns: 

428 ------- 

429 float 

430 The profit (or loss) achieved due to price changes since the 

431 last price update 

432 

433 """ 

434 return self._profit 

435 

436 @property 

437 def aum(self) -> float: 

438 """Get the current assets under management (AUM) of the portfolio. 

439 

440 Returns: 

441 ------- 

442 float 

443 The total assets under management 

444 

445 """ 

446 return self._aum 

447 

448 @aum.setter 

449 def aum(self, aum: float) -> None: 

450 """Update the assets under management (AUM) of the portfolio. 

451 

452 Parameters 

453 ---------- 

454 aum : float 

455 The new assets under management value to set 

456 

457 """ 

458 self._aum = aum 

459 

460 @property 

461 def weights(self) -> pd.Series: 

462 """Get the weight of each asset in the portfolio. 

463 

464 This computes the weighting of each asset as a fraction of the 

465 total portfolio value (NAV). 

466 

467 Returns: 

468 ------- 

469 pd.Series 

470 Series containing the weight of each asset as a fraction of the 

471 total portfolio value, indexed by asset 

472 

473 Raises: 

474 ------ 

475 ValueError 

476 If a position is held while the NAV is zero or not finite, so no 

477 weight is defined 

478 

479 Notes: 

480 ----- 

481 If positions are missing, a series of zeros is effectively returned. 

482 The sum of weights equals 1.0 for a fully invested portfolio with no leverage. 

483 

484 Examples: 

485 -------- 

486 >>> import pandas as pd 

487 >>> from cvx.simulator import State 

488 >>> state = State() 

489 >>> state.aum = 1000.0 

490 >>> state.prices = pd.Series({"A": 100.0, "B": 50.0}) 

491 >>> state.position = pd.Series({"A": 5.0, "B": 4.0}) 

492 

493 The weights are fractions of NAV, so they sum to less than 1.0 here — 

494 the remaining 0.3 is the uninvested cash: 

495 

496 >>> state.weights 

497 A 0.5 

498 B 0.2 

499 dtype: float64 

500 >>> float(state.weights.sum()) 

501 0.7 

502 

503 """ 

504 holding = bool((self.cashposition.abs() > 0).any()) 

505 if holding and (self.nav == 0 or not np.isfinite(self.nav)): 

506 msg = f"weights are undefined for a NAV of {self.nav}; set `aum` to a finite, non-zero value first." 

507 raise ValueError(msg) 

508 

509 return self.cashposition / self.nav 

510 

511 @property 

512 def leverage(self) -> float: 

513 """Get the leverage of the portfolio. 

514 

515 Leverage is calculated as the sum of the absolute values of all position 

516 weights. For a long-only portfolio with no cash, this equals 1.0. 

517 For a portfolio with shorts or leverage, this will be greater than 1.0. 

518 

519 Returns: 

520 ------- 

521 float 

522 The leverage ratio of the portfolio 

523 

524 Notes: 

525 ----- 

526 A leverage of 2.0 means the portfolio has twice the market exposure 

527 compared to its net asset value, which could be achieved through 

528 borrowing or short selling. 

529 

530 Examples: 

531 -------- 

532 A long 1000 / short 500 book on a NAV of 1000. The net exposure is only 

533 500, but the leverage counts both legs: 

534 

535 >>> import pandas as pd 

536 >>> from cvx.simulator import State 

537 >>> state = State() 

538 >>> state.aum = 1000.0 

539 >>> state.prices = pd.Series({"A": 100.0, "B": 50.0}) 

540 >>> state.position = pd.Series({"A": 10.0, "B": -10.0}) 

541 >>> state.weights 

542 A 1.0 

543 B -0.5 

544 dtype: float64 

545 >>> state.leverage 

546 1.5 

547 >>> state.gmv 

548 1500.0 

549 

550 """ 

551 return float(self.weights.abs().sum())