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

98 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 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 # assert np.isclose(self.value + self.cash, self.aum), f"{self.value + self.cash} != {self.aum}" 

163 # return self.value + self.cash 

164 return self.aum 

165 

166 @property 

167 def value(self) -> float: 

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

169 

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

171 not including cash. 

172 

173 Returns: 

174 ------- 

175 float 

176 The sum of values of all positions 

177 

178 Notes: 

179 ----- 

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

181 

182 """ 

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

184 

185 @property 

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

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

188 

189 This computes the cash value of each position by multiplying 

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

191 

192 Returns: 

193 ------- 

194 pd.Series 

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

196 

197 """ 

198 return self.prices * self.position 

199 

200 @property 

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

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

203 

204 Returns: 

205 ------- 

206 pd.Series 

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

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

209 correct index. 

210 

211 Examples: 

212 -------- 

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

214 

215 >>> import pandas as pd 

216 >>> from cvx.simulator import State 

217 >>> state = State() 

218 >>> state.aum = 1000.0 

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

220 >>> state.position 

221 A NaN 

222 B NaN 

223 dtype: float64 

224 

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

226 

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

228 >>> state.position 

229 A 5.0 

230 B 4.0 

231 dtype: float64 

232 >>> state.trades 

233 A 5.0 

234 B 4.0 

235 dtype: float64 

236 

237 """ 

238 if self._position is None: 

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

240 

241 return self._position 

242 

243 @position.setter 

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

245 """Update the position of the portfolio. 

246 

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

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

249 the internal state. 

250 

251 Parameters 

252 ---------- 

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

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

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

256 

257 """ 

258 # update the position 

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

260 

261 # compute the trades (can be fractional) 

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

263 

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

265 self._position = position 

266 

267 @property 

268 def gmv(self) -> float: 

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

270 

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

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

273 

274 Returns: 

275 ------- 

276 float 

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

278 

279 """ 

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

281 

282 @property 

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

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

285 

286 Returns: 

287 ------- 

288 Optional[datetime] 

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

290 

291 """ 

292 return self._time 

293 

294 @time.setter 

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

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

297 

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

299 between the new time and the previous time. 

300 

301 Parameters 

302 ---------- 

303 time : datetime 

304 The new time to set 

305 

306 """ 

307 if self.time is None: 

308 self._days = 0 

309 self._time = time 

310 else: 

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

312 self._time = time 

313 

314 @property 

315 def days(self) -> int: 

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

317 

318 Returns: 

319 ------- 

320 int 

321 Number of days between the current and previous time 

322 

323 Notes: 

324 ----- 

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

326 time-dependent calculations. 

327 

328 """ 

329 return self._days 

330 

331 @property 

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

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

334 

335 Returns: 

336 ------- 

337 pd.Index 

338 Index of assets with valid prices in the portfolio. 

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

340 

341 """ 

342 if self._prices is None: 

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

344 

345 return self.prices.dropna().index 

346 

347 @property 

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

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

350 

351 Returns: 

352 ------- 

353 Optional[pd.Series] 

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

355 None if no trades have been calculated yet. 

356 

357 Notes: 

358 ----- 

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

360 Positive values represent buys, negative values represent sells. 

361 

362 """ 

363 return self._trades 

364 

365 @property 

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

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

368 

369 Returns: 

370 ------- 

371 np.ndarray 

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

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

374 

375 """ 

376 if self._prices is None: 

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

378 

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

380 

381 @property 

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

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

384 

385 Returns: 

386 ------- 

387 pd.Series 

388 Series of current prices indexed by asset. 

389 Returns an empty series if no prices are set. 

390 

391 """ 

392 if self._prices is None: 

393 return pd.Series(dtype=float) 

394 return self._prices 

395 

396 @prices.setter 

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

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

399 

400 This method updates the prices and calculates the profit achieved 

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

402 the profit. 

403 

404 Parameters 

405 ---------- 

406 prices : pd.Series 

407 New prices for assets in the portfolio 

408 

409 Notes: 

410 ----- 

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

412 before and after the price update. 

413 

414 """ 

415 # Convert dict to Series if necessary 

416 prices = pd.Series(prices) 

417 

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

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

420 

421 self._prices = prices 

422 self._profit = value_after - value_before 

423 self.aum += self.profit 

424 

425 @property 

426 def profit(self) -> float: 

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

428 

429 Returns: 

430 ------- 

431 float 

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

433 last price update 

434 

435 """ 

436 return self._profit 

437 

438 @property 

439 def aum(self) -> float: 

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

441 

442 Returns: 

443 ------- 

444 float 

445 The total assets under management 

446 

447 """ 

448 return self._aum 

449 

450 @aum.setter 

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

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

453 

454 Parameters 

455 ---------- 

456 aum : float 

457 The new assets under management value to set 

458 

459 """ 

460 self._aum = aum 

461 

462 @property 

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

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

465 

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

467 total portfolio value (NAV). 

468 

469 Returns: 

470 ------- 

471 pd.Series 

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

473 total portfolio value, indexed by asset 

474 

475 Notes: 

476 ----- 

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

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

479 

480 Examples: 

481 -------- 

482 >>> import pandas as pd 

483 >>> from cvx.simulator import State 

484 >>> state = State() 

485 >>> state.aum = 1000.0 

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

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

488 

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

490 the remaining 0.3 is the uninvested cash: 

491 

492 >>> state.weights 

493 A 0.5 

494 B 0.2 

495 dtype: float64 

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

497 0.7 

498 

499 """ 

500 if not np.isclose(self.nav, self.aum): 

501 msg = f"{self.nav} != {self.aum}" 

502 raise ValueError(msg) 

503 

504 return self.cashposition / self.nav 

505 

506 @property 

507 def leverage(self) -> float: 

508 """Get the leverage of the portfolio. 

509 

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

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

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

513 

514 Returns: 

515 ------- 

516 float 

517 The leverage ratio of the portfolio 

518 

519 Notes: 

520 ----- 

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

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

523 borrowing or short selling. 

524 

525 Examples: 

526 -------- 

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

528 500, but the leverage counts both legs: 

529 

530 >>> import pandas as pd 

531 >>> from cvx.simulator import State 

532 >>> state = State() 

533 >>> state.aum = 1000.0 

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

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

536 >>> state.weights 

537 A 1.0 

538 B -0.5 

539 dtype: float64 

540 >>> state.leverage 

541 1.5 

542 >>> state.gmv 

543 1500.0 

544 

545 """ 

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